import { useCallback, useEffect, useLayoutEffect, useState } from "react"; import { Brain, ChevronDown, Cpu, DollarSign, Eye, RefreshCw, Settings2, Star, Wrench, Zap, } from "lucide-react"; import { api } from "@/lib/api"; import type { AuxiliaryModelsResponse, AuxiliaryTaskAssignment, ModelsAnalyticsModelEntry, ModelsAnalyticsResponse, } from "@/lib/api"; import { timeAgo } from "@/lib/utils"; import { formatTokenCount } from "@/lib/format"; import { Button, Spinner, Stats } from "@nous-research/ui"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@nous-research/ui"; import { usePageHeader } from "@/contexts/usePageHeader"; import { useI18n } from "@/i18n"; import { PluginSlot } from "@/plugins"; import { ModelPickerDialog } from "@/components/ModelPickerDialog"; 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: "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" }, ] 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; const segments = [ { value: cacheRead, color: "bg-blue-400/60", label: "Cache Read" }, { value: reasoning, color: "bg-purple-400/60", label: "Reasoning" }, { value: input, color: "bg-[#ffe6cb]/70", label: "Input" }, { value: output, color: "bg-emerald-500/70", label: "Output" }, ].filter((s) => s.value > 0); return (
{segments.map((s, i) => (
))}
{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 assign = async ( scope: "main" | "auxiliary", task: string, ) => { if (!provider || !model) { setError("Missing provider/model"); return; } setBusy(true); setError(null); try { await api.setModelAssignment({ scope, provider, model, task }); 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}
)}
)}
); } /* ──────────────────────────────────────────────────────────────────── */ /* ModelCard */ /* ──────────────────────────────────────────────────────────────────── */ function ModelCard({ entry, rank, main, aux, onAssigned, }: { entry: ModelsAnalyticsModelEntry; rank: number; main: { provider: string; model: string } | null; aux: AuxiliaryTaskAssignment[]; onAssigned(): void; }) { 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 )}
{formatTokens(totalTokens)}
{t.models.tokens}
{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}
{entry.estimated_cost > 0 && ( {formatCost(entry.estimated_cost)} )} {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 }; function ModelSettingsPanel({ aux, refreshKey, onSaved, }: { aux: AuxiliaryModelsResponse | null; refreshKey: number; onSaved(): void; }) { const [expanded, setExpanded] = useState(false); const [picker, setPicker] = useState(null); const [resetBusy, setResetBusy] = useState(false); const mainProv = aux?.main.provider ?? ""; const mainModel = aux?.main.model ?? ""; const applyAssignment = async ({ scope, task, provider, model, }: { scope: "main" | "auxiliary"; task: string; provider: string; model: string; }) => { await api.setModelAssignment({ scope, task, provider, model }); onSaved(); }; const resetAllAux = async () => { if (!window.confirm("Reset every auxiliary task to 'auto'? This overrides any per-task overrides you've set.")) { return; } setResetBusy(true); try { await api.setModelAssignment({ scope: "auxiliary", task: "__reset__", provider: "", model: "", }); onSaved(); } finally { setResetBusy(false); } }; return (
Model Settings applies to new sessions
{/* Main row */}
Main model
{mainProv || "(unset)"} {mainProv && mainModel && " · "} {mainModel || "(unset)"}
{/* Auxiliary rows */} {expanded && (
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 && ( t.key === picker.task)?.label ?? picker.task }` } onApply={async ({ provider, model }) => { await applyAssignment({ scope: picker.kind === "main" ? "main" : "auxiliary", task: picker.kind === "main" ? "" : picker.task, provider, model, }); }} onClose={() => setPicker(null)} /> )}
); } /* ──────────────────────────────────────────────────────────────────── */ /* 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); const { t } = useI18n(); const { setAfterTitle, setEnd } = usePageHeader(); 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 onAssigned = useCallback(() => { // Reload aux state after any assignment change. api .getAuxiliaryModels() .then(setAux) .catch(() => {}); setSaveKey((k) => k + 1); }, []); useLayoutEffect(() => { const periodLabel = PERIODS.find((p) => p.days === days)?.label ?? `${days}d`; setAfterTitle( {loading && } {periodLabel} , ); setEnd(
{PERIODS.map((p) => ( ))}
, ); return () => { setAfterTitle(null); setEnd(null); }; }, [days, loading, load, setAfterTitle, setEnd, t.common.refresh]); useEffect(() => { load(); }, [load]); return (
{loading && !data && (
)} {error && (

{error}

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

{t.models.noModelsData}

{t.models.startSession}

)} )}
); }