import { useCallback, useEffect, useLayoutEffect, useState } from "react"; import { createPortal } from "react-dom"; import { Brain, ChevronDown, Cpu, DollarSign, Eye, RefreshCw, Settings2, Star, Wrench, X, Zap, } from "lucide-react"; import { api } from "@/lib/api"; import type { AuxiliaryModelsResponse, AuxiliaryTaskAssignment, MoaConfigResponse, MoaModelSlot, ModelsAnalyticsModelEntry, ModelsAnalyticsResponse, } from "@/lib/api"; import { timeAgo, cn, themedBody } from "@/lib/utils"; import { DASHBOARD_MODAL_BACKDROP, DASHBOARD_MODAL_PANEL, shouldCloseOuterModalOnEscape, } from "@/lib/dashboard-modal-shell"; import { formatTokenCount } from "@/lib/format"; import { Button } from "@nous-research/ui/ui/components/button"; import { Spinner } from "@nous-research/ui/ui/components/spinner"; import { Stats } from "@nous-research/ui/ui/components/stats"; import { Card, CardContent, CardHeader, CardTitle } from "@nous-research/ui/ui/components/card"; import { Badge } from "@nous-research/ui/ui/components/badge"; import { Switch } from "@nous-research/ui/ui/components/switch"; import { ConfirmDialog } from "@/components/ConfirmDialog"; import { useModalBehavior } from "@/hooks/useModalBehavior"; import { usePageHeader } from "@/contexts/usePageHeader"; import { useI18n } from "@/i18n"; import { PluginSlot } from "@/plugins"; import { ModelPickerDialog } from "@/components/ModelPickerDialog"; import { ModelReloadConfirm } from "@/components/ModelReloadConfirm"; const PERIODS = [ { label: "7d", days: 7 }, { label: "30d", days: 30 }, { label: "90d", days: 90 }, ] as const; // Must match _AUX_TASK_SLOTS in hermes_cli/web_server.py. const AUX_TASKS: readonly { key: string; label: string; hint: string }[] = [ { key: "vision", label: "Vision", hint: "Image analysis" }, { key: "compression", label: "Compression", hint: "Context compaction" }, { 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: "review", label: "Review", hint: "/review subagent" }, { key: "triage_specifier", label: "Triage Specifier", hint: "Kanban spec fleshing" }, { key: "kanban_decomposer", label: "Kanban Decomposer", hint: "Task decomposition" }, { key: "profile_describer", label: "Profile Describer", hint: "Auto profile descriptions" }, { key: "curator", label: "Curator", hint: "Skill-usage review" }, ] as const; function formatTokens(n: number): string { if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; return String(n); } function formatCost(n: number): string { if (n >= 1) return `$${n.toFixed(2)}`; if (n >= 0.01) return `$${n.toFixed(3)}`; if (n > 0) return `$${n.toFixed(4)}`; return "$0"; } /** Short model name: strip vendor prefix like "openrouter/" or "anthropic/". */ function shortModelName(model: string): string { const slashIdx = model.indexOf("/"); if (slashIdx > 0) return model.slice(slashIdx + 1); return model; } /** Extract vendor prefix from a model string like "anthropic/claude-opus-4.7" → "anthropic". */ function modelVendor(model: string, fallback?: string): string { const slashIdx = model.indexOf("/"); if (slashIdx > 0) return model.slice(0, slashIdx); return fallback || ""; } function TokenBar({ input, output, cacheRead, reasoning, }: { input: number; output: number; cacheRead: number; reasoning: number; }) { const total = input + output + cacheRead + reasoning; if (total === 0) return null; // Segments carry a CSS color value (hex or `var(--token)`) rather than // a Tailwind class so the input/output series can pick up the active // theme's `--series-*-token` vars — see `themes/types.ts` // `ThemeSeriesColors`. The /60–/70 fade on the bar is applied via // color-mix on the same value so themes don't need to ship two // separate hex literals. const segments: Array<{ color: string; label: string; value: number }> = [ { value: cacheRead, color: "#60a5fa", label: "Cache Read" }, // tailwind blue-400 { value: reasoning, color: "#c084fc", label: "Reasoning" }, // tailwind purple-400 { value: input, color: "var(--series-input-token)", label: "Input" }, { value: output, color: "var(--series-output-token)", label: "Output" }, ].filter((s) => s.value > 0); return (
{/* Stacked bar — segments fill proportionally to their share of total */}
{segments.map((s, i) => (
{/* Stepped fill pattern overlay */}
))}
{/* Legend */}
{segments.map((s, i) => ( {s.label} {formatTokens(s.value)} ))}
); } function CapabilityBadges({ capabilities, }: { capabilities: ModelsAnalyticsModelEntry["capabilities"]; }) { const hasAny = capabilities.supports_tools || capabilities.supports_vision || capabilities.supports_reasoning || capabilities.model_family; if (!hasAny) return null; return (
{capabilities.supports_tools && ( Tools )} {capabilities.supports_vision && ( Vision )} {capabilities.supports_reasoning && ( Reasoning )} {capabilities.model_family && ( {capabilities.model_family} )}
); } /* ──────────────────────────────────────────────────────────────────── */ /* Per-card "Use as" menu */ /* ──────────────────────────────────────────────────────────────────── */ function UseAsMenu({ provider, model, isMain, mainAuxTask, onAssigned, }: { provider: string; model: string; /** True when this card's model+provider match config.yaml's main slot. */ isMain: boolean; /** If this model is assigned to a specific aux task, that task's key. */ mainAuxTask: string | null; onAssigned(): void; }) { const [open, setOpen] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [pendingConfirm, setPendingConfirm] = useState<{ message: string; scope: "main" | "auxiliary"; task: string; } | null>(null); const assign = async ( scope: "main" | "auxiliary", task: string, confirmExpensiveModel = false, ) => { if (!provider || !model) { setError("Missing provider/model"); return; } setBusy(true); setError(null); try { const result = await api.setModelAssignment({ confirm_expensive_model: confirmExpensiveModel, scope, provider, model, task, }); if (result.confirm_required) { setPendingConfirm({ scope, task, message: result.confirm_message || "This model has unusually high known pricing.", }); return; } onAssigned(); setOpen(false); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setBusy(false); } }; // Close on outside click. useEffect(() => { if (!open) return; const onDown = (e: MouseEvent) => { const target = e.target as HTMLElement | null; if (target && !target.closest?.("[data-use-as-menu]")) setOpen(false); }; window.addEventListener("mousedown", onDown); return () => window.removeEventListener("mousedown", onDown); }, [open]); return (
{open && (
Auxiliary task
{AUX_TASKS.map((t) => ( ))} {error && (
{error}
)}
)} setPendingConfirm(null)} onConfirm={() => { const pending = pendingConfirm; if (!pending) return; setPendingConfirm(null); void assign(pending.scope, pending.task, true); }} />
); } /* ──────────────────────────────────────────────────────────────────── */ /* ModelCard */ /* ──────────────────────────────────────────────────────────────────── */ function ModelCard({ entry, rank, main, aux, onAssigned, showTokens, }: { entry: ModelsAnalyticsModelEntry; rank: number; main: { provider: string; model: string } | null; aux: AuxiliaryTaskAssignment[]; onAssigned(): void; showTokens: boolean; }) { const { t } = useI18n(); const provider = entry.provider || modelVendor(entry.model); const totalTokens = entry.input_tokens + entry.output_tokens; const caps = entry.capabilities; const isMain = !!main && main.provider === provider && main.model === entry.model; // First aux task currently using this model (if any). const mainAuxTask = aux.find( (a) => a.provider === provider && a.model === entry.model, )?.task ?? null; return (
#{rank} {shortModelName(entry.model)} {isMain && ( main )} {mainAuxTask && ( aux · {mainAuxTask} )}
{provider && ( {provider} )} {caps.context_window && caps.context_window > 0 && ( {formatTokenCount(caps.context_window)} ctx )} {caps.max_output_tokens && caps.max_output_tokens > 0 && ( {formatTokenCount(caps.max_output_tokens)} out )}
{showTokens ? (
{formatTokens(totalTokens)}
{t.models.tokens}
) : ( entry.sessions > 0 && (
{entry.sessions}
{t.models.sessions}
) )}
{showTokens && ( <>
{entry.sessions}
{t.models.sessions}
{formatTokens(entry.avg_tokens_per_session)}
{t.models.avgPerSession}
{entry.api_calls > 0 ? formatTokens(entry.api_calls) : "—"}
{t.models.apiCalls}
)}
{showTokens && entry.estimated_cost > 0 && ( {formatCost(entry.estimated_cost)} )} {showTokens && entry.tool_calls > 0 && ( {entry.tool_calls} {t.models.toolCalls} )}
{entry.last_used_at > 0 && ( {timeAgo(entry.last_used_at)} )}
); } /* ──────────────────────────────────────────────────────────────────── */ /* Model Settings panel (top of page) */ /* ──────────────────────────────────────────────────────────────────── */ type PickerTarget = | { kind: "main" } | { kind: "aux"; task: string }; type MoaPickerTarget = | { kind: "reference"; index: number } | { kind: "aggregator" }; function AuxiliaryTasksModal({ aux, refreshKey, onSaved, onClose, }: { aux: AuxiliaryModelsResponse | null; refreshKey: number; onSaved(): void; onClose(): void; }) { const [picker, setPicker] = useState(null); const [resetBusy, setResetBusy] = useState(false); const [confirmReset, setConfirmReset] = useState(false); const modalRef = useModalBehavior({ open: true, onClose }); const resetAllAux = async () => { setConfirmReset(false); setResetBusy(true); try { await api.setModelAssignment({ scope: "auxiliary", task: "__reset__", provider: "", model: "", }); onSaved(); } finally { setResetBusy(false); } }; return (
e.target === e.currentTarget && onClose()} role="dialog" aria-modal="true" aria-labelledby="aux-modal-title" >

Auxiliary Tasks

Auxiliary tasks handle side-jobs like vision, session search, and compression. auto means "use the main model". Override per-task when you want a cheap/fast model for a specific job.

{AUX_TASKS.map((t) => { const cur = aux?.tasks.find((a) => a.task === t.key); const isAuto = !cur || cur.provider === "auto" || !cur.provider; return (
{t.label} {t.hint}
{isAuto ? "auto (use main model)" : `${cur?.provider} · ${cur?.model || "(provider default)"}`}
); })}
{picker && picker.kind === "aux" && ( t.key === picker.task)?.label ?? picker.task }`} onApply={async ({ provider, model, confirmExpensiveModel }) => { const result = await api.setModelAssignment({ confirm_expensive_model: confirmExpensiveModel, scope: "auxiliary", task: picker.task, provider, model, }); if (!result.confirm_required) onSaved(); return result; }} onClose={() => setPicker(null)} /> )} setConfirmReset(false)} onConfirm={() => void resetAllAux()} title="Reset auxiliary models" description="Reset every auxiliary task to 'auto'? This overrides any per-task overrides you've set." destructive confirmLabel="Reset all" loading={resetBusy} />
); } function MoaModelsModal({ config, refreshKey, onClose, onSaved, }: { config: MoaConfigResponse; refreshKey: number; onClose(): void; onSaved(next: MoaConfigResponse): void; }) { const [draft, setDraft] = useState(config); const [selected, setSelected] = useState(config.default_preset || Object.keys(config.presets)[0] || "default"); const [newName, setNewName] = useState(""); const [picker, setPicker] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); // Nested ModelPickerDialog owns Escape while open — don't dismiss MoA too. const closeMoaUnlessPickerOpen = useCallback(() => { if (!shouldCloseOuterModalOnEscape(picker !== null)) return; onClose(); }, [picker, onClose]); const modalRef = useModalBehavior({ open: true, onClose: closeMoaUnlessPickerOpen, }); const presetNames = Object.keys(draft.presets || {}); const preset = draft.presets[selected] || draft.presets[presetNames[0]]; const slotLabel = (slot: MoaModelSlot) => `${slot.provider || "(provider)"} · ${slot.model || "(model)"}`; const updateSelectedPreset = (updater: (preset: MoaConfigResponse["presets"][string]) => MoaConfigResponse["presets"][string]) => { setDraft((prev) => ({ ...prev, presets: { ...prev.presets, [selected]: updater(prev.presets[selected]), }, })); }; const save = async () => { setBusy(true); setError(null); try { const saved = await api.saveMoaModels(draft); onSaved(saved); onClose(); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setBusy(false); } }; const addPreset = () => { const name = newName.trim(); if (!name || draft.presets[name]) return; const seed = preset || { reference_models: draft.reference_models, aggregator: draft.aggregator, reference_temperature: draft.reference_temperature, aggregator_temperature: draft.aggregator_temperature, reference_timeout: draft.reference_timeout, degraded_reference_policy: draft.degraded_reference_policy, max_tokens: draft.max_tokens, enabled: draft.enabled, }; setDraft((prev) => ({ ...prev, default_preset: prev.default_preset || name, presets: { ...prev.presets, [name]: { ...seed, reference_models: [...seed.reference_models] } }, })); setSelected(name); setNewName(""); }; const deletePreset = () => { if (presetNames.length <= 1) return; const remaining = presetNames.filter((name) => name !== selected); const nextSelected = remaining[0]; setDraft((prev) => { const next = { ...prev.presets }; delete next[selected]; return { ...prev, presets: next, default_preset: prev.default_preset === selected ? nextSelected : prev.default_preset, active_preset: prev.active_preset === selected ? "" : prev.active_preset, }; }); setSelected(nextSelected); }; if (!preset) return null; // Portal to document.body: the main dashboard column is `relative z-2`, // which traps fixed descendants below the sidebar (same as ModelPickerDialog). return createPortal(
{ if (e.target === e.currentTarget) closeMoaUnlessPickerOpen(); }} role="dialog" aria-modal="true" aria-labelledby="moa-modal-title" > {/* Opaque panel — do not use here; Card defaults to bg-background-base/80. */}

Configure Mixture of Agents presets

Presets appear as models under the Mixture of Agents provider. References produce perspectives; the aggregator is the acting model that answers and calls tools.

setNewName(event.target.value)} />
Default: {draft.default_preset}
Reference models
{preset.reference_models.map((slot, index) => (
updateSelectedPreset((prev) => ({ ...prev, reference_models: prev.reference_models.map((s, i) => i === index ? { ...s, enabled: checked === true } : s ), })) } />
{slotLabel(slot)}
))}
Aggregator
{slotLabel(preset.aggregator)}
{error &&
{error}
}
{picker && ( { if ((provider || "").toLowerCase() === "moa") { setError("MoA presets can't reference or aggregate the Mixture of Agents provider (no recursive MoA)."); return; } setError(null); updateSelectedPreset((prev) => { if (picker.kind === "aggregator") return { ...prev, aggregator: { provider, model } }; return { ...prev, reference_models: prev.reference_models.map((slot, i) => i === picker.index ? { ...slot, provider, model } : slot), }; }); }} onClose={() => setPicker(null)} /> )}
, document.body, ); } function ModelSettingsPanel({ aux, refreshKey, onSaved, }: { aux: AuxiliaryModelsResponse | null; refreshKey: number; onSaved(): void; }) { const [auxModalOpen, setAuxModalOpen] = useState(false); const [moaModalOpen, setMoaModalOpen] = useState(false); const [moa, setMoa] = useState(null); const [picker, setPicker] = useState(null); const [pendingReloadModel, setPendingReloadModel] = useState( null, ); const mainProv = aux?.main.provider ?? ""; const mainModel = aux?.main.model ?? ""; useEffect(() => { api.getMoaModels().then(setMoa).catch(() => setMoa(null)); }, [refreshKey]); const applyAssignment = async ({ scope, task, provider, model, confirmExpensiveModel, }: { confirmExpensiveModel?: boolean; scope: "main" | "auxiliary"; task: string; provider: string; model: string; }) => { const result = await api.setModelAssignment({ confirm_expensive_model: confirmExpensiveModel, scope, task, provider, model, }); if (!result.confirm_required) onSaved(); return result; }; // Count how many aux tasks have overrides const auxOverrideCount = aux?.tasks.filter( (a) => a.provider && a.provider !== "auto", ).length ?? 0; return (
Model Settings applies to new sessions
{/* Main row */}
Main model
{mainProv || "(unset)"} {mainProv && mainModel && " · "} {mainModel || "(unset)"}
{/* Auxiliary tasks summary + open modal */}
Auxiliary tasks
{auxOverrideCount > 0 ? `${auxOverrideCount} override${auxOverrideCount > 1 ? "s" : ""} · ${AUX_TASKS.length - auxOverrideCount} auto` : `${AUX_TASKS.length} tasks · all auto`}
Mixture of Agents
{moa ? `${moa.reference_models.length} reference${moa.reference_models.length === 1 ? "" : "s"} · ${moa.aggregator.provider}/${shortModelName(moa.aggregator.model)}` : "not loaded"}
{picker && ( { const result = await applyAssignment({ confirmExpensiveModel, scope: "main", task: "", provider, model, }); if (!result.confirm_required) { setPendingReloadModel(model.split("/").slice(-1)[0]); } return result; }} onClose={() => setPicker(null)} /> )} {auxModalOpen && ( setAuxModalOpen(false)} /> )} setPendingReloadModel(null)} /> {moaModalOpen && moa && ( { setMoa(next); onSaved(); }} onClose={() => setMoaModalOpen(false)} /> )}
); } /* ──────────────────────────────────────────────────────────────────── */ /* Page */ /* ──────────────────────────────────────────────────────────────────── */ export default function ModelsPage() { const [days, setDays] = useState(30); const [data, setData] = useState(null); const [aux, setAux] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [saveKey, setSaveKey] = useState(0); // Gate the token/cost UI on `dashboard.show_token_analytics`. See // hermes_cli/config.py for the rationale: the numbers exclude auxiliary // calls and retries, so they're misleading next to provider billing. const [showTokens, setShowTokens] = useState(false); const { t } = useI18n(); const { setAfterTitle, setEnd } = usePageHeader(); useEffect(() => { api .getConfig() .then((cfg) => { const dash = (cfg?.dashboard ?? {}) as { show_token_analytics?: unknown }; setShowTokens(dash.show_token_analytics === true); }) .catch(() => { // Default to hidden on any failure — safer than showing wrong numbers. setShowTokens(false); }); }, []); const load = useCallback(() => { setLoading(true); setError(null); Promise.all([ api.getModelsAnalytics(days), api.getAuxiliaryModels().catch(() => null), ]) .then(([models, auxData]) => { setData(models); setAux(auxData); }) .catch((err) => setError(String(err))) .finally(() => setLoading(false)); }, [days]); const refreshAux = useCallback(() => { api .getAuxiliaryModels() .then(setAux) .catch(() => {}); }, []); const onAssigned = useCallback(() => { // Reload aux state after any assignment change. refreshAux(); setSaveKey((k) => k + 1); }, [refreshAux]); useLayoutEffect(() => { // Period selector + refresh both live in afterTitle so the controls // sit immediately next to the page title instead of being pinned to // the far-right `end` slot. The active period is conveyed by the // filled (non-outlined) button — no redundant period badge. setAfterTitle(
{PERIODS.map((p) => ( ))}
, ); setEnd(null); return () => { setAfterTitle(null); setEnd(null); }; }, [days, loading, load, setAfterTitle, setEnd, t.common.refresh]); useEffect(() => { load(); }, [load]); // Model assignments can change outside this page (config editor, chat // /model --global, CLI), so refetch them when the page regains focus. useEffect(() => { let last = 0; const onFocus = () => { if (document.visibilityState !== "visible") return; if (Date.now() - last < 1000) return; last = Date.now(); refreshAux(); }; window.addEventListener("focus", onFocus); document.addEventListener("visibilitychange", onFocus); return () => { window.removeEventListener("focus", onFocus); document.removeEventListener("visibilitychange", onFocus); }; }, [refreshAux]); return (
{data && (
{!showTokens && (

Token & cost analytics are hidden because the local counts exclude auxiliary calls (compression, vision, web extract, …) and provider retries, so they diverge from your provider bill. Enable{" "} dashboard.show_token_analytics{" "} in Config to show the local debug estimate anyway.

)}
)}
{loading && !data && (
)} {error && (

{error}

)} {data && ( <> {data.models.length > 0 ? (
{data.models.map((m, i) => ( ))}
) : (

{t.models.noModelsData}

{t.models.startSession}

)} )}
); }