mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
fix(desktop): structured Fallback Models editor
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 <noreply@anthropic.com>
This commit is contained in:
parent
68107ae9d0
commit
21781d54ec
5 changed files with 154 additions and 0 deletions
|
|
@ -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({
|
|||
<ListRow action={action} description={descriptionNode} title={label} wide={wide} />
|
||||
)
|
||||
|
||||
// `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(<FallbackModelsField onChange={onChange} value={value} />, true)
|
||||
}
|
||||
|
||||
if (schema.type === 'boolean') {
|
||||
return row(
|
||||
<div className="flex items-center justify-end">
|
||||
|
|
|
|||
137
apps/desktop/src/app/settings/fallback-models-field.tsx
Normal file
137
apps/desktop/src/app/settings/fallback-models-field.tsx
Normal file
|
|
@ -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<string, unknown>
|
||||
|
||||
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<FallbackEntry[]>(() => normalizeEntries(value))
|
||||
|
||||
const commit = (next: FallbackEntry[]) => {
|
||||
setRows(next)
|
||||
onChange(next.filter(entry => entry.provider && entry.model))
|
||||
}
|
||||
|
||||
const updateRow = (index: number, patch: Partial<FallbackEntry>) =>
|
||||
commit(rows.map((entry, i) => (i === index ? { ...entry, ...patch } : entry)))
|
||||
|
||||
return (
|
||||
<div className="grid w-full gap-1.5">
|
||||
{rows.length === 0 && <p className="text-xs text-muted-foreground">{m.fallbackEmpty}</p>}
|
||||
{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 (
|
||||
<div className="flex flex-wrap items-center gap-2" key={index}>
|
||||
<span className="w-4 shrink-0 text-center font-mono text-[0.7rem] text-muted-foreground">{index + 1}</span>
|
||||
<Select onValueChange={provider => updateRow(index, { provider, model: '' })} value={entry.provider}>
|
||||
<SelectTrigger className={cn('min-w-36', CONTROL_TEXT)}>
|
||||
<SelectValue placeholder={m.provider} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providers.map(provider => (
|
||||
<SelectItem key={provider.slug} value={provider.slug}>
|
||||
{provider.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select onValueChange={model => updateRow(index, { model })} value={entry.model}>
|
||||
<SelectTrigger className={cn('min-w-52 flex-1', CONTROL_TEXT)}>
|
||||
<SelectValue placeholder={m.model} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{modelItems.map(model => (
|
||||
<SelectItem key={model} value={model}>
|
||||
{model}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
aria-label={t.common.remove}
|
||||
onClick={() => commit(rows.filter((_, i) => i !== index))}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div>
|
||||
<Button onClick={() => commit([...rows, { provider: '', model: '' }])} size="sm" variant="textStrong">
|
||||
<Plus className="size-3.5" />
|
||||
{m.fallbackAdd}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -611,6 +611,9 @@ export interface Translations {
|
|||
change: string
|
||||
autoUseMain: string
|
||||
providerDefault: string
|
||||
fallbackAdd: string
|
||||
fallbackEmpty: string
|
||||
notInCatalog: string
|
||||
tasks: Record<string, AuxTaskCopy>
|
||||
}
|
||||
providers: {
|
||||
|
|
|
|||
|
|
@ -895,6 +895,9 @@ export const zh: Translations = {
|
|||
change: '更改',
|
||||
autoUseMain: '自动 · 使用主模型',
|
||||
providerDefault: '(提供方默认)',
|
||||
fallbackAdd: '添加备用模型',
|
||||
fallbackEmpty: '未配置备用模型 — 默认模型失败时才会使用备用模型。',
|
||||
notInCatalog: '不在该提供方的模型列表中 — 调用可能回退到备用模型。',
|
||||
tasks: {
|
||||
vision: { label: '视觉', hint: '图片分析' },
|
||||
web_extract: { label: '网页提取', hint: '页面总结' },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue