@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.
Files changed (98) hide show
  1. package/eslint.config.js +23 -0
  2. package/index.html +24 -0
  3. package/package.json +54 -26
  4. package/package.json.bak +89 -0
  5. package/package.json.pub +88 -0
  6. package/src/App.tsx +1173 -0
  7. package/src/components/AuthWidget.tsx +150 -0
  8. package/src/components/AutoField.tsx +206 -0
  9. package/src/components/Backdrop.tsx +93 -0
  10. package/src/components/ChatSidebar.tsx +394 -0
  11. package/src/components/DeleteConfirmDialog.tsx +40 -0
  12. package/src/components/LanguageSwitcher.tsx +186 -0
  13. package/src/components/Markdown.tsx +383 -0
  14. package/src/components/ModelInfoCard.tsx +112 -0
  15. package/src/components/ModelPickerDialog.tsx +470 -0
  16. package/src/components/OAuthLoginModal.tsx +374 -0
  17. package/src/components/OAuthProvidersCard.tsx +287 -0
  18. package/src/components/PlatformsCard.tsx +97 -0
  19. package/src/components/ScheduleBuilder.tsx +273 -0
  20. package/src/components/SidebarFooter.tsx +42 -0
  21. package/src/components/SidebarStatusStrip.tsx +72 -0
  22. package/src/components/SlashPopover.tsx +171 -0
  23. package/src/components/ThemeSwitcher.tsx +243 -0
  24. package/src/components/ToolCall.tsx +228 -0
  25. package/src/components/ToolsetConfigDrawer.tsx +448 -0
  26. package/src/contexts/PageHeaderProvider.tsx +139 -0
  27. package/src/contexts/SystemActions.tsx +120 -0
  28. package/src/contexts/page-header-context.ts +12 -0
  29. package/src/contexts/system-actions-context.ts +18 -0
  30. package/src/contexts/usePageHeader.ts +10 -0
  31. package/src/contexts/useSystemActions.ts +15 -0
  32. package/src/hooks/useModalBehavior.ts +44 -0
  33. package/src/hooks/useSidebarStatus.ts +27 -0
  34. package/src/i18n/af.ts +702 -0
  35. package/src/i18n/context.tsx +123 -0
  36. package/src/i18n/de.ts +701 -0
  37. package/src/i18n/en.ts +708 -0
  38. package/src/i18n/es.ts +701 -0
  39. package/src/i18n/fr.ts +701 -0
  40. package/src/i18n/ga.ts +702 -0
  41. package/src/i18n/hu.ts +702 -0
  42. package/src/i18n/index.ts +2 -0
  43. package/src/i18n/it.ts +701 -0
  44. package/src/i18n/ja.ts +702 -0
  45. package/src/i18n/ko.ts +702 -0
  46. package/src/i18n/pt.ts +702 -0
  47. package/src/i18n/ru.ts +702 -0
  48. package/src/i18n/tr.ts +702 -0
  49. package/src/i18n/types.ts +710 -0
  50. package/src/i18n/uk.ts +702 -0
  51. package/src/i18n/zh-hant.ts +702 -0
  52. package/src/i18n/zh.ts +698 -0
  53. package/src/index.css +274 -0
  54. package/src/lib/api.ts +1585 -0
  55. package/src/lib/dashboard-flags.ts +15 -0
  56. package/src/lib/format.ts +9 -0
  57. package/src/lib/fuzzy.ts +192 -0
  58. package/src/lib/gatewayClient.ts +253 -0
  59. package/src/lib/nested.ts +23 -0
  60. package/src/lib/resolve-page-title.ts +41 -0
  61. package/src/lib/schedule.ts +382 -0
  62. package/src/lib/slashExec.ts +163 -0
  63. package/src/lib/utils.ts +35 -0
  64. package/src/main.tsx +25 -0
  65. package/src/pages/AnalyticsPage.tsx +601 -0
  66. package/src/pages/ChannelsPage.tsx +772 -0
  67. package/src/pages/ChatPage.tsx +889 -0
  68. package/src/pages/ConfigPage.tsx +660 -0
  69. package/src/pages/CronPage.tsx +524 -0
  70. package/src/pages/DocsPage.tsx +69 -0
  71. package/src/pages/EnvPage.tsx +918 -0
  72. package/src/pages/LogsPage.tsx +246 -0
  73. package/src/pages/McpPage.tsx +757 -0
  74. package/src/pages/ModelsPage.tsx +994 -0
  75. package/src/pages/PairingPage.tsx +276 -0
  76. package/src/pages/PluginsPage.tsx +580 -0
  77. package/src/pages/ProfilesPage.tsx +559 -0
  78. package/src/pages/SessionsPage.tsx +936 -0
  79. package/src/pages/SkillsPage.tsx +557 -0
  80. package/src/pages/SystemPage.tsx +1259 -0
  81. package/src/pages/WebhooksPage.tsx +483 -0
  82. package/src/plugins/PluginPage.tsx +64 -0
  83. package/src/plugins/index.ts +6 -0
  84. package/src/plugins/registry.ts +151 -0
  85. package/src/plugins/sdk.d.ts +160 -0
  86. package/src/plugins/slots.ts +199 -0
  87. package/src/plugins/types.ts +37 -0
  88. package/src/plugins/usePlugins.ts +133 -0
  89. package/src/themes/context.tsx +443 -0
  90. package/src/themes/fonts.ts +160 -0
  91. package/src/themes/index.ts +3 -0
  92. package/src/themes/presets.ts +477 -0
  93. package/src/themes/types.ts +187 -0
  94. package/tsconfig.app.json +34 -0
  95. package/tsconfig.json +7 -0
  96. package/tsconfig.node.json +26 -0
  97. package/vite.config.ts +124 -0
  98. 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
+ &quot;use the main model&quot;. 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
+ }