diff --git a/apps/desktop/src/app/command-center/index.tsx b/apps/desktop/src/app/command-center/index.tsx index c61ed8e54..eb4156894 100644 --- a/apps/desktop/src/app/command-center/index.tsx +++ b/apps/desktop/src/app/command-center/index.tsx @@ -3,37 +3,29 @@ import { IconBookmark, IconBookmarkFilled, IconDownload, - IconLoader2, IconRefresh, - IconSparkles, IconTrash } from '@tabler/icons-react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { getActionStatus, - getAuxiliaryModels, - getGlobalModelInfo, - getGlobalModelOptions, getLogs, getStatus, getUsageAnalytics, restartGateway, searchSessions, - setModelAssignment, updateHermes } from '@/hermes' import type { ActionStatusResponse, AnalyticsResponse, - AuxiliaryModelsResponse, - ModelOptionProvider, SessionInfo, SessionSearchResult as SessionSearchApiResult, StatusResponse } from '@/hermes' import { sessionTitle } from '@/lib/chat-runtime' -import { Activity, AlertCircle, BarChart3, Cpu, Pin } from '@/lib/icons' +import { Activity, AlertCircle, BarChart3, Pin } from '@/lib/icons' import { exportSession } from '@/lib/session-export' import { cn } from '@/lib/utils' import { upsertDesktopActionTask } from '@/store/activity' @@ -47,30 +39,9 @@ import { OverlayMain, OverlayNavItem, OverlaySidebar, OverlaySplitLayout } from import { OverlayView } from '../overlays/overlay-view' import { ARTIFACTS_ROUTE, MESSAGING_ROUTE, NEW_CHAT_ROUTE, SETTINGS_ROUTE, SKILLS_ROUTE } from '../routes' -export type CommandCenterSection = 'models' | 'sessions' | 'system' | 'usage' +export type CommandCenterSection = 'sessions' | 'system' | 'usage' -const SECTIONS = ['sessions', 'system', 'models', 'usage'] as const satisfies readonly CommandCenterSection[] - -// Mirrors `_AUX_TASK_SLOTS` in hermes_cli/web_server.py. Friendly labels and -// hints make the assignments panel readable; raw task keys (vision, mcp, …) -// are opaque to most users. -interface AuxTaskMeta { - hint: string - key: string - label: string -} - -const AUX_TASKS: readonly AuxTaskMeta[] = [ - { key: 'vision', label: 'Vision', hint: 'Image analysis' }, - { key: 'web_extract', label: 'Web extract', hint: 'Page summarization' }, - { key: 'compression', label: 'Compression', hint: 'Context compaction' }, - { key: 'session_search', label: 'Session search', hint: 'Recall queries' }, - { key: 'skills_hub', label: 'Skills hub', hint: 'Skill search' }, - { key: 'approval', label: 'Approval', hint: 'Smart auto-approve' }, - { key: 'mcp', label: 'MCP', hint: 'MCP tool routing' }, - { key: 'title_generation', label: 'Title gen', hint: 'Session titles' }, - { key: 'curator', label: 'Curator', hint: 'Skill-usage review' } -] +const SECTIONS = ['sessions', 'system', 'usage'] as const satisfies readonly CommandCenterSection[] const USAGE_PERIODS = [7, 30, 90] as const type UsagePeriod = (typeof USAGE_PERIODS)[number] @@ -79,7 +50,6 @@ interface CommandCenterViewProps { initialSection?: CommandCenterSection onClose: () => void onDeleteSession: (sessionId: string) => Promise - onMainModelChanged?: (provider: string, model: string) => void onNavigateRoute: (path: string) => void onOpenSession: (sessionId: string) => void } @@ -87,14 +57,12 @@ interface CommandCenterViewProps { const SECTION_LABELS: Record = { sessions: 'Sessions', system: 'System', - models: 'Models', usage: 'Usage' } const SECTION_DESCRIPTIONS: Record = { sessions: 'Search and manage sessions', system: 'Status, logs, and system actions', - models: 'Global and auxiliary model controls', usage: 'Token, cost, and skill activity over time' } @@ -128,7 +96,6 @@ const NAVIGATION_SEARCH_ENTRIES: readonly NavigationSearchEntry[] = [ const SECTION_SEARCH_ENTRIES: readonly SectionSearchEntry[] = [ { id: 'section-sessions', section: 'sessions', title: 'Sessions panel', detail: 'Search, pin, and manage sessions' }, { id: 'section-system', section: 'system', title: 'System panel', detail: 'Gateway status, logs, restart/update' }, - { id: 'section-models', section: 'models', title: 'Models panel', detail: 'Main and auxiliary model assignments' }, { id: 'section-usage', section: 'usage', title: 'Usage panel', detail: 'Token, cost, and skill activity' } ] @@ -216,7 +183,6 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, - onMainModelChanged, onNavigateRoute, onOpenSession }: CommandCenterViewProps) { @@ -233,16 +199,6 @@ export function CommandCenterView({ const [systemLoading, setSystemLoading] = useState(false) const [systemError, setSystemError] = useState('') const [systemAction, setSystemAction] = useState(null) - const [modelsLoading, setModelsLoading] = useState(false) - const [modelsError, setModelsError] = useState('') - const [mainModel, setMainModel] = useState<{ model: string; provider: string } | null>(null) - const [providers, setProviders] = useState([]) - const [selectedProvider, setSelectedProvider] = useState('') - const [selectedModel, setSelectedModel] = useState('') - const [auxiliary, setAuxiliary] = useState(null) - const [applyingModel, setApplyingModel] = useState(false) - const [editingAuxTask, setEditingAuxTask] = useState(null) - const [auxDraft, setAuxDraft] = useState<{ model: string; provider: string }>({ model: '', provider: '' }) const [usagePeriod, setUsagePeriod] = useState(30) const [usage, setUsage] = useState(null) const [usageLoading, setUsageLoading] = useState(false) @@ -265,11 +221,6 @@ export function CommandCenterView({ [sessions] ) - const selectedProviderModels = useMemo( - () => providers.find(provider => provider.slug === selectedProvider)?.models ?? [], - [providers, selectedProvider] - ) - const searchProviders = useMemo( () => [ { @@ -342,29 +293,6 @@ export function CommandCenterView({ } }, []) - const refreshModels = useCallback(async () => { - setModelsLoading(true) - setModelsError('') - - try { - const [modelInfo, modelOptions, auxiliaryModels] = await Promise.all([ - getGlobalModelInfo(), - getGlobalModelOptions(), - getAuxiliaryModels() - ]) - - setMainModel({ model: modelInfo.model, provider: modelInfo.provider }) - setProviders(modelOptions.providers || []) - setSelectedProvider(prev => prev || modelInfo.provider) - setSelectedModel(prev => prev || modelInfo.model) - setAuxiliary(auxiliaryModels) - } catch (error) { - setModelsError(error instanceof Error ? error.message : String(error)) - } finally { - setModelsLoading(false) - } - }, []) - const refreshUsage = useCallback(async (days: UsagePeriod) => { const requestId = usageRequestRef.current + 1 usageRequestRef.current = requestId @@ -430,28 +358,12 @@ export function CommandCenterView({ } }, [refreshSystem, section, status, systemLoading]) - useEffect(() => { - if (section === 'models' && !mainModel && !modelsLoading) { - void refreshModels() - } - }, [mainModel, modelsLoading, refreshModels, section]) - useEffect(() => { if (section === 'usage') { void refreshUsage(usagePeriod) } }, [refreshUsage, section, usagePeriod]) - useEffect(() => { - if (!selectedProviderModels.length) { - return - } - - if (!selectedProviderModels.includes(selectedModel)) { - setSelectedModel(selectedProviderModels[0]) - } - }, [selectedModel, selectedProviderModels]) - const showGlobalSearchResults = debouncedQuery.length > 0 const hasGlobalSearchResults = searchGroups.length > 0 const sessionListHasResults = filteredSessions.length > 0 @@ -497,128 +409,6 @@ export function CommandCenterView({ [refreshSystem] ) - const applyMainModel = useCallback(async () => { - if (!selectedProvider || !selectedModel) { - return - } - - setApplyingModel(true) - setModelsError('') - - try { - const result = await setModelAssignment({ - model: selectedModel, - provider: selectedProvider, - scope: 'main' - }) - - const provider = result.provider || selectedProvider - const model = result.model || selectedModel - setMainModel({ provider, model }) - onMainModelChanged?.(provider, model) - await refreshModels() - } catch (error) { - setModelsError(error instanceof Error ? error.message : String(error)) - } finally { - setApplyingModel(false) - } - }, [onMainModelChanged, refreshModels, selectedModel, selectedProvider]) - - const setAuxiliaryToMain = useCallback( - async (task: string) => { - if (!mainModel) { - return - } - - setApplyingModel(true) - setModelsError('') - - try { - await setModelAssignment({ - model: mainModel.model, - provider: mainModel.provider, - scope: 'auxiliary', - task - }) - await refreshModels() - } catch (error) { - setModelsError(error instanceof Error ? error.message : String(error)) - } finally { - setApplyingModel(false) - } - }, - [mainModel, refreshModels] - ) - - const applyAuxiliaryDraft = useCallback( - async (task: string) => { - if (!auxDraft.provider || !auxDraft.model) { - return - } - - setApplyingModel(true) - setModelsError('') - - try { - await setModelAssignment({ - model: auxDraft.model, - provider: auxDraft.provider, - scope: 'auxiliary', - task - }) - setEditingAuxTask(null) - await refreshModels() - } catch (error) { - setModelsError(error instanceof Error ? error.message : String(error)) - } finally { - setApplyingModel(false) - } - }, - [auxDraft, refreshModels] - ) - - const beginAuxiliaryEdit = useCallback( - (task: string) => { - const current = auxiliary?.tasks.find(entry => entry.task === task) - - const initialProvider = - current?.provider && current.provider !== 'auto' ? current.provider : (mainModel?.provider ?? '') - - const initialModel = current?.model || mainModel?.model || '' - setAuxDraft({ provider: initialProvider, model: initialModel }) - setEditingAuxTask(task) - }, - [auxiliary, mainModel] - ) - - const auxDraftProviderModels = useMemo( - () => providers.find(provider => provider.slug === auxDraft.provider)?.models ?? [], - [auxDraft.provider, providers] - ) - - const resetAuxiliaryModels = useCallback(async () => { - if (!mainModel) { - return - } - - setApplyingModel(true) - setModelsError('') - - try { - await setModelAssignment({ - model: mainModel.model, - provider: mainModel.provider, - scope: 'auxiliary', - task: '__reset__' - }) - await refreshModels() - } catch (error) { - setModelsError(error instanceof Error ? error.message : String(error)) - } finally { - setApplyingModel(false) - } - }, [mainModel, refreshModels]) - const handleSearchSelect = useCallback( (result: CommandCenterSearchResult) => { if (result.kind === 'route') { @@ -658,7 +448,7 @@ export function CommandCenterView({ {SECTIONS.map(value => ( setSection(value)} @@ -684,12 +474,6 @@ export function CommandCenterView({ {usageLoading ? 'Refreshing...' : 'Refresh'} )} - {section === 'models' && ( - void refreshModels()}> - - {modelsLoading ? 'Refreshing...' : 'Refresh'} - - )} {showGlobalSearchResults ? ( @@ -844,7 +628,7 @@ export function CommandCenterView({ period={usagePeriod} usage={usage} /> - ) : section === 'system' ? ( + ) : (
{status ? ( @@ -902,154 +686,6 @@ export function CommandCenterView({
- ) : ( -
- - {mainModel ? ( - <> -
Main model
-
- {mainModel.provider} / {mainModel.model} -
- - ) : ( -
Loading model state...
- )} -
- - -
Set global main model
-
- - - void applyMainModel()} - > - {applyingModel ? ( - - ) : ( - - )} - {applyingModel ? 'Applying...' : 'Apply'} - -
- {modelsError &&
{modelsError}
} -
- - -
- Auxiliary assignments - void resetAuxiliaryModels()} - tone="subtle" - > - Reset all - -
-
- {AUX_TASKS.map(meta => { - const current = auxiliary?.tasks.find(entry => entry.task === meta.key) - const isAuto = !current || !current.provider || current.provider === 'auto' - const isEditing = editingAuxTask === meta.key - - return ( - -
-
-
- {meta.label} - {meta.hint} -
-
- {isAuto - ? 'auto · use main model' - : `${current.provider} · ${current.model || '(provider default)'}`} -
-
- {!isEditing && ( - <> - void setAuxiliaryToMain(meta.key)} - tone="subtle" - > - Set to main - - beginAuxiliaryEdit(meta.key)} - > - Change - - - )} -
- - {isEditing && ( -
- - - void applyAuxiliaryDraft(meta.key)} - > - {applyingModel ? 'Applying...' : 'Apply'} - - setEditingAuxTask(null)} tone="subtle"> - Cancel - -
- )} -
- ) - })} -
-
-
)} diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index ba513a2cf..6ede12a19 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -531,6 +531,13 @@ export function DesktopController() { void refreshCurrentModel() void queryClient.invalidateQueries({ queryKey: ['model-options'] }) }} + onMainModelChanged={(provider, model) => { + setCurrentProvider(provider) + setCurrentModel(model) + updateModelOptionsCache(provider, model, true) + void refreshCurrentModel() + void queryClient.invalidateQueries({ queryKey: ['model-options'] }) + }} /> )} @@ -541,13 +548,6 @@ export function DesktopController() { initialSection={commandCenterInitialSection} onClose={closeOverlayToPreviousRoute} onDeleteSession={removeSession} - onMainModelChanged={(provider, model) => { - setCurrentProvider(provider) - setCurrentModel(model) - updateModelOptionsCache(provider, model, true) - void refreshCurrentModel() - void queryClient.invalidateQueries({ queryKey: ['model-options'] }) - }} onNavigateRoute={path => navigate(path)} onOpenSession={sessionId => navigate(sessionRoute(sessionId))} /> diff --git a/apps/desktop/src/app/settings/config-settings.tsx b/apps/desktop/src/app/settings/config-settings.tsx index aab075630..3969c2c09 100644 --- a/apps/desktop/src/app/settings/config-settings.tsx +++ b/apps/desktop/src/app/settings/config-settings.tsx @@ -18,6 +18,7 @@ import type { ConfigFieldSchema, HermesConfigRecord } from '@/types/hermes' import { CONTROL_TEXT, EMPTY_SELECT_VALUE, FIELD_DESCRIPTIONS, FIELD_LABELS, SECTIONS } from './constants' import { enumOptionsFor, getNested, includesQuery, prettyName, setNested } from './helpers' +import { ModelSettings } from './model-settings' import { EmptyState, ListRow, LoadingState, SettingsContent } from './primitives' import type { SearchProps } from './types' @@ -167,10 +168,12 @@ export function ConfigSettings({ query, activeSectionId, onConfigSaved, + onMainModelChanged, importInputRef }: SearchProps & { activeSectionId: string onConfigSaved?: () => void + onMainModelChanged?: (provider: string, model: string) => void importInputRef: React.RefObject }) { const [config, setConfig] = useState(null) @@ -322,6 +325,11 @@ export function ConfigSettings({ return ( + {activeSectionId === 'model' && !query.trim() && ( +
+ +
+ )} {query.trim() && (
{fields.length} result{fields.length === 1 ? '' : 's'} diff --git a/apps/desktop/src/app/settings/constants.ts b/apps/desktop/src/app/settings/constants.ts index 93c1e8374..51f44dcc7 100644 --- a/apps/desktop/src/app/settings/constants.ts +++ b/apps/desktop/src/app/settings/constants.ts @@ -141,13 +141,7 @@ export const FIELD_LABELS: Record = { 'delegation.max_iterations': 'Subagent Turn Limit', 'delegation.max_concurrent_children': 'Parallel Subagents', 'delegation.child_timeout_seconds': 'Subagent Timeout', - 'delegation.reasoning_effort': 'Subagent Reasoning Effort', - 'auxiliary.vision.provider': 'Vision Provider', - 'auxiliary.vision.model': 'Vision Model', - 'auxiliary.compression.provider': 'Compression Provider', - 'auxiliary.compression.model': 'Compression Model', - 'auxiliary.title_generation.provider': 'Title Provider', - 'auxiliary.title_generation.model': 'Title Model' + 'delegation.reasoning_effort': 'Subagent Reasoning Effort' } export const FIELD_DESCRIPTIONS: Record = { @@ -183,7 +177,7 @@ export const SECTIONS: DesktopConfigSection[] = [ id: 'model', label: 'Model', icon: Sparkles, - keys: ['model', 'model_context_length', 'fallback_providers'] + keys: ['model_context_length', 'fallback_providers'] }, { id: 'chat', @@ -287,13 +281,7 @@ export const SECTIONS: DesktopConfigSection[] = [ 'delegation.max_iterations', 'delegation.max_concurrent_children', 'delegation.child_timeout_seconds', - 'delegation.reasoning_effort', - 'auxiliary.vision.provider', - 'auxiliary.vision.model', - 'auxiliary.compression.provider', - 'auxiliary.compression.model', - 'auxiliary.title_generation.provider', - 'auxiliary.title_generation.model' + 'delegation.reasoning_effort' ] } ] diff --git a/apps/desktop/src/app/settings/index.tsx b/apps/desktop/src/app/settings/index.tsx index edad8ca62..9aa590c87 100644 --- a/apps/desktop/src/app/settings/index.tsx +++ b/apps/desktop/src/app/settings/index.tsx @@ -31,7 +31,7 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [ 'about' ] -export function SettingsView({ gateway, onClose, onConfigSaved }: SettingsPageProps) { +export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChanged }: SettingsPageProps) { const [activeView, setActiveView] = useRouteEnumParam('tab', SETTINGS_VIEWS, 'config:model' as SettingsViewId) const [queries, setQueries] = useState>({ @@ -194,6 +194,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved }: SettingsPagePr activeSectionId={activeView.slice('config:'.length)} importInputRef={importInputRef} onConfigSaved={onConfigSaved} + onMainModelChanged={onMainModelChanged} query={queries.config} /> ) : activeView === 'keys' ? ( diff --git a/apps/desktop/src/app/settings/model-settings.test.tsx b/apps/desktop/src/app/settings/model-settings.test.tsx new file mode 100644 index 000000000..4300b9e85 --- /dev/null +++ b/apps/desktop/src/app/settings/model-settings.test.tsx @@ -0,0 +1,70 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const getGlobalModelInfo = vi.fn() +const getGlobalModelOptions = vi.fn() +const getAuxiliaryModels = vi.fn() +const setModelAssignment = vi.fn() + +vi.mock('@/hermes', () => ({ + getGlobalModelInfo: () => getGlobalModelInfo(), + getGlobalModelOptions: () => getGlobalModelOptions(), + getAuxiliaryModels: () => getAuxiliaryModels(), + setModelAssignment: (body: unknown) => setModelAssignment(body) +})) + +beforeEach(() => { + getGlobalModelInfo.mockResolvedValue({ provider: 'nous', model: 'hermes-4' }) + getGlobalModelOptions.mockResolvedValue({ + providers: [{ name: 'Nous', slug: 'nous', models: ['hermes-4', 'hermes-4-mini'] }] + }) + getAuxiliaryModels.mockResolvedValue({ + main: { provider: 'nous', model: 'hermes-4' }, + tasks: [{ task: 'vision', provider: 'auto', model: '', base_url: '' }] + }) + setModelAssignment.mockResolvedValue({ provider: 'nous', model: 'hermes-4', gateway_tools: [] }) +}) + +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) + +async function renderModelSettings() { + const { ModelSettings } = await import('./model-settings') + + return render() +} + +describe('ModelSettings', () => { + it('loads and shows the current main model', async () => { + await renderModelSettings() + + await waitFor(() => expect(getGlobalModelInfo).toHaveBeenCalled()) + expect(screen.getByText('nous / hermes-4')).toBeTruthy() + }) + + it('renders the auxiliary task rows', async () => { + await renderModelSettings() + + expect(await screen.findByText('Vision')).toBeTruthy() + expect(screen.getAllByText('auto · use main model').length).toBeGreaterThan(0) + }) + + it('assigns an auxiliary task to the main model via setModelAssignment', async () => { + await renderModelSettings() + + // One "Set to main" button per task slot; the first is Vision. + const setToMainButtons = await screen.findAllByRole('button', { name: 'Set to main' }) + fireEvent.click(setToMainButtons[0]) + + await waitFor(() => + expect(setModelAssignment).toHaveBeenCalledWith({ + model: 'hermes-4', + provider: 'nous', + scope: 'auxiliary', + task: 'vision' + }) + ) + }) +}) diff --git a/apps/desktop/src/app/settings/model-settings.tsx b/apps/desktop/src/app/settings/model-settings.tsx new file mode 100644 index 000000000..4b27b59fd --- /dev/null +++ b/apps/desktop/src/app/settings/model-settings.tsx @@ -0,0 +1,358 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select' +import { getAuxiliaryModels, getGlobalModelInfo, getGlobalModelOptions, setModelAssignment } from '@/hermes' +import type { AuxiliaryModelsResponse, ModelOptionProvider } from '@/hermes' +import { Cpu, Loader2, Sparkles } from '@/lib/icons' +import { cn } from '@/lib/utils' + +import { CONTROL_TEXT } from './constants' +import { ListRow, LoadingState, Pill, SectionHeading } from './primitives' + +// Mirrors `_AUX_TASK_SLOTS` in hermes_cli/web_server.py. Friendly labels and +// hints make the assignments readable; raw task keys (vision, mcp, …) are +// opaque to most users. +interface AuxTaskMeta { + hint: string + key: string + label: string +} + +const AUX_TASKS: readonly AuxTaskMeta[] = [ + { key: 'vision', label: 'Vision', hint: 'Image analysis' }, + { key: 'web_extract', label: 'Web extract', hint: 'Page summarization' }, + { key: 'compression', label: 'Compression', hint: 'Context compaction' }, + { key: 'session_search', label: 'Session search', hint: 'Recall queries' }, + { key: 'skills_hub', label: 'Skills hub', hint: 'Skill search' }, + { key: 'approval', label: 'Approval', hint: 'Smart auto-approve' }, + { key: 'mcp', label: 'MCP', hint: 'MCP tool routing' }, + { key: 'title_generation', label: 'Title gen', hint: 'Session titles' }, + { key: 'curator', label: 'Curator', hint: 'Skill-usage review' } +] + +const NO_PROVIDERS: readonly ModelOptionProvider[] = [{ name: '—', slug: '', models: [] }] + +interface ModelSettingsProps { + /** Notified after the main model is applied, so live UI stores can sync. */ + onMainModelChanged?: (provider: string, model: string) => void +} + +export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [mainModel, setMainModel] = useState<{ model: string; provider: string } | null>(null) + const [providers, setProviders] = useState([]) + const [selectedProvider, setSelectedProvider] = useState('') + const [selectedModel, setSelectedModel] = useState('') + const [auxiliary, setAuxiliary] = useState(null) + const [applying, setApplying] = useState(false) + const [editingAuxTask, setEditingAuxTask] = useState(null) + const [auxDraft, setAuxDraft] = useState<{ model: string; provider: string }>({ model: '', provider: '' }) + + const refresh = useCallback(async () => { + setLoading(true) + setError('') + + try { + const [modelInfo, modelOptions, auxiliaryModels] = await Promise.all([ + getGlobalModelInfo(), + getGlobalModelOptions(), + getAuxiliaryModels() + ]) + + setMainModel({ model: modelInfo.model, provider: modelInfo.provider }) + setProviders(modelOptions.providers || []) + setSelectedProvider(prev => prev || modelInfo.provider) + setSelectedModel(prev => prev || modelInfo.model) + setAuxiliary(auxiliaryModels) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + void refresh() + }, [refresh]) + + const providerOptions = providers.length ? providers : NO_PROVIDERS + + const selectedProviderModels = useMemo( + () => providers.find(provider => provider.slug === selectedProvider)?.models ?? [], + [providers, selectedProvider] + ) + + const auxDraftProviderModels = useMemo( + () => providers.find(provider => provider.slug === auxDraft.provider)?.models ?? [], + [auxDraft.provider, providers] + ) + + const applyMainModel = useCallback(async () => { + if (!selectedProvider || !selectedModel) { + return + } + + setApplying(true) + setError('') + + try { + const result = await setModelAssignment({ model: selectedModel, provider: selectedProvider, scope: 'main' }) + const provider = result.provider || selectedProvider + const model = result.model || selectedModel + setMainModel({ provider, model }) + onMainModelChanged?.(provider, model) + await refresh() + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setApplying(false) + } + }, [onMainModelChanged, refresh, selectedModel, selectedProvider]) + + const setAuxiliaryToMain = useCallback( + async (task: string) => { + if (!mainModel) { + return + } + + setApplying(true) + setError('') + + try { + await setModelAssignment({ model: mainModel.model, provider: mainModel.provider, scope: 'auxiliary', task }) + await refresh() + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setApplying(false) + } + }, + [mainModel, refresh] + ) + + const applyAuxiliaryDraft = useCallback( + async (task: string) => { + if (!auxDraft.provider || !auxDraft.model) { + return + } + + setApplying(true) + setError('') + + try { + await setModelAssignment({ model: auxDraft.model, provider: auxDraft.provider, scope: 'auxiliary', task }) + setEditingAuxTask(null) + await refresh() + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setApplying(false) + } + }, + [auxDraft, refresh] + ) + + const beginAuxiliaryEdit = useCallback( + (task: string) => { + const current = auxiliary?.tasks.find(entry => entry.task === task) + + const initialProvider = + current?.provider && current.provider !== 'auto' ? current.provider : (mainModel?.provider ?? '') + + const initialModel = current?.model || mainModel?.model || '' + setAuxDraft({ provider: initialProvider, model: initialModel }) + setEditingAuxTask(task) + }, + [auxiliary, mainModel] + ) + + const resetAuxiliaryModels = useCallback(async () => { + if (!mainModel) { + return + } + + setApplying(true) + setError('') + + try { + await setModelAssignment({ + model: mainModel.model, + provider: mainModel.provider, + scope: 'auxiliary', + task: '__reset__' + }) + await refresh() + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setApplying(false) + } + }, [mainModel, refresh]) + + if (loading && !mainModel) { + return + } + + return ( +
+
+ +

+ Applies to new sessions. Use the model picker in the composer to hot-swap the active chat. +

+
+ + + +
+ {error &&
{error}
} +
+ +
+
+ + +
+

+ Helper tasks run on the main model by default. Assign a dedicated model to any task to override. +

+
+ {AUX_TASKS.map(meta => { + const current = auxiliary?.tasks.find(entry => entry.task === meta.key) + const isAuto = !current || !current.provider || current.provider === 'auto' + const isEditing = editingAuxTask === meta.key + + return ( + + + +
+ ) + } + below={ + isEditing && ( +
+ + + + +
+ ) + } + description={ + + {isAuto ? 'auto · use main model' : `${current.provider} · ${current.model || '(provider default)'}`} + + } + key={meta.key} + title={ + + {meta.label} + {meta.hint} + + } + /> + ) + })} +
+ +
+ ) +} diff --git a/apps/desktop/src/app/settings/types.ts b/apps/desktop/src/app/settings/types.ts index b0f4c4865..6ab9384ae 100644 --- a/apps/desktop/src/app/settings/types.ts +++ b/apps/desktop/src/app/settings/types.ts @@ -12,6 +12,7 @@ export interface SettingsPageProps { gateway?: HermesGateway | null onClose: () => void onConfigSaved?: () => void + onMainModelChanged?: (provider: string, model: string) => void } export interface SearchProps { diff --git a/apps/desktop/src/app/shell/hooks/use-overlay-routing.ts b/apps/desktop/src/app/shell/hooks/use-overlay-routing.ts index bf1139b2a..d3b9311e2 100644 --- a/apps/desktop/src/app/shell/hooks/use-overlay-routing.ts +++ b/apps/desktop/src/app/shell/hooks/use-overlay-routing.ts @@ -4,7 +4,7 @@ import { useLocation, useNavigate } from 'react-router-dom' import { type CommandCenterSection } from '@/app/command-center' import { AGENTS_ROUTE, appViewForPath, COMMAND_CENTER_ROUTE, NEW_CHAT_ROUTE } from '@/app/routes' -const SECTIONS = ['models', 'sessions', 'system'] as const +const SECTIONS = ['sessions', 'system', 'usage'] as const const OVERLAY_VIEWS = new Set(['settings', 'command-center', 'agents']) export function useOverlayRouting() {