From aa6e0e1cb7dcf6ac80db599b7ca7edc96947300a Mon Sep 17 00:00:00 2001 From: Sivan Date: Wed, 10 Jun 2026 13:29:10 +0800 Subject: [PATCH 1/4] fix(desktop): preserve curated provider models on refresh --- .../frontend/src/__tests__/provider-model-refresh.test.ts | 6 ++++++ desktop/frontend/src/components/SettingsPanel.tsx | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/desktop/frontend/src/__tests__/provider-model-refresh.test.ts b/desktop/frontend/src/__tests__/provider-model-refresh.test.ts index 120b8a1fa..77e2fa686 100644 --- a/desktop/frontend/src/__tests__/provider-model-refresh.test.ts +++ b/desktop/frontend/src/__tests__/provider-model-refresh.test.ts @@ -35,6 +35,12 @@ eq( "background refresh does not re-add deleted models", ); +eq( + mergedFetchedProviderModels(["mimo-v2.5-pro"], ["mimo-v2-flash", "mimo-v2-omni", "mimo-v2.5-pro"], { preserveCurated: true }), + ["mimo-v2.5-pro"], + "manual access refresh preserves selected MiMo model instead of importing provider catalog", +); + eq( mergedFetchedProviderModels([], ["coding-pro", "chat"], { preserveCurated: true }), ["coding-pro", "chat"], diff --git a/desktop/frontend/src/components/SettingsPanel.tsx b/desktop/frontend/src/components/SettingsPanel.tsx index 32019f9ea..fa5bca8e6 100644 --- a/desktop/frontend/src/components/SettingsPanel.tsx +++ b/desktop/frontend/src/components/SettingsPanel.tsx @@ -1946,7 +1946,7 @@ function ProvidersSection({ s, busy, apply }: SectionProps) { }); return; } - const models = mergedFetchedProviderModels(p.models, fetched); + const models = mergedFetchedProviderModels(p.models, fetched, { preserveCurated: true }); const currentDefault = providerDefaultModel(p.default, models); await app.SaveProvider({ ...p, models, default: currentDefault }); setGroupFetchResult(group.id, { From 73cfe5f646b2ef7a85fdc24eb6ea066743468e52 Mon Sep 17 00:00:00 2001 From: Sivan Date: Wed, 10 Jun 2026 13:43:32 +0800 Subject: [PATCH 2/4] fix(desktop): satisfy React 19 frontend types --- desktop/frontend/src/components/AnchoredPopover.tsx | 2 +- desktop/frontend/src/components/Composer.tsx | 2 +- desktop/frontend/src/components/PromptShelf.tsx | 2 +- desktop/frontend/src/components/WorkspacePanel.tsx | 5 +++-- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/desktop/frontend/src/components/AnchoredPopover.tsx b/desktop/frontend/src/components/AnchoredPopover.tsx index 9474748d7..742d584d2 100644 --- a/desktop/frontend/src/components/AnchoredPopover.tsx +++ b/desktop/frontend/src/components/AnchoredPopover.tsx @@ -54,7 +54,7 @@ export function AnchoredPopover({ closing = false, }: { open: boolean; - anchorRef: RefObject; + anchorRef: RefObject; onClose: () => void; className: string; children: ReactNode; diff --git a/desktop/frontend/src/components/Composer.tsx b/desktop/frontend/src/components/Composer.tsx index 676485302..ffcd32296 100644 --- a/desktop/frontend/src/components/Composer.tsx +++ b/desktop/frontend/src/components/Composer.tsx @@ -381,7 +381,7 @@ export function Composer({ // by 120ms so rapid typing doesn't flood the backend with IPC calls — the // menu only updates after the user pauses. const [argRes, setArgRes] = useState(null); - const debounceRef = useRef>(); + const debounceRef = useRef | undefined>(undefined); useEffect(() => { if (!text.startsWith("/") || !/\s/.test(text)) { setArgRes(null); diff --git a/desktop/frontend/src/components/PromptShelf.tsx b/desktop/frontend/src/components/PromptShelf.tsx index 8d57607cb..6d1ee8372 100644 --- a/desktop/frontend/src/components/PromptShelf.tsx +++ b/desktop/frontend/src/components/PromptShelf.tsx @@ -21,7 +21,7 @@ export function PromptShelf({ children?: ReactNode; crumbs?: ReactNode; quickActions?: ReactNode; - barRef?: RefObject; + barRef?: RefObject; actionsWrap?: boolean; }) { return ( diff --git a/desktop/frontend/src/components/WorkspacePanel.tsx b/desktop/frontend/src/components/WorkspacePanel.tsx index 7da435203..b45eebd15 100644 --- a/desktop/frontend/src/components/WorkspacePanel.tsx +++ b/desktop/frontend/src/components/WorkspacePanel.tsx @@ -5,6 +5,7 @@ import type { KeyboardEvent, MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent, + ReactElement, } from "react"; import { ChevronDown, @@ -112,7 +113,7 @@ function languageFor(path: string): string | undefined { return byExt[ext]; } -function renderMediaPreview(preview: FilePreview): JSX.Element | null { +function renderMediaPreview(preview: FilePreview): ReactElement | null { if (!preview.url) return null; if (preview.kind === "image") { return ( @@ -641,7 +642,7 @@ export function WorkspacePanel({ } }; - const renderRows = (dir: string, depth: number): JSX.Element[] => { + const renderRows = (dir: string, depth: number): ReactElement[] => { const entries = entriesByDir[dir] ?? []; return entries.flatMap((entry) => { const path = entryPath(dir, entry); From 225605bd5bf4cd186803a81c42a393ed961ed6ec Mon Sep 17 00:00:00 2001 From: Sivan Date: Wed, 10 Jun 2026 13:50:21 +0800 Subject: [PATCH 3/4] feat(desktop): require explicit enabled model saves --- .../__tests__/provider-model-refresh.test.ts | 8 +- .../frontend/src/components/SettingsPanel.tsx | 236 +++++++++++++++--- desktop/frontend/src/lib/providerModels.ts | 4 + desktop/frontend/src/locales/en.ts | 12 +- desktop/frontend/src/locales/zh.ts | 12 +- desktop/frontend/src/styles.css | 68 +++++ 6 files changed, 306 insertions(+), 34 deletions(-) diff --git a/desktop/frontend/src/__tests__/provider-model-refresh.test.ts b/desktop/frontend/src/__tests__/provider-model-refresh.test.ts index 77e2fa686..e929ed444 100644 --- a/desktop/frontend/src/__tests__/provider-model-refresh.test.ts +++ b/desktop/frontend/src/__tests__/provider-model-refresh.test.ts @@ -1,6 +1,6 @@ // Run: tsx src/__tests__/provider-model-refresh.test.ts -import { mergedFetchedProviderModels, providerDefaultModel } from "../lib/providerModels"; +import { mergedFetchedProviderModels, providerDefaultModel, providerModelCandidates } from "../lib/providerModels"; let passed = 0; let failed = 0; @@ -41,6 +41,12 @@ eq( "manual access refresh preserves selected MiMo model instead of importing provider catalog", ); +eq( + providerModelCandidates(["mimo-v2.5-pro"], ["mimo-v2-flash", "mimo-v2-omni", "mimo-v2.5-pro"]), + ["mimo-v2.5-pro", "mimo-v2-flash", "mimo-v2-omni"], + "manual access refresh can show provider catalog as unsaved candidates", +); + eq( mergedFetchedProviderModels([], ["coding-pro", "chat"], { preserveCurated: true }), ["coding-pro", "chat"], diff --git a/desktop/frontend/src/components/SettingsPanel.tsx b/desktop/frontend/src/components/SettingsPanel.tsx index fa5bca8e6..579694c04 100644 --- a/desktop/frontend/src/components/SettingsPanel.tsx +++ b/desktop/frontend/src/components/SettingsPanel.tsx @@ -4,7 +4,7 @@ import { asArray } from "../lib/array"; import { useDeferredClose } from "../lib/useMountTransition"; import { app } from "../lib/bridge"; import { normalizeLangPref, useI18n, useT, type DictKey, type LangPref } from "../lib/i18n"; -import { mergedFetchedProviderModels, providerDefaultModel } from "../lib/providerModels"; +import { mergedFetchedProviderModels, providerDefaultModel, providerModelCandidates } from "../lib/providerModels"; import { useUpdater } from "../lib/useUpdater"; import { THEME_STYLES, @@ -1913,6 +1913,7 @@ function ProvidersSection({ s, busy, apply }: SectionProps) { const [adding, setAdding] = useState(null); const [fetchingProvider, setFetchingProvider] = useState(null); const [fetchResults, setFetchResults] = useState>({}); + const [modelDrafts, setModelDrafts] = useState>({}); const groups = providerAccessGroups(s.providers.filter((p) => p.added), t); const setGroupFetchResult = (groupID: string, result: ProviderFetchResult | null) => { @@ -1924,35 +1925,67 @@ function ProvidersSection({ s, busy, apply }: SectionProps) { }); }; + const setGroupModelDraft = (groupID: string, draft: ProviderModelDraft | null) => { + setModelDrafts((prev) => { + const next = { ...prev }; + if (draft) next[groupID] = draft; + else delete next[groupID]; + return next; + }); + }; + + const modelDraftForFetch = (p: ProviderView, fetched: string[]): ProviderModelDraft => { + const candidates = providerModelCandidates(p.models, fetched); + const selected = mergedFetchedProviderModels(p.models, fetched, { preserveCurated: true }); + return { + providerName: p.name, + candidates, + selected: candidates.filter((model) => selected.includes(model)), + }; + }; + + const updateModelDraftSelection = (groupID: string, nextSelected: (draft: ProviderModelDraft) => string[]) => { + setModelDrafts((prev) => { + const draft = prev[groupID]; + if (!draft) return prev; + const selectedSet = new Set(nextSelected(draft)); + return { + ...prev, + [groupID]: { + ...draft, + selected: draft.candidates.filter((model) => selectedSet.has(model)), + }, + }; + }); + }; + const refreshModels = async (group: ProviderAccessGroup, p: ProviderView) => { setFetchingProvider(group.id); setGroupFetchResult(group.id, null); + setGroupModelDraft(group.id, null); try { - await apply(async () => { - let fetched: string[]; - try { - fetched = await app.FetchProviderModels(p); - } catch (e) { - setGroupFetchResult(group.id, { - kind: "warn", - text: t("settings.fetchModelsFailedForProvider", { provider: group.label, err: String((e as Error)?.message ?? e) }), - }); - return; - } - if (fetched.length === 0) { - setGroupFetchResult(group.id, { - kind: "warn", - text: t("settings.fetchModelsEmptyForProvider", { provider: group.label }), - }); - return; - } - const models = mergedFetchedProviderModels(p.models, fetched, { preserveCurated: true }); - const currentDefault = providerDefaultModel(p.default, models); - await app.SaveProvider({ ...p, models, default: currentDefault }); + let fetched: string[]; + try { + fetched = await app.FetchProviderModels(p); + } catch (e) { + setGroupFetchResult(group.id, { + kind: "warn", + text: t("settings.fetchModelsFailedForProvider", { provider: group.label, err: String((e as Error)?.message ?? e) }), + }); + return; + } + if (fetched.length === 0) { setGroupFetchResult(group.id, { - kind: "ok", - text: t("settings.fetchModelsUpdatedForProvider", { provider: group.label, n: models.length }), + kind: "warn", + text: t("settings.fetchModelsEmptyForProvider", { provider: group.label }), }); + return; + } + const draft = modelDraftForFetch(p, fetched); + setGroupModelDraft(group.id, draft); + setGroupFetchResult(group.id, { + kind: "ok", + text: t("settings.fetchModelsReadyForProvider", { provider: group.label, n: draft.candidates.length }), }); } finally { setFetchingProvider(null); @@ -1970,18 +2003,18 @@ function ProvidersSection({ s, busy, apply }: SectionProps) { if (!probe || !apiKeyEnv) return; setFetchingProvider(group.id); setGroupFetchResult(group.id, null); + setGroupModelDraft(group.id, null); try { await apply(async () => { await app.SetProviderKey(apiKeyEnv, value); try { const fetched = await app.FetchProviderModels({ ...probe, apiKeyEnv }); if (fetched.length > 0) { - const models = mergedFetchedProviderModels(probe.models, fetched, { preserveCurated: true }); - const currentDefault = providerDefaultModel(probe.default, models); - await app.SaveProvider({ ...probe, apiKeyEnv, models, default: currentDefault }); + const draft = modelDraftForFetch({ ...probe, apiKeyEnv }, fetched); + setGroupModelDraft(group.id, draft); setGroupFetchResult(group.id, { kind: "ok", - text: t("settings.fetchModelsUpdatedForProvider", { provider: group.label, n: models.length }), + text: t("settings.fetchModelsReadyForProvider", { provider: group.label, n: draft.candidates.length }), }); return; } @@ -2004,6 +2037,7 @@ function ProvidersSection({ s, busy, apply }: SectionProps) { const saveProviderKey = async (group: ProviderAccessGroup, apiKeyEnv: string, value: string) => { if (!apiKeyEnv) return; setGroupFetchResult(group.id, null); + setGroupModelDraft(group.id, null); await apply(() => app.SetProviderKey(apiKeyEnv, value)); }; @@ -2012,6 +2046,24 @@ function ProvidersSection({ s, busy, apply }: SectionProps) { await apply(() => app.ClearProviderKey(apiKeyEnv)); }; + const saveModelDraft = async (group: ProviderAccessGroup) => { + const draft = modelDrafts[group.id]; + const provider = draft ? group.providers.find((p) => p.name === draft.providerName) : null; + const models = uniqueStrings(draft?.selected ?? []); + if (!draft || !provider || models.length === 0) return; + let saved = false; + await apply(async () => { + await app.SaveProvider({ ...provider, models, default: providerDefaultModel(provider.default, models) }); + saved = true; + }); + if (!saved) return; + setGroupModelDraft(group.id, null); + setGroupFetchResult(group.id, { + kind: "ok", + text: t("settings.enabledModelsSavedForProvider", { provider: group.label, n: models.length }), + }); + }; + return ( fetchingProvider === p.name)} fetchResult={fetchResults[group.id]} + modelDraft={modelDrafts[group.id]} defaultProvider={defaultProvider} editing={editing} kinds={s.providerKinds} onEdit={setEditing} onCancelEdit={() => setEditing(null)} - onSave={(pv) => apply(() => app.SaveProvider(pv)).then(() => setEditing(null))} + onSave={(pv) => apply(() => app.SaveProvider(pv)).then(() => { + setEditing(null); + setGroupModelDraft(group.id, null); + })} onRefresh={() => void refreshGroup(group)} + onToggleDraftModel={(model) => updateModelDraftSelection(group.id, (draft) => ( + draft.selected.includes(model) + ? draft.selected.filter((candidate) => candidate !== model) + : [...draft.selected, model] + ))} + onSelectAllDraftModels={() => updateModelDraftSelection(group.id, (draft) => draft.candidates)} + onClearDraftModels={() => updateModelDraftSelection(group.id, () => [])} + onCancelDraftModels={() => setGroupModelDraft(group.id, null)} + onSaveDraftModels={() => void saveModelDraft(group)} onSaveEditorKey={(env, value) => group.builtIn ? saveProviderKey(group, env, value) : saveKeyEnvAndAutoRefresh(group, env, value)} onClearEditorKey={clearProviderKey} onDelete={(p) => apply(() => app.RemoveProviderAccess(p.name))} @@ -2090,6 +2155,12 @@ type ProviderFetchResult = { text: string; }; +type ProviderModelDraft = { + providerName: string; + candidates: string[]; + selected: string[]; +}; + type AddProviderMode = null | "official" | "custom"; type OfficialProviderKind = "deepseek" | "mimo-api" | "mimo-token-plan"; @@ -2226,6 +2297,7 @@ function ProviderAccessCard({ busy, fetching, fetchResult, + modelDraft, defaultProvider, editing, kinds, @@ -2233,6 +2305,11 @@ function ProviderAccessCard({ onCancelEdit, onSave, onRefresh, + onToggleDraftModel, + onSelectAllDraftModels, + onClearDraftModels, + onCancelDraftModels, + onSaveDraftModels, onSaveEditorKey, onClearEditorKey, onDelete, @@ -2241,6 +2318,7 @@ function ProviderAccessCard({ busy: boolean; fetching: boolean; fetchResult?: ProviderFetchResult; + modelDraft?: ProviderModelDraft; defaultProvider: string; editing: string | null; kinds: string[]; @@ -2248,6 +2326,11 @@ function ProviderAccessCard({ onCancelEdit: () => void; onSave: (p: ProviderView) => void | Promise; onRefresh: () => void; + onToggleDraftModel: (model: string) => void; + onSelectAllDraftModels: () => void; + onClearDraftModels: () => void; + onCancelDraftModels: () => void; + onSaveDraftModels: () => void; onSaveEditorKey: (apiKeyEnv: string, value: string) => Promise; onClearEditorKey?: (apiKeyEnv: string) => Promise; onDelete?: (p: ProviderView) => Promise; @@ -2318,8 +2401,8 @@ function ProviderAccessCard({
-
{t(group.keySet ? "settings.availableModels" : "settings.modelList")}
-
+
{t(group.keySet ? "settings.enabledModels" : "settings.modelList")}
+
{visibleModels.length > 0 ? visibleModels.map((model) => ( {model} @@ -2343,6 +2426,19 @@ function ProviderAccessCard({ )}
+ {modelDraft && ( + + )} + {group.providers.length > 1 && (
{group.providers.map((p) => { @@ -2380,6 +2476,84 @@ function ProviderAccessCard({ ); } +function ProviderModelDraftPicker({ + draft, + busy, + fetching, + onToggle, + onSelectAll, + onClear, + onCancel, + onSave, +}: { + draft: ProviderModelDraft; + busy: boolean; + fetching: boolean; + onToggle: (model: string) => void; + onSelectAll: () => void; + onClear: () => void; + onCancel: () => void; + onSave: () => void; +}) { + const t = useT(); + const [query, setQuery] = useState(""); + const selected = new Set(draft.selected); + const q = query.trim().toLowerCase(); + const visibleCandidates = q + ? draft.candidates.filter((model) => model.toLowerCase().includes(q)) + : draft.candidates; + const disabled = busy || fetching; + + return ( +
+
+
+
{t("settings.modelCandidates")}
+ {t("settings.modelCandidatesSelected", { n: draft.selected.length })} +
+
+ + +
+
+ setQuery(e.target.value)} + /> +
+ {visibleCandidates.length > 0 ? visibleCandidates.map((model) => ( + + )) : ( +
{t("settings.noMatchingCandidateModels")}
+ )} +
+
+ + +
+
+ ); +} + function providerAccessGroups(providers: ProviderView[], t: ReturnType): ProviderAccessGroup[] { const groups = new Map(); for (const p of providers) { diff --git a/desktop/frontend/src/lib/providerModels.ts b/desktop/frontend/src/lib/providerModels.ts index ca331f989..b0cf0f321 100644 --- a/desktop/frontend/src/lib/providerModels.ts +++ b/desktop/frontend/src/lib/providerModels.ts @@ -4,6 +4,10 @@ export function mergedFetchedProviderModels(current: string[], fetched: string[] return uniqueStrings([...saved, ...fetched]); } +export function providerModelCandidates(current: string[], fetched: string[]): string[] { + return uniqueStrings([...current, ...fetched]); +} + export function providerDefaultModel(currentDefault: string, models: string[]): string { return currentDefault && models.includes(currentDefault) ? currentDefault : models[0] ?? ""; } diff --git a/desktop/frontend/src/locales/en.ts b/desktop/frontend/src/locales/en.ts index 64fe6e3c7..f2f571378 100644 --- a/desktop/frontend/src/locales/en.ts +++ b/desktop/frontend/src/locales/en.ts @@ -566,6 +566,7 @@ export const en = { "settings.openSkills": "Open skills management", "settings.openMemory": "Open memory management", "settings.availableModels": "Available models", + "settings.enabledModels": "Enabled models", "settings.modelList": "Model list", "settings.modelsRequireKey": "Set a key before selecting these models.", "settings.defaultModel": "Default model", @@ -600,7 +601,7 @@ export const en = { "settings.removeProviderAccess": "Remove access", "settings.confirmRemoveProviderAccess": "Confirm remove access", "settings.providerAccess": "Provider access", - "settings.providerAccessHint": "Providers appear here after you add them. Available models follow the provider-returned list.", + "settings.providerAccessHint": "Providers appear here after you add them. Only saved enabled models appear in the model picker.", "settings.providerAccessEmptyTitle": "No providers added yet", "settings.builtinProviders": "Built-in providers", "settings.builtinProvider": "built-in", @@ -608,6 +609,13 @@ export const en = { "settings.providerLabel.mimoApi": "Mimo API Official", "settings.providerLabel.mimoTokenPlan": "Mimo Token Plan", "settings.fetchModels": "Refresh models", + "settings.saveEnabledModels": "Save enabled models", + "settings.modelCandidates": "Discovered models", + "settings.modelCandidatesSelected": "{n} selected", + "settings.modelCandidateSearch": "Search discovered models...", + "settings.noMatchingCandidateModels": "No matching models", + "settings.selectAllModels": "Select all", + "settings.clearModelSelection": "Clear", "settings.fetchModelsMissingBaseUrl": "Enter an API address first", "settings.fetchModelsMissingKeyEnv": "Enter api_key_env first", "settings.clearKey": "Clear key", @@ -868,6 +876,8 @@ export const en = { "settings.fetchingModels": "Refreshing…", "settings.fetchModelsSuccess": "Fetched {n} models", "settings.fetchModelsUpdatedForProvider": "Updated available models for {provider}, {n} total", + "settings.fetchModelsReadyForProvider": "Found {n} models for {provider}. Review and save the enabled list.", + "settings.enabledModelsSavedForProvider": "Saved {n} enabled models for {provider}", "settings.fetchModelsEmpty": "No available models were returned", "settings.fetchModelsEmptyForProvider": "{provider} did not return any available models", "settings.fetchModelsFailedForProvider": "Failed to refresh {provider}: {err}", diff --git a/desktop/frontend/src/locales/zh.ts b/desktop/frontend/src/locales/zh.ts index 5630bb94a..0d249f1c3 100644 --- a/desktop/frontend/src/locales/zh.ts +++ b/desktop/frontend/src/locales/zh.ts @@ -568,6 +568,7 @@ export const zh: Record = { "settings.openSkills": "打开技能管理", "settings.openMemory": "打开记忆管理", "settings.availableModels": "可用模型", + "settings.enabledModels": "已启用模型", "settings.modelList": "模型列表", "settings.modelsRequireKey": "填写密钥后才可选择这些模型。", "settings.defaultModel": "默认模型", @@ -602,7 +603,7 @@ export const zh: Record = { "settings.removeProviderAccess": "移除接入", "settings.confirmRemoveProviderAccess": "确认移除接入", "settings.providerAccess": "供应商接入", - "settings.providerAccessHint": "添加官方或自定义供应商后,才会出现在这里。可用模型以供应商返回的列表为准。", + "settings.providerAccessHint": "添加官方或自定义供应商后,才会出现在这里。会话模型列表只显示已保存的启用模型。", "settings.providerAccessEmptyTitle": "还没有添加供应商", "settings.builtinProviders": "内置供应商", "settings.builtinProvider": "内置", @@ -610,6 +611,13 @@ export const zh: Record = { "settings.providerLabel.mimoApi": "Mimo API 官方", "settings.providerLabel.mimoTokenPlan": "Mimo Token Plan", "settings.fetchModels": "刷新模型", + "settings.saveEnabledModels": "保存启用模型", + "settings.modelCandidates": "发现的模型", + "settings.modelCandidatesSelected": "已选择 {n} 个", + "settings.modelCandidateSearch": "搜索发现的模型...", + "settings.noMatchingCandidateModels": "没有匹配的模型", + "settings.selectAllModels": "全选", + "settings.clearModelSelection": "清空", "settings.fetchModelsMissingBaseUrl": "请先填写 API 地址", "settings.fetchModelsMissingKeyEnv": "请先填写 api_key_env", "settings.clearKey": "清除密钥", @@ -870,6 +878,8 @@ export const zh: Record = { "settings.fetchingModels": "刷新中…", "settings.fetchModelsSuccess": "已获取 {n} 个模型", "settings.fetchModelsUpdatedForProvider": "已更新可用模型:{provider},共 {n} 个", + "settings.fetchModelsReadyForProvider": "已发现 {provider} 的 {n} 个模型,请确认后保存启用列表。", + "settings.enabledModelsSavedForProvider": "已保存 {provider} 的 {n} 个启用模型", "settings.fetchModelsEmpty": "没有获取到可用模型", "settings.fetchModelsEmptyForProvider": "{provider} 没有返回可用模型", "settings.fetchModelsFailedForProvider": "刷新 {provider} 失败:{err}", diff --git a/desktop/frontend/src/styles.css b/desktop/frontend/src/styles.css index 500974e67..b3920205c 100644 --- a/desktop/frontend/src/styles.css +++ b/desktop/frontend/src/styles.css @@ -9261,6 +9261,74 @@ a[href] { background: color-mix(in srgb, var(--warn) 10%, transparent); color: var(--warn); } +.provider-model-draft { + display: flex; + flex-direction: column; + gap: 8px; + padding-top: 12px; + border-top: 1px solid var(--border-soft); +} +.provider-model-draft__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + min-width: 0; +} +.provider-model-draft__head > div:first-child { + min-width: 0; +} +.provider-model-draft__head span { + display: block; + margin-top: 3px; + color: var(--fg-muted); + font-size: var(--font-control-small); +} +.provider-model-draft__tools, +.provider-model-draft__actions { + display: flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + gap: 8px; +} +.provider-model-draft__search { + min-height: 34px; +} +.provider-model-draft__list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: 6px; + max-height: 220px; + overflow: auto; + padding-right: 2px; +} +.provider-model-draft__option { + min-width: 0; + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: 7px; + min-height: 32px; + padding: 5px 7px; + border: 1px solid var(--border-soft); + border-radius: 7px; + background: color-mix(in srgb, var(--bg) 70%, transparent); + color: var(--fg); + font-family: var(--mono); + font-size: var(--text-2xs); +} +.provider-model-draft__option input { + margin: 0; +} +.provider-model-draft__option span { + min-width: 0; + overflow-wrap: anywhere; +} +.provider-model-draft__empty { + color: var(--fg-muted); + font-size: var(--font-control-small); +} .provider-key-status { display: flex; align-items: center; From 9b797b023f9a8049e25831ab241567bf39475c3b Mon Sep 17 00:00:00 2001 From: Sivan Date: Wed, 10 Jun 2026 14:37:01 +0800 Subject: [PATCH 4/4] fix(desktop): align enabled model saves with chat picker --- .../__tests__/provider-model-refresh.test.ts | 19 +++++- desktop/frontend/src/lib/providerModels.ts | 22 +++++- desktop/settings_app.go | 42 ++++++++++-- desktop/settings_app_test.go | 68 +++++++++++++++++++ 4 files changed, 143 insertions(+), 8 deletions(-) diff --git a/desktop/frontend/src/__tests__/provider-model-refresh.test.ts b/desktop/frontend/src/__tests__/provider-model-refresh.test.ts index e929ed444..bf1d4f193 100644 --- a/desktop/frontend/src/__tests__/provider-model-refresh.test.ts +++ b/desktop/frontend/src/__tests__/provider-model-refresh.test.ts @@ -1,6 +1,6 @@ // Run: tsx src/__tests__/provider-model-refresh.test.ts -import { mergedFetchedProviderModels, providerDefaultModel, providerModelCandidates } from "../lib/providerModels"; +import { isLikelyChatModel, mergedFetchedProviderModels, providerDefaultModel, providerModelCandidates } from "../lib/providerModels"; let passed = 0; let failed = 0; @@ -47,6 +47,23 @@ eq( "manual access refresh can show provider catalog as unsaved candidates", ); +eq( + providerModelCandidates(["mimo-v2.5-pro"], ["mimo-v2.5-asr", "mimo-v2.5-tts", "mimo-v2.5", "mimo-v2.5-pro"]), + ["mimo-v2.5-pro", "mimo-v2.5"], + "manual access refresh filters non-chat candidates before saving", +); + +eq( + [ + isLikelyChatModel("mimo-v2.5-pro"), + isLikelyChatModel("mimo-v2.5-asr"), + isLikelyChatModel("mimo-v2.5-tts"), + isLikelyChatModel("text-embedding-3-small"), + ], + [true, false, false, false], + "matches backend non-chat model heuristic", +); + eq( mergedFetchedProviderModels([], ["coding-pro", "chat"], { preserveCurated: true }), ["coding-pro", "chat"], diff --git a/desktop/frontend/src/lib/providerModels.ts b/desktop/frontend/src/lib/providerModels.ts index b0cf0f321..094d463eb 100644 --- a/desktop/frontend/src/lib/providerModels.ts +++ b/desktop/frontend/src/lib/providerModels.ts @@ -5,13 +5,33 @@ export function mergedFetchedProviderModels(current: string[], fetched: string[] } export function providerModelCandidates(current: string[], fetched: string[]): string[] { - return uniqueStrings([...current, ...fetched]); + return uniqueStrings([...current, ...fetched]).filter(isLikelyChatModel); } export function providerDefaultModel(currentDefault: string, models: string[]): string { return currentDefault && models.includes(currentDefault) ? currentDefault : models[0] ?? ""; } +export function isLikelyChatModel(model: string): boolean { + const lower = model.trim().toLowerCase(); + if (!lower) return false; + for (const term of ["text-embedding", "text-to-speech", "speech-to-text"]) { + if (lower.includes(term)) return false; + } + const nonChatTokens = new Set([ + "asr", + "stt", + "tts", + "whisper", + "embedding", + "moderation", + "rerank", + "dall", + "transcription", + ]); + return !lower.split(/[-_./:]+/).some((token) => nonChatTokens.has(token)); +} + function uniqueStrings(values: string[]): string[] { const seen = new Set(); const out: string[] = []; diff --git a/desktop/settings_app.go b/desktop/settings_app.go index d467ce11e..4bd1858e2 100644 --- a/desktop/settings_app.go +++ b/desktop/settings_app.go @@ -794,6 +794,35 @@ func officialProviderTemplate(kind string) ([]config.ProviderEntry, string, erro } } +func chatProviderModels(models []string) []string { + out := make([]string, 0, len(models)) + seen := map[string]bool{} + for _, model := range models { + model = strings.TrimSpace(model) + if model == "" || seen[model] || !config.IsLikelyChatModel(model) { + continue + } + seen[model] = true + out = append(out, model) + } + return out +} + +func providerDefaultForModels(currentDefault string, models []string) string { + currentDefault = strings.TrimSpace(currentDefault) + if currentDefault != "" { + for _, model := range models { + if model == currentDefault { + return currentDefault + } + } + } + if len(models) > 0 { + return models[0] + } + return "" +} + // SaveProvider adds or updates a provider. A single model fills `model`; several // fill `models` (with `default`). The shared key/endpoint live on the entry. func (a *App) SaveProvider(p ProviderView) error { @@ -818,11 +847,12 @@ func (a *App) SaveProvider(p ProviderView) error { e.Model = "" e.Models = nil e.Default = "" - if len(p.Models) > 0 { - e.Model = p.Models[0] // also satisfies validateProvider's model requirement - if len(p.Models) > 1 { - e.Models = p.Models - e.Default = p.Default + models := chatProviderModels(p.Models) + if len(models) > 0 { + e.Model = models[0] // also satisfies validateProvider's model requirement + if len(models) > 1 { + e.Models = models + e.Default = providerDefaultForModels(p.Default, models) } } if err := c.UpsertProvider(e); err != nil { @@ -875,7 +905,7 @@ func (a *App) FetchProviderModels(p ProviderView) ([]string, error) { if err != nil { return []string{}, err } - return nonNil(models), nil + return nonNil(chatProviderModels(models)), nil } // DeleteProvider removes a provider and retargets open idle tabs that used it. diff --git a/desktop/settings_app_test.go b/desktop/settings_app_test.go index 65330a5dc..d771c9200 100644 --- a/desktop/settings_app_test.go +++ b/desktop/settings_app_test.go @@ -1,6 +1,9 @@ package main import ( + "encoding/json" + "net/http" + "net/http/httptest" "reflect" "testing" @@ -57,6 +60,71 @@ func TestProviderViewFromEntry_FiltersNonChatModels(t *testing.T) { } } +func TestFetchProviderModelsFiltersNonChatModels(t *testing.T) { + t.Setenv("TEST_PROVIDER_KEY", "test-key") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/models" { + http.NotFound(w, r) + return + } + if r.Header.Get("Authorization") != "Bearer test-key" { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "object": "list", + "data": []map[string]string{ + {"id": "mimo-v2.5-pro", "object": "model"}, + {"id": "mimo-v2.5-asr", "object": "model"}, + {"id": "mimo-v2.5-tts", "object": "model"}, + }, + }) + })) + defer srv.Close() + + got, err := NewApp().FetchProviderModels(ProviderView{ + Name: "mimo-api", + BaseURL: srv.URL, + APIKeyEnv: "TEST_PROVIDER_KEY", + }) + if err != nil { + t.Fatalf("FetchProviderModels: %v", err) + } + want := []string{"mimo-v2.5-pro"} + if !reflect.DeepEqual(got, want) { + t.Errorf("FetchProviderModels = %v, want %v", got, want) + } +} + +func TestSaveProviderFiltersNonChatModels(t *testing.T) { + isolateDesktopUserDirs(t) + + app := NewApp() + if err := app.SaveProvider(ProviderView{ + Name: "mimo-api", + Kind: "openai", + BaseURL: "https://api.xiaomimimo.com/v1", + Models: []string{"mimo-v2.5-asr", "mimo-v2.5-pro", "mimo-v2.5-tts"}, + Default: "mimo-v2.5-asr", + APIKeyEnv: "MIMO_API_KEY", + }); err != nil { + t.Fatalf("SaveProvider: %v", err) + } + + cfg := config.LoadForEdit(config.UserConfigPath()) + got, ok := cfg.Provider("mimo-api") + if !ok { + t.Fatal("saved provider not found") + } + want := []string{"mimo-v2.5-pro"} + if !reflect.DeepEqual(got.ModelList(), want) { + t.Errorf("saved provider models = %v, want %v", got.ModelList(), want) + } + if got.DefaultModel() != "mimo-v2.5-pro" { + t.Errorf("saved provider default = %q, want mimo-v2.5-pro", got.DefaultModel()) + } +} + func TestSetAgentParamsPersistsStepLimitsToUserConfig(t *testing.T) { isolateDesktopUserDirs(t)