import { useEffect, useState } from "preact/hooks"; import { LANG, t } from "../../i18n"; import { publicModelId, publicText } from "../../features/scrub/scrub"; import { api, apiErrorText } from "../../features/customize/api"; import { custTab } from "../../features/customize/actions"; import { defaultModel } from "../../stores/customize"; import { asList, asString, confirmAction, hint, loadModels, refreshKeyBanner, } from "../../features/customize/host"; import { loopbackModelBase, modelProtocolOptions, protocolLabelOf, readCapabilityReceipt, sanitizeLocalModelDiscovery, type CapabilityReceipt, type LocalDiscovery, type ProtocolOption, } from "../../features/customize/models"; import { CapabilityBadges } from "../onboarding/CapabilityBadges"; import { useAlive } from "./use-timer-lease"; import { markCustomizeFailed, markCustomizeLoaded } from "../../features/customize/load"; import { Empty, Hdr, IconGhost, Pill, Subhead } from "./ui"; import { VolcenginePanel } from "./vendors/volcengine"; type Profile = Record; /** * UI-LIVE-06 copy. Feature-local on purpose: `i18n/en.ts` / `zh.ts` are * generated extracts of the legacy dictionary and are byte-checked. */ const COPY = { en: { liveSource: "In use now · from the daemon's environment or saved settings, not a saved profile", liveNoProfiles: "No saved profiles. The active model above comes from the environment or saved settings.", envKey: "🔑 Key from environment", }, zh: { liveSource: "当前在用 · 来自守护进程的环境变量或已保存的设置,不是已保存的配置档", liveNoProfiles: "还没有已保存的配置档。上方正在使用的模型来自环境变量或已保存的设置。", envKey: "🔑 密钥来自环境变量", }, } as const; function copy(key: keyof (typeof COPY)["en"]): string { return (LANG === "zh" ? COPY.zh : COPY.en)[key]; } /** * The key half of a profile row. `has_api_key` is only "holds a key of its * own", so a profile dispatched under the daemon's environment key for the same * provider read "No key" beside a `ready` card; `credential_source` says which. */ export function profileKeyLabel(p: Record): string { if (p.has_api_key) return t("cust.models.hasKey"); if (p.credential_source === "environment") return copy("envKey"); if (p.credential_source === "local" || loopbackModelBase(p.base_url)) { return t("cust.models.local.keyless"); } return t("cust.models.noKey"); } type LiveModel = { provider: string; model: string; baseUrl: string; hasKey: boolean }; /** * What `GET /config/llm` says the daemon runs on. Never carries key material. * The model id and protocol go through `publicModelId`, not `publicText`: the * generic credential regex rendered `ark-code-latest` as "[redacted]" (the * hazard `features/customize/models.ts` `entryText` already names). */ function readLiveModel(raw: unknown): LiveModel | null { if (!raw || typeof raw !== "object") return null; const row = raw as Record; const model = publicModelId(asString(row.model), 200); const provider = publicModelId(asString(row.provider), 64); if (!model) return null; return { provider, model, baseUrl: publicText(asString(row.base_url), 300).trim(), hasKey: row.has_api_key === true, }; } export function ModelsTab() { const alive = useAlive(); const [err, setErr] = useState(null); const [data, setData] = useState<{ profiles: Profile[]; active_id: string; protocols: unknown[]; }>({ profiles: [], active_id: "", protocols: [] }); const [live, setLive] = useState(null); const [discovery, setDiscovery] = useState(null); const [scanning, setScanning] = useState(false); const [scanErr, setScanErr] = useState(null); const [editing, setEditing] = useState(null); const [name, setName] = useState(""); const [provider, setProvider] = useState("chatgpt"); const [baseUrl, setBaseUrl] = useState(""); const [model, setModel] = useState(""); const [apiKey, setApiKey] = useState(""); const [saving, setSaving] = useState(false); useEffect(() => { void (async () => { try { // The live configuration alongside the saved profiles. An install // driven by `.env` has no profiles at all, and this tab then said "No // models configured yet" while every turn ran on the environment's // model. Best-effort: an unreadable config must not hide the profiles. const [next, conf] = await Promise.all([ api("/model-profiles"), api("/config/llm").catch(() => null), ]); if (!alive()) return; setData({ profiles: asList(next.profiles) as Profile[], active_id: asString(next.active_id), protocols: asList(next.protocols), }); setLive(readLiveModel(conf)); markCustomizeLoaded(); } catch (e) { if (!alive()) return; const message = t("versions.load.err", (e as Error).message); setErr(message); markCustomizeFailed(message); } })(); }, [alive]); const protocols: ProtocolOption[] = modelProtocolOptions(data.protocols); const protocolIds = new Set(protocols.map((item) => item.value)); const resetForm = () => { setEditing(null); setName(""); setProvider("chatgpt"); setBaseUrl(""); setModel(""); setApiKey(""); }; const startEdit = (p: Profile) => { setEditing(p); setName(asString(p.name)); setProvider(protocolIds.has(asString(p.provider)) ? asString(p.provider) : asString(p.provider)); setBaseUrl(asString(p.base_url)); setModel(asString(p.model)); setApiKey(""); }; const runLocalScan = async (force: boolean) => { setScanning(true); setScanErr(null); try { const result = sanitizeLocalModelDiscovery( await api("/model-endpoints/discover" + (force ? "?force=1" : "")), ); if (alive()) setDiscovery(result); } catch (error) { if (alive()) setScanErr( t("cust.models.local.error", publicText((error as Error).message, 240)), ); } finally { if (alive()) setScanning(false); } }; if (err) { return (
{err}
); } return (
{t("cust.volc.title")} {t("cust.models.local.title")}
{t("cust.models.local.desc")}
{scanErr ?
{scanErr}
: null} {scanning && !discovery ? ( {t("cust.models.local.scanning")} ) : discovery ? ( discovery.endpoints.length ? ( discovery.endpoints.map((endpoint) => ( )) ) : ( {t("cust.models.local.none")} ) ) : ( {t("cust.models.local.idle")} )}
{editing ? t("cust.models.editHeading", asString(editing.name || editing.id)) : t("cust.models.addHeading")}
setName((e.target as HTMLInputElement).value)} /> setBaseUrl((e.target as HTMLInputElement).value)} /> setModel((e.target as HTMLInputElement).value)} /> setApiKey((e.target as HTMLInputElement).value)} />
{editing ? ( ) : null}
{t("cust.models.configuredHeading")} {live && !data.active_id ? liveModelRow(live, protocols) : null} {!data.profiles.length ? ( {live && !data.active_id ? copy("liveNoProfiles") : t("cust.models.empty2")} ) : ( data.profiles.map((p) => ( startEdit(p)} /> )) )}
); } /** * The configuration turns run on when no saved profile is active. A plain * render helper rather than a component: it holds no state of its own. */ function liveModelRow(live: LiveModel, protocols: ProtocolOption[]) { const bits: string[] = []; if (live.provider) bits.push(protocolLabelOf(protocols, live.provider)); bits.push(live.model); bits.push( live.hasKey ? t("cust.models.hasKey") : loopbackModelBase(live.baseUrl) ? t("cust.models.local.keyless") : t("cust.models.noKey"), ); return (
{live.model} {t("cust.models.activePill")}
{bits.join(" · ") + (live.baseUrl ? " · " + live.baseUrl : "")}
{copy("liveSource")}
); } function LocalEndpointRow({ endpoint, profiles, }: { endpoint: { label: string; base_url: string; models: string[]; default_model: string; }; profiles: Profile[]; }) { const [model, setModel] = useState(endpoint.default_model); const configured = profiles.some( (profile) => loopbackModelBase(profile.base_url) === endpoint.base_url && profile.model === model, ); return (
{endpoint.label}
{endpoint.base_url + " · " + t("cust.models.local.models", endpoint.models.length)}
); } function ProfileRow({ p, activeId, protocols, onEdit, }: { p: Profile; activeId: string; protocols: ProtocolOption[]; onEdit: () => void; }) { const isActive = p.id === activeId; const rd = (p.readiness && typeof p.readiness === "object" ? p.readiness : {}) as Record; const [probe, setProbe] = useState(null); const [probeClass, setProbeClass] = useState("ds prof-probe"); const [probeReason, setProbeReason] = useState(""); const [receipt, setReceipt] = useState( readCapabilityReceipt(p.capability_receipt), ); const [testing, setTesting] = useState(false); const bits: string[] = []; if (p.provider) bits.push(protocolLabelOf(protocols, p.provider)); if (p.model) bits.push(asString(p.model)); bits.push(profileKeyLabel(p)); return (
{asString(p.name || p.id)} {isActive ? ( <> {" "} {t("cust.models.activePill")} ) : null}
{bits.join(" · ") + (p.base_url ? " · " + asString(p.base_url) : "")}
{rd.state && rd.state !== "ready" ? (
{publicText(rd.detail || rd.state, 200)}
) : null} {probe != null ?
{probe}
: null}
{!isActive ? ( ) : (
)} { if (!confirmAction(t("model.delete.confirm", asString(p.name || p.id)))) return; try { await api(`/model-profiles/${p.id}`, { method: "DELETE" }); hint(t("toast.deleted")); if (isActive) { await refreshKeyBanner(); await loadModels(); } custTab("models"); } catch (e) { hint(t("toast.deleteFailed", apiErrorText(e)), true); } }} />
); }