@nastechai/agent 0.16.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/eslint.config.js +23 -0
- package/index.html +24 -0
- package/package.json +54 -26
- package/package.json.bak +89 -0
- package/package.json.pub +88 -0
- package/src/App.tsx +1173 -0
- package/src/components/AuthWidget.tsx +150 -0
- package/src/components/AutoField.tsx +206 -0
- package/src/components/Backdrop.tsx +93 -0
- package/src/components/ChatSidebar.tsx +394 -0
- package/src/components/DeleteConfirmDialog.tsx +40 -0
- package/src/components/LanguageSwitcher.tsx +186 -0
- package/src/components/Markdown.tsx +383 -0
- package/src/components/ModelInfoCard.tsx +112 -0
- package/src/components/ModelPickerDialog.tsx +470 -0
- package/src/components/OAuthLoginModal.tsx +374 -0
- package/src/components/OAuthProvidersCard.tsx +287 -0
- package/src/components/PlatformsCard.tsx +97 -0
- package/src/components/ScheduleBuilder.tsx +273 -0
- package/src/components/SidebarFooter.tsx +42 -0
- package/src/components/SidebarStatusStrip.tsx +72 -0
- package/src/components/SlashPopover.tsx +171 -0
- package/src/components/ThemeSwitcher.tsx +243 -0
- package/src/components/ToolCall.tsx +228 -0
- package/src/components/ToolsetConfigDrawer.tsx +448 -0
- package/src/contexts/PageHeaderProvider.tsx +139 -0
- package/src/contexts/SystemActions.tsx +120 -0
- package/src/contexts/page-header-context.ts +12 -0
- package/src/contexts/system-actions-context.ts +18 -0
- package/src/contexts/usePageHeader.ts +10 -0
- package/src/contexts/useSystemActions.ts +15 -0
- package/src/hooks/useModalBehavior.ts +44 -0
- package/src/hooks/useSidebarStatus.ts +27 -0
- package/src/i18n/af.ts +702 -0
- package/src/i18n/context.tsx +123 -0
- package/src/i18n/de.ts +701 -0
- package/src/i18n/en.ts +708 -0
- package/src/i18n/es.ts +701 -0
- package/src/i18n/fr.ts +701 -0
- package/src/i18n/ga.ts +702 -0
- package/src/i18n/hu.ts +702 -0
- package/src/i18n/index.ts +2 -0
- package/src/i18n/it.ts +701 -0
- package/src/i18n/ja.ts +702 -0
- package/src/i18n/ko.ts +702 -0
- package/src/i18n/pt.ts +702 -0
- package/src/i18n/ru.ts +702 -0
- package/src/i18n/tr.ts +702 -0
- package/src/i18n/types.ts +710 -0
- package/src/i18n/uk.ts +702 -0
- package/src/i18n/zh-hant.ts +702 -0
- package/src/i18n/zh.ts +698 -0
- package/src/index.css +274 -0
- package/src/lib/api.ts +1585 -0
- package/src/lib/dashboard-flags.ts +15 -0
- package/src/lib/format.ts +9 -0
- package/src/lib/fuzzy.ts +192 -0
- package/src/lib/gatewayClient.ts +253 -0
- package/src/lib/nested.ts +23 -0
- package/src/lib/resolve-page-title.ts +41 -0
- package/src/lib/schedule.ts +382 -0
- package/src/lib/slashExec.ts +163 -0
- package/src/lib/utils.ts +35 -0
- package/src/main.tsx +25 -0
- package/src/pages/AnalyticsPage.tsx +601 -0
- package/src/pages/ChannelsPage.tsx +772 -0
- package/src/pages/ChatPage.tsx +889 -0
- package/src/pages/ConfigPage.tsx +660 -0
- package/src/pages/CronPage.tsx +524 -0
- package/src/pages/DocsPage.tsx +69 -0
- package/src/pages/EnvPage.tsx +918 -0
- package/src/pages/LogsPage.tsx +246 -0
- package/src/pages/McpPage.tsx +757 -0
- package/src/pages/ModelsPage.tsx +994 -0
- package/src/pages/PairingPage.tsx +276 -0
- package/src/pages/PluginsPage.tsx +580 -0
- package/src/pages/ProfilesPage.tsx +559 -0
- package/src/pages/SessionsPage.tsx +936 -0
- package/src/pages/SkillsPage.tsx +557 -0
- package/src/pages/SystemPage.tsx +1259 -0
- package/src/pages/WebhooksPage.tsx +483 -0
- package/src/plugins/PluginPage.tsx +64 -0
- package/src/plugins/index.ts +6 -0
- package/src/plugins/registry.ts +151 -0
- package/src/plugins/sdk.d.ts +160 -0
- package/src/plugins/slots.ts +199 -0
- package/src/plugins/types.ts +37 -0
- package/src/plugins/usePlugins.ts +133 -0
- package/src/themes/context.tsx +443 -0
- package/src/themes/fonts.ts +160 -0
- package/src/themes/index.ts +3 -0
- package/src/themes/presets.ts +477 -0
- package/src/themes/types.ts +187 -0
- package/tsconfig.app.json +34 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +26 -0
- package/vite.config.ts +124 -0
- package/vite.config.ts.timestamp-1780999102396-af6b77b30ebd8.mjs +105 -0
|
@@ -0,0 +1,994 @@
|
|
|
1
|
+
import { useCallback, useEffect, useLayoutEffect, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Brain,
|
|
4
|
+
ChevronDown,
|
|
5
|
+
Cpu,
|
|
6
|
+
DollarSign,
|
|
7
|
+
Eye,
|
|
8
|
+
RefreshCw,
|
|
9
|
+
Settings2,
|
|
10
|
+
Star,
|
|
11
|
+
Wrench,
|
|
12
|
+
X,
|
|
13
|
+
Zap,
|
|
14
|
+
} from "lucide-react";
|
|
15
|
+
import { api } from "@/lib/api";
|
|
16
|
+
import type {
|
|
17
|
+
AuxiliaryModelsResponse,
|
|
18
|
+
AuxiliaryTaskAssignment,
|
|
19
|
+
ModelsAnalyticsModelEntry,
|
|
20
|
+
ModelsAnalyticsResponse,
|
|
21
|
+
} from "@/lib/api";
|
|
22
|
+
import { timeAgo, cn, themedBody } from "@/lib/utils";
|
|
23
|
+
import { formatTokenCount } from "@/lib/format";
|
|
24
|
+
import { Button } from "@nastechai/ui/ui/components/button";
|
|
25
|
+
import { Spinner } from "@nastechai/ui/ui/components/spinner";
|
|
26
|
+
import { Stats } from "@nastechai/ui/ui/components/stats";
|
|
27
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@nastechai/ui/ui/components/card";
|
|
28
|
+
import { Badge } from "@nastechai/ui/ui/components/badge";
|
|
29
|
+
import { ConfirmDialog } from "@nastechai/ui/ui/components/confirm-dialog";
|
|
30
|
+
import { useModalBehavior } from "@/hooks/useModalBehavior";
|
|
31
|
+
import { usePageHeader } from "@/contexts/usePageHeader";
|
|
32
|
+
import { useI18n } from "@/i18n";
|
|
33
|
+
import { PluginSlot } from "@/plugins";
|
|
34
|
+
import { ModelPickerDialog } from "@/components/ModelPickerDialog";
|
|
35
|
+
|
|
36
|
+
const PERIODS = [
|
|
37
|
+
{ label: "7d", days: 7 },
|
|
38
|
+
{ label: "30d", days: 30 },
|
|
39
|
+
{ label: "90d", days: 90 },
|
|
40
|
+
] as const;
|
|
41
|
+
|
|
42
|
+
// Must match _AUX_TASK_SLOTS in nastech_cli/web_server.py.
|
|
43
|
+
const AUX_TASKS: readonly { key: string; label: string; hint: string }[] = [
|
|
44
|
+
{ key: "vision", label: "Vision", hint: "Image analysis" },
|
|
45
|
+
{ key: "web_extract", label: "Web Extract", hint: "Page summarization" },
|
|
46
|
+
{ key: "compression", label: "Compression", hint: "Context compaction" },
|
|
47
|
+
{ key: "skills_hub", label: "Skills Hub", hint: "Skill search" },
|
|
48
|
+
{ key: "approval", label: "Approval", hint: "Smart auto-approve" },
|
|
49
|
+
{ key: "mcp", label: "MCP", hint: "MCP tool routing" },
|
|
50
|
+
{ key: "title_generation", label: "Title Gen", hint: "Session titles" },
|
|
51
|
+
{ key: "triage_specifier", label: "Triage Specifier", hint: "Kanban spec fleshing" },
|
|
52
|
+
{ key: "kanban_decomposer", label: "Kanban Decomposer", hint: "Task decomposition" },
|
|
53
|
+
{ key: "profile_describer", label: "Profile Describer", hint: "Auto profile descriptions" },
|
|
54
|
+
{ key: "curator", label: "Curator", hint: "Skill-usage review" },
|
|
55
|
+
] as const;
|
|
56
|
+
|
|
57
|
+
function formatTokens(n: number): string {
|
|
58
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
59
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
|
60
|
+
return String(n);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function formatCost(n: number): string {
|
|
64
|
+
if (n >= 1) return `$${n.toFixed(2)}`;
|
|
65
|
+
if (n >= 0.01) return `$${n.toFixed(3)}`;
|
|
66
|
+
if (n > 0) return `$${n.toFixed(4)}`;
|
|
67
|
+
return "$0";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Short model name: strip vendor prefix like "openrouter/" or "anthropic/". */
|
|
71
|
+
function shortModelName(model: string): string {
|
|
72
|
+
const slashIdx = model.indexOf("/");
|
|
73
|
+
if (slashIdx > 0) return model.slice(slashIdx + 1);
|
|
74
|
+
return model;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Extract vendor prefix from a model string like "anthropic/claude-opus-4.7" → "anthropic". */
|
|
78
|
+
function modelVendor(model: string, fallback?: string): string {
|
|
79
|
+
const slashIdx = model.indexOf("/");
|
|
80
|
+
if (slashIdx > 0) return model.slice(0, slashIdx);
|
|
81
|
+
return fallback || "";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function TokenBar({
|
|
85
|
+
input,
|
|
86
|
+
output,
|
|
87
|
+
cacheRead,
|
|
88
|
+
reasoning,
|
|
89
|
+
}: {
|
|
90
|
+
input: number;
|
|
91
|
+
output: number;
|
|
92
|
+
cacheRead: number;
|
|
93
|
+
reasoning: number;
|
|
94
|
+
}) {
|
|
95
|
+
const total = input + output + cacheRead + reasoning;
|
|
96
|
+
if (total === 0) return null;
|
|
97
|
+
|
|
98
|
+
const segments = [
|
|
99
|
+
{ value: cacheRead, color: "bg-blue-400/60", dotColor: "bg-blue-400", label: "Cache Read" },
|
|
100
|
+
{ value: reasoning, color: "bg-purple-400/60", dotColor: "bg-purple-400", label: "Reasoning" },
|
|
101
|
+
{ value: input, color: "bg-[#ffe6cb]/70", dotColor: "bg-[#ffe6cb]", label: "Input" },
|
|
102
|
+
{ value: output, color: "bg-emerald-500/70", dotColor: "bg-emerald-500", label: "Output" },
|
|
103
|
+
].filter((s) => s.value > 0);
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<div className="space-y-1.5">
|
|
107
|
+
{/* Stacked bar — segments fill proportionally to their share of total */}
|
|
108
|
+
<div className="relative flex min-h-[1.5rem] w-full items-stretch overflow-hidden">
|
|
109
|
+
{segments.map((s, i) => (
|
|
110
|
+
<div
|
|
111
|
+
key={i}
|
|
112
|
+
className={`${s.color} relative flex items-center transition-all duration-300`}
|
|
113
|
+
style={{ width: `${(s.value / total) * 100}%` }}
|
|
114
|
+
>
|
|
115
|
+
{/* Stepped fill pattern overlay */}
|
|
116
|
+
<div
|
|
117
|
+
className="absolute inset-0 opacity-30"
|
|
118
|
+
style={{
|
|
119
|
+
backgroundImage:
|
|
120
|
+
"repeating-linear-gradient(to right, transparent 0 0.4rem, currentColor 0.4rem calc(0.4rem + 1px))",
|
|
121
|
+
}}
|
|
122
|
+
/>
|
|
123
|
+
</div>
|
|
124
|
+
))}
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
{/* Legend */}
|
|
128
|
+
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs text-text-secondary">
|
|
129
|
+
{segments.map((s, i) => (
|
|
130
|
+
<span key={i} className="flex items-center gap-1">
|
|
131
|
+
<span className={`inline-block h-1.5 w-1.5 rounded-full ${s.dotColor}`} />
|
|
132
|
+
{s.label} {formatTokens(s.value)}
|
|
133
|
+
</span>
|
|
134
|
+
))}
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function CapabilityBadges({
|
|
141
|
+
capabilities,
|
|
142
|
+
}: {
|
|
143
|
+
capabilities: ModelsAnalyticsModelEntry["capabilities"];
|
|
144
|
+
}) {
|
|
145
|
+
const hasAny =
|
|
146
|
+
capabilities.supports_tools ||
|
|
147
|
+
capabilities.supports_vision ||
|
|
148
|
+
capabilities.supports_reasoning ||
|
|
149
|
+
capabilities.model_family;
|
|
150
|
+
if (!hasAny) return null;
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<div className="flex flex-wrap items-center gap-1.5">
|
|
154
|
+
{capabilities.supports_tools && (
|
|
155
|
+
<span className="inline-flex items-center gap-1 bg-emerald-500/10 px-1.5 py-0.5 text-xs font-medium text-emerald-600 dark:text-emerald-400">
|
|
156
|
+
<Wrench className="h-2.5 w-2.5" /> Tools
|
|
157
|
+
</span>
|
|
158
|
+
)}
|
|
159
|
+
{capabilities.supports_vision && (
|
|
160
|
+
<span className="inline-flex items-center gap-1 bg-blue-500/10 px-1.5 py-0.5 text-xs font-medium text-blue-600 dark:text-blue-400">
|
|
161
|
+
<Eye className="h-2.5 w-2.5" /> Vision
|
|
162
|
+
</span>
|
|
163
|
+
)}
|
|
164
|
+
{capabilities.supports_reasoning && (
|
|
165
|
+
<span className="inline-flex items-center gap-1 bg-purple-500/10 px-1.5 py-0.5 text-xs font-medium text-purple-600 dark:text-purple-400">
|
|
166
|
+
<Brain className="h-2.5 w-2.5" /> Reasoning
|
|
167
|
+
</span>
|
|
168
|
+
)}
|
|
169
|
+
{capabilities.model_family && (
|
|
170
|
+
<span className="inline-flex items-center bg-muted px-1.5 py-0.5 text-xs font-medium text-text-secondary">
|
|
171
|
+
{capabilities.model_family}
|
|
172
|
+
</span>
|
|
173
|
+
)}
|
|
174
|
+
</div>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/* ──────────────────────────────────────────────────────────────────── */
|
|
179
|
+
/* Per-card "Use as" menu */
|
|
180
|
+
/* ──────────────────────────────────────────────────────────────────── */
|
|
181
|
+
|
|
182
|
+
function UseAsMenu({
|
|
183
|
+
provider,
|
|
184
|
+
model,
|
|
185
|
+
isMain,
|
|
186
|
+
mainAuxTask,
|
|
187
|
+
onAssigned,
|
|
188
|
+
}: {
|
|
189
|
+
provider: string;
|
|
190
|
+
model: string;
|
|
191
|
+
/** True when this card's model+provider match config.yaml's main slot. */
|
|
192
|
+
isMain: boolean;
|
|
193
|
+
/** If this model is assigned to a specific aux task, that task's key. */
|
|
194
|
+
mainAuxTask: string | null;
|
|
195
|
+
onAssigned(): void;
|
|
196
|
+
}) {
|
|
197
|
+
const [open, setOpen] = useState(false);
|
|
198
|
+
const [busy, setBusy] = useState(false);
|
|
199
|
+
const [error, setError] = useState<string | null>(null);
|
|
200
|
+
|
|
201
|
+
const assign = async (
|
|
202
|
+
scope: "main" | "auxiliary",
|
|
203
|
+
task: string,
|
|
204
|
+
) => {
|
|
205
|
+
if (!provider || !model) {
|
|
206
|
+
setError("Missing provider/model");
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
setBusy(true);
|
|
210
|
+
setError(null);
|
|
211
|
+
try {
|
|
212
|
+
await api.setModelAssignment({ scope, provider, model, task });
|
|
213
|
+
onAssigned();
|
|
214
|
+
setOpen(false);
|
|
215
|
+
} catch (e) {
|
|
216
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
217
|
+
} finally {
|
|
218
|
+
setBusy(false);
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
// Close on outside click.
|
|
223
|
+
useEffect(() => {
|
|
224
|
+
if (!open) return;
|
|
225
|
+
const onDown = (e: MouseEvent) => {
|
|
226
|
+
const target = e.target as HTMLElement | null;
|
|
227
|
+
if (target && !target.closest?.("[data-use-as-menu]")) setOpen(false);
|
|
228
|
+
};
|
|
229
|
+
window.addEventListener("mousedown", onDown);
|
|
230
|
+
return () => window.removeEventListener("mousedown", onDown);
|
|
231
|
+
}, [open]);
|
|
232
|
+
|
|
233
|
+
return (
|
|
234
|
+
<div className="relative" data-use-as-menu>
|
|
235
|
+
<Button
|
|
236
|
+
size="sm"
|
|
237
|
+
outlined
|
|
238
|
+
onClick={() => setOpen((v) => !v)}
|
|
239
|
+
disabled={busy}
|
|
240
|
+
className="h-6 px-2 text-xs uppercase"
|
|
241
|
+
prefix={busy ? <Spinner /> : null}
|
|
242
|
+
>
|
|
243
|
+
Use as <ChevronDown className="h-3 w-3" />
|
|
244
|
+
</Button>
|
|
245
|
+
{open && (
|
|
246
|
+
<div className="absolute right-0 top-full mt-1 z-50 min-w-[220px] border border-border bg-card shadow-lg">
|
|
247
|
+
<button
|
|
248
|
+
type="button"
|
|
249
|
+
onClick={() => assign("main", "")}
|
|
250
|
+
disabled={busy}
|
|
251
|
+
className="flex w-full items-center justify-between px-3 py-2 text-xs uppercase hover:bg-muted/50 disabled:opacity-40"
|
|
252
|
+
>
|
|
253
|
+
<span className="flex items-center gap-2">
|
|
254
|
+
<Star className="h-3 w-3" />
|
|
255
|
+
Main model
|
|
256
|
+
</span>
|
|
257
|
+
{isMain && (
|
|
258
|
+
<span className="text-display text-xs tracking-wider text-primary">
|
|
259
|
+
current
|
|
260
|
+
</span>
|
|
261
|
+
)}
|
|
262
|
+
</button>
|
|
263
|
+
|
|
264
|
+
<div className="border-t border-border/50 px-3 py-1.5 text-display text-xs tracking-wider text-text-tertiary">
|
|
265
|
+
Auxiliary task
|
|
266
|
+
</div>
|
|
267
|
+
|
|
268
|
+
<button
|
|
269
|
+
type="button"
|
|
270
|
+
onClick={() => assign("auxiliary", "")}
|
|
271
|
+
disabled={busy}
|
|
272
|
+
className="flex w-full items-center justify-between px-3 py-1.5 text-xs uppercase hover:bg-muted/50 disabled:opacity-40"
|
|
273
|
+
>
|
|
274
|
+
<span>All auxiliary tasks</span>
|
|
275
|
+
</button>
|
|
276
|
+
|
|
277
|
+
{AUX_TASKS.map((t) => (
|
|
278
|
+
<button
|
|
279
|
+
key={t.key}
|
|
280
|
+
type="button"
|
|
281
|
+
onClick={() => assign("auxiliary", t.key)}
|
|
282
|
+
disabled={busy}
|
|
283
|
+
className="flex w-full items-center justify-between px-3 py-1.5 text-xs uppercase hover:bg-muted/50 disabled:opacity-40"
|
|
284
|
+
>
|
|
285
|
+
<span>{t.label}</span>
|
|
286
|
+
{mainAuxTask === t.key && (
|
|
287
|
+
<span className="text-display text-xs tracking-wider text-primary">
|
|
288
|
+
current
|
|
289
|
+
</span>
|
|
290
|
+
)}
|
|
291
|
+
</button>
|
|
292
|
+
))}
|
|
293
|
+
|
|
294
|
+
{error && (
|
|
295
|
+
<div className="px-3 py-2 text-xs text-destructive border-t border-border/50">
|
|
296
|
+
{error}
|
|
297
|
+
</div>
|
|
298
|
+
)}
|
|
299
|
+
</div>
|
|
300
|
+
)}
|
|
301
|
+
</div>
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/* ──────────────────────────────────────────────────────────────────── */
|
|
306
|
+
/* ModelCard */
|
|
307
|
+
/* ──────────────────────────────────────────────────────────────────── */
|
|
308
|
+
|
|
309
|
+
function ModelCard({
|
|
310
|
+
entry,
|
|
311
|
+
rank,
|
|
312
|
+
main,
|
|
313
|
+
aux,
|
|
314
|
+
onAssigned,
|
|
315
|
+
showTokens,
|
|
316
|
+
}: {
|
|
317
|
+
entry: ModelsAnalyticsModelEntry;
|
|
318
|
+
rank: number;
|
|
319
|
+
main: { provider: string; model: string } | null;
|
|
320
|
+
aux: AuxiliaryTaskAssignment[];
|
|
321
|
+
onAssigned(): void;
|
|
322
|
+
showTokens: boolean;
|
|
323
|
+
}) {
|
|
324
|
+
const { t } = useI18n();
|
|
325
|
+
const provider = entry.provider || modelVendor(entry.model);
|
|
326
|
+
const totalTokens = entry.input_tokens + entry.output_tokens;
|
|
327
|
+
const caps = entry.capabilities;
|
|
328
|
+
|
|
329
|
+
const isMain =
|
|
330
|
+
!!main &&
|
|
331
|
+
main.provider === provider &&
|
|
332
|
+
main.model === entry.model;
|
|
333
|
+
|
|
334
|
+
// First aux task currently using this model (if any).
|
|
335
|
+
const mainAuxTask =
|
|
336
|
+
aux.find(
|
|
337
|
+
(a) => a.provider === provider && a.model === entry.model,
|
|
338
|
+
)?.task ?? null;
|
|
339
|
+
|
|
340
|
+
return (
|
|
341
|
+
<Card
|
|
342
|
+
className={`min-w-0 max-w-full overflow-hidden${isMain ? " ring-1 ring-primary/40" : ""}`}
|
|
343
|
+
>
|
|
344
|
+
<CardHeader className="pb-3">
|
|
345
|
+
<div className="flex items-start justify-between gap-2">
|
|
346
|
+
<div className="min-w-0 flex-1">
|
|
347
|
+
<div className="flex items-center gap-2">
|
|
348
|
+
<span className="text-text-tertiary text-xs font-mono">
|
|
349
|
+
#{rank}
|
|
350
|
+
</span>
|
|
351
|
+
<CardTitle className="text-sm font-mono-ui truncate">
|
|
352
|
+
{shortModelName(entry.model)}
|
|
353
|
+
</CardTitle>
|
|
354
|
+
{isMain && (
|
|
355
|
+
<span className="inline-flex items-center gap-0.5 bg-primary/15 px-1.5 py-0.5 text-display text-xs font-medium tracking-wider text-primary">
|
|
356
|
+
<Star className="h-2.5 w-2.5" /> main
|
|
357
|
+
</span>
|
|
358
|
+
)}
|
|
359
|
+
{mainAuxTask && (
|
|
360
|
+
<span className="inline-flex items-center bg-purple-500/10 px-1.5 py-0.5 text-display text-xs font-medium tracking-wider text-purple-600 dark:text-purple-400">
|
|
361
|
+
aux · {mainAuxTask}
|
|
362
|
+
</span>
|
|
363
|
+
)}
|
|
364
|
+
</div>
|
|
365
|
+
<div className="flex items-center gap-2 mt-1">
|
|
366
|
+
{provider && (
|
|
367
|
+
<Badge tone="secondary" className="text-xs">
|
|
368
|
+
{provider}
|
|
369
|
+
</Badge>
|
|
370
|
+
)}
|
|
371
|
+
{caps.context_window && caps.context_window > 0 && (
|
|
372
|
+
<span className="text-xs text-text-secondary">
|
|
373
|
+
{formatTokenCount(caps.context_window)} ctx
|
|
374
|
+
</span>
|
|
375
|
+
)}
|
|
376
|
+
{caps.max_output_tokens && caps.max_output_tokens > 0 && (
|
|
377
|
+
<span className="text-xs text-text-secondary">
|
|
378
|
+
{formatTokenCount(caps.max_output_tokens)} out
|
|
379
|
+
</span>
|
|
380
|
+
)}
|
|
381
|
+
</div>
|
|
382
|
+
</div>
|
|
383
|
+
<div className="flex flex-col items-end gap-1 shrink-0">
|
|
384
|
+
{showTokens ? (
|
|
385
|
+
<div className="text-right">
|
|
386
|
+
<div className="text-xs font-mono font-semibold">
|
|
387
|
+
{formatTokens(totalTokens)}
|
|
388
|
+
</div>
|
|
389
|
+
<div className="text-xs text-text-tertiary">
|
|
390
|
+
{t.models.tokens}
|
|
391
|
+
</div>
|
|
392
|
+
</div>
|
|
393
|
+
) : (
|
|
394
|
+
entry.sessions > 0 && (
|
|
395
|
+
<div className="text-right">
|
|
396
|
+
<div className="text-xs font-mono font-semibold">
|
|
397
|
+
{entry.sessions}
|
|
398
|
+
</div>
|
|
399
|
+
<div className="text-xs text-text-tertiary">
|
|
400
|
+
{t.models.sessions}
|
|
401
|
+
</div>
|
|
402
|
+
</div>
|
|
403
|
+
)
|
|
404
|
+
)}
|
|
405
|
+
<UseAsMenu
|
|
406
|
+
provider={provider}
|
|
407
|
+
model={entry.model}
|
|
408
|
+
isMain={isMain}
|
|
409
|
+
mainAuxTask={mainAuxTask}
|
|
410
|
+
onAssigned={onAssigned}
|
|
411
|
+
/>
|
|
412
|
+
</div>
|
|
413
|
+
</div>
|
|
414
|
+
</CardHeader>
|
|
415
|
+
<CardContent className="space-y-3 pt-3">
|
|
416
|
+
{showTokens && (
|
|
417
|
+
<>
|
|
418
|
+
<TokenBar
|
|
419
|
+
input={entry.input_tokens}
|
|
420
|
+
output={entry.output_tokens}
|
|
421
|
+
cacheRead={entry.cache_read_tokens}
|
|
422
|
+
reasoning={entry.reasoning_tokens}
|
|
423
|
+
/>
|
|
424
|
+
|
|
425
|
+
<div className="grid grid-cols-3 gap-2 text-xs">
|
|
426
|
+
<div className="text-center">
|
|
427
|
+
<div className="font-mono font-semibold">{entry.sessions}</div>
|
|
428
|
+
<div className="text-xs text-text-tertiary">
|
|
429
|
+
{t.models.sessions}
|
|
430
|
+
</div>
|
|
431
|
+
</div>
|
|
432
|
+
<div className="text-center">
|
|
433
|
+
<div className="font-mono font-semibold">
|
|
434
|
+
{formatTokens(entry.avg_tokens_per_session)}
|
|
435
|
+
</div>
|
|
436
|
+
<div className="text-xs text-text-tertiary">
|
|
437
|
+
{t.models.avgPerSession}
|
|
438
|
+
</div>
|
|
439
|
+
</div>
|
|
440
|
+
<div className="text-center">
|
|
441
|
+
<div className="font-mono font-semibold">
|
|
442
|
+
{entry.api_calls > 0 ? formatTokens(entry.api_calls) : "—"}
|
|
443
|
+
</div>
|
|
444
|
+
<div className="text-xs text-text-tertiary">
|
|
445
|
+
{t.models.apiCalls}
|
|
446
|
+
</div>
|
|
447
|
+
</div>
|
|
448
|
+
</div>
|
|
449
|
+
</>
|
|
450
|
+
)}
|
|
451
|
+
|
|
452
|
+
<div className="flex items-center justify-between text-xs text-text-secondary border-t border-border/30 pt-2">
|
|
453
|
+
<div className="flex items-center gap-3">
|
|
454
|
+
{showTokens && entry.estimated_cost > 0 && (
|
|
455
|
+
<span className="flex items-center gap-0.5">
|
|
456
|
+
<DollarSign className="h-2.5 w-2.5" />
|
|
457
|
+
{formatCost(entry.estimated_cost)}
|
|
458
|
+
</span>
|
|
459
|
+
)}
|
|
460
|
+
{showTokens && entry.tool_calls > 0 && (
|
|
461
|
+
<span className="flex items-center gap-0.5">
|
|
462
|
+
<Zap className="h-2.5 w-2.5" />
|
|
463
|
+
{entry.tool_calls} {t.models.toolCalls}
|
|
464
|
+
</span>
|
|
465
|
+
)}
|
|
466
|
+
</div>
|
|
467
|
+
{entry.last_used_at > 0 && (
|
|
468
|
+
<span>{timeAgo(entry.last_used_at)}</span>
|
|
469
|
+
)}
|
|
470
|
+
</div>
|
|
471
|
+
|
|
472
|
+
<CapabilityBadges capabilities={entry.capabilities} />
|
|
473
|
+
</CardContent>
|
|
474
|
+
</Card>
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/* ──────────────────────────────────────────────────────────────────── */
|
|
479
|
+
/* Model Settings panel (top of page) */
|
|
480
|
+
/* ──────────────────────────────────────────────────────────────────── */
|
|
481
|
+
|
|
482
|
+
type PickerTarget =
|
|
483
|
+
| { kind: "main" }
|
|
484
|
+
| { kind: "aux"; task: string };
|
|
485
|
+
|
|
486
|
+
function AuxiliaryTasksModal({
|
|
487
|
+
aux,
|
|
488
|
+
refreshKey,
|
|
489
|
+
onSaved,
|
|
490
|
+
onClose,
|
|
491
|
+
}: {
|
|
492
|
+
aux: AuxiliaryModelsResponse | null;
|
|
493
|
+
refreshKey: number;
|
|
494
|
+
onSaved(): void;
|
|
495
|
+
onClose(): void;
|
|
496
|
+
}) {
|
|
497
|
+
const [picker, setPicker] = useState<PickerTarget | null>(null);
|
|
498
|
+
const [resetBusy, setResetBusy] = useState(false);
|
|
499
|
+
const [confirmReset, setConfirmReset] = useState(false);
|
|
500
|
+
const modalRef = useModalBehavior({ open: true, onClose });
|
|
501
|
+
|
|
502
|
+
const resetAllAux = async () => {
|
|
503
|
+
setConfirmReset(false);
|
|
504
|
+
setResetBusy(true);
|
|
505
|
+
try {
|
|
506
|
+
await api.setModelAssignment({
|
|
507
|
+
scope: "auxiliary",
|
|
508
|
+
task: "__reset__",
|
|
509
|
+
provider: "",
|
|
510
|
+
model: "",
|
|
511
|
+
});
|
|
512
|
+
onSaved();
|
|
513
|
+
} finally {
|
|
514
|
+
setResetBusy(false);
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
return (
|
|
519
|
+
<div
|
|
520
|
+
ref={modalRef}
|
|
521
|
+
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
|
|
522
|
+
onClick={(e) => e.target === e.currentTarget && onClose()}
|
|
523
|
+
role="dialog"
|
|
524
|
+
aria-modal="true"
|
|
525
|
+
aria-labelledby="aux-modal-title"
|
|
526
|
+
>
|
|
527
|
+
<div className={cn(themedBody, "relative w-full max-w-2xl max-h-[80vh] border border-border bg-card shadow-2xl flex flex-col")}>
|
|
528
|
+
<Button
|
|
529
|
+
ghost
|
|
530
|
+
size="icon"
|
|
531
|
+
onClick={onClose}
|
|
532
|
+
className="absolute right-2 top-2 text-muted-foreground hover:text-foreground"
|
|
533
|
+
aria-label="Close"
|
|
534
|
+
>
|
|
535
|
+
<X />
|
|
536
|
+
</Button>
|
|
537
|
+
|
|
538
|
+
<header className="p-5 pb-3 border-b border-border">
|
|
539
|
+
<div className="flex items-center justify-between gap-3 pr-8">
|
|
540
|
+
<h2
|
|
541
|
+
id="aux-modal-title"
|
|
542
|
+
className="font-mondwest text-display text-base tracking-wider"
|
|
543
|
+
>
|
|
544
|
+
Auxiliary Tasks
|
|
545
|
+
</h2>
|
|
546
|
+
<Button
|
|
547
|
+
size="sm"
|
|
548
|
+
outlined
|
|
549
|
+
onClick={() => setConfirmReset(true)}
|
|
550
|
+
disabled={resetBusy}
|
|
551
|
+
className="h-6 text-xs uppercase"
|
|
552
|
+
prefix={resetBusy ? <Spinner /> : null}
|
|
553
|
+
>
|
|
554
|
+
Reset all to auto
|
|
555
|
+
</Button>
|
|
556
|
+
</div>
|
|
557
|
+
<p className="text-xs text-text-secondary mt-2">
|
|
558
|
+
Auxiliary tasks handle side-jobs like vision, session search, and
|
|
559
|
+
compression. <span className="font-mono">auto</span> means
|
|
560
|
+
"use the main model". Override per-task when you want a
|
|
561
|
+
cheap/fast model for a specific job.
|
|
562
|
+
</p>
|
|
563
|
+
</header>
|
|
564
|
+
|
|
565
|
+
<div className="flex-1 overflow-y-auto p-5 space-y-1">
|
|
566
|
+
{AUX_TASKS.map((t) => {
|
|
567
|
+
const cur = aux?.tasks.find((a) => a.task === t.key);
|
|
568
|
+
const isAuto =
|
|
569
|
+
!cur || cur.provider === "auto" || !cur.provider;
|
|
570
|
+
return (
|
|
571
|
+
<div
|
|
572
|
+
key={t.key}
|
|
573
|
+
className="flex items-center justify-between gap-3 px-3 py-2 border border-border/30 bg-card/50 hover:bg-muted/20 transition-colors"
|
|
574
|
+
>
|
|
575
|
+
<div className="min-w-0 flex-1">
|
|
576
|
+
<div className="flex items-baseline gap-2">
|
|
577
|
+
<span className="text-xs font-medium">{t.label}</span>
|
|
578
|
+
<span className="text-xs text-text-tertiary">
|
|
579
|
+
{t.hint}
|
|
580
|
+
</span>
|
|
581
|
+
</div>
|
|
582
|
+
<div className="text-xs font-mono text-text-secondary truncate">
|
|
583
|
+
{isAuto
|
|
584
|
+
? "auto (use main model)"
|
|
585
|
+
: `${cur?.provider} · ${cur?.model || "(provider default)"}`}
|
|
586
|
+
</div>
|
|
587
|
+
</div>
|
|
588
|
+
<Button
|
|
589
|
+
size="sm"
|
|
590
|
+
outlined
|
|
591
|
+
onClick={() => setPicker({ kind: "aux", task: t.key })}
|
|
592
|
+
className="h-6 text-xs uppercase"
|
|
593
|
+
>
|
|
594
|
+
Change
|
|
595
|
+
</Button>
|
|
596
|
+
</div>
|
|
597
|
+
);
|
|
598
|
+
})}
|
|
599
|
+
</div>
|
|
600
|
+
|
|
601
|
+
{picker && picker.kind === "aux" && (
|
|
602
|
+
<ModelPickerDialog
|
|
603
|
+
key={`picker-${refreshKey}`}
|
|
604
|
+
loader={api.getModelOptions}
|
|
605
|
+
alwaysGlobal
|
|
606
|
+
title={`Set Auxiliary: ${
|
|
607
|
+
AUX_TASKS.find((t) => t.key === picker.task)?.label ??
|
|
608
|
+
picker.task
|
|
609
|
+
}`}
|
|
610
|
+
onApply={async ({ provider, model }) => {
|
|
611
|
+
await api.setModelAssignment({
|
|
612
|
+
scope: "auxiliary",
|
|
613
|
+
task: picker.task,
|
|
614
|
+
provider,
|
|
615
|
+
model,
|
|
616
|
+
});
|
|
617
|
+
onSaved();
|
|
618
|
+
}}
|
|
619
|
+
onClose={() => setPicker(null)}
|
|
620
|
+
/>
|
|
621
|
+
)}
|
|
622
|
+
<ConfirmDialog
|
|
623
|
+
open={confirmReset}
|
|
624
|
+
onCancel={() => setConfirmReset(false)}
|
|
625
|
+
onConfirm={() => void resetAllAux()}
|
|
626
|
+
title="Reset auxiliary models"
|
|
627
|
+
description="Reset every auxiliary task to 'auto'? This overrides any per-task overrides you've set."
|
|
628
|
+
destructive
|
|
629
|
+
confirmLabel="Reset all"
|
|
630
|
+
loading={resetBusy}
|
|
631
|
+
/>
|
|
632
|
+
</div>
|
|
633
|
+
</div>
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function ModelSettingsPanel({
|
|
638
|
+
aux,
|
|
639
|
+
refreshKey,
|
|
640
|
+
onSaved,
|
|
641
|
+
}: {
|
|
642
|
+
aux: AuxiliaryModelsResponse | null;
|
|
643
|
+
refreshKey: number;
|
|
644
|
+
onSaved(): void;
|
|
645
|
+
}) {
|
|
646
|
+
const [auxModalOpen, setAuxModalOpen] = useState(false);
|
|
647
|
+
const [picker, setPicker] = useState<PickerTarget | null>(null);
|
|
648
|
+
|
|
649
|
+
const mainProv = aux?.main.provider ?? "";
|
|
650
|
+
const mainModel = aux?.main.model ?? "";
|
|
651
|
+
|
|
652
|
+
const applyAssignment = async ({
|
|
653
|
+
scope,
|
|
654
|
+
task,
|
|
655
|
+
provider,
|
|
656
|
+
model,
|
|
657
|
+
}: {
|
|
658
|
+
scope: "main" | "auxiliary";
|
|
659
|
+
task: string;
|
|
660
|
+
provider: string;
|
|
661
|
+
model: string;
|
|
662
|
+
}) => {
|
|
663
|
+
await api.setModelAssignment({ scope, task, provider, model });
|
|
664
|
+
onSaved();
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
// Count how many aux tasks have overrides
|
|
668
|
+
const auxOverrideCount = aux?.tasks.filter(
|
|
669
|
+
(a) => a.provider && a.provider !== "auto",
|
|
670
|
+
).length ?? 0;
|
|
671
|
+
|
|
672
|
+
return (
|
|
673
|
+
<Card className="min-w-0 max-w-full overflow-hidden">
|
|
674
|
+
<CardHeader className="min-w-0 pb-3">
|
|
675
|
+
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
|
|
676
|
+
<Settings2 className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
677
|
+
<CardTitle className="text-sm">Model Settings</CardTitle>
|
|
678
|
+
<span className="max-w-full min-w-0 text-xs text-text-secondary [overflow-wrap:anywhere]">
|
|
679
|
+
applies to new sessions
|
|
680
|
+
</span>
|
|
681
|
+
</div>
|
|
682
|
+
</CardHeader>
|
|
683
|
+
|
|
684
|
+
<CardContent className="min-w-0 space-y-3 pt-3">
|
|
685
|
+
{/* Main row */}
|
|
686
|
+
<div className="flex min-w-0 flex-col gap-2 bg-muted/20 border border-border/50 px-3 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
|
687
|
+
<div className="min-w-0 flex-1">
|
|
688
|
+
<div className="flex items-center gap-2 mb-0.5">
|
|
689
|
+
<Star className="h-3 w-3 text-primary" />
|
|
690
|
+
<span className="text-display text-xs font-medium tracking-wider">
|
|
691
|
+
Main model
|
|
692
|
+
</span>
|
|
693
|
+
</div>
|
|
694
|
+
<div className="text-xs font-mono text-text-secondary truncate">
|
|
695
|
+
{mainProv || "(unset)"}
|
|
696
|
+
{mainProv && mainModel && " · "}
|
|
697
|
+
{mainModel || "(unset)"}
|
|
698
|
+
</div>
|
|
699
|
+
</div>
|
|
700
|
+
<Button
|
|
701
|
+
size="sm"
|
|
702
|
+
onClick={() => setPicker({ kind: "main" })}
|
|
703
|
+
className="shrink-0 self-start text-xs uppercase sm:self-center"
|
|
704
|
+
>
|
|
705
|
+
Change
|
|
706
|
+
</Button>
|
|
707
|
+
</div>
|
|
708
|
+
|
|
709
|
+
{/* Auxiliary tasks summary + open modal */}
|
|
710
|
+
<div className="flex min-w-0 flex-col gap-2 bg-muted/20 border border-border/50 px-3 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
|
711
|
+
<div className="min-w-0 flex-1">
|
|
712
|
+
<div className="flex items-center gap-2 mb-0.5">
|
|
713
|
+
<Cpu className="h-3 w-3 text-text-tertiary" />
|
|
714
|
+
<span className="text-display text-xs font-medium tracking-wider">
|
|
715
|
+
Auxiliary tasks
|
|
716
|
+
</span>
|
|
717
|
+
</div>
|
|
718
|
+
<div className="text-xs font-mono text-text-secondary truncate">
|
|
719
|
+
{auxOverrideCount > 0
|
|
720
|
+
? `${auxOverrideCount} override${auxOverrideCount > 1 ? "s" : ""} · ${AUX_TASKS.length - auxOverrideCount} auto`
|
|
721
|
+
: `${AUX_TASKS.length} tasks · all auto`}
|
|
722
|
+
</div>
|
|
723
|
+
</div>
|
|
724
|
+
<Button
|
|
725
|
+
size="sm"
|
|
726
|
+
outlined
|
|
727
|
+
onClick={() => setAuxModalOpen(true)}
|
|
728
|
+
className="shrink-0 self-start text-xs uppercase sm:self-center"
|
|
729
|
+
>
|
|
730
|
+
Configure
|
|
731
|
+
</Button>
|
|
732
|
+
</div>
|
|
733
|
+
|
|
734
|
+
{picker && (
|
|
735
|
+
<ModelPickerDialog
|
|
736
|
+
key={`picker-${refreshKey}`}
|
|
737
|
+
loader={api.getModelOptions}
|
|
738
|
+
alwaysGlobal
|
|
739
|
+
title="Set Main Model"
|
|
740
|
+
onApply={async ({ provider, model }) => {
|
|
741
|
+
await applyAssignment({
|
|
742
|
+
scope: "main",
|
|
743
|
+
task: "",
|
|
744
|
+
provider,
|
|
745
|
+
model,
|
|
746
|
+
});
|
|
747
|
+
}}
|
|
748
|
+
onClose={() => setPicker(null)}
|
|
749
|
+
/>
|
|
750
|
+
)}
|
|
751
|
+
|
|
752
|
+
{auxModalOpen && (
|
|
753
|
+
<AuxiliaryTasksModal
|
|
754
|
+
aux={aux}
|
|
755
|
+
refreshKey={refreshKey}
|
|
756
|
+
onSaved={onSaved}
|
|
757
|
+
onClose={() => setAuxModalOpen(false)}
|
|
758
|
+
/>
|
|
759
|
+
)}
|
|
760
|
+
</CardContent>
|
|
761
|
+
</Card>
|
|
762
|
+
);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/* ──────────────────────────────────────────────────────────────────── */
|
|
766
|
+
/* Page */
|
|
767
|
+
/* ──────────────────────────────────────────────────────────────────── */
|
|
768
|
+
|
|
769
|
+
export default function ModelsPage() {
|
|
770
|
+
const [days, setDays] = useState(30);
|
|
771
|
+
const [data, setData] = useState<ModelsAnalyticsResponse | null>(null);
|
|
772
|
+
const [aux, setAux] = useState<AuxiliaryModelsResponse | null>(null);
|
|
773
|
+
const [loading, setLoading] = useState(true);
|
|
774
|
+
const [error, setError] = useState<string | null>(null);
|
|
775
|
+
const [saveKey, setSaveKey] = useState(0);
|
|
776
|
+
// Gate the token/cost UI on `dashboard.show_token_analytics`. See
|
|
777
|
+
// nastech_cli/config.py for the rationale: the numbers exclude auxiliary
|
|
778
|
+
// calls and retries, so they're misleading next to provider billing.
|
|
779
|
+
const [showTokens, setShowTokens] = useState(false);
|
|
780
|
+
const { t } = useI18n();
|
|
781
|
+
const { setAfterTitle, setEnd } = usePageHeader();
|
|
782
|
+
|
|
783
|
+
useEffect(() => {
|
|
784
|
+
api
|
|
785
|
+
.getConfig()
|
|
786
|
+
.then((cfg) => {
|
|
787
|
+
const dash = (cfg?.dashboard ?? {}) as { show_token_analytics?: unknown };
|
|
788
|
+
setShowTokens(dash.show_token_analytics === true);
|
|
789
|
+
})
|
|
790
|
+
.catch(() => {
|
|
791
|
+
// Default to hidden on any failure — safer than showing wrong numbers.
|
|
792
|
+
setShowTokens(false);
|
|
793
|
+
});
|
|
794
|
+
}, []);
|
|
795
|
+
|
|
796
|
+
const load = useCallback(() => {
|
|
797
|
+
setLoading(true);
|
|
798
|
+
setError(null);
|
|
799
|
+
Promise.all([
|
|
800
|
+
api.getModelsAnalytics(days),
|
|
801
|
+
api.getAuxiliaryModels().catch(() => null),
|
|
802
|
+
])
|
|
803
|
+
.then(([models, auxData]) => {
|
|
804
|
+
setData(models);
|
|
805
|
+
setAux(auxData);
|
|
806
|
+
})
|
|
807
|
+
.catch((err) => setError(String(err)))
|
|
808
|
+
.finally(() => setLoading(false));
|
|
809
|
+
}, [days]);
|
|
810
|
+
|
|
811
|
+
const onAssigned = useCallback(() => {
|
|
812
|
+
// Reload aux state after any assignment change.
|
|
813
|
+
api
|
|
814
|
+
.getAuxiliaryModels()
|
|
815
|
+
.then(setAux)
|
|
816
|
+
.catch(() => {});
|
|
817
|
+
setSaveKey((k) => k + 1);
|
|
818
|
+
}, []);
|
|
819
|
+
|
|
820
|
+
useLayoutEffect(() => {
|
|
821
|
+
const periodLabel =
|
|
822
|
+
PERIODS.find((p) => p.days === days)?.label ?? `${days}d`;
|
|
823
|
+
setAfterTitle(
|
|
824
|
+
<span className="flex items-center gap-1.5">
|
|
825
|
+
<Badge tone="secondary" className="text-xs">
|
|
826
|
+
{periodLabel}
|
|
827
|
+
</Badge>
|
|
828
|
+
<Button
|
|
829
|
+
type="button"
|
|
830
|
+
ghost
|
|
831
|
+
size="icon"
|
|
832
|
+
className="text-muted-foreground hover:text-foreground"
|
|
833
|
+
onClick={load}
|
|
834
|
+
disabled={loading}
|
|
835
|
+
aria-label={t.common.refresh}
|
|
836
|
+
>
|
|
837
|
+
{loading ? <Spinner /> : <RefreshCw />}
|
|
838
|
+
</Button>
|
|
839
|
+
</span>,
|
|
840
|
+
);
|
|
841
|
+
setEnd(
|
|
842
|
+
<div className="flex w-full min-w-0 flex-wrap items-center justify-start gap-2 sm:justify-end sm:gap-2">
|
|
843
|
+
<div className="flex flex-wrap items-center gap-1.5">
|
|
844
|
+
{PERIODS.map((p) => (
|
|
845
|
+
<Button
|
|
846
|
+
key={p.label}
|
|
847
|
+
type="button"
|
|
848
|
+
size="sm"
|
|
849
|
+
outlined={days !== p.days}
|
|
850
|
+
onClick={() => setDays(p.days)}
|
|
851
|
+
className="uppercase"
|
|
852
|
+
>
|
|
853
|
+
{p.label}
|
|
854
|
+
</Button>
|
|
855
|
+
))}
|
|
856
|
+
</div>
|
|
857
|
+
</div>,
|
|
858
|
+
);
|
|
859
|
+
return () => {
|
|
860
|
+
setAfterTitle(null);
|
|
861
|
+
setEnd(null);
|
|
862
|
+
};
|
|
863
|
+
}, [days, loading, load, setAfterTitle, setEnd, t.common.refresh]);
|
|
864
|
+
|
|
865
|
+
useEffect(() => {
|
|
866
|
+
load();
|
|
867
|
+
}, [load]);
|
|
868
|
+
|
|
869
|
+
return (
|
|
870
|
+
<div className="flex min-w-0 max-w-full flex-col gap-6">
|
|
871
|
+
<PluginSlot name="models:top" />
|
|
872
|
+
|
|
873
|
+
<div className="grid min-w-0 gap-6 lg:grid-cols-2">
|
|
874
|
+
<ModelSettingsPanel
|
|
875
|
+
aux={aux}
|
|
876
|
+
refreshKey={saveKey}
|
|
877
|
+
onSaved={onAssigned}
|
|
878
|
+
/>
|
|
879
|
+
|
|
880
|
+
{data && (
|
|
881
|
+
<Card className="min-w-0 max-w-full overflow-hidden">
|
|
882
|
+
<CardContent className="min-w-0 py-6">
|
|
883
|
+
<div className="min-w-0 max-w-full [&_div.grid]:grid-cols-[auto_minmax(0,1fr)_auto]">
|
|
884
|
+
<Stats
|
|
885
|
+
className="min-w-0"
|
|
886
|
+
items={
|
|
887
|
+
showTokens
|
|
888
|
+
? [
|
|
889
|
+
{
|
|
890
|
+
label: t.models.modelsUsed,
|
|
891
|
+
value: String(data.totals.distinct_models),
|
|
892
|
+
},
|
|
893
|
+
{
|
|
894
|
+
label: t.analytics.totalTokens,
|
|
895
|
+
value: formatTokens(
|
|
896
|
+
data.totals.total_input + data.totals.total_output,
|
|
897
|
+
),
|
|
898
|
+
},
|
|
899
|
+
{
|
|
900
|
+
label: t.analytics.input,
|
|
901
|
+
value: formatTokens(data.totals.total_input),
|
|
902
|
+
},
|
|
903
|
+
{
|
|
904
|
+
label: t.analytics.output,
|
|
905
|
+
value: formatTokens(data.totals.total_output),
|
|
906
|
+
},
|
|
907
|
+
{
|
|
908
|
+
label: t.models.estimatedCost,
|
|
909
|
+
value: formatCost(data.totals.total_estimated_cost),
|
|
910
|
+
},
|
|
911
|
+
{
|
|
912
|
+
label: t.analytics.totalSessions,
|
|
913
|
+
value: String(data.totals.total_sessions),
|
|
914
|
+
},
|
|
915
|
+
]
|
|
916
|
+
: [
|
|
917
|
+
{
|
|
918
|
+
label: t.models.modelsUsed,
|
|
919
|
+
value: String(data.totals.distinct_models),
|
|
920
|
+
},
|
|
921
|
+
{
|
|
922
|
+
label: t.analytics.totalSessions,
|
|
923
|
+
value: String(data.totals.total_sessions),
|
|
924
|
+
},
|
|
925
|
+
]
|
|
926
|
+
}
|
|
927
|
+
/>
|
|
928
|
+
</div>
|
|
929
|
+
{!showTokens && (
|
|
930
|
+
<p className="mt-4 text-xs text-text-tertiary leading-relaxed">
|
|
931
|
+
Token & cost analytics are hidden because the local counts
|
|
932
|
+
exclude auxiliary calls (compression, vision, web extract,
|
|
933
|
+
…) and provider retries, so they diverge from your provider
|
|
934
|
+
bill. Enable{" "}
|
|
935
|
+
<span className="font-mono">dashboard.show_token_analytics</span>{" "}
|
|
936
|
+
in <a href="/config" className="underline">Config</a> to
|
|
937
|
+
show the local debug estimate anyway.
|
|
938
|
+
</p>
|
|
939
|
+
)}
|
|
940
|
+
</CardContent>
|
|
941
|
+
</Card>
|
|
942
|
+
)}
|
|
943
|
+
</div>
|
|
944
|
+
|
|
945
|
+
{loading && !data && (
|
|
946
|
+
<div className="flex items-center justify-center py-24">
|
|
947
|
+
<Spinner className="text-2xl text-primary" />
|
|
948
|
+
</div>
|
|
949
|
+
)}
|
|
950
|
+
|
|
951
|
+
{error && (
|
|
952
|
+
<Card>
|
|
953
|
+
<CardContent className="py-6">
|
|
954
|
+
<p className="text-sm text-destructive text-center">{error}</p>
|
|
955
|
+
</CardContent>
|
|
956
|
+
</Card>
|
|
957
|
+
)}
|
|
958
|
+
|
|
959
|
+
{data && (
|
|
960
|
+
<>
|
|
961
|
+
{data.models.length > 0 ? (
|
|
962
|
+
<div className="grid min-w-0 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
|
963
|
+
{data.models.map((m, i) => (
|
|
964
|
+
<ModelCard
|
|
965
|
+
key={`${m.model}:${m.provider}`}
|
|
966
|
+
entry={m}
|
|
967
|
+
rank={i + 1}
|
|
968
|
+
main={aux?.main ?? null}
|
|
969
|
+
aux={aux?.tasks ?? []}
|
|
970
|
+
onAssigned={onAssigned}
|
|
971
|
+
showTokens={showTokens}
|
|
972
|
+
/>
|
|
973
|
+
))}
|
|
974
|
+
</div>
|
|
975
|
+
) : (
|
|
976
|
+
<Card>
|
|
977
|
+
<CardContent className="py-12">
|
|
978
|
+
<div className="flex flex-col items-center text-muted-foreground">
|
|
979
|
+
<Cpu className="h-8 w-8 mb-3 opacity-40" />
|
|
980
|
+
<p className="text-sm font-medium">{t.models.noModelsData}</p>
|
|
981
|
+
<p className="text-xs mt-1 text-text-tertiary">
|
|
982
|
+
{t.models.startSession}
|
|
983
|
+
</p>
|
|
984
|
+
</div>
|
|
985
|
+
</CardContent>
|
|
986
|
+
</Card>
|
|
987
|
+
)}
|
|
988
|
+
</>
|
|
989
|
+
)}
|
|
990
|
+
|
|
991
|
+
<PluginSlot name="models:bottom" />
|
|
992
|
+
</div>
|
|
993
|
+
);
|
|
994
|
+
}
|