mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-03 02:11:48 +00:00
Merge upstream/main and address Copilot review feedback
Merge resolved conflicts in web/src/{i18n/{en,zh,types}.ts,lib/api.ts}
by keeping both this branch's `profiles` additions and upstream's new
`models` page additions.
Copilot review feedback:
- Implement POST /api/profiles/{name}/open-terminal endpoint (already
present); align Windows branch to `cmd.exe /c start "" <cmd>` so it
matches the new test and spawns a fresh window instead of /k reusing
the parent console.
- Move backslash escaping out of the macOS AppleScript f-string
expression (Python <3.12 disallows backslashes inside f-string
expression parts).
- Patch `_get_wrapper_dir` via monkeypatch in
test_profiles_create_creates_wrapper_alias_when_safe so the test no
longer writes to the real `~/.local/bin`.
- Extend test_dashboard_browser_safe_imports to scan `.ts` files in
addition to `.tsx`.
- Switch upstream's new ModelsPage.tsx away from the `@nous-research/ui`
root barrel onto per-component subpaths to satisfy the stricter scan.
- Fix NouiTypography `leading-1.4` -> `leading-[1.4]` so Tailwind
actually emits the line-height for the `sm` variant.
- Guard ProfilesPage.openSoulEditor against out-of-order responses by
tracking the latest requested profile via a ref.
- Replace ProfilesPage's hand-rolled setup command with a fetch to
`/api/profiles/{name}/setup-command` so the copied command always
matches what the backend would actually run (handles wrapper-alias
collisions and reserved names correctly).
- Wire SOUL.md textarea label `htmlFor` -> textarea `id` so screen
readers and clicking the label work as expected.
This commit is contained in:
commit
ca7f46beb5
496 changed files with 47367 additions and 2854 deletions
|
|
@ -20,6 +20,7 @@ import {
|
|||
BookOpen,
|
||||
Clock,
|
||||
Code,
|
||||
Cpu,
|
||||
Database,
|
||||
Download,
|
||||
Eye,
|
||||
|
|
@ -60,6 +61,7 @@ import EnvPage from "@/pages/EnvPage";
|
|||
import SessionsPage from "@/pages/SessionsPage";
|
||||
import LogsPage from "@/pages/LogsPage";
|
||||
import AnalyticsPage from "@/pages/AnalyticsPage";
|
||||
import ModelsPage from "@/pages/ModelsPage";
|
||||
import CronPage from "@/pages/CronPage";
|
||||
import ProfilesPage from "@/pages/ProfilesPage";
|
||||
import SkillsPage from "@/pages/SkillsPage";
|
||||
|
|
@ -96,6 +98,7 @@ const BUILTIN_ROUTES_CORE: Record<string, ComponentType> = {
|
|||
"/": RootRedirect,
|
||||
"/sessions": SessionsPage,
|
||||
"/analytics": AnalyticsPage,
|
||||
"/models": ModelsPage,
|
||||
"/logs": LogsPage,
|
||||
"/cron": CronPage,
|
||||
"/skills": SkillsPage,
|
||||
|
|
@ -126,6 +129,12 @@ const BUILTIN_NAV_REST: NavItem[] = [
|
|||
label: "Analytics",
|
||||
icon: BarChart3,
|
||||
},
|
||||
{
|
||||
path: "/models",
|
||||
labelKey: "models",
|
||||
label: "Models",
|
||||
icon: Cpu,
|
||||
},
|
||||
{ path: "/logs", labelKey: "logs", label: "Logs", icon: FileText },
|
||||
{ path: "/cron", labelKey: "cron", label: "Cron", icon: Clock },
|
||||
{ path: "/skills", labelKey: "skills", label: "Skills", icon: Package },
|
||||
|
|
@ -144,6 +153,7 @@ const ICON_MAP: Record<string, ComponentType<{ className?: string }>> = {
|
|||
Activity,
|
||||
BarChart3,
|
||||
Clock,
|
||||
Cpu,
|
||||
FileText,
|
||||
KeyRound,
|
||||
MessageSquare,
|
||||
|
|
|
|||
|
|
@ -13,9 +13,18 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
|||
* Stage 1: pick provider (authenticated providers only)
|
||||
* Stage 2: pick model within that provider
|
||||
*
|
||||
* On confirm, emits `/model <model> --provider <slug> [--global]` through
|
||||
* the parent callback so ChatPage can dispatch it via the existing slash
|
||||
* pipeline. That keeps persistence + actual switch logic in one place.
|
||||
* Two invocation modes:
|
||||
*
|
||||
* 1. Chat-session mode (ChatSidebar) — pass `gw` + `sessionId`. The picker
|
||||
* loads options via `model.options` JSON-RPC and emits the result as a
|
||||
* slash command string (`/model <model> --provider <slug> [--global]`)
|
||||
* through `onSubmit`, which the ChatPage pipes to `slashExec`.
|
||||
*
|
||||
* 2. Standalone mode (ModelsPage, Config settings) — pass a `loader` and
|
||||
* `onApply`. The picker fetches options via the REST endpoint and calls
|
||||
* `onApply(provider, model, persistGlobal)` instead of emitting a slash
|
||||
* command. This lets the Models page reuse the same UI without
|
||||
* requiring an open chat PTY.
|
||||
*/
|
||||
|
||||
interface ModelOptionProvider {
|
||||
|
|
@ -34,14 +43,38 @@ interface ModelOptionsResponse {
|
|||
}
|
||||
|
||||
interface Props {
|
||||
gw: GatewayClient;
|
||||
sessionId: string;
|
||||
/** Chat-mode: when present, picker emits a slash command via onSubmit. */
|
||||
gw?: GatewayClient;
|
||||
sessionId?: string;
|
||||
onSubmit?(slashCommand: string): void;
|
||||
|
||||
/** Standalone-mode: when present (and onSubmit absent), picker calls onApply. */
|
||||
loader?(): Promise<ModelOptionsResponse>;
|
||||
onApply?(args: {
|
||||
provider: string;
|
||||
model: string;
|
||||
persistGlobal: boolean;
|
||||
}): Promise<void> | void;
|
||||
|
||||
onClose(): void;
|
||||
/** Parent runs the resulting slash command through slashExec. */
|
||||
onSubmit(slashCommand: string): void;
|
||||
title?: string;
|
||||
/** If true, hides "Persist globally" checkbox — always saves to config.yaml. */
|
||||
alwaysGlobal?: boolean;
|
||||
}
|
||||
|
||||
export function ModelPickerDialog({ gw, sessionId, onClose, onSubmit }: Props) {
|
||||
export function ModelPickerDialog(props: Props) {
|
||||
const {
|
||||
gw,
|
||||
sessionId,
|
||||
onSubmit,
|
||||
loader,
|
||||
onApply,
|
||||
onClose,
|
||||
title = "Switch Model",
|
||||
alwaysGlobal = false,
|
||||
} = props;
|
||||
const standalone = !!loader && !!onApply;
|
||||
|
||||
const [providers, setProviders] = useState<ModelOptionProvider[]>([]);
|
||||
const [currentModel, setCurrentModel] = useState("");
|
||||
const [currentProviderSlug, setCurrentProviderSlug] = useState("");
|
||||
|
|
@ -50,17 +83,22 @@ export function ModelPickerDialog({ gw, sessionId, onClose, onSubmit }: Props) {
|
|||
const [selectedSlug, setSelectedSlug] = useState("");
|
||||
const [selectedModel, setSelectedModel] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
const [persistGlobal, setPersistGlobal] = useState(false);
|
||||
const [persistGlobal, setPersistGlobal] = useState(alwaysGlobal);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const closedRef = useRef(false);
|
||||
|
||||
// Load providers + models on open.
|
||||
useEffect(() => {
|
||||
closedRef.current = false;
|
||||
|
||||
gw.request<ModelOptionsResponse>(
|
||||
"model.options",
|
||||
sessionId ? { session_id: sessionId } : {},
|
||||
)
|
||||
const promise = standalone
|
||||
? (loader as () => Promise<ModelOptionsResponse>)()
|
||||
: (gw as GatewayClient).request<ModelOptionsResponse>(
|
||||
"model.options",
|
||||
sessionId ? { session_id: sessionId } : {},
|
||||
);
|
||||
|
||||
promise
|
||||
.then((r) => {
|
||||
if (closedRef.current) return;
|
||||
const next = r?.providers ?? [];
|
||||
|
|
@ -82,7 +120,9 @@ export function ModelPickerDialog({ gw, sessionId, onClose, onSubmit }: Props) {
|
|||
return () => {
|
||||
closedRef.current = true;
|
||||
};
|
||||
}, [gw, sessionId]);
|
||||
// Deliberately omit props from deps — stable for the dialog's lifetime.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Esc closes.
|
||||
useEffect(() => {
|
||||
|
|
@ -127,15 +167,31 @@ export function ModelPickerDialog({ gw, sessionId, onClose, onSubmit }: Props) {
|
|||
[models, needle],
|
||||
);
|
||||
|
||||
const canConfirm = !!selectedProvider && !!selectedModel;
|
||||
const canConfirm = !!selectedProvider && !!selectedModel && !applying;
|
||||
|
||||
const confirm = () => {
|
||||
if (!canConfirm) return;
|
||||
const global = persistGlobal ? " --global" : "";
|
||||
onSubmit(
|
||||
`/model ${selectedModel} --provider ${selectedProvider.slug}${global}`,
|
||||
);
|
||||
onClose();
|
||||
const confirm = async () => {
|
||||
if (!canConfirm || !selectedProvider) return;
|
||||
if (standalone && onApply) {
|
||||
setApplying(true);
|
||||
try {
|
||||
await onApply({
|
||||
provider: selectedProvider.slug,
|
||||
model: selectedModel,
|
||||
persistGlobal,
|
||||
});
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
} else if (onSubmit) {
|
||||
const global = persistGlobal ? " --global" : "";
|
||||
onSubmit(
|
||||
`/model ${selectedModel} --provider ${selectedProvider.slug}${global}`,
|
||||
);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -162,7 +218,7 @@ export function ModelPickerDialog({ gw, sessionId, onClose, onSubmit }: Props) {
|
|||
id="model-picker-title"
|
||||
className="font-display text-base tracking-wider uppercase"
|
||||
>
|
||||
Switch Model
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1 font-mono">
|
||||
current: {currentModel || "(unknown)"}
|
||||
|
|
@ -214,22 +270,28 @@ export function ModelPickerDialog({ gw, sessionId, onClose, onSubmit }: Props) {
|
|||
</div>
|
||||
|
||||
<footer className="border-t border-border p-3 flex items-center justify-between gap-3 flex-wrap">
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={persistGlobal}
|
||||
onChange={(e) => setPersistGlobal(e.target.checked)}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
Persist globally (otherwise this session only)
|
||||
</label>
|
||||
{alwaysGlobal ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Saves to config.yaml — applies to new sessions.
|
||||
</span>
|
||||
) : (
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={persistGlobal}
|
||||
onChange={(e) => setPersistGlobal(e.target.checked)}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
Persist globally (otherwise this session only)
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<Button outlined onClick={onClose}>
|
||||
<Button outlined onClick={onClose} disabled={applying}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={confirm} disabled={!canConfirm}>
|
||||
Switch
|
||||
{applying ? <Spinner /> : "Switch"}
|
||||
</Button>
|
||||
</div>
|
||||
</footer>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ type TypographyProps = HTMLAttributes<HTMLElement> & {
|
|||
};
|
||||
|
||||
const variantClasses: Record<NonNullable<TypographyProps["variant"]>, string> = {
|
||||
sm: "leading-1.4 text-[.9375rem] tracking-[0.1875rem]",
|
||||
sm: "leading-[1.4] text-[.9375rem] tracking-[0.1875rem]",
|
||||
md: "text-[2.625rem] leading-[1] tracking-[0.0525rem]",
|
||||
lg: "text-[2.625rem] leading-[1] tracking-[0.0525rem]",
|
||||
xl: "text-[4.5rem] leading-[1] tracking-[0.135rem]",
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ export const en: Translations = {
|
|||
documentation: "Documentation",
|
||||
keys: "Keys",
|
||||
logs: "Logs",
|
||||
models: "Models",
|
||||
profiles: "profiles : multi agents",
|
||||
sessions: "Sessions",
|
||||
skills: "Skills",
|
||||
|
|
@ -173,6 +174,18 @@ export const en: Translations = {
|
|||
inOut: "{input} in / {output} out",
|
||||
},
|
||||
|
||||
models: {
|
||||
modelsUsed: "Models Used",
|
||||
estimatedCost: "Est. Cost",
|
||||
tokens: "tokens",
|
||||
sessions: "sessions",
|
||||
avgPerSession: "avg/session",
|
||||
apiCalls: "API calls",
|
||||
toolCalls: "tool calls",
|
||||
noModelsData: "No model usage data for this period",
|
||||
startSession: "Start a session to see model data here",
|
||||
},
|
||||
|
||||
logs: {
|
||||
title: "Logs",
|
||||
autoRefresh: "Auto-refresh",
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ export interface Translations {
|
|||
documentation: string;
|
||||
keys: string;
|
||||
logs: string;
|
||||
models: string;
|
||||
profiles: string;
|
||||
sessions: string;
|
||||
skills: string;
|
||||
|
|
@ -175,6 +176,19 @@ export interface Translations {
|
|||
inOut: string;
|
||||
};
|
||||
|
||||
// ── Models page ──
|
||||
models: {
|
||||
modelsUsed: string;
|
||||
estimatedCost: string;
|
||||
tokens: string;
|
||||
sessions: string;
|
||||
avgPerSession: string;
|
||||
apiCalls: string;
|
||||
toolCalls: string;
|
||||
noModelsData: string;
|
||||
startSession: string;
|
||||
};
|
||||
|
||||
// ── Logs page ──
|
||||
logs: {
|
||||
title: string;
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ export const zh: Translations = {
|
|||
documentation: "文档",
|
||||
keys: "密钥",
|
||||
logs: "日志",
|
||||
models: "模型",
|
||||
profiles: "多Agent配置",
|
||||
sessions: "会话",
|
||||
skills: "技能",
|
||||
|
|
@ -171,6 +172,18 @@ export const zh: Translations = {
|
|||
inOut: "输入 {input} / 输出 {output}",
|
||||
},
|
||||
|
||||
models: {
|
||||
modelsUsed: "使用模型数",
|
||||
estimatedCost: "预估费用",
|
||||
tokens: "Token",
|
||||
sessions: "会话",
|
||||
avgPerSession: "平均/会话",
|
||||
apiCalls: "API 调用",
|
||||
toolCalls: "工具调用",
|
||||
noModelsData: "该时间段暂无模型使用数据",
|
||||
startSession: "开始会话后将在此显示模型数据",
|
||||
},
|
||||
|
||||
logs: {
|
||||
title: "日志",
|
||||
autoRefresh: "自动刷新",
|
||||
|
|
|
|||
|
|
@ -63,10 +63,20 @@ export const api = {
|
|||
},
|
||||
getAnalytics: (days: number) =>
|
||||
fetchJSON<AnalyticsResponse>(`/api/analytics/usage?days=${days}`),
|
||||
getModelsAnalytics: (days: number) =>
|
||||
fetchJSON<ModelsAnalyticsResponse>(`/api/analytics/models?days=${days}`),
|
||||
getConfig: () => fetchJSON<Record<string, unknown>>("/api/config"),
|
||||
getDefaults: () => fetchJSON<Record<string, unknown>>("/api/config/defaults"),
|
||||
getSchema: () => fetchJSON<{ fields: Record<string, unknown>; category_order: string[] }>("/api/config/schema"),
|
||||
getModelInfo: () => fetchJSON<ModelInfoResponse>("/api/model/info"),
|
||||
getModelOptions: () => fetchJSON<ModelOptionsResponse>("/api/model/options"),
|
||||
getAuxiliaryModels: () => fetchJSON<AuxiliaryModelsResponse>("/api/model/auxiliary"),
|
||||
setModelAssignment: (body: ModelAssignmentRequest) =>
|
||||
fetchJSON<ModelAssignmentResponse>("/api/model/set", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
saveConfig: (config: Record<string, unknown>) =>
|
||||
fetchJSON<{ ok: boolean }>("/api/config", {
|
||||
method: "PUT",
|
||||
|
|
@ -145,6 +155,10 @@ export const api = {
|
|||
`/api/profiles/${encodeURIComponent(name)}`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
getProfileSetupCommand: (name: string) =>
|
||||
fetchJSON<{ command: string }>(
|
||||
`/api/profiles/${encodeURIComponent(name)}/setup-command`,
|
||||
),
|
||||
getProfileSoul: (name: string) =>
|
||||
fetchJSON<{ content: string; exists: boolean }>(
|
||||
`/api/profiles/${encodeURIComponent(name)}/soul`,
|
||||
|
|
@ -417,6 +431,46 @@ export interface ProfileInfo {
|
|||
skill_count: number;
|
||||
}
|
||||
|
||||
export interface ModelsAnalyticsModelEntry {
|
||||
model: string;
|
||||
provider: string;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
cache_read_tokens: number;
|
||||
reasoning_tokens: number;
|
||||
estimated_cost: number;
|
||||
actual_cost: number;
|
||||
sessions: number;
|
||||
api_calls: number;
|
||||
tool_calls: number;
|
||||
last_used_at: number;
|
||||
avg_tokens_per_session: number;
|
||||
capabilities: {
|
||||
supports_tools?: boolean;
|
||||
supports_vision?: boolean;
|
||||
supports_reasoning?: boolean;
|
||||
context_window?: number;
|
||||
max_output_tokens?: number;
|
||||
model_family?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ModelsAnalyticsResponse {
|
||||
models: ModelsAnalyticsModelEntry[];
|
||||
totals: {
|
||||
distinct_models: number;
|
||||
total_input: number;
|
||||
total_output: number;
|
||||
total_cache_read: number;
|
||||
total_reasoning: number;
|
||||
total_estimated_cost: number;
|
||||
total_actual_cost: number;
|
||||
total_sessions: number;
|
||||
total_api_calls: number;
|
||||
};
|
||||
period_days: number;
|
||||
}
|
||||
|
||||
export interface CronJob {
|
||||
id: string;
|
||||
name?: string;
|
||||
|
|
@ -478,6 +532,54 @@ export interface ModelInfoResponse {
|
|||
};
|
||||
}
|
||||
|
||||
// ── Model options / assignment types ──────────────────────────────────
|
||||
|
||||
export interface ModelOptionProvider {
|
||||
name: string;
|
||||
slug: string;
|
||||
models?: string[];
|
||||
total_models?: number;
|
||||
is_current?: boolean;
|
||||
is_user_defined?: boolean;
|
||||
source?: string;
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
export interface ModelOptionsResponse {
|
||||
model?: string;
|
||||
provider?: string;
|
||||
providers?: ModelOptionProvider[];
|
||||
}
|
||||
|
||||
export interface AuxiliaryTaskAssignment {
|
||||
task: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
base_url: string;
|
||||
}
|
||||
|
||||
export interface AuxiliaryModelsResponse {
|
||||
tasks: AuxiliaryTaskAssignment[];
|
||||
main: { provider: string; model: string };
|
||||
}
|
||||
|
||||
export interface ModelAssignmentRequest {
|
||||
scope: "main" | "auxiliary";
|
||||
provider: string;
|
||||
model: string;
|
||||
/** For auxiliary: task slot name, "" for all, "__reset__" to reset all. */
|
||||
task?: string;
|
||||
}
|
||||
|
||||
export interface ModelAssignmentResponse {
|
||||
ok: boolean;
|
||||
scope?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
tasks?: string[];
|
||||
reset?: boolean;
|
||||
}
|
||||
|
||||
// ── OAuth provider types ────────────────────────────────────────────────
|
||||
|
||||
export interface OAuthProviderStatus {
|
||||
|
|
|
|||
817
web/src/pages/ModelsPage.tsx
Normal file
817
web/src/pages/ModelsPage.tsx
Normal file
|
|
@ -0,0 +1,817 @@
|
|||
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 } 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 "@/components/ui/card";
|
||||
import { Badge } from "@nous-research/ui/ui/components/badge";
|
||||
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 (
|
||||
<div className="space-y-1">
|
||||
<div className="flex h-2 w-full overflow-hidden rounded-sm bg-muted/30">
|
||||
{segments.map((s, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`${s.color} transition-all duration-300`}
|
||||
style={{ width: `${(s.value / total) * 100}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-[10px] text-muted-foreground">
|
||||
{segments.map((s, i) => (
|
||||
<span key={i} className="flex items-center gap-1">
|
||||
<span className={`inline-block h-1.5 w-1.5 rounded-full ${s.color}`} />
|
||||
{s.label} {formatTokens(s.value)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{capabilities.supports_tools && (
|
||||
<span className="inline-flex items-center gap-1 bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-600 dark:text-emerald-400">
|
||||
<Wrench className="h-2.5 w-2.5" /> Tools
|
||||
</span>
|
||||
)}
|
||||
{capabilities.supports_vision && (
|
||||
<span className="inline-flex items-center gap-1 bg-blue-500/10 px-1.5 py-0.5 text-[10px] font-medium text-blue-600 dark:text-blue-400">
|
||||
<Eye className="h-2.5 w-2.5" /> Vision
|
||||
</span>
|
||||
)}
|
||||
{capabilities.supports_reasoning && (
|
||||
<span className="inline-flex items-center gap-1 bg-purple-500/10 px-1.5 py-0.5 text-[10px] font-medium text-purple-600 dark:text-purple-400">
|
||||
<Brain className="h-2.5 w-2.5" /> Reasoning
|
||||
</span>
|
||||
)}
|
||||
{capabilities.model_family && (
|
||||
<span className="inline-flex items-center bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
{capabilities.model_family}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────── */
|
||||
/* 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<string | null>(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 (
|
||||
<div className="relative" data-use-as-menu>
|
||||
<Button
|
||||
size="sm"
|
||||
outlined
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
disabled={busy}
|
||||
className="text-[10px] h-6 px-2"
|
||||
prefix={busy ? <Spinner /> : null}
|
||||
>
|
||||
Use as <ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full mt-1 z-50 min-w-[220px] border border-border bg-card shadow-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => assign("main", "")}
|
||||
disabled={busy}
|
||||
className="flex w-full items-center justify-between px-3 py-2 text-xs hover:bg-muted/50 disabled:opacity-40"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Star className="h-3 w-3" />
|
||||
Main model
|
||||
</span>
|
||||
{isMain && (
|
||||
<span className="text-[9px] uppercase tracking-wider text-primary/80">
|
||||
current
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="border-t border-border/50 px-3 py-1.5 text-[9px] uppercase tracking-wider text-muted-foreground">
|
||||
Auxiliary task
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => assign("auxiliary", "")}
|
||||
disabled={busy}
|
||||
className="flex w-full items-center justify-between px-3 py-1.5 text-xs hover:bg-muted/50 disabled:opacity-40"
|
||||
>
|
||||
<span>All auxiliary tasks</span>
|
||||
</button>
|
||||
|
||||
{AUX_TASKS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
onClick={() => assign("auxiliary", t.key)}
|
||||
disabled={busy}
|
||||
className="flex w-full items-center justify-between px-3 py-1.5 text-xs hover:bg-muted/50 disabled:opacity-40"
|
||||
>
|
||||
<span>{t.label}</span>
|
||||
{mainAuxTask === t.key && (
|
||||
<span className="text-[9px] uppercase tracking-wider text-primary/80">
|
||||
current
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{error && (
|
||||
<div className="px-3 py-2 text-[10px] text-destructive border-t border-border/50">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────── */
|
||||
/* 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 (
|
||||
<Card className={isMain ? "ring-1 ring-primary/40" : undefined}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground/50 text-xs font-mono">
|
||||
#{rank}
|
||||
</span>
|
||||
<CardTitle className="text-sm font-mono-ui truncate">
|
||||
{shortModelName(entry.model)}
|
||||
</CardTitle>
|
||||
{isMain && (
|
||||
<span className="inline-flex items-center gap-0.5 bg-primary/15 px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-wider text-primary">
|
||||
<Star className="h-2.5 w-2.5" /> main
|
||||
</span>
|
||||
)}
|
||||
{mainAuxTask && (
|
||||
<span className="inline-flex items-center bg-purple-500/10 px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-wider text-purple-600 dark:text-purple-400">
|
||||
aux · {mainAuxTask}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{provider && (
|
||||
<Badge tone="secondary" className="text-[9px]">
|
||||
{provider}
|
||||
</Badge>
|
||||
)}
|
||||
{caps.context_window && caps.context_window > 0 && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{formatTokenCount(caps.context_window)} ctx
|
||||
</span>
|
||||
)}
|
||||
{caps.max_output_tokens && caps.max_output_tokens > 0 && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{formatTokenCount(caps.max_output_tokens)} out
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1 shrink-0">
|
||||
<div className="text-right">
|
||||
<div className="text-xs font-mono font-semibold">
|
||||
{formatTokens(totalTokens)}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{t.models.tokens}
|
||||
</div>
|
||||
</div>
|
||||
<UseAsMenu
|
||||
provider={provider}
|
||||
model={entry.model}
|
||||
isMain={isMain}
|
||||
mainAuxTask={mainAuxTask}
|
||||
onAssigned={onAssigned}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 pt-0">
|
||||
<TokenBar
|
||||
input={entry.input_tokens}
|
||||
output={entry.output_tokens}
|
||||
cacheRead={entry.cache_read_tokens}
|
||||
reasoning={entry.reasoning_tokens}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||
<div className="text-center">
|
||||
<div className="font-mono font-semibold">{entry.sessions}</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{t.models.sessions}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="font-mono font-semibold">
|
||||
{formatTokens(entry.avg_tokens_per_session)}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{t.models.avgPerSession}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="font-mono font-semibold">
|
||||
{entry.api_calls > 0 ? formatTokens(entry.api_calls) : "—"}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{t.models.apiCalls}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-[10px] text-muted-foreground border-t border-border/30 pt-2">
|
||||
<div className="flex items-center gap-3">
|
||||
{entry.estimated_cost > 0 && (
|
||||
<span className="flex items-center gap-0.5">
|
||||
<DollarSign className="h-2.5 w-2.5" />
|
||||
{formatCost(entry.estimated_cost)}
|
||||
</span>
|
||||
)}
|
||||
{entry.tool_calls > 0 && (
|
||||
<span className="flex items-center gap-0.5">
|
||||
<Zap className="h-2.5 w-2.5" />
|
||||
{entry.tool_calls} {t.models.toolCalls}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{entry.last_used_at > 0 && (
|
||||
<span>{timeAgo(entry.last_used_at)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CapabilityBadges capabilities={entry.capabilities} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────── */
|
||||
/* 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<PickerTarget | null>(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 (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings2 className="h-4 w-4 text-muted-foreground" />
|
||||
<CardTitle className="text-sm">Model Settings</CardTitle>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
applies to new sessions
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
outlined
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="text-xs"
|
||||
>
|
||||
{expanded ? "Hide auxiliary" : "Show auxiliary"}
|
||||
<ChevronDown
|
||||
className={`h-3 w-3 transition-transform ${expanded ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-3 pt-0">
|
||||
{/* Main row */}
|
||||
<div className="flex items-center justify-between gap-3 bg-muted/20 border border-border/50 px-3 py-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<Star className="h-3 w-3 text-primary" />
|
||||
<span className="text-xs font-medium uppercase tracking-wider">
|
||||
Main model
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs font-mono text-muted-foreground truncate">
|
||||
{mainProv || "(unset)"}
|
||||
{mainProv && mainModel && " · "}
|
||||
{mainModel || "(unset)"}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setPicker({ kind: "main" })}
|
||||
className="text-xs"
|
||||
>
|
||||
Change
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Auxiliary rows */}
|
||||
{expanded && (
|
||||
<div className="space-y-1 border-t border-border/50 pt-3">
|
||||
<div className="flex items-center justify-between pb-1">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
Auxiliary tasks
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
outlined
|
||||
onClick={resetAllAux}
|
||||
disabled={resetBusy}
|
||||
className="text-[10px] h-6"
|
||||
prefix={resetBusy ? <Spinner /> : null}
|
||||
>
|
||||
Reset all to auto
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-muted-foreground/80 pb-2">
|
||||
Auxiliary tasks handle side-jobs like vision, session search, and
|
||||
compression. <span className="font-mono">auto</span> means
|
||||
"use the main model". Override per-task when you want a
|
||||
cheap/fast model for a specific job.
|
||||
</p>
|
||||
|
||||
{AUX_TASKS.map((t) => {
|
||||
const cur = aux?.tasks.find((a) => a.task === t.key);
|
||||
const isAuto =
|
||||
!cur || cur.provider === "auto" || !cur.provider;
|
||||
return (
|
||||
<div
|
||||
key={t.key}
|
||||
className="flex items-center justify-between gap-3 px-3 py-1.5 border border-border/30 bg-card/50 hover:bg-muted/20 transition-colors"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-xs font-medium">{t.label}</span>
|
||||
<span className="text-[10px] text-muted-foreground/60">
|
||||
{t.hint}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[10px] font-mono text-muted-foreground truncate">
|
||||
{isAuto
|
||||
? "auto (use main model)"
|
||||
: `${cur?.provider} · ${cur?.model || "(provider default)"}`}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
outlined
|
||||
onClick={() => setPicker({ kind: "aux", task: t.key })}
|
||||
className="text-[10px] h-6"
|
||||
>
|
||||
Change
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{picker && (
|
||||
<ModelPickerDialog
|
||||
key={`picker-${refreshKey}`}
|
||||
loader={api.getModelOptions}
|
||||
alwaysGlobal
|
||||
title={
|
||||
picker.kind === "main"
|
||||
? "Set Main Model"
|
||||
: `Set Auxiliary: ${
|
||||
AUX_TASKS.find((t) => 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)}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────── */
|
||||
/* Page */
|
||||
/* ──────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export default function ModelsPage() {
|
||||
const [days, setDays] = useState(30);
|
||||
const [data, setData] = useState<ModelsAnalyticsResponse | null>(null);
|
||||
const [aux, setAux] = useState<AuxiliaryModelsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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(
|
||||
<span className="flex items-center gap-2">
|
||||
{loading && <Spinner className="shrink-0 text-base text-primary" />}
|
||||
<Badge tone="secondary" className="text-[10px]">
|
||||
{periodLabel}
|
||||
</Badge>
|
||||
</span>,
|
||||
);
|
||||
setEnd(
|
||||
<div className="flex w-full min-w-0 flex-wrap items-center justify-end gap-2 sm:gap-2">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{PERIODS.map((p) => (
|
||||
<Button
|
||||
key={p.label}
|
||||
type="button"
|
||||
size="sm"
|
||||
outlined={days !== p.days}
|
||||
onClick={() => setDays(p.days)}
|
||||
>
|
||||
{p.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
outlined
|
||||
onClick={load}
|
||||
disabled={loading}
|
||||
prefix={loading ? <Spinner /> : <RefreshCw />}
|
||||
>
|
||||
{t.common.refresh}
|
||||
</Button>
|
||||
</div>,
|
||||
);
|
||||
return () => {
|
||||
setAfterTitle(null);
|
||||
setEnd(null);
|
||||
};
|
||||
}, [days, loading, load, setAfterTitle, setEnd, t.common.refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<PluginSlot name="models:top" />
|
||||
|
||||
<ModelSettingsPanel
|
||||
aux={aux}
|
||||
refreshKey={saveKey}
|
||||
onSaved={onAssigned}
|
||||
/>
|
||||
|
||||
{loading && !data && (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<Spinner className="text-2xl text-primary" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Card>
|
||||
<CardContent className="py-6">
|
||||
<p className="text-sm text-destructive text-center">{error}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<>
|
||||
<Card>
|
||||
<CardContent className="py-6">
|
||||
<Stats
|
||||
items={[
|
||||
{
|
||||
label: t.models.modelsUsed,
|
||||
value: String(data.totals.distinct_models),
|
||||
},
|
||||
{
|
||||
label: t.analytics.totalTokens,
|
||||
value: formatTokens(
|
||||
data.totals.total_input + data.totals.total_output,
|
||||
),
|
||||
},
|
||||
{
|
||||
label: t.analytics.input,
|
||||
value: formatTokens(data.totals.total_input),
|
||||
},
|
||||
{
|
||||
label: t.analytics.output,
|
||||
value: formatTokens(data.totals.total_output),
|
||||
},
|
||||
{
|
||||
label: t.models.estimatedCost,
|
||||
value: formatCost(data.totals.total_estimated_cost),
|
||||
},
|
||||
{
|
||||
label: t.analytics.totalSessions,
|
||||
value: String(data.totals.total_sessions),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{data.models.length > 0 ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{data.models.map((m, i) => (
|
||||
<ModelCard
|
||||
key={`${m.model}:${m.provider}`}
|
||||
entry={m}
|
||||
rank={i + 1}
|
||||
main={aux?.main ?? null}
|
||||
aux={aux?.tasks ?? []}
|
||||
onAssigned={onAssigned}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="py-12">
|
||||
<div className="flex flex-col items-center text-muted-foreground">
|
||||
<Cpu className="h-8 w-8 mb-3 opacity-40" />
|
||||
<p className="text-sm font-medium">{t.models.noModelsData}</p>
|
||||
<p className="text-xs mt-1 text-muted-foreground/60">
|
||||
{t.models.startSession}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<PluginSlot name="models:bottom" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ChevronDown, Pencil, Plus, Terminal, Trash2, Users } from "lucide-react";
|
||||
import { H2 } from "@/components/NouiTypography";
|
||||
import { api } from "@/lib/api";
|
||||
|
|
@ -37,6 +37,9 @@ export default function ProfilesPage() {
|
|||
const [editingSoulFor, setEditingSoulFor] = useState<string | null>(null);
|
||||
const [soulText, setSoulText] = useState("");
|
||||
const [soulSaving, setSoulSaving] = useState(false);
|
||||
// Tracks the latest SOUL request so out-of-order responses don't overwrite
|
||||
// newer state when the user switches profiles or closes the editor.
|
||||
const activeSoulRequest = useRef<string | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
api
|
||||
|
|
@ -99,16 +102,22 @@ export default function ProfilesPage() {
|
|||
const openSoulEditor = useCallback(
|
||||
async (name: string) => {
|
||||
if (editingSoulFor === name) {
|
||||
activeSoulRequest.current = null;
|
||||
setEditingSoulFor(null);
|
||||
return;
|
||||
}
|
||||
setEditingSoulFor(name);
|
||||
setSoulText("");
|
||||
activeSoulRequest.current = name;
|
||||
try {
|
||||
const soul = await api.getProfileSoul(name);
|
||||
setSoulText(soul.content);
|
||||
if (activeSoulRequest.current === name) {
|
||||
setSoulText(soul.content);
|
||||
}
|
||||
} catch (e) {
|
||||
showToast(`${t.status.error}: ${e}`, "error");
|
||||
if (activeSoulRequest.current === name) {
|
||||
showToast(`${t.status.error}: ${e}`, "error");
|
||||
}
|
||||
}
|
||||
},
|
||||
[editingSoulFor, showToast, t.status.error],
|
||||
|
|
@ -127,7 +136,14 @@ export default function ProfilesPage() {
|
|||
};
|
||||
|
||||
const handleCopyTerminalCommand = async (name: string) => {
|
||||
const cmd = name === "default" ? "hermes setup" : `${name} setup`;
|
||||
let cmd: string;
|
||||
try {
|
||||
const res = await api.getProfileSetupCommand(name);
|
||||
cmd = res.command;
|
||||
} catch (e) {
|
||||
showToast(`${t.status.error}: ${e}`, "error");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(cmd);
|
||||
showToast(`${t.profiles.commandCopied}: ${cmd}`, "success");
|
||||
|
|
@ -395,10 +411,14 @@ export default function ProfilesPage() {
|
|||
|
||||
{isEditingSoul && (
|
||||
<div className="border-t border-border px-4 pb-4 pt-3 flex flex-col gap-2">
|
||||
<Label className="flex items-center gap-2 text-xs uppercase tracking-wider text-muted-foreground">
|
||||
<Label
|
||||
htmlFor={`soul-editor-${p.name}`}
|
||||
className="flex items-center gap-2 text-xs uppercase tracking-wider text-muted-foreground"
|
||||
>
|
||||
{t.profiles.soulSection}
|
||||
</Label>
|
||||
<textarea
|
||||
id={`soul-editor-${p.name}`}
|
||||
className="flex min-h-[180px] w-full border border-input bg-transparent px-3 py-2 text-sm font-mono shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
placeholder={t.profiles.soulPlaceholder}
|
||||
value={soulText}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue