From 71216e1602290f48251cc697a46113812888da0d Mon Sep 17 00:00:00 2001 From: baizhi958216 <1475289190@qq.com> Date: Thu, 11 Jun 2026 10:21:36 +0800 Subject: [PATCH] feat(ui): add ModelSettingsModal for configuring text/image/vision providers Signed-off-by: baizhi958216 <1475289190@qq.com> --- components/ModelSettingsModal.tsx | 305 ++++++++++++++++++++++++++++++ 1 file changed, 305 insertions(+) create mode 100644 components/ModelSettingsModal.tsx diff --git a/components/ModelSettingsModal.tsx b/components/ModelSettingsModal.tsx new file mode 100644 index 0000000..167afb7 --- /dev/null +++ b/components/ModelSettingsModal.tsx @@ -0,0 +1,305 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { ProviderProtocol } from "@infiplot/types"; +import { + clearStoredModelConfig, + readStoredModelConfig, + writeStoredModelConfig, +} from "@/lib/clientModelConfig"; + +const PROVIDER_OPTIONS: { value: ProviderProtocol | ""; label: string }[] = [ + { value: "", label: "自动推断(推荐)" }, + { value: "openai_compatible", label: "OpenAI Compatible" }, + { value: "openai", label: "OpenAI (Native)" }, + { value: "anthropic", label: "Anthropic" }, + { value: "google", label: "Google Gemini" }, + { value: "runware", label: "Runware" }, +]; + +type ModelGroup = { + key: "text" | "image" | "vision"; + label: string; + icon: string; + baseUrl: string; + apiKey: string; + model: string; + provider: string; +}; + +export function ModelSettingsModal({ + onClose, + onSaved, +}: { + onClose: () => void; + onSaved: () => void; +}) { + const initial = readStoredModelConfig(); + + const [groups, setGroups] = useState([ + { + key: "text", + label: "文本模型", + icon: "fa-solid fa-pen-nib", + baseUrl: initial?.textBaseUrl ?? "", + apiKey: initial?.textApiKey ?? "", + model: initial?.textModel ?? "", + provider: initial?.textProvider ?? "", + }, + { + key: "image", + label: "绘图模型", + icon: "fa-solid fa-palette", + baseUrl: initial?.imageBaseUrl ?? "", + apiKey: initial?.imageApiKey ?? "", + model: initial?.imageModel ?? "", + provider: initial?.imageProvider ?? "", + }, + { + key: "vision", + label: "识图模型", + icon: "fa-solid fa-eye", + baseUrl: initial?.visionBaseUrl ?? "", + apiKey: initial?.visionApiKey ?? "", + model: initial?.visionModel ?? "", + provider: initial?.visionProvider ?? "", + }, + ]); + + const [showKeys, setShowKeys] = useState>({}); + const [shown, setShown] = useState(false); + + useEffect(() => { + const id = requestAnimationFrame(() => setShown(true)); + return () => cancelAnimationFrame(id); + }, []); + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") close(); + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, []); + + const close = () => { + setShown(false); + setTimeout(onClose, 280); + }; + + const updateGroup = ( + key: string, + field: keyof Omit, + value: string, + ) => { + setGroups((prev) => + prev.map((g) => (g.key === key ? { ...g, [field]: value } : g)), + ); + }; + + const save = () => { + const [text, image, vision] = groups; + writeStoredModelConfig({ + textBaseUrl: text.baseUrl, + textApiKey: text.apiKey, + textModel: text.model, + textProvider: (text.provider as ProviderProtocol) || undefined, + imageBaseUrl: image.baseUrl, + imageApiKey: image.apiKey, + imageModel: image.model, + imageProvider: (image.provider as ProviderProtocol) || undefined, + visionBaseUrl: vision.baseUrl, + visionApiKey: vision.apiKey, + visionModel: vision.model, + visionProvider: (vision.provider as ProviderProtocol) || undefined, + }); + onSaved(); + close(); + }; + + const clearAll = () => { + clearStoredModelConfig(); + setGroups((prev) = + prev.map((g) => ({ ...g, baseUrl: "", apiKey: "", model: "", provider: "" })), + ); + onSaved(); + close(); + }; + + const hasAnySetting = groups.some( + (g) => g.baseUrl.trim() && g.apiKey.trim() && g.model.trim(), + ); + + return ( +
+
e.stopPropagation()} + className={ + "flex w-[600px] max-w-[96vw] max-h-[90vh] flex-col overflow-hidden rounded-sm border border-clay-900/15 bg-cream-50 shadow-2xl shadow-clay-900/25 transition-all duration-300 " + + (shown ? "opacity-100 scale-100" : "opacity-0 scale-95") + } + > + {/* Header */} +
+
+ + 模型设置 + + + API Key 仅保存在浏览器本地,不会发送到服务器 + +
+ +
+ +
+ {groups.map((g, idx) => ( +
+ {idx > 0 && ( +
+ )} +
+
+ + + + + {g.label} + +
+ +
+ + B A S E · U R L + + updateGroup(g.key, "baseUrl", e.target.value)} + type="text" + autoComplete="off" + spellCheck={false} + placeholder="https://api.example.com/v1" + className="h-10 w-full rounded-sm border border-clay-900/15 bg-cream-100 px-4 font-sans text-sm text-clay-900 outline-none transition-colors focus:border-ember-500 placeholder:text-clay-400" + /> +
+ +
+ + A P I · K e y + +
+ updateGroup(g.key, "apiKey", e.target.value)} + type={showKeys[g.key] ? "text" : "password"} + autoComplete="off" + spellCheck={false} + placeholder="sk-..." + className="h-10 w-full rounded-sm border border-clay-900/15 bg-cream-100 pl-4 pr-11 font-sans text-sm text-clay-900 outline-none transition-colors focus:border-ember-500 placeholder:text-clay-400" + /> + +
+
+ +
+ + M o d e l + + updateGroup(g.key, "model", e.target.value)} + type="text" + autoComplete="off" + spellCheck={false} + placeholder="gpt-4o / claude-3-5-sonnet / flux-1-dev ..." + className="h-10 w-full rounded-sm border border-clay-900/15 bg-cream-100 px-4 font-sans text-sm text-clay-900 outline-none transition-colors focus:border-ember-500 placeholder:text-clay-400" + /> +
+ +
+ + P r o v i d e r(可选) + + + + 留空时系统会根据 Base URL 自动推断协议。 + +
+
+
+ ))} + +
+ +
+

+ + 请确保你的 API 端点支持浏览器跨域请求(CORS)。大多数主流提供商(OpenAI、Anthropic、Gemini、Runware 等)已默认支持。 +

+
+
+ + {/* Footer */} +
+ {hasAnySetting && ( + + )} + +
+
+
+ ); +}