mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-17 09:41:58 +00:00
fix(desktop): honor pre-session model pick + restore global reasoning/speed defaults (#47447)
* fix(desktop): keep the pre-session model pick selected in the picker The composer picker derived its "current" row from `model.options ?? store`, so model.options always won. Pre-session that query returns the PROFILE DEFAULT, not the sticky composer pick — so selecting a model before a session exists left the checkmark (and the picker's "current" line) on the default, making the pick look ignored even though the pill updated. Add `currentPickerSelection()`: with a live session the gateway's model.options is authoritative; pre-session the sticky `$currentModel`/`$currentProvider` wins, falling back to options. Wire it into ModelMenuPanel and ModelPickerDialog. * feat(desktop): global reasoning/speed defaults in Settings → Model The composer picker is now sticky-UI/per-session only and never writes the profile default (#46959), but Settings → Model had no reasoning/speed control and `agent.reasoning_effort` wasn't in the curated config surface at all (`service_tier` was buried in Advanced) — so there was nowhere to set the profile default that crons/subagents/messaging resolve from. Add capability-gated Reasoning (effort) + Fast controls beside the main model, gated by the applied model's reported capabilities (reasoning defaults on, fast off when unreported — same as the composer). They read/write `agent.reasoning_effort` and `agent.service_tier` by round-tripping the config record, matching the gateway's value semantics (service_tier "fast"/"priority"/"on" ⇒ fast). * refactor(desktop): don't open the reasoning select from its row label A <label> wrapping the Select forwarded text clicks to the trigger, opening the dropdown unexpectedly. Plain row for reasoning; Fast stays a <label> so clicking its text toggles the switch (expected for a checkbox-like control).
This commit is contained in:
parent
d1ecebcbfd
commit
b7f0c9cd52
9 changed files with 197 additions and 10 deletions
|
|
@ -16,6 +16,8 @@ const getAuxiliaryModels = vi.fn()
|
|||
const setModelAssignment = vi.fn()
|
||||
const getRecommendedDefaultModel = vi.fn()
|
||||
const setEnvVar = vi.fn()
|
||||
const getHermesConfigRecord = vi.fn()
|
||||
const saveHermesConfig = vi.fn()
|
||||
const startManualProviderOAuth = vi.fn()
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
|
|
@ -24,7 +26,9 @@ vi.mock('@/hermes', () => ({
|
|||
getAuxiliaryModels: () => getAuxiliaryModels(),
|
||||
setModelAssignment: (body: unknown) => setModelAssignment(body),
|
||||
getRecommendedDefaultModel: (slug: string) => getRecommendedDefaultModel(slug),
|
||||
setEnvVar: (key: string, value: string) => setEnvVar(key, value)
|
||||
setEnvVar: (key: string, value: string) => setEnvVar(key, value),
|
||||
getHermesConfigRecord: () => getHermesConfigRecord(),
|
||||
saveHermesConfig: (config: unknown) => saveHermesConfig(config)
|
||||
}))
|
||||
|
||||
vi.mock('@/store/onboarding', () => ({
|
||||
|
|
@ -35,7 +39,13 @@ beforeEach(() => {
|
|||
getGlobalModelInfo.mockResolvedValue({ provider: 'nous', model: 'hermes-4' })
|
||||
getGlobalModelOptions.mockResolvedValue({
|
||||
providers: [
|
||||
{ name: 'Nous', slug: 'nous', models: ['hermes-4', 'hermes-4-mini'], authenticated: true },
|
||||
{
|
||||
name: 'Nous',
|
||||
slug: 'nous',
|
||||
models: ['hermes-4', 'hermes-4-mini'],
|
||||
authenticated: true,
|
||||
capabilities: { 'hermes-4': { reasoning: true, fast: true } }
|
||||
},
|
||||
// An unconfigured api_key provider — surfaced by the full-universe payload.
|
||||
{ name: 'DeepSeek', slug: 'deepseek', models: [], authenticated: false, auth_type: 'api_key', key_env: 'DEEPSEEK_API_KEY' }
|
||||
]
|
||||
|
|
@ -47,6 +57,8 @@ beforeEach(() => {
|
|||
setModelAssignment.mockResolvedValue({ provider: 'nous', model: 'hermes-4', gateway_tools: [] })
|
||||
getRecommendedDefaultModel.mockResolvedValue({ provider: 'deepseek', model: 'deepseek-chat', free_tier: null })
|
||||
setEnvVar.mockResolvedValue({ ok: true })
|
||||
getHermesConfigRecord.mockResolvedValue({ agent: { reasoning_effort: 'medium', service_tier: 'normal' } })
|
||||
saveHermesConfig.mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -100,6 +112,31 @@ describe('ModelSettings', () => {
|
|||
await waitFor(() => expect(setEnvVar).toHaveBeenCalledWith('DEEPSEEK_API_KEY', 'sk-test-123'))
|
||||
})
|
||||
|
||||
it('writes the profile default speed (service_tier) when the fast switch is toggled', async () => {
|
||||
await renderModelSettings()
|
||||
await waitFor(() => expect(getHermesConfigRecord).toHaveBeenCalled())
|
||||
|
||||
const fastSwitch = await screen.findByRole('switch')
|
||||
fireEvent.click(fastSwitch)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(saveHermesConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agent: expect.objectContaining({ service_tier: 'fast' }) })
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('hides the reasoning/speed defaults when the main model reports no capabilities', async () => {
|
||||
getGlobalModelOptions.mockResolvedValueOnce({
|
||||
providers: [{ name: 'Nous', slug: 'nous', models: ['hermes-4'], authenticated: true, capabilities: { 'hermes-4': { reasoning: false, fast: false } } }]
|
||||
})
|
||||
|
||||
await renderModelSettings()
|
||||
await waitFor(() => expect(getHermesConfigRecord).toHaveBeenCalled())
|
||||
|
||||
expect(screen.queryByRole('switch')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders the auxiliary task rows', async () => {
|
||||
await renderModelSettings()
|
||||
|
||||
|
|
|
|||
|
|
@ -3,11 +3,14 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
getAuxiliaryModels,
|
||||
getGlobalModelInfo,
|
||||
getGlobalModelOptions,
|
||||
getHermesConfigRecord,
|
||||
getRecommendedDefaultModel,
|
||||
saveHermesConfig,
|
||||
setEnvVar,
|
||||
setModelAssignment
|
||||
} from '@/hermes'
|
||||
|
|
@ -15,11 +18,26 @@ import type { AuxiliaryModelsResponse, ModelOptionProvider, StaleAuxAssignment }
|
|||
import { useI18n } from '@/i18n'
|
||||
import { AlertTriangle, Cpu, Loader2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { startManualLocalEndpoint, startManualProviderOAuth } from '@/store/onboarding'
|
||||
import type { HermesConfigRecord } from '@/types/hermes'
|
||||
|
||||
import { CONTROL_TEXT } from './constants'
|
||||
import { getNested, setNested } from './helpers'
|
||||
import { ListRow, LoadingState, Pill, SectionHeading } from './primitives'
|
||||
|
||||
// Hermes' reasoning levels (VALID_REASONING_EFFORTS); `none` = thinking off.
|
||||
// Empty config = Hermes default (medium), shown as Medium.
|
||||
const EFFORT_VALUES = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh'] as const
|
||||
|
||||
// agent.service_tier stores "fast"/"priority"/"on" for fast; anything else is
|
||||
// normal (mirrors tui_gateway _load_service_tier).
|
||||
const isFastTier = (tier: unknown): boolean =>
|
||||
['fast', 'priority', 'on'].includes(String(tier ?? '').trim().toLowerCase())
|
||||
|
||||
// Reuse the composer's effort labels (`xhigh` shows as "Max", else 1:1).
|
||||
const effortLabelKey = (v: string) => (v === 'xhigh' ? 'max' : v) as 'high' | 'low' | 'max' | 'medium' | 'minimal'
|
||||
|
||||
// A provider row is "ready" to pick a model from when it reports models. The
|
||||
// backend now surfaces the full `hermes model` universe (every canonical
|
||||
// provider), so unconfigured providers come back with `authenticated:false`
|
||||
|
|
@ -97,6 +115,9 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
|||
const [selectedProvider, setSelectedProvider] = useState('')
|
||||
const [selectedModel, setSelectedModel] = useState('')
|
||||
const [auxiliary, setAuxiliary] = useState<AuxiliaryModelsResponse | null>(null)
|
||||
// Full profile config, kept so the reasoning/speed defaults round-trip
|
||||
// (read agent.* → write back the whole record) like the generic config page.
|
||||
const [config, setConfig] = useState<HermesConfigRecord | null>(null)
|
||||
const [applying, setApplying] = useState(false)
|
||||
const [editingAuxTask, setEditingAuxTask] = useState<null | string>(null)
|
||||
const [auxDraft, setAuxDraft] = useState<{ model: string; provider: string }>({ model: '', provider: '' })
|
||||
|
|
@ -113,10 +134,11 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
|||
setError('')
|
||||
|
||||
try {
|
||||
const [modelInfo, modelOptions, auxiliaryModels] = await Promise.all([
|
||||
const [modelInfo, modelOptions, auxiliaryModels, cfg] = await Promise.all([
|
||||
getGlobalModelInfo(),
|
||||
getGlobalModelOptions(),
|
||||
getAuxiliaryModels()
|
||||
getAuxiliaryModels(),
|
||||
getHermesConfigRecord()
|
||||
])
|
||||
|
||||
setMainModel({ model: modelInfo.model, provider: modelInfo.provider })
|
||||
|
|
@ -124,6 +146,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
|||
setSelectedProvider(prev => prev || modelInfo.provider)
|
||||
setSelectedModel(prev => prev || modelInfo.model)
|
||||
setAuxiliary(auxiliaryModels)
|
||||
setConfig(cfg)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
|
|
@ -181,6 +204,42 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
|||
.map(entry => ({ task: entry.task, provider: entry.provider, model: entry.model }))
|
||||
}, [auxiliary, mainModel])
|
||||
|
||||
// Capabilities of the APPLIED main model — gates the profile-default
|
||||
// reasoning/speed controls the same way the composer picker gates per-model
|
||||
// edits (reasoning defaults on, fast defaults off when unreported).
|
||||
const mainCaps = useMemo(() => {
|
||||
const row = providers.find(provider => provider.slug === mainModel?.provider)
|
||||
|
||||
return mainModel ? row?.capabilities?.[mainModel.model] : undefined
|
||||
}, [providers, mainModel])
|
||||
|
||||
const reasoningSupported = mainCaps?.reasoning ?? true
|
||||
const fastSupported = mainCaps?.fast ?? false
|
||||
const effortValue = String(getNested(config ?? {}, 'agent.reasoning_effort') ?? '').trim().toLowerCase() || 'medium'
|
||||
const fastOn = isFastTier(getNested(config ?? {}, 'agent.service_tier'))
|
||||
|
||||
// Persist a single agent.* default by round-tripping the whole config record
|
||||
// (PUT /api/config replaces it) — optimistic, with rollback on failure.
|
||||
const writeAgentDefault = useCallback(
|
||||
async (key: string, value: string) => {
|
||||
if (!config) {
|
||||
return
|
||||
}
|
||||
|
||||
const prev = config
|
||||
const next = setNested(config, key, value)
|
||||
setConfig(next)
|
||||
|
||||
try {
|
||||
await saveHermesConfig(next)
|
||||
} catch (err) {
|
||||
setConfig(prev)
|
||||
notifyError(err, m.defaultsFailed)
|
||||
}
|
||||
},
|
||||
[config, m.defaultsFailed]
|
||||
)
|
||||
|
||||
// Paste an API key for the selected `api_key` provider, persist it, then
|
||||
// refresh so the now-authenticated provider's models populate. Auto-selects
|
||||
// the recommended default model so the user can Apply in one more click.
|
||||
|
|
@ -433,6 +492,38 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
|||
: `${selectedProviderRow?.name} signs in through your browser — Hermes runs the flow for you.`}
|
||||
</p>
|
||||
)}
|
||||
{config && mainModel && (reasoningSupported || fastSupported) && (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-x-6 gap-y-3">
|
||||
<span className="text-xs text-muted-foreground">{m.defaultsLabel}</span>
|
||||
{reasoningSupported && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
{m.reasoning}
|
||||
<Select onValueChange={value => void writeAgentDefault('agent.reasoning_effort', value)} value={effortValue}>
|
||||
<SelectTrigger className={cn('min-w-28', CONTROL_TEXT)}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{EFFORT_VALUES.map(value => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{value === 'none' ? m.reasoningOff : t.shell.modelOptions[effortLabelKey(value)]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
{fastSupported && (
|
||||
<label className="flex items-center gap-2 text-xs">
|
||||
{t.shell.modelOptions.fast}
|
||||
<Switch
|
||||
checked={fastOn}
|
||||
onCheckedChange={checked => void writeAgentDefault('agent.service_tier', checked ? 'fast' : 'normal')}
|
||||
size="xs"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="mt-2 text-xs text-destructive">{error}</div>}
|
||||
{switchStaleAux.length > 0 && (
|
||||
<div className="mt-2">
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import { Skeleton } from '@/components/ui/skeleton'
|
|||
import type { HermesGateway } from '@/hermes'
|
||||
import { getGlobalModelOptions } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { displayModelName, modelDisplayParts, reasoningEffortLabel } from '@/lib/model-status-label'
|
||||
import { currentPickerSelection, displayModelName, modelDisplayParts, reasoningEffortLabel } from '@/lib/model-status-label'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $modelPresets, applyModelPreset, modelPresetKey } from '@/store/model-presets'
|
||||
import {
|
||||
|
|
@ -84,8 +84,12 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
|
|||
}
|
||||
})
|
||||
|
||||
const optionsModel = String(modelOptions.data?.model ?? currentModel ?? '')
|
||||
const optionsProvider = String(modelOptions.data?.provider ?? currentProvider ?? '')
|
||||
const { model: optionsModel, provider: optionsProvider } = currentPickerSelection(
|
||||
!!activeSessionId,
|
||||
{ model: currentModel, provider: currentProvider },
|
||||
modelOptions.data
|
||||
)
|
||||
|
||||
const loading = modelOptions.isPending && !modelOptions.data
|
||||
|
||||
const error = modelOptions.error
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query'
|
|||
import { useState } from 'react'
|
||||
|
||||
import { useI18n } from '@/i18n'
|
||||
import { currentPickerSelection } from '@/lib/model-status-label'
|
||||
import type { ModelOptionProvider, ModelOptionsResponse, ModelPricing } from '@/types/hermes'
|
||||
|
||||
import type { HermesGateway } from '../hermes'
|
||||
|
|
@ -66,8 +67,13 @@ export function ModelPickerDialog({
|
|||
})
|
||||
|
||||
const providers = modelOptions.data?.providers ?? []
|
||||
const optionsModel = String(modelOptions.data?.model ?? currentModel ?? '')
|
||||
const optionsProvider = String(modelOptions.data?.provider ?? currentProvider ?? '')
|
||||
|
||||
const { model: optionsModel, provider: optionsProvider } = currentPickerSelection(
|
||||
!!sessionId,
|
||||
{ model: currentModel, provider: currentProvider },
|
||||
modelOptions.data
|
||||
)
|
||||
|
||||
const loading = modelOptions.isPending && !modelOptions.data
|
||||
|
||||
const error = modelOptions.error
|
||||
|
|
|
|||
|
|
@ -538,6 +538,10 @@ export const en: Translations = {
|
|||
provider: 'Provider',
|
||||
model: 'Model',
|
||||
applying: 'Applying...',
|
||||
defaultsLabel: 'Defaults',
|
||||
reasoning: 'Reasoning',
|
||||
reasoningOff: 'Off',
|
||||
defaultsFailed: 'Failed to save model defaults',
|
||||
auxiliaryTitle: 'Auxiliary models',
|
||||
resetAllToMain: 'Reset all to main',
|
||||
auxiliaryDesc: 'Helper tasks run on the main model by default. Assign a dedicated model to any task to override.',
|
||||
|
|
|
|||
|
|
@ -430,6 +430,10 @@ export interface Translations {
|
|||
provider: string
|
||||
model: string
|
||||
applying: string
|
||||
defaultsLabel: string
|
||||
reasoning: string
|
||||
reasoningOff: string
|
||||
defaultsFailed: string
|
||||
auxiliaryTitle: string
|
||||
resetAllToMain: string
|
||||
auxiliaryDesc: string
|
||||
|
|
|
|||
|
|
@ -733,6 +733,10 @@ export const zh: Translations = {
|
|||
provider: '提供方',
|
||||
model: '模型',
|
||||
applying: '应用中...',
|
||||
defaultsLabel: '默认值',
|
||||
reasoning: '推理',
|
||||
reasoningOff: '关闭',
|
||||
defaultsFailed: '保存模型默认值失败',
|
||||
auxiliaryTitle: '辅助模型',
|
||||
resetAllToMain: '全部重置为主模型',
|
||||
auxiliaryDesc: '辅助任务默认使用主模型。你可以为任意任务指定专用模型。',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { displayModelName, formatModelStatusLabel, reasoningEffortLabel } from './model-status-label'
|
||||
import { currentPickerSelection, displayModelName, formatModelStatusLabel, reasoningEffortLabel } from './model-status-label'
|
||||
|
||||
describe('model-status-label', () => {
|
||||
it('formats display names consistently', () => {
|
||||
|
|
@ -35,4 +35,25 @@ describe('model-status-label', () => {
|
|||
it('returns just the placeholder name when there is no model', () => {
|
||||
expect(formatModelStatusLabel('')).toBe('No model')
|
||||
})
|
||||
|
||||
describe('currentPickerSelection', () => {
|
||||
const store = { model: 'opus', provider: 'anthropic' }
|
||||
const options = { model: 'hermes-4', provider: 'nous' }
|
||||
|
||||
it('prefers the sticky composer pick over the profile default pre-session', () => {
|
||||
expect(currentPickerSelection(false, store, options)).toEqual(store)
|
||||
})
|
||||
|
||||
it('lets the live session model.options win when a session exists', () => {
|
||||
expect(currentPickerSelection(true, store, options)).toEqual(options)
|
||||
})
|
||||
|
||||
it('falls back to options when the store is empty', () => {
|
||||
expect(currentPickerSelection(false, { model: '', provider: '' }, options)).toEqual(options)
|
||||
})
|
||||
|
||||
it('falls back to the store while options are still loading', () => {
|
||||
expect(currentPickerSelection(true, store, undefined)).toEqual(store)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -17,6 +17,22 @@ export function reasoningEffortLabel(effort: string): string {
|
|||
return REASONING_LABELS[key] ?? effort
|
||||
}
|
||||
|
||||
/** Which model/provider a picker should mark "current". With a live session the
|
||||
* gateway's `model.options` is authoritative; pre-session there is no server
|
||||
* "current", so the sticky composer pick wins over the profile default the
|
||||
* global options query returns — else the checkmark snaps back to the default
|
||||
* and the pick looks ignored. */
|
||||
export function currentPickerSelection(
|
||||
hasSession: boolean,
|
||||
store: { model: string; provider: string },
|
||||
options?: { model?: string; provider?: string }
|
||||
): { model: string; provider: string } {
|
||||
return {
|
||||
model: String((hasSession && options?.model) || store.model || options?.model || ''),
|
||||
provider: String((hasSession && options?.provider) || store.provider || options?.provider || '')
|
||||
}
|
||||
}
|
||||
|
||||
/** Strip provider prefix and normalize for display. */
|
||||
export function modelBaseId(model: string): string {
|
||||
const trimmed = model.trim()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue