From 21781d54ecb3da1d53a608f46eea64bc67f6c225 Mon Sep 17 00:00:00 2001 From: Mark Vlcek Date: Tue, 16 Jun 2026 17:35:04 -0700 Subject: [PATCH] fix(desktop): structured Fallback Models editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Model rendered `fallback_providers` (a list of `{provider, model}` objects) through the generic `list` config field, which does `value.join(', ')` and stringified each entry to `[object Object], [object Object]`. Add a dedicated provider+model row editor (add/remove), sourced from the same `getGlobalModelOptions()` the composer picker uses, that reads and writes the `{provider, model}` chain. Half-filled rows are kept in local state so the config autosave never persists a partial entry, and an out-of-catalog model stays selectable so existing custom entries render. Co-Authored-By: Claude Opus 4.8 --- .../src/app/settings/config-settings.tsx | 8 + .../app/settings/fallback-models-field.tsx | 137 ++++++++++++++++++ apps/desktop/src/i18n/en.ts | 3 + apps/desktop/src/i18n/types.ts | 3 + apps/desktop/src/i18n/zh.ts | 3 + 5 files changed, 154 insertions(+) create mode 100644 apps/desktop/src/app/settings/fallback-models-field.tsx diff --git a/apps/desktop/src/app/settings/config-settings.tsx b/apps/desktop/src/app/settings/config-settings.tsx index 6a87590b3cb1..fa9e00eb3497 100644 --- a/apps/desktop/src/app/settings/config-settings.tsx +++ b/apps/desktop/src/app/settings/config-settings.tsx @@ -19,6 +19,7 @@ import { useOnProfileSwitch } from '../hooks/use-on-profile-switch' import { PanelEmpty } from '../overlays/panel' import { CONTROL_TEXT, EMPTY_SELECT_VALUE, FIELD_DESCRIPTIONS, FIELD_LABELS, SECTIONS } from './constants' +import { FallbackModelsField } from './fallback-models-field' import { fieldCopyForSchemaKey } from './field-copy' import { enumOptionsFor, getNested, prettyName, setNested } from './helpers' import { MemoryConnect } from './memory/connect' @@ -100,6 +101,13 @@ function ConfigField({ ) + // `fallback_providers` is a list of {provider, model} objects; the generic + // `list` branch below would stringify them to "[object Object]". Render the + // dedicated structured editor instead. + if (schemaKey === 'fallback_providers') { + return row(, true) + } + if (schema.type === 'boolean') { return row(
diff --git a/apps/desktop/src/app/settings/fallback-models-field.tsx b/apps/desktop/src/app/settings/fallback-models-field.tsx new file mode 100644 index 000000000000..b2b62011548b --- /dev/null +++ b/apps/desktop/src/app/settings/fallback-models-field.tsx @@ -0,0 +1,137 @@ +import { useQuery } from '@tanstack/react-query' +import { useState } from 'react' + +import { Button } from '@/components/ui/button' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { getGlobalModelOptions } from '@/hermes' +import { useI18n } from '@/i18n' +import { Plus, X } from '@/lib/icons' +import { cn } from '@/lib/utils' + +import { CONTROL_TEXT } from './constants' + +interface FallbackEntry { + provider: string + model: string +} + +// Normalize the raw config value (`fallback_providers`: a list of +// `{provider, model}` dicts) into editor rows. Defensive against legacy string +// entries ("provider/model") so the editor never crashes on odd data. +function normalizeEntries(value: unknown): FallbackEntry[] { + if (!Array.isArray(value)) { + return [] + } + + return value.map(item => { + if (item && typeof item === 'object') { + const record = item as Record + + return { provider: String(record.provider ?? ''), model: String(record.model ?? '') } + } + + if (typeof item === 'string') { + const slash = item.indexOf('/') + + return slash > 0 ? { provider: item.slice(0, slash), model: item.slice(slash + 1) } : { provider: '', model: item } + } + + return { provider: '', model: '' } + }) +} + +/** + * Structured editor for the top-level `fallback_providers` config list — a + * chain of `{provider, model}` pairs tried in order when the default model + * fails. Replaces the generic comma-string `list` input, which stringified the + * objects to "[object Object], [object Object]". + * + * Mirrors the Auxiliary Models picker in `model-settings.tsx`: provider + model + * selects sourced from `getGlobalModelOptions()`. Half-filled rows are kept in + * local state and only complete pairs are emitted upward, so the config + * autosave never persists a partial `{provider, model: ''}`. + */ +export function FallbackModelsField({ + value, + onChange +}: { + value: unknown + onChange: (next: FallbackEntry[]) => void +}) { + const { t } = useI18n() + const m = t.settings.model + + const modelOptions = useQuery({ + queryKey: ['model-options', 'global'], + queryFn: () => getGlobalModelOptions() + }) + + const providers = (modelOptions.data?.providers ?? []).filter(provider => provider.slug) + + const [rows, setRows] = useState(() => normalizeEntries(value)) + + const commit = (next: FallbackEntry[]) => { + setRows(next) + onChange(next.filter(entry => entry.provider && entry.model)) + } + + const updateRow = (index: number, patch: Partial) => + commit(rows.map((entry, i) => (i === index ? { ...entry, ...patch } : entry))) + + return ( +
+ {rows.length === 0 &&

{m.fallbackEmpty}

} + {rows.map((entry, index) => { + const providerRow = providers.find(provider => provider.slug === entry.provider) + const catalog = providerRow?.models ?? [] + // Keep an out-of-catalog model selectable so an existing custom + // provider/model renders instead of showing a blank box. + const modelItems = entry.model && !catalog.includes(entry.model) ? [entry.model, ...catalog] : catalog + + return ( +
+ {index + 1} + + + +
+ ) + })} +
+ +
+
+ ) +} diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 552ce5a465f7..9e82820e83bc 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -707,6 +707,9 @@ export const en: Translations = { change: 'Change', autoUseMain: 'auto · use main model', providerDefault: '(provider default)', + fallbackAdd: 'Add fallback', + fallbackEmpty: 'No fallback models — the default model is used unless it fails.', + notInCatalog: "isn't in this provider's model list — calls may fall back to a backup.", tasks: { vision: { label: 'Vision', hint: 'Image analysis' }, web_extract: { label: 'Web extract', hint: 'Page summarization' }, diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 1c84bcb7ab52..03f7b8949b51 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -611,6 +611,9 @@ export interface Translations { change: string autoUseMain: string providerDefault: string + fallbackAdd: string + fallbackEmpty: string + notInCatalog: string tasks: Record } providers: { diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 90d2732b94c3..b08a0927ed38 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -895,6 +895,9 @@ export const zh: Translations = { change: '更改', autoUseMain: '自动 · 使用主模型', providerDefault: '(提供方默认)', + fallbackAdd: '添加备用模型', + fallbackEmpty: '未配置备用模型 — 默认模型失败时才会使用备用模型。', + notInCatalog: '不在该提供方的模型列表中 — 调用可能回退到备用模型。', tasks: { vision: { label: '视觉', hint: '图片分析' }, web_extract: { label: '网页提取', hint: '页面总结' },