From 5e55b35cc8fb1e66117b19f494f828c609e527cd Mon Sep 17 00:00:00 2001 From: emozilla Date: Tue, 2 Jun 2026 05:53:15 -0400 Subject: [PATCH 1/3] refactor(desktop): move model management from Command Center into Settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Command Center's Models section and Settings > Model rendered the same model state with identical persistence semantics — both write config and apply to new sessions only (POST /api/model/set). The Command Center UI was strictly better (provider catalog, curated model lists, friendly auxiliary-task labels, Nous-gateway auto-routing on main-provider switch), while Settings > Model was three barebones config fields. Extract that UI into a shared settings/model-settings.tsx (restyled with Settings primitives) and render it at the top of Settings > Model: main model picker via setModelAssignment + the 9 auxiliary task slots with per-task set-to-main / change / reset-all. model_context_length and fallback_providers stay as config fields below it; the raw auxiliary.* keys are dropped from Advanced (now covered by the panel). Strip the Models section from Command Center entirely (section, state, handlers, render, nav, search entry) leaving it focused on Sessions / System / Usage, and move the live store-sync callback (onMainModelChanged) from CommandCenterView to SettingsView. The composer's per-session model picker (the only live hot-swap, via /model) is unchanged. --- apps/desktop/src/app/command-center/index.tsx | 374 +----------------- apps/desktop/src/app/desktop-controller.tsx | 14 +- .../src/app/settings/config-settings.tsx | 8 + apps/desktop/src/app/settings/constants.ts | 18 +- apps/desktop/src/app/settings/index.tsx | 3 +- .../src/app/settings/model-settings.test.tsx | 70 ++++ .../src/app/settings/model-settings.tsx | 358 +++++++++++++++++ apps/desktop/src/app/settings/types.ts | 1 + .../app/shell/hooks/use-overlay-routing.ts | 2 +- 9 files changed, 455 insertions(+), 393 deletions(-) create mode 100644 apps/desktop/src/app/settings/model-settings.test.tsx create mode 100644 apps/desktop/src/app/settings/model-settings.tsx 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() { From 195c4d2a9862ac6533408d708e35287c936b5328 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 2 Jun 2026 05:52:54 -0700 Subject: [PATCH 2/3] feat(streaming): per-platform streaming defaults (Telegram on, Discord off) + dashboard toggles (#37303) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Streaming quality differs sharply by platform: Telegram has native animated draft streaming (sendMessageDraft) which is smooth, while Discord/Slack only have edit-based streaming (repeated editMessage) which visibly flickers. Ship defaults that match reality instead of one global flag. - hermes_cli/config.py: DEFAULT_CONFIG display.platforms now ships telegram.streaming=true and discord.streaming=false (was empty {}). These are gap-fillers — config deep-merge has user values win, so anyone who explicitly sets discord.streaming=true keeps it. The global streaming.enabled master switch still gates everything; these per-platform flags only take effect once streaming is on. - Dashboard exposure comes for free: the web settings schema is generated from DEFAULT_CONFIG, so display.platforms.telegram.streaming and .discord.streaming now surface as editable boolean toggles in the UI with no frontend change. (Previously the per-platform tree was {} and invisible.) - tests: pin the defaults, the resolver outcome (telegram on / discord off / unlisted platforms follow global), user-override-wins, and dashboard schema exposure. No _config_version bump: deep-merge fills the gap for existing installs; no value migration needed. --- hermes_cli/config.py | 22 ++++++- .../test_per_platform_streaming_defaults.py | 65 +++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 tests/gateway/test_per_platform_streaming_defaults.py diff --git a/hermes_cli/config.py b/hermes_cli/config.py index d5a063a97..cec27809f 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1345,7 +1345,27 @@ DEFAULT_CONFIG = { # responses and content messages are never touched. Default 0 # (disabled) preserves prior behavior. "ephemeral_system_ttl": 0, - "platforms": {}, # Per-platform display overrides: {"telegram": {"tool_progress": "all"}, "slack": {"tool_progress": "off"}} + # Per-platform display/streaming overrides. Each key is a gateway + # platform ("telegram", "discord", "slack", …) mapping to a dict of + # display settings that override the global value for that platform + # only. A setting left unset here falls through to the global default. + # + # Shipped defaults encode the streaming experience that works best + # per platform: + # - Telegram has native animated draft streaming (sendMessageDraft), + # which is smooth, so streaming is on by default there. + # - Discord/Slack/etc. only have edit-based streaming (repeated + # editMessage), which flickers and is noticeably jankier, so + # streaming is off by default there. + # These are gap-fillers: a user who explicitly sets, e.g., + # display.platforms.discord.streaming: true keeps their value + # (config deep-merge has user values win over defaults). The global + # streaming.enabled master switch still gates everything — these + # per-platform flags only take effect once streaming is enabled. + "platforms": { + "telegram": {"streaming": True}, + "discord": {"streaming": False}, + }, # Gateway runtime-metadata footer appended to the FINAL message of a turn # (disabled by default to keep replies minimal). When enabled, renders # e.g. `model · 68% · ~/projects/hermes`. Per-platform overrides go under diff --git a/tests/gateway/test_per_platform_streaming_defaults.py b/tests/gateway/test_per_platform_streaming_defaults.py new file mode 100644 index 000000000..4b183e666 --- /dev/null +++ b/tests/gateway/test_per_platform_streaming_defaults.py @@ -0,0 +1,65 @@ +"""Per-platform streaming defaults + dashboard exposure. + +Streaming is smooth on Telegram (native sendMessageDraft) but flickers on +edit-only platforms like Discord. The shipped defaults encode that: +display.platforms.telegram.streaming=true, .discord.streaming=false. These are +gap-fillers (user values win via deep-merge) and, because the dashboard schema +is generated from DEFAULT_CONFIG, they automatically appear as editable toggles +in the web UI. +""" + +from __future__ import annotations + + +def test_default_per_platform_streaming_flags(): + from hermes_cli.config import DEFAULT_CONFIG + plats = DEFAULT_CONFIG["display"]["platforms"] + assert plats["telegram"]["streaming"] is True + assert plats["discord"]["streaming"] is False + + +def test_resolver_telegram_on_discord_off_when_global_enabled(): + """With global streaming on, the per-platform defaults make Telegram stream + and Discord not — matching the platforms' actual streaming quality.""" + from hermes_cli.config import DEFAULT_CONFIG + from gateway.display_config import resolve_display_setting + + cfg = dict(DEFAULT_CONFIG) + cfg["streaming"] = {"enabled": True, "transport": "auto"} + + def streams(plat): + ov = resolve_display_setting(cfg, plat, "streaming") + # global enabled; None override = follow global (True) + return True if ov is None else bool(ov) + + assert streams("telegram") is True + assert streams("discord") is False + # A platform with no default entry follows the global switch. + assert streams("slack") is True + + +def test_user_override_wins_over_default(): + """A user who explicitly enables Discord streaming keeps their value — the + default false must not clobber it (config deep-merge: user wins).""" + from hermes_cli.config import DEFAULT_CONFIG, _deep_merge + + user = {"display": {"platforms": {"discord": {"streaming": True}}}} + merged = _deep_merge(dict(DEFAULT_CONFIG), user) + assert merged["display"]["platforms"]["discord"]["streaming"] is True + # Partial override must not wipe the sibling telegram default. + assert merged["display"]["platforms"]["telegram"]["streaming"] is True + + +def test_dashboard_schema_exposes_per_platform_streaming(): + """Because the web settings schema is built from DEFAULT_CONFIG, the + per-platform streaming toggles surface in the dashboard automatically.""" + import pytest + pytest.importorskip("fastapi") # web_server requires fastapi/uvicorn + from hermes_cli.web_server import CONFIG_SCHEMA + + assert "display.platforms.telegram.streaming" in CONFIG_SCHEMA + assert "display.platforms.discord.streaming" in CONFIG_SCHEMA + assert CONFIG_SCHEMA["display.platforms.discord.streaming"]["type"] == "boolean" + # Global streaming controls are exposed too. + assert "streaming.enabled" in CONFIG_SCHEMA + assert "streaming.transport" in CONFIG_SCHEMA From afea650e16c544457efed023c1df05fadd86c500 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 2 Jun 2026 06:31:37 -0700 Subject: [PATCH 3/3] fix(model-picker): OpenAI shows curated models; OpenRouter no longer phantom-shows (#37404) The model picker now matches `hermes model` for OpenAI, and OpenRouter stops appearing as authenticated when only OPENAI_API_KEY is set. - models.py: provider_model_ids() for the default api.openai.com endpoint intersects the live /v1/models dump (120+ entries incl. embeddings, whisper, tts, dall-e, moderation, legacy chat) with the curated agentic list, preserving curated order. Custom OpenAI-compatible endpoints keep the live list verbatim so discovery still works. - providers.py: drop extra_env_vars=("OPENAI_API_KEY",) from the openrouter overlay. list_authenticated_providers reads extra_env_vars to decide whether a provider is authenticated, so any OpenAI user saw a phantom OpenRouter row. Runtime OpenRouter credential resolution still falls back to OPENAI_API_KEY (runtime_provider.py), independent of the overlay. - Regression tests for both paths. --- hermes_cli/models.py | 23 +++++ hermes_cli/providers.py | 1 - .../hermes_cli/test_openai_picker_curated.py | 96 +++++++++++++++++++ 3 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 tests/hermes_cli/test_openai_picker_curated.py diff --git a/hermes_cli/models.py b/hermes_cli/models.py index e1e066851..99f9d462c 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -2106,9 +2106,32 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False) if api_key: base_raw = os.getenv("OPENAI_BASE_URL", "").strip().rstrip("/") base = base_raw or "https://api.openai.com/v1" + # Custom OpenAI-compatible endpoints (proxies, gateways, self-hosted) + # may serve a small curated catalog — use the live list verbatim so + # discovery works. But the canonical api.openai.com /v1/models dump + # is 120+ entries of embeddings, whisper, tts, dall-e, moderation and + # legacy chat models — none of which belong in the agent model picker. + # For the default endpoint, intersect the live list with our curated + # agentic catalog so ``/model`` matches what ``hermes model`` shows. + is_default_openai = base.rstrip("/") in ( + "https://api.openai.com/v1", + "https://api.openai.com", + ) try: live = fetch_api_models(api_key, base) if live: + if is_default_openai: + live_lower = {m.lower() for m in live} + curated = list(_PROVIDER_MODELS.get(normalized, [])) + # Keep curated order; only surface curated models the + # account actually has access to. + filtered = [m for m in curated if m.lower() in live_lower] + if filtered: + return filtered + # Account serves none of the curated models (rare — + # e.g. org without GPT-5 access). Fall back to curated + # so the picker still offers sane defaults. + return curated or live return live except Exception: pass diff --git a/hermes_cli/providers.py b/hermes_cli/providers.py index f81790aa2..ba25f7e63 100644 --- a/hermes_cli/providers.py +++ b/hermes_cli/providers.py @@ -47,7 +47,6 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = { "openrouter": HermesOverlay( transport="openai_chat", is_aggregator=True, - extra_env_vars=("OPENAI_API_KEY",), base_url_env_var="OPENROUTER_BASE_URL", ), "nous": HermesOverlay( diff --git a/tests/hermes_cli/test_openai_picker_curated.py b/tests/hermes_cli/test_openai_picker_curated.py new file mode 100644 index 000000000..67b7f2c11 --- /dev/null +++ b/tests/hermes_cli/test_openai_picker_curated.py @@ -0,0 +1,96 @@ +"""Regression tests for two OpenAI/OpenRouter model-picker bugs. + +Bug 1 — OpenAI picker dumped the raw ``/v1/models`` catalog + ``provider_model_ids("openai")`` hit ``api.openai.com/v1/models`` and + returned the full 120+ entry catalog (embeddings, whisper, tts, dall-e, + moderation, gpt-3.5, …). The ``hermes model`` CLI shows only the curated + agentic list. The picker now intersects the live default-endpoint catalog + with the curated list (preserving curated order) so both surfaces match. + Custom OpenAI-compatible endpoints (proxies, gateways) keep the live list + verbatim so discovery still works. + +Bug 2 — OpenRouter appeared authenticated whenever OPENAI_API_KEY was set + OpenRouter's HermesOverlay carried ``extra_env_vars=("OPENAI_API_KEY",)``. + ``list_authenticated_providers`` reads ``extra_env_vars`` to decide whether + a provider has credentials, so any OpenAI user saw a phantom OpenRouter + row. The overlay entry is removed; runtime credential resolution still + falls back to OPENAI_API_KEY for explicitly-selected OpenRouter (handled + in runtime_provider.py, independent of the overlay). +""" + +import os +from unittest.mock import patch + +import pytest + +from hermes_cli import models as M +from hermes_cli.providers import HERMES_OVERLAYS + + +# --- Bug 2: overlay no longer lists OPENAI_API_KEY -------------------------- + +def test_openrouter_overlay_does_not_list_openai_api_key(): + overlay = HERMES_OVERLAYS["openrouter"] + assert "OPENAI_API_KEY" not in overlay.extra_env_vars + + +# --- Bug 1: default OpenAI endpoint filters to curated agentic models ------- + +def test_default_openai_endpoint_filters_to_curated(monkeypatch): + """The 126-model /v1/models dump is intersected with the curated list.""" + monkeypatch.setenv("OPENAI_API_KEY", "sk-fake") + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + + curated = M._PROVIDER_MODELS["openai-api"] + # Live catalog: every curated model PLUS a pile of non-agentic junk. + live = list(curated) + [ + "text-embedding-3-large", "whisper-1", "tts-1", "dall-e-3", + "gpt-3.5-turbo", "davinci-002", "omni-moderation-latest", + ] + with patch.object(M, "fetch_api_models", return_value=live): + result = M.provider_model_ids("openai-api", force_refresh=True) + + # Only curated models survive, in curated order, no junk. + assert result == list(curated) + for m in result: + assert m in curated + + +def test_default_openai_endpoint_intersects_account_access(monkeypatch): + """Curated models the account can't access are dropped (intersection).""" + monkeypatch.setenv("OPENAI_API_KEY", "sk-fake") + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + + curated = M._PROVIDER_MODELS["openai-api"] + # Account only serves the first two curated models. + live = list(curated[:2]) + ["text-embedding-3-large", "whisper-1"] + with patch.object(M, "fetch_api_models", return_value=live): + result = M.provider_model_ids("openai-api", force_refresh=True) + + assert result == list(curated[:2]) + + +def test_default_openai_endpoint_falls_back_when_no_curated_access(monkeypatch): + """If the account serves none of the curated models, fall back to curated.""" + monkeypatch.setenv("OPENAI_API_KEY", "sk-fake") + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + + curated = M._PROVIDER_MODELS["openai-api"] + live = ["text-embedding-3-large", "whisper-1", "tts-1"] # all junk + with patch.object(M, "fetch_api_models", return_value=live): + result = M.provider_model_ids("openai-api", force_refresh=True) + + # No curated overlap -> serve the curated defaults so the picker isn't empty. + assert result == list(curated) + + +def test_custom_openai_compatible_endpoint_keeps_live_list(monkeypatch): + """Custom OPENAI_BASE_URL endpoints keep the live catalog verbatim.""" + monkeypatch.setenv("OPENAI_API_KEY", "sk-fake") + monkeypatch.setenv("OPENAI_BASE_URL", "https://my-proxy.example.com/v1") + + live = ["custom-model-a", "custom-model-b", "some-embedding-model"] + with patch.object(M, "fetch_api_models", return_value=live): + result = M.provider_model_ids("openai-api", force_refresh=True) + + assert result == live