Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/desktop-session-list
This commit is contained in:
@ -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<void>
|
||||
onMainModelChanged?: (provider: string, model: string) => void
|
||||
onNavigateRoute: (path: string) => void
|
||||
onOpenSession: (sessionId: string) => void
|
||||
}
|
||||
@ -87,14 +57,12 @@ interface CommandCenterViewProps {
|
||||
const SECTION_LABELS: Record<CommandCenterSection, string> = {
|
||||
sessions: 'Sessions',
|
||||
system: 'System',
|
||||
models: 'Models',
|
||||
usage: 'Usage'
|
||||
}
|
||||
|
||||
const SECTION_DESCRIPTIONS: Record<CommandCenterSection, string> = {
|
||||
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<ActionStatusResponse | null>(null)
|
||||
const [modelsLoading, setModelsLoading] = useState(false)
|
||||
const [modelsError, setModelsError] = useState('')
|
||||
const [mainModel, setMainModel] = useState<{ model: string; provider: string } | null>(null)
|
||||
const [providers, setProviders] = useState<ModelOptionProvider[]>([])
|
||||
const [selectedProvider, setSelectedProvider] = useState('')
|
||||
const [selectedModel, setSelectedModel] = useState('')
|
||||
const [auxiliary, setAuxiliary] = useState<AuxiliaryModelsResponse | null>(null)
|
||||
const [applyingModel, setApplyingModel] = useState(false)
|
||||
const [editingAuxTask, setEditingAuxTask] = useState<null | string>(null)
|
||||
const [auxDraft, setAuxDraft] = useState<{ model: string; provider: string }>({ model: '', provider: '' })
|
||||
const [usagePeriod, setUsagePeriod] = useState<UsagePeriod>(30)
|
||||
const [usage, setUsage] = useState<AnalyticsResponse | null>(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<readonly CommandCenterSearchProvider[]>(
|
||||
() => [
|
||||
{
|
||||
@ -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 => (
|
||||
<OverlayNavItem
|
||||
active={section === value}
|
||||
icon={value === 'sessions' ? Pin : value === 'system' ? Activity : value === 'models' ? Cpu : BarChart3}
|
||||
icon={value === 'sessions' ? Pin : value === 'system' ? Activity : BarChart3}
|
||||
key={value}
|
||||
label={SECTION_LABELS[value]}
|
||||
onClick={() => setSection(value)}
|
||||
@ -684,12 +474,6 @@ export function CommandCenterView({
|
||||
{usageLoading ? 'Refreshing...' : 'Refresh'}
|
||||
</OverlayActionButton>
|
||||
)}
|
||||
{section === 'models' && (
|
||||
<OverlayActionButton disabled={modelsLoading} onClick={() => void refreshModels()}>
|
||||
<IconRefresh className={cn('mr-1.5 size-3.5', modelsLoading && 'animate-spin')} />
|
||||
{modelsLoading ? 'Refreshing...' : 'Refresh'}
|
||||
</OverlayActionButton>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{showGlobalSearchResults ? (
|
||||
@ -844,7 +628,7 @@ export function CommandCenterView({
|
||||
period={usagePeriod}
|
||||
usage={usage}
|
||||
/>
|
||||
) : section === 'system' ? (
|
||||
) : (
|
||||
<div className="grid min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)] gap-3">
|
||||
<OverlayCard className="p-3 text-sm">
|
||||
{status ? (
|
||||
@ -902,154 +686,6 @@ export function CommandCenterView({
|
||||
</pre>
|
||||
</OverlayCard>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid min-h-0 flex-1 grid-rows-[auto_auto_minmax(0,1fr)] gap-3">
|
||||
<OverlayCard className="p-3">
|
||||
{mainModel ? (
|
||||
<>
|
||||
<div className="text-sm font-medium text-foreground">Main model</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{mainModel.provider} / {mainModel.model}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground">Loading model state...</div>
|
||||
)}
|
||||
</OverlayCard>
|
||||
|
||||
<OverlayCard className="p-3">
|
||||
<div className="mb-2 text-xs font-medium text-muted-foreground">Set global main model</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
className="h-8 min-w-36 rounded-md border border-border bg-background px-2 text-xs text-foreground"
|
||||
onChange={event => setSelectedProvider(event.target.value)}
|
||||
value={selectedProvider}
|
||||
>
|
||||
{(providers.length ? providers : [{ name: '—', slug: '', models: [] }]).map(provider => (
|
||||
<option key={provider.slug || 'none'} value={provider.slug}>
|
||||
{provider.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="h-8 min-w-58 rounded-md border border-border bg-background px-2 text-xs text-foreground"
|
||||
onChange={event => setSelectedModel(event.target.value)}
|
||||
value={selectedModel}
|
||||
>
|
||||
{(selectedProviderModels.length ? selectedProviderModels : ['']).map(model => (
|
||||
<option key={model || 'none'} value={model}>
|
||||
{model || 'No models available'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<OverlayActionButton
|
||||
disabled={!selectedProvider || !selectedModel || applyingModel}
|
||||
onClick={() => void applyMainModel()}
|
||||
>
|
||||
{applyingModel ? (
|
||||
<IconLoader2 className="mr-1.5 size-3.5 animate-spin" />
|
||||
) : (
|
||||
<IconSparkles className="mr-1.5 size-3.5" />
|
||||
)}
|
||||
{applyingModel ? 'Applying...' : 'Apply'}
|
||||
</OverlayActionButton>
|
||||
</div>
|
||||
{modelsError && <div className="mt-2 text-xs text-destructive">{modelsError}</div>}
|
||||
</OverlayCard>
|
||||
|
||||
<OverlayCard className="min-h-0 overflow-auto p-2">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground">Auxiliary assignments</span>
|
||||
<OverlayActionButton
|
||||
disabled={!mainModel || applyingModel}
|
||||
onClick={() => void resetAuxiliaryModels()}
|
||||
tone="subtle"
|
||||
>
|
||||
Reset all
|
||||
</OverlayActionButton>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
{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 (
|
||||
<OverlayCard className="px-2 py-1.5" key={meta.key}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-xs font-medium text-foreground">{meta.label}</span>
|
||||
<span className="text-[0.62rem] text-muted-foreground/70">{meta.hint}</span>
|
||||
</div>
|
||||
<div className="truncate font-mono text-[0.62rem] text-muted-foreground">
|
||||
{isAuto
|
||||
? 'auto · use main model'
|
||||
: `${current.provider} · ${current.model || '(provider default)'}`}
|
||||
</div>
|
||||
</div>
|
||||
{!isEditing && (
|
||||
<>
|
||||
<OverlayActionButton
|
||||
disabled={!mainModel || applyingModel}
|
||||
onClick={() => void setAuxiliaryToMain(meta.key)}
|
||||
tone="subtle"
|
||||
>
|
||||
Set to main
|
||||
</OverlayActionButton>
|
||||
<OverlayActionButton
|
||||
disabled={!providers.length || applyingModel}
|
||||
onClick={() => beginAuxiliaryEdit(meta.key)}
|
||||
>
|
||||
Change
|
||||
</OverlayActionButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEditing && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 border-t border-border/40 pt-2">
|
||||
<select
|
||||
className="h-7 min-w-28 rounded-md border border-border bg-background px-2 text-[0.7rem] text-foreground"
|
||||
onChange={event =>
|
||||
setAuxDraft(prev => ({ ...prev, provider: event.target.value, model: '' }))
|
||||
}
|
||||
value={auxDraft.provider}
|
||||
>
|
||||
{(providers.length ? providers : [{ name: '—', slug: '', models: [] }]).map(provider => (
|
||||
<option key={provider.slug || 'none'} value={provider.slug}>
|
||||
{provider.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="h-7 min-w-44 rounded-md border border-border bg-background px-2 text-[0.7rem] text-foreground"
|
||||
onChange={event => setAuxDraft(prev => ({ ...prev, model: event.target.value }))}
|
||||
value={auxDraft.model}
|
||||
>
|
||||
{(auxDraftProviderModels.length ? auxDraftProviderModels : ['']).map(model => (
|
||||
<option key={model || 'none'} value={model}>
|
||||
{model || 'No models available'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<OverlayActionButton
|
||||
disabled={!auxDraft.provider || !auxDraft.model || applyingModel}
|
||||
onClick={() => void applyAuxiliaryDraft(meta.key)}
|
||||
>
|
||||
{applyingModel ? 'Applying...' : 'Apply'}
|
||||
</OverlayActionButton>
|
||||
<OverlayActionButton onClick={() => setEditingAuxTask(null)} tone="subtle">
|
||||
Cancel
|
||||
</OverlayActionButton>
|
||||
</div>
|
||||
)}
|
||||
</OverlayCard>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</OverlayCard>
|
||||
</div>
|
||||
)}
|
||||
</OverlayMain>
|
||||
</OverlaySplitLayout>
|
||||
|
||||
@ -537,6 +537,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'] })
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
@ -547,13 +554,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))}
|
||||
/>
|
||||
|
||||
@ -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<HTMLInputElement | null>
|
||||
}) {
|
||||
const [config, setConfig] = useState<HermesConfigRecord | null>(null)
|
||||
@ -322,6 +325,11 @@ export function ConfigSettings({
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
{activeSectionId === 'model' && !query.trim() && (
|
||||
<div className="mb-6">
|
||||
<ModelSettings onMainModelChanged={onMainModelChanged} />
|
||||
</div>
|
||||
)}
|
||||
{query.trim() && (
|
||||
<div className="mb-4 text-xs text-muted-foreground">
|
||||
{fields.length} result{fields.length === 1 ? '' : 's'}
|
||||
|
||||
@ -141,13 +141,7 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
'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<string, string> = {
|
||||
@ -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'
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@ -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<Record<SettingsQueryKey, string>>({
|
||||
@ -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' ? (
|
||||
|
||||
70
apps/desktop/src/app/settings/model-settings.test.tsx
Normal file
70
apps/desktop/src/app/settings/model-settings.test.tsx
Normal file
@ -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(<ModelSettings />)
|
||||
}
|
||||
|
||||
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'
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
358
apps/desktop/src/app/settings/model-settings.tsx
Normal file
358
apps/desktop/src/app/settings/model-settings.tsx
Normal file
@ -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<ModelOptionProvider[]>([])
|
||||
const [selectedProvider, setSelectedProvider] = useState('')
|
||||
const [selectedModel, setSelectedModel] = useState('')
|
||||
const [auxiliary, setAuxiliary] = useState<AuxiliaryModelsResponse | null>(null)
|
||||
const [applying, setApplying] = useState(false)
|
||||
const [editingAuxTask, setEditingAuxTask] = useState<null | string>(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 <LoadingState label="Loading model configuration..." />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<section>
|
||||
<SectionHeading
|
||||
icon={Sparkles}
|
||||
meta={mainModel ? `${mainModel.provider} / ${mainModel.model}` : undefined}
|
||||
title="Main model"
|
||||
/>
|
||||
<p className="mb-3 text-xs text-muted-foreground">
|
||||
Applies to new sessions. Use the model picker in the composer to hot-swap the active chat.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select onValueChange={setSelectedProvider} value={selectedProvider}>
|
||||
<SelectTrigger className={cn('min-w-40', CONTROL_TEXT)}>
|
||||
<SelectValue placeholder="Provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providerOptions.map(provider => (
|
||||
<SelectItem key={provider.slug || 'none'} value={provider.slug || 'none'}>
|
||||
{provider.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select onValueChange={setSelectedModel} value={selectedModel}>
|
||||
<SelectTrigger className={cn('min-w-60', CONTROL_TEXT)}>
|
||||
<SelectValue placeholder="Model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(selectedProviderModels.length ? selectedProviderModels : []).map(model => (
|
||||
<SelectItem key={model} value={model}>
|
||||
{model}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button disabled={!selectedProvider || !selectedModel || applying} onClick={() => void applyMainModel()} size="sm">
|
||||
{applying ? <Loader2 className="size-3.5 animate-spin" /> : <Sparkles className="size-3.5" />}
|
||||
{applying ? 'Applying...' : 'Apply'}
|
||||
</Button>
|
||||
</div>
|
||||
{error && <div className="mt-2 text-xs text-destructive">{error}</div>}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className="mb-2.5 flex items-center justify-between">
|
||||
<SectionHeading icon={Cpu} title="Auxiliary models" />
|
||||
<Button
|
||||
disabled={!mainModel || applying}
|
||||
onClick={() => void resetAuxiliaryModels()}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
Reset all to main
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mb-2 text-xs text-muted-foreground">
|
||||
Helper tasks run on the main model by default. Assign a dedicated model to any task to override.
|
||||
</p>
|
||||
<div className="divide-y divide-border/40">
|
||||
{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 (
|
||||
<ListRow
|
||||
action={
|
||||
!isEditing && (
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<Button
|
||||
disabled={!mainModel || applying}
|
||||
onClick={() => void setAuxiliaryToMain(meta.key)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
Set to main
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!providers.length || applying}
|
||||
onClick={() => beginAuxiliaryEdit(meta.key)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
Change
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
below={
|
||||
isEditing && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 border-t border-border/40 pt-2">
|
||||
<Select
|
||||
onValueChange={value => setAuxDraft(prev => ({ ...prev, provider: value, model: '' }))}
|
||||
value={auxDraft.provider}
|
||||
>
|
||||
<SelectTrigger className={cn('min-w-32', CONTROL_TEXT)}>
|
||||
<SelectValue placeholder="Provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providerOptions.map(provider => (
|
||||
<SelectItem key={provider.slug || 'none'} value={provider.slug || 'none'}>
|
||||
{provider.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
onValueChange={value => setAuxDraft(prev => ({ ...prev, model: value }))}
|
||||
value={auxDraft.model}
|
||||
>
|
||||
<SelectTrigger className={cn('min-w-48', CONTROL_TEXT)}>
|
||||
<SelectValue placeholder="Model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(auxDraftProviderModels.length ? auxDraftProviderModels : []).map(model => (
|
||||
<SelectItem key={model} value={model}>
|
||||
{model}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
disabled={!auxDraft.provider || !auxDraft.model || applying}
|
||||
onClick={() => void applyAuxiliaryDraft(meta.key)}
|
||||
size="sm"
|
||||
>
|
||||
{applying ? 'Applying...' : 'Apply'}
|
||||
</Button>
|
||||
<Button onClick={() => setEditingAuxTask(null)} size="sm" variant="ghost">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
description={
|
||||
<span className="font-mono text-[0.68rem]">
|
||||
{isAuto ? 'auto · use main model' : `${current.provider} · ${current.model || '(provider default)'}`}
|
||||
</span>
|
||||
}
|
||||
key={meta.key}
|
||||
title={
|
||||
<span className="flex items-baseline gap-2">
|
||||
{meta.label}
|
||||
<Pill>{meta.hint}</Pill>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -12,6 +12,7 @@ export interface SettingsPageProps {
|
||||
gateway?: HermesGateway | null
|
||||
onClose: () => void
|
||||
onConfigSaved?: () => void
|
||||
onMainModelChanged?: (provider: string, model: string) => void
|
||||
}
|
||||
|
||||
export interface SearchProps {
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
65
tests/gateway/test_per_platform_streaming_defaults.py
Normal file
65
tests/gateway/test_per_platform_streaming_defaults.py
Normal file
@ -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
|
||||
96
tests/hermes_cli/test_openai_picker_curated.py
Normal file
96
tests/hermes_cli/test_openai_picker_curated.py
Normal file
@ -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
|
||||
Reference in New Issue
Block a user