diff --git a/desktop/frontend/package.json b/desktop/frontend/package.json index 8c975b947..a970acf74 100644 --- a/desktop/frontend/package.json +++ b/desktop/frontend/package.json @@ -10,9 +10,9 @@ "check:css": "node scripts/check-css-syntax.mjs src/styles.css && node scripts/check-z-index-tokens.mjs src/styles.css", "test:todo-visibility": "node scripts/test-todo-visibility.mjs", "typecheck": "tsc --noEmit", - "test": "tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/text-size.test.ts", + "test": "tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/provider-model-refresh.test.ts", "test:typecheck": "tsc --noEmit -p tsconfig.test.json", - "test:all": "tsc --noEmit -p tsconfig.test.json && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/text-size.test.ts" + "test:all": "tsc --noEmit -p tsconfig.test.json && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/provider-model-refresh.test.ts" }, "dependencies": { "@tanstack/react-virtual": "^3.14.2", diff --git a/desktop/frontend/src/__tests__/provider-model-refresh.test.ts b/desktop/frontend/src/__tests__/provider-model-refresh.test.ts new file mode 100644 index 000000000..120b8a1fa --- /dev/null +++ b/desktop/frontend/src/__tests__/provider-model-refresh.test.ts @@ -0,0 +1,57 @@ +// Run: tsx src/__tests__/provider-model-refresh.test.ts + +import { mergedFetchedProviderModels, providerDefaultModel } from "../lib/providerModels"; + +let passed = 0; +let failed = 0; + +function eq(a: unknown, b: unknown, label: string) { + if (JSON.stringify(a) === JSON.stringify(b)) { + process.stdout.write(` PASS ${label}\n`); + passed += 1; + } else { + process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`); + failed += 1; + } +} + +console.log("\nprovider model refresh"); + +eq( + mergedFetchedProviderModels(["coding-pro"], ["coding-pro", "chat", "vision"]), + ["coding-pro", "chat", "vision"], + "appends discovered models without removing curated ones", +); + +eq( + mergedFetchedProviderModels(["coding-pro"], ["coding-pro", "chat", "vision"], { preserveCurated: true }), + ["coding-pro"], + "background refresh preserves manually curated model list", +); + +eq( + mergedFetchedProviderModels(["coding-pro"], ["chat", "vision"], { preserveCurated: true }), + ["coding-pro"], + "background refresh does not re-add deleted models", +); + +eq( + mergedFetchedProviderModels([], ["coding-pro", "chat"], { preserveCurated: true }), + ["coding-pro", "chat"], + "background refresh can populate an empty model list", +); + +eq( + providerDefaultModel("coding-pro", ["coding-pro", "chat"]), + "coding-pro", + "preserves current default when it remains available", +); + +eq( + providerDefaultModel("deleted", ["coding-pro", "chat"]), + "coding-pro", + "falls back to first saved model when default is unavailable", +); + +console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`); +if (failed > 0) process.exit(1); diff --git a/desktop/frontend/src/components/SettingsPanel.tsx b/desktop/frontend/src/components/SettingsPanel.tsx index 429759194..620155751 100644 --- a/desktop/frontend/src/components/SettingsPanel.tsx +++ b/desktop/frontend/src/components/SettingsPanel.tsx @@ -3,6 +3,7 @@ import { Check, ChevronDown } from "lucide-react"; import { asArray } from "../lib/array"; import { app } from "../lib/bridge"; import { normalizeLangPref, useI18n, useT, type DictKey, type LangPref } from "../lib/i18n"; +import { mergedFetchedProviderModels, providerDefaultModel } from "../lib/providerModels"; import { useUpdater } from "../lib/useUpdater"; import { THEME_STYLES, @@ -761,9 +762,10 @@ function ModelsSection({ s, busy, apply, backgroundApply }: ModelsSectionProps) try { const fetched = await app.FetchProviderModels(provider); if (fetched.length === 0) continue; - const currentDefault = provider.default && fetched.includes(provider.default) ? provider.default : fetched[0]; - if (sameStringList(provider.models, fetched) && provider.default === currentDefault) continue; - await app.SaveProvider({ ...provider, models: fetched, default: currentDefault }); + const models = mergedFetchedProviderModels(provider.models, fetched, { preserveCurated: true }); + const currentDefault = providerDefaultModel(provider.default, models); + if (sameStringList(provider.models, models) && provider.default === currentDefault) continue; + await app.SaveProvider({ ...provider, models, default: currentDefault }); } catch { // Background discovery is opportunistic; manual refresh shows errors. } @@ -1143,11 +1145,12 @@ function ProvidersSection({ s, busy, apply }: SectionProps) { }); return; } - const currentDefault = p.default && fetched.includes(p.default) ? p.default : fetched[0]; - await app.SaveProvider({ ...p, models: fetched, default: currentDefault }); + const models = mergedFetchedProviderModels(p.models, fetched); + const currentDefault = providerDefaultModel(p.default, models); + await app.SaveProvider({ ...p, models, default: currentDefault }); setGroupFetchResult(group.id, { kind: "ok", - text: t("settings.fetchModelsUpdatedForProvider", { provider: group.label, n: fetched.length }), + text: t("settings.fetchModelsUpdatedForProvider", { provider: group.label, n: models.length }), }); }); } finally { @@ -1172,11 +1175,12 @@ function ProvidersSection({ s, busy, apply }: SectionProps) { try { const fetched = await app.FetchProviderModels({ ...probe, apiKeyEnv }); if (fetched.length > 0) { - const currentDefault = probe.default && fetched.includes(probe.default) ? probe.default : fetched[0]; - await app.SaveProvider({ ...probe, apiKeyEnv, models: fetched, default: currentDefault }); + const models = mergedFetchedProviderModels(probe.models, fetched, { preserveCurated: true }); + const currentDefault = providerDefaultModel(probe.default, models); + await app.SaveProvider({ ...probe, apiKeyEnv, models, default: currentDefault }); setGroupFetchResult(group.id, { kind: "ok", - text: t("settings.fetchModelsUpdatedForProvider", { provider: group.label, n: fetched.length }), + text: t("settings.fetchModelsUpdatedForProvider", { provider: group.label, n: models.length }), }); return; } diff --git a/desktop/frontend/src/lib/providerModels.ts b/desktop/frontend/src/lib/providerModels.ts new file mode 100644 index 000000000..ca331f989 --- /dev/null +++ b/desktop/frontend/src/lib/providerModels.ts @@ -0,0 +1,21 @@ +export function mergedFetchedProviderModels(current: string[], fetched: string[], options: { preserveCurated?: boolean } = {}): string[] { + const saved = uniqueStrings(current); + if (options.preserveCurated && saved.length > 0) return saved; + return uniqueStrings([...saved, ...fetched]); +} + +export function providerDefaultModel(currentDefault: string, models: string[]): string { + return currentDefault && models.includes(currentDefault) ? currentDefault : models[0] ?? ""; +} + +function uniqueStrings(values: string[]): string[] { + const seen = new Set(); + const out: string[] = []; + for (const value of values) { + const model = value.trim(); + if (!model || seen.has(model)) continue; + seen.add(model); + out.push(model); + } + return out; +}