@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,601 @@
1
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from "react";
2
+ import {
3
+ ArrowDown,
4
+ ArrowUp,
5
+ ArrowUpDown,
6
+ BarChart3,
7
+ Brain,
8
+ Cpu,
9
+ RefreshCw,
10
+ TrendingUp,
11
+ } from "lucide-react";
12
+ import { api } from "@/lib/api";
13
+ import type {
14
+ AnalyticsResponse,
15
+ AnalyticsDailyEntry,
16
+ AnalyticsModelEntry,
17
+ AnalyticsSkillEntry,
18
+ } from "@/lib/api";
19
+ import { timeAgo } from "@/lib/utils";
20
+ import { Button } from "@nastechai/ui/ui/components/button";
21
+ import { Spinner } from "@nastechai/ui/ui/components/spinner";
22
+ import { Stats } from "@nastechai/ui/ui/components/stats";
23
+ import { Card, CardContent, CardHeader, CardTitle } from "@nastechai/ui/ui/components/card";
24
+ import { Badge } from "@nastechai/ui/ui/components/badge";
25
+ import { usePageHeader } from "@/contexts/usePageHeader";
26
+ import { useI18n } from "@/i18n";
27
+ import { PluginSlot } from "@/plugins";
28
+
29
+ const PERIODS = [
30
+ { label: "7d", days: 7 },
31
+ { label: "30d", days: 30 },
32
+ { label: "90d", days: 90 },
33
+ ] as const;
34
+
35
+ const CHART_HEIGHT_PX = 160;
36
+
37
+ function formatTokens(n: number): string {
38
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
39
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
40
+ return String(n);
41
+ }
42
+
43
+ function formatDate(day: string): string {
44
+ try {
45
+ const d = new Date(day + "T00:00:00");
46
+ return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
47
+ } catch {
48
+ return day;
49
+ }
50
+ }
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Sorting
54
+ // ---------------------------------------------------------------------------
55
+
56
+ function useTableSort<T>(
57
+ data: T[],
58
+ defaultKey: keyof T & string,
59
+ defaultDir: "asc" | "desc" = "desc",
60
+ ) {
61
+ const [sortKey, setSortKey] = useState<string>(defaultKey);
62
+ const [sortDir, setSortDir] = useState<"asc" | "desc">(defaultDir);
63
+
64
+ const sorted = useMemo(() => {
65
+ return [...data].sort((a, b) => {
66
+ const aVal = a[sortKey as keyof T];
67
+ const bVal = b[sortKey as keyof T];
68
+ // Nulls always last regardless of direction
69
+ if (aVal === null || aVal === undefined) return 1;
70
+ if (bVal === null || bVal === undefined) return -1;
71
+ if (aVal === bVal) return 0;
72
+ const cmp = aVal > bVal ? 1 : -1;
73
+ return sortDir === "asc" ? cmp : -cmp;
74
+ });
75
+ }, [data, sortKey, sortDir]);
76
+
77
+ const toggle = useCallback(
78
+ (key: string) => {
79
+ if (key === sortKey) {
80
+ setSortDir((d) => (d === "asc" ? "desc" : "asc"));
81
+ } else {
82
+ setSortKey(key);
83
+ setSortDir("desc");
84
+ }
85
+ },
86
+ [sortKey],
87
+ );
88
+
89
+ return { sorted, sortKey, sortDir, toggle };
90
+ }
91
+
92
+ function SortHeader({
93
+ label,
94
+ col,
95
+ sortKey,
96
+ sortDir,
97
+ toggle,
98
+ className,
99
+ }: {
100
+ label: string;
101
+ col: string;
102
+ sortKey: string;
103
+ sortDir: "asc" | "desc";
104
+ toggle: (key: string) => void;
105
+ className?: string;
106
+ }) {
107
+ const active = col === sortKey;
108
+ return (
109
+ <th
110
+ onClick={() => toggle(col)}
111
+ className={`cursor-pointer select-none ${className ?? ""}`}
112
+ >
113
+ <span className="inline-flex items-center gap-1.5 rounded px-1 -mx-1 py-0.5 hover:bg-muted/40 transition-colors">
114
+ {label}
115
+ {active ? (
116
+ sortDir === "asc" ? (
117
+ <ArrowUp className="h-3.5 w-3.5 text-foreground/80 shrink-0" />
118
+ ) : (
119
+ <ArrowDown className="h-3.5 w-3.5 text-foreground/80 shrink-0" />
120
+ )
121
+ ) : (
122
+ <ArrowUpDown className="h-3 w-3 text-text-tertiary shrink-0" />
123
+ )}
124
+ </span>
125
+ </th>
126
+ );
127
+ }
128
+
129
+
130
+
131
+ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
132
+ const { t } = useI18n();
133
+ if (daily.length === 0) return null;
134
+
135
+ const maxTokens = Math.max(
136
+ ...daily.map((d) => d.input_tokens + d.output_tokens),
137
+ 1,
138
+ );
139
+
140
+ return (
141
+ <Card>
142
+ <CardHeader>
143
+ <div className="flex items-center gap-2">
144
+ <BarChart3 className="h-5 w-5 text-muted-foreground" />
145
+ <CardTitle className="text-base">
146
+ {t.analytics.dailyTokenUsage}
147
+ </CardTitle>
148
+ </div>
149
+ <div className="flex items-center gap-4 font-mondwest normal-case text-xs text-muted-foreground">
150
+ <div className="flex items-center gap-1.5">
151
+ <div className="h-2.5 w-2.5 bg-[#ffe6cb]" />
152
+ {t.analytics.input}
153
+ </div>
154
+ <div className="flex items-center gap-1.5">
155
+ <div className="h-2.5 w-2.5 bg-emerald-500" />
156
+ {t.analytics.output}
157
+ </div>
158
+ </div>
159
+ </CardHeader>
160
+ <CardContent>
161
+ <div
162
+ className="flex items-end gap-[2px]"
163
+ style={{ height: CHART_HEIGHT_PX }}
164
+ >
165
+ {daily.map((d) => {
166
+ const total = d.input_tokens + d.output_tokens;
167
+ const inputH = Math.round(
168
+ (d.input_tokens / maxTokens) * CHART_HEIGHT_PX,
169
+ );
170
+ const outputH = Math.round(
171
+ (d.output_tokens / maxTokens) * CHART_HEIGHT_PX,
172
+ );
173
+ return (
174
+ <div
175
+ key={d.day}
176
+ className="flex-1 min-w-0 group relative flex flex-col justify-end"
177
+ style={{ height: CHART_HEIGHT_PX }}
178
+ >
179
+ <div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 hidden group-hover:block z-10 pointer-events-none">
180
+ <div className="font-mondwest normal-case bg-card border border-border px-2.5 py-1.5 text-xs text-foreground shadow-lg whitespace-nowrap">
181
+ <div className="font-medium">{formatDate(d.day)}</div>
182
+ <div>
183
+ {t.analytics.input}: {formatTokens(d.input_tokens)}
184
+ </div>
185
+ <div>
186
+ {t.analytics.output}: {formatTokens(d.output_tokens)}
187
+ </div>
188
+ <div>
189
+ {t.analytics.total}: {formatTokens(total)}
190
+ </div>
191
+ </div>
192
+ </div>
193
+
194
+ <div
195
+ className="w-full bg-[#ffe6cb]/70"
196
+ style={{ height: Math.max(inputH, total > 0 ? 1 : 0) }}
197
+ />
198
+
199
+ <div
200
+ className="w-full bg-emerald-500/70"
201
+ style={{
202
+ height: Math.max(outputH, d.output_tokens > 0 ? 1 : 0),
203
+ }}
204
+ />
205
+ </div>
206
+ );
207
+ })}
208
+ </div>
209
+
210
+ <div className="flex justify-between mt-2 font-mondwest normal-case text-xs text-text-tertiary">
211
+ <span>{daily.length > 0 ? formatDate(daily[0].day) : ""}</span>
212
+ {daily.length > 2 && (
213
+ <span>{formatDate(daily[Math.floor(daily.length / 2)].day)}</span>
214
+ )}
215
+ <span>
216
+ {daily.length > 1 ? formatDate(daily[daily.length - 1].day) : ""}
217
+ </span>
218
+ </div>
219
+ </CardContent>
220
+ </Card>
221
+ );
222
+ }
223
+
224
+ function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) {
225
+ const { t } = useI18n();
226
+ const { sorted, sortKey, sortDir, toggle } = useTableSort(daily, "day", "desc");
227
+
228
+ if (daily.length === 0) return null;
229
+
230
+ return (
231
+ <Card>
232
+ <CardHeader>
233
+ <div className="flex items-center gap-2">
234
+ <TrendingUp className="h-5 w-5 text-muted-foreground" />
235
+ <CardTitle className="text-base">
236
+ {t.analytics.dailyBreakdown}
237
+ </CardTitle>
238
+ </div>
239
+ </CardHeader>
240
+ <CardContent>
241
+ <div className="overflow-x-auto">
242
+ <table className="w-full font-mondwest normal-case text-sm">
243
+ <thead>
244
+ <tr className="border-b border-border text-muted-foreground text-xs">
245
+ <SortHeader label={t.analytics.date} col="day" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-left py-2 pr-4 font-medium" />
246
+ <SortHeader label={t.sessions.title} col="sessions" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-right py-2 px-4 font-medium" />
247
+ <SortHeader label={t.analytics.input} col="input_tokens" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-right py-2 px-4 font-medium" />
248
+ <SortHeader label={t.analytics.output} col="output_tokens" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-right py-2 pl-4 font-medium" />
249
+ </tr>
250
+ </thead>
251
+ <tbody>
252
+ {sorted.map((d) => (
253
+ <tr
254
+ key={d.day}
255
+ className="border-b border-border/50 hover:bg-secondary/20 transition-colors"
256
+ >
257
+ <td className="py-2 pr-4 font-medium">
258
+ {formatDate(d.day)}
259
+ </td>
260
+ <td className="text-right py-2 px-4 text-muted-foreground">
261
+ {d.sessions}
262
+ </td>
263
+ <td className="text-right py-2 px-4">
264
+ <span className="text-[#ffe6cb]">
265
+ {formatTokens(d.input_tokens)}
266
+ </span>
267
+ </td>
268
+ <td className="text-right py-2 pl-4">
269
+ <span className="text-emerald-400">
270
+ {formatTokens(d.output_tokens)}
271
+ </span>
272
+ </td>
273
+ </tr>
274
+ ))}
275
+ </tbody>
276
+ </table>
277
+ </div>
278
+ </CardContent>
279
+ </Card>
280
+ );
281
+ }
282
+
283
+ function ModelTable({ models }: { models: AnalyticsModelEntry[] }) {
284
+ const { t } = useI18n();
285
+ const { sorted, sortKey, sortDir, toggle } = useTableSort(models, "input_tokens", "desc");
286
+
287
+ if (models.length === 0) return null;
288
+
289
+ return (
290
+ <Card>
291
+ <CardHeader>
292
+ <div className="flex items-center gap-2">
293
+ <Cpu className="h-5 w-5 text-muted-foreground" />
294
+ <CardTitle className="text-base">
295
+ {t.analytics.perModelBreakdown}
296
+ </CardTitle>
297
+ </div>
298
+ </CardHeader>
299
+ <CardContent>
300
+ <div className="overflow-x-auto">
301
+ <table className="w-full font-mondwest normal-case text-sm">
302
+ <thead>
303
+ <tr className="border-b border-border text-muted-foreground text-xs">
304
+ <SortHeader label={t.analytics.model} col="model" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-left py-2 pr-4 font-medium" />
305
+ <SortHeader label={t.sessions.title} col="sessions" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-right py-2 px-4 font-medium" />
306
+ <SortHeader label={t.analytics.tokens} col="input_tokens" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-right py-2 pl-4 font-medium" />
307
+ </tr>
308
+ </thead>
309
+ <tbody>
310
+ {sorted.map((m) => (
311
+ <tr
312
+ key={m.model}
313
+ className="border-b border-border/50 hover:bg-secondary/20 transition-colors"
314
+ >
315
+ <td className="py-2 pr-4">
316
+ <span className="font-mono-ui text-xs">{m.model}</span>
317
+ </td>
318
+ <td className="text-right py-2 px-4 text-muted-foreground">
319
+ {m.sessions}
320
+ </td>
321
+ <td className="text-right py-2 pl-4">
322
+ <span className="text-[#ffe6cb]">
323
+ {formatTokens(m.input_tokens)}
324
+ </span>
325
+ {" / "}
326
+ <span className="text-emerald-400">
327
+ {formatTokens(m.output_tokens)}
328
+ </span>
329
+ </td>
330
+ </tr>
331
+ ))}
332
+ </tbody>
333
+ </table>
334
+ </div>
335
+ </CardContent>
336
+ </Card>
337
+ );
338
+ }
339
+
340
+ function SkillTable({ skills }: { skills: AnalyticsSkillEntry[] }) {
341
+ const { t } = useI18n();
342
+ const { sorted, sortKey, sortDir, toggle } = useTableSort(skills, "total_count", "desc");
343
+
344
+ if (skills.length === 0) return null;
345
+
346
+ return (
347
+ <Card>
348
+ <CardHeader>
349
+ <div className="flex items-center gap-2">
350
+ <Brain className="h-5 w-5 text-muted-foreground" />
351
+ <CardTitle className="text-base">{t.analytics.topSkills}</CardTitle>
352
+ </div>
353
+ </CardHeader>
354
+ <CardContent>
355
+ <div className="overflow-x-auto">
356
+ <table className="w-full font-mondwest normal-case text-sm">
357
+ <thead>
358
+ <tr className="border-b border-border text-muted-foreground text-xs">
359
+ <SortHeader label={t.analytics.skill} col="skill" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-left py-2 pr-4 font-medium" />
360
+ <SortHeader label={t.analytics.loads} col="view_count" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-right py-2 px-4 font-medium" />
361
+ <SortHeader label={t.analytics.edits} col="manage_count" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-right py-2 px-4 font-medium" />
362
+ <SortHeader label={t.analytics.total} col="total_count" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-right py-2 px-4 font-medium" />
363
+ <SortHeader label={t.analytics.lastUsed} col="last_used_at" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-right py-2 pl-4 font-medium" />
364
+ </tr>
365
+ </thead>
366
+ <tbody>
367
+ {sorted.map((skill) => (
368
+ <tr
369
+ key={skill.skill}
370
+ className="border-b border-border/50 hover:bg-secondary/20 transition-colors"
371
+ >
372
+ <td className="py-2 pr-4">
373
+ <span className="font-mono-ui text-xs">{skill.skill}</span>
374
+ </td>
375
+ <td className="text-right py-2 px-4 text-muted-foreground">
376
+ {skill.view_count}
377
+ </td>
378
+ <td className="text-right py-2 px-4 text-muted-foreground">
379
+ {skill.manage_count}
380
+ </td>
381
+ <td className="text-right py-2 px-4">{skill.total_count}</td>
382
+ <td className="text-right py-2 pl-4 text-muted-foreground">
383
+ {skill.last_used_at ? timeAgo(skill.last_used_at) : "—"}
384
+ </td>
385
+ </tr>
386
+ ))}
387
+ </tbody>
388
+ </table>
389
+ </div>
390
+ </CardContent>
391
+ </Card>
392
+ );
393
+ }
394
+
395
+ export default function AnalyticsPage() {
396
+ const [days, setDays] = useState(30);
397
+ const [data, setData] = useState<AnalyticsResponse | null>(null);
398
+ const [loading, setLoading] = useState(true);
399
+ const [error, setError] = useState<string | null>(null);
400
+ // Gated on `dashboard.show_token_analytics` (default off). When off the
401
+ // page renders an explanation card instead of fetching analytics — the
402
+ // local token counts exclude auxiliary calls and provider retries, so
403
+ // they diverge from provider billing in ways that mislead users.
404
+ const [showTokens, setShowTokens] = useState<boolean | null>(null);
405
+ const { t } = useI18n();
406
+ const { setAfterTitle, setEnd } = usePageHeader();
407
+
408
+ useEffect(() => {
409
+ api
410
+ .getConfig()
411
+ .then((cfg) => {
412
+ const dash = (cfg?.dashboard ?? {}) as { show_token_analytics?: unknown };
413
+ setShowTokens(dash.show_token_analytics === true);
414
+ })
415
+ .catch(() => setShowTokens(false));
416
+ }, []);
417
+
418
+ const load = useCallback(() => {
419
+ if (!showTokens) return;
420
+ setLoading(true);
421
+ setError(null);
422
+ api
423
+ .getAnalytics(days)
424
+ .then(setData)
425
+ .catch((err) => setError(String(err)))
426
+ .finally(() => setLoading(false));
427
+ }, [days, showTokens]);
428
+
429
+ useLayoutEffect(() => {
430
+ const periodLabel =
431
+ PERIODS.find((p) => p.days === days)?.label ?? `${days}d`;
432
+ setAfterTitle(
433
+ <span className="flex items-center gap-1.5">
434
+ <Badge tone="secondary" className="text-xs">
435
+ {periodLabel}
436
+ </Badge>
437
+ {showTokens !== false && (
438
+ <Button
439
+ type="button"
440
+ ghost
441
+ size="icon"
442
+ className="text-muted-foreground hover:text-foreground"
443
+ onClick={load}
444
+ disabled={loading}
445
+ aria-label={t.common.refresh}
446
+ >
447
+ {loading ? <Spinner /> : <RefreshCw />}
448
+ </Button>
449
+ )}
450
+ </span>,
451
+ );
452
+ setEnd(
453
+ showTokens === false ? null : (
454
+ <div className="flex w-full min-w-0 flex-wrap items-center justify-start gap-2 sm:justify-end sm:gap-2">
455
+ <div className="flex flex-wrap items-center gap-1.5">
456
+ {PERIODS.map((p) => (
457
+ <Button
458
+ key={p.label}
459
+ type="button"
460
+ size="sm"
461
+ outlined={days !== p.days}
462
+ onClick={() => setDays(p.days)}
463
+ >
464
+ {p.label}
465
+ </Button>
466
+ ))}
467
+ </div>
468
+ </div>
469
+ ),
470
+ );
471
+ return () => {
472
+ setAfterTitle(null);
473
+ setEnd(null);
474
+ };
475
+ }, [days, loading, load, setAfterTitle, setEnd, t.common.refresh, showTokens]);
476
+
477
+ useEffect(() => {
478
+ load();
479
+ }, [load]);
480
+
481
+ return (
482
+ <div className="flex flex-col gap-6">
483
+ <PluginSlot name="analytics:top" />
484
+
485
+ {showTokens === false && (
486
+ <Card>
487
+ <CardContent className="py-12">
488
+ <div className="mx-auto flex max-w-2xl flex-col gap-3 text-sm text-muted-foreground">
489
+ <h2 className="font-mondwest text-display text-base tracking-wider text-foreground">
490
+ Token analytics hidden
491
+ </h2>
492
+ <p>
493
+ The token, cost, and per-day analytics on this page are a
494
+ local debug estimate. They only count successful main-agent
495
+ responses with a usable <span className="font-mono">usage</span>{" "}
496
+ block, and silently exclude auxiliary calls (context
497
+ compression, title generation, vision, session search, web
498
+ extract, smart approvals, MCP routing, plugin LLM access)
499
+ plus provider-side retries and fallback attempts. Cache
500
+ writes are missing entirely.
501
+ </p>
502
+ <p>
503
+ On models with heavy auxiliary traffic (Kimi K2.6, MiniMax
504
+ M2.7) the local total can be 10x–100x lower than what your
505
+ provider bills. Hiding these numbers is safer than letting
506
+ them look authoritative.
507
+ </p>
508
+ <p>
509
+ Check your provider dashboard (OpenRouter, Anthropic, etc.)
510
+ for actual usage and billing. To re-enable the local debug
511
+ estimate anyway, set{" "}
512
+ <span className="font-mono">
513
+ dashboard.show_token_analytics: true
514
+ </span>{" "}
515
+ in <a href="/config" className="underline">Config</a>.
516
+ </p>
517
+ </div>
518
+ </CardContent>
519
+ </Card>
520
+ )}
521
+
522
+ {showTokens && loading && !data && (
523
+ <div className="flex items-center justify-center py-24">
524
+ <Spinner className="text-2xl text-primary" />
525
+ </div>
526
+ )}
527
+
528
+ {showTokens && error && (
529
+ <Card>
530
+ <CardContent className="py-6">
531
+ <p className="text-sm text-destructive text-center">{error}</p>
532
+ </CardContent>
533
+ </Card>
534
+ )}
535
+
536
+ {showTokens && data && (
537
+ <>
538
+ <div className="grid gap-6 lg:grid-cols-2">
539
+ <Card>
540
+ <CardContent className="py-6">
541
+ <Stats
542
+ items={[
543
+ {
544
+ label: t.analytics.totalTokens,
545
+ value: formatTokens(
546
+ data.totals.total_input + data.totals.total_output,
547
+ ),
548
+ },
549
+ {
550
+ label: t.analytics.input,
551
+ value: formatTokens(data.totals.total_input),
552
+ },
553
+ {
554
+ label: t.analytics.output,
555
+ value: formatTokens(data.totals.total_output),
556
+ },
557
+ {
558
+ label: t.analytics.totalSessions,
559
+ value: `${data.totals.total_sessions} (~${(data.totals.total_sessions / days).toFixed(1)}${t.analytics.perDayAvg})`,
560
+ },
561
+ {
562
+ label: t.analytics.apiCalls,
563
+ value: String(
564
+ data.totals.total_api_calls ??
565
+ data.daily.reduce((sum, d) => sum + d.sessions, 0),
566
+ ),
567
+ },
568
+ ]}
569
+ />
570
+ </CardContent>
571
+ </Card>
572
+
573
+ <TokenBarChart daily={data.daily} />
574
+ </div>
575
+
576
+ <DailyTable daily={data.daily} />
577
+ <ModelTable models={data.by_model} />
578
+ <SkillTable skills={data.skills.top_skills} />
579
+ </>
580
+ )}
581
+
582
+ {data &&
583
+ data.daily.length === 0 &&
584
+ data.by_model.length === 0 &&
585
+ data.skills.top_skills.length === 0 && (
586
+ <Card>
587
+ <CardContent className="py-12">
588
+ <div className="flex flex-col items-center text-muted-foreground">
589
+ <BarChart3 className="h-8 w-8 mb-3 opacity-40" />
590
+ <p className="text-sm font-medium">{t.analytics.noUsageData}</p>
591
+ <p className="text-xs mt-1 text-text-tertiary">
592
+ {t.analytics.startSession}
593
+ </p>
594
+ </div>
595
+ </CardContent>
596
+ </Card>
597
+ )}
598
+ <PluginSlot name="analytics:bottom" />
599
+ </div>
600
+ );
601
+ }