@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,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
|
+
}
|