返回 DeepSeek-Reasonix
ProviderModelsEditor.tsx
根目录 / desktop / frontend / src / components / ProviderModelsEditor.tsx
1 import { useProviderT as useT } from "../lib/providerSettingsLocale";
2 import { useEffect, useRef, useState } from "react";
3 import { Pencil, Plus, Plug, RefreshCw, Search, Trash2 } from "lucide-react";
4
5 import type { ProviderModelCapabilityView, ProviderModelOverrideView, ProviderView } from "../lib/types";
6 import { applyModelDraft, modelDraftError, type ModelDraft } from "../lib/providerModelDraft";
7 import { providerVisionModelsForView } from "../lib/providerVisionCapability";
8 import { ModelImageInputControl } from "./ModelImageInputControl";
9 import { imageInputModeForModel, imageInputModes, mergeImageInputModes, modelCapabilityForModel, matchingModelKey } from "../lib/providerImageInput";
10 import { ProviderDialog } from "./ProviderDialog";
11
12 export function ProviderModelsEditor({ provider, disabled, canFetch, onChange, onFetch, onTest, probeKey, draft = false }: {
13 provider: ProviderView; probeKey?: string; disabled: boolean; canFetch: boolean; draft?: boolean;
14 onChange: (models: string[], overrides: ProviderModelOverrideView[], capabilities: ProviderModelCapabilityView[]) => void;
15 onFetch: () => Promise<ProviderModelCapabilityView[]>;
16 onTest: (model: string) => Promise<void>;
17 }) {
18 const t = useT();
19 const [editor, setEditor] = useState<{ original?: string; value: ModelDraft } | null>(null);
20 const [error, setError] = useState<string | null>(null);
21 const [discovery, setDiscovery] = useState<ProviderModelCapabilityView[] | null>(null);
22 const [selection, setSelection] = useState<string[]>([]);
23 const [query, setQuery] = useState("");
24 const [fetching, setFetching] = useState(false);
25 const [tests, setTests] = useState<Record<string, { busy: boolean; error?: string }>>({});
26 // An endpoint/key/parameter edit invalidates every in-flight result for this draft.
27 const identity = JSON.stringify([provider, probeKey]);
28 const identityRef = useRef(identity); identityRef.current = identity;
29 const epoch = useRef(0);
30 useEffect(() => { epoch.current += 1; setFetching(false); setTests({}); setDiscovery(null); setError(null); }, [identity]);
31 useEffect(() => () => { epoch.current += 1; }, []);
32 const filteredDiscovery = discovery?.filter((item) => item.model.toLowerCase().includes(query.trim().toLowerCase())) ?? [];
33 const overrides = provider.modelOverrides ?? [];
34 const capabilities = provider.modelCapabilities ?? [];
35 const vision = providerVisionModelsForView(provider);
36 const openEditor = (model?: string) => {
37 const override = overrides.find((item) => item.model === matchingModelKey(overrides.map((item) => item.model), model ?? ""));
38 setError(null);
39 setEditor({ original: model, value: {
40 model: model ?? "", context: override?.contextWindow ? String(override.contextWindow) : "",
41 output: override?.maxOutputTokens ? String(override.maxOutputTokens) : "",
42 vision: override?.vision == null ? "auto" : override.vision ? "yes" : "no",
43 } });
44 };
45 const fetchModels = async () => {
46 const generation = epoch.current, fingerprint = identity;
47 setFetching(true); setError(null);
48 try {
49 const items = await onFetch();
50 if (generation !== epoch.current || identityRef.current !== fingerprint) return;
51 setDiscovery(Array.from(new Map(items.map((item) => [item.model, item])).values()));
52 setSelection([]); setQuery("");
53 } catch (e) {
54 if (generation === epoch.current && identityRef.current === fingerprint) setError(String((e as Error).message ?? e));
55 } finally { if (generation === epoch.current && identityRef.current === fingerprint) setFetching(false); }
56 };
57 const test = async (model: string) => {
58 const generation = epoch.current, fingerprint = identity;
59 setTests((prev) => ({ ...prev, [model]: { busy: true } }));
60 try {
61 await onTest(model);
62 if (generation === epoch.current && identityRef.current === fingerprint) setTests((prev) => ({ ...prev, [model]: { busy: false } }));
63 } catch (e) {
64 if (generation === epoch.current && identityRef.current === fingerprint) setTests((prev) => ({ ...prev, [model]: { busy: false, error: String((e as Error).message ?? e) } }));
65 }
66 };
67 const saveModel = () => {
68 if (!editor) return;
69 const invalid = modelDraftError(editor.value, provider.models, editor.original);
70 if (invalid) { setError(t(`providerUI.validation.${invalid}`)); return; }
71 const model = editor.value.model.trim();
72 const models = editor.original ? provider.models.map((name) => name === editor.original ? model : name) : [...provider.models, model];
73 // Discovery describes a remote ID; it cannot be transferred to a renamed ID.
74 const nextCapabilities = model !== editor.original ? capabilities.filter((item) => item.model !== editor.original) : capabilities;
75 onChange(models, applyModelDraft(overrides, editor.value, editor.original), nextCapabilities);
76 setEditor(null); setError(null);
77 };
78 return <section className="provider-models-editor">
79 <div className="provider-models-editor__head"><strong>{t("settings.modelList")}</strong><div>
80 <button type="button" className="btn btn--small" disabled={disabled || fetching || !canFetch} onClick={() => void fetchModels()}><RefreshCw size={14} className={fetching ? "provider-spinning" : undefined} />{t(fetching ? "settings.fetchingModels" : "settings.fetchModels")}</button>
81 <button type="button" className="btn btn--small" disabled={disabled} onClick={() => openEditor()}><Plus size={14} />{t("providerUI.manualAdd")}</button>
82 </div></div>
83 {!editor && error && <p role="alert" className="provider-fetch-status provider-fetch-status--warn">{error}</p>}
84 {!provider.models.length && <p className="provider-models-editor__empty">{t("providerUI.emptyModels")}</p>}
85 <div className="provider-models-editor__list">
86 {provider.models.map((model) => {
87 const override = overrides.find((item) => item.model === matchingModelKey(overrides.map((item) => item.model), model ?? ""));
88 const result = tests[model];
89 return <div className="provider-model-row" key={model}>
90 <div className="provider-model-row__main"><span className="provider-model-row__name">{model}</span>
91 <span className="badge badge--neutral" title={t("providerUI.context")}>{override?.contextWindow ? override.contextWindow.toLocaleString() : t("providerUI.auto")}</span>
92 <span className="badge badge--neutral">{t(vision.includes(model) ? "providerUI.image" : "providerUI.text")}</span>
93 <button type="button" className="btn btn--small" aria-label={`${t("providerUI.test")} ${model}`} title={t("providerUI.test")} disabled={disabled || !canFetch || result?.busy} onClick={() => void test(model)}><Plug size={14} /></button>
94 <button type="button" className="btn btn--small" aria-label={`${t("providerUI.editModel")} ${model}`} title={t("providerUI.editModel")} disabled={disabled} onClick={() => openEditor(model)}><Pencil size={14} /></button>
95 <button type="button" className="btn btn--small" aria-label={`${t("common.delete")} ${model}`} title={t("common.delete")} disabled={disabled} onClick={() => onChange(provider.models.filter((name) => name !== model), overrides.filter((item) => item.model !== model), capabilities.filter((item) => item.model !== model))}><Trash2 size={14} /></button>
96 </div>
97 <ModelImageInputControl model={model} baseURL={provider.baseUrl} capability={modelCapabilityForModel(capabilities, model)} mode={imageInputModeForModel(imageInputModes(overrides), model)} disabled={disabled}
98 onChange={(mode) => onChange(provider.models, mergeImageInputModes(overrides, provider.models, { ...imageInputModes(overrides), [model]: mode }), capabilities)} />
99 {result && <div role="status" className={`provider-fetch-status provider-fetch-status--${result.error ? "warn" : "ok"}`}>{result.busy ? t("providerUI.testing") : result.error || t("providerUI.testSuccess")}</div>}
100 </div>;
101 })}
102 </div>
103 {editor && <ProviderDialog title={t(editor.original ? "providerUI.editModel" : "providerUI.addModel")} onClose={() => { setEditor(null); setError(null); }}>
104 <form onSubmit={(e) => { e.preventDefault(); saveModel(); }}>
105 <label className="provider-field">{t("providerUI.modelID")}<input className="mem-input" value={editor.value.model} onChange={(e) => setEditor({ ...editor, value: { ...editor.value, model: e.target.value } })} /></label>
106 <div className="provider-field-grid">{(["context", "output"] as const).map((field) => <label className="provider-field" key={field}>{t(field === "context" ? "providerUI.context" : "providerUI.output")}<input className="mem-input" inputMode="numeric" placeholder={t("providerUI.auto")} value={editor.value[field]} onChange={(e) => setEditor({ ...editor, value: { ...editor.value, [field]: e.target.value } })} /><small>{t(field === "output" ? "providerUI.outputHint" : "providerUI.inherit")}</small></label>)}</div>
107 <ModelImageInputControl model={editor.value.model} baseURL={provider.baseUrl} capability={modelCapabilityForModel(capabilities, editor.value.model)}
108 mode={editor.value.vision === "yes" ? "on" : editor.value.vision === "no" ? "off" : "auto"} disabled={disabled}
109 onChange={(mode) => setEditor({ ...editor, value: { ...editor.value, vision: mode === "on" ? "yes" : mode === "off" ? "no" : "auto" } })} />
110 {error && <p role="alert" className="provider-fetch-status provider-fetch-status--warn">{error}</p>}
111 <footer><button type="button" className="btn btn--small" onClick={() => { setEditor(null); setError(null); }}>{t("common.cancel")}</button><button type="submit" className="btn btn--primary btn--small" disabled={disabled}>{t(draft ? "providerUI.saveDraft" : "providerUI.applyModel")}</button></footer>
112 </form>
113 </ProviderDialog>}
114 {discovery && <ProviderDialog title={t("settings.fetchModels")} onClose={() => setDiscovery(null)}>
115 <label className="provider-catalog-search"><Search size={15} /><input className="mem-input" placeholder={t("settings.modelCandidateSearch")} value={query} onChange={(e) => setQuery(e.target.value)} /></label>
116 <div className="provider-discovery-list">{filteredDiscovery.map((item) => <label key={item.model} className="provider-discovery-row"><input type="checkbox" disabled={provider.models.includes(item.model)} checked={provider.models.includes(item.model) || selection.includes(item.model)} onChange={(e) => setSelection((prev) => e.target.checked ? [...prev, item.model] : prev.filter((name) => name !== item.model))} /><span>{item.model}</span>{provider.models.includes(item.model) && <small>{t("providerUI.alreadyAdded")}</small>}</label>)}{!filteredDiscovery.length && <p>{t(discovery.length ? "settings.noMatchingCandidateModels" : "providerUI.noDiscoveredModels")}</p>}</div>
117 <p className="mem-hint">{t("providerUI.discoveryHint")}</p>
118 <footer><span>{t("providerUI.selected", { n: selection.length })}</span><button className="btn btn--small" onClick={() => setDiscovery(null)}>{t("common.cancel")}</button><button className="btn btn--primary btn--small" disabled={disabled || !selection.length} onClick={() => {
119 const added = selection.filter((name) => !provider.models.includes(name));
120 onChange([...provider.models, ...added], overrides, [...capabilities.filter((item) => !added.includes(item.model)), ...discovery.filter((item) => added.includes(item.model))]);
121 setDiscovery(null);
122 }}>{t("providerUI.addSelected")}</button></footer>
123 </ProviderDialog>}
124 </section>;
125 }
126
126 lines Plain Text