| 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 |