@jeffusion/bungee-core 5.1.5 → 5.2.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/dist/main.js +30 -16
- package/dist/plugins/ai-transformer/index.js.map +2 -2
- package/dist/plugins/anthropic-request-sanitizer/index.js.map +2 -2
- package/dist/plugins/anthropic-tool-name-transformer/index.js.map +2 -2
- package/dist/plugins/chatgpt-oauth/index.js.map +2 -2
- package/dist/plugins/deepseek-reasoning-fix/index.js.map +2 -2
- package/dist/plugins/model-mapping/index.js.map +2 -2
- package/dist/plugins/openai-messages-to-chat/index.js.map +2 -2
- package/dist/plugins/signature-repair/index.js.map +2 -2
- package/dist/plugins/token-stats/index.js.map +2 -2
- package/dist/plugins/token-stats/manifest.json +72 -7
- package/dist/plugins/token-stats/ui/TokenStatsChart.svelte +31 -299
- package/dist/plugins/token-stats/ui/TokenStatsMetric.svelte +76 -0
- package/dist/plugins/token-stats/ui/TokenStatsPage.svelte +136 -0
- package/dist/plugins/token-stats/ui/UsageTimeChart.svelte +181 -0
- package/dist/plugins/token-stats/ui/labels.ts +28 -0
- package/dist/plugins/token-stats/ui/stats-resource.ts +52 -0
- package/dist/plugins/token-stats/ui/stats.ts +15 -0
- package/package.json +2 -2
|
@@ -31,17 +31,31 @@
|
|
|
31
31
|
{
|
|
32
32
|
"name": "TokenStatsSettings",
|
|
33
33
|
"entry": "ui/TokenStatsSettings.svelte"
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"name": "TokenStatsMetric",
|
|
37
|
+
"entry": "ui/TokenStatsMetric.svelte"
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"name": "TokenStatsPage",
|
|
41
|
+
"entry": "ui/TokenStatsPage.svelte"
|
|
34
42
|
}
|
|
35
43
|
]
|
|
36
44
|
},
|
|
37
45
|
"contributes": {
|
|
38
46
|
"nativeWidgets": [
|
|
39
47
|
{
|
|
40
|
-
"id": "token-stats-
|
|
41
|
-
"title": "widgets.
|
|
42
|
-
"size": "
|
|
43
|
-
"component": "
|
|
44
|
-
"
|
|
48
|
+
"id": "token-stats-overview",
|
|
49
|
+
"title": "widgets.overview.title",
|
|
50
|
+
"size": "medium",
|
|
51
|
+
"component": "TokenStatsMetric",
|
|
52
|
+
"presentation": "kpi"
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"id": "token-stats-time",
|
|
56
|
+
"title": "widgets.time.title",
|
|
57
|
+
"size": "full",
|
|
58
|
+
"component": "TokenStatsChart"
|
|
45
59
|
}
|
|
46
60
|
],
|
|
47
61
|
"api": [
|
|
@@ -78,6 +92,15 @@
|
|
|
78
92
|
"execution": "control"
|
|
79
93
|
}
|
|
80
94
|
],
|
|
95
|
+
"navigation": [
|
|
96
|
+
{
|
|
97
|
+
"label": "page.title",
|
|
98
|
+
"path": "/statistics",
|
|
99
|
+
"component": "TokenStatsPage",
|
|
100
|
+
"icon": "bar_chart",
|
|
101
|
+
"target": "header"
|
|
102
|
+
}
|
|
103
|
+
],
|
|
81
104
|
"settings": "/pricing",
|
|
82
105
|
"nativeSettingsComponent": "TokenStatsSettings"
|
|
83
106
|
},
|
|
@@ -137,7 +160,28 @@
|
|
|
137
160
|
"settings.timeoutError": "Download timed out",
|
|
138
161
|
"settings.networkError": "Network or source error",
|
|
139
162
|
"settings.invalid_catalogError": "No valid model prices returned",
|
|
140
|
-
"settings.storageError": "Unable to save the price cache"
|
|
163
|
+
"settings.storageError": "Unable to save the price cache",
|
|
164
|
+
"widgets.overview.title": "Token Overview",
|
|
165
|
+
"widgets.time.title": "Token Trend",
|
|
166
|
+
"page.title": "Token Statistics",
|
|
167
|
+
"page.viewModels": "View model statistics",
|
|
168
|
+
"ui.refresh": "Refresh",
|
|
169
|
+
"page.range": "Time range",
|
|
170
|
+
"page.range.1h": "1 hour",
|
|
171
|
+
"page.range.12h": "12 hours",
|
|
172
|
+
"page.range.24h": "24 hours",
|
|
173
|
+
"page.updatedAt": "Updated at",
|
|
174
|
+
"page.tokens": "Token",
|
|
175
|
+
"page.activity": "Activity over time",
|
|
176
|
+
"page.composition": "Token composition",
|
|
177
|
+
"page.cacheRead": "Cache read",
|
|
178
|
+
"page.cacheWrite": "Cache write",
|
|
179
|
+
"page.sort": "Sort models",
|
|
180
|
+
"page.sort.tokens": "Token",
|
|
181
|
+
"page.sort.cost": "Cost",
|
|
182
|
+
"page.searchModels": "Search models",
|
|
183
|
+
"page.noModels": "No matching models",
|
|
184
|
+
"page.retry": "Retry"
|
|
141
185
|
},
|
|
142
186
|
"zh-CN": {
|
|
143
187
|
"metadata.name": "Token 统计",
|
|
@@ -188,7 +232,28 @@
|
|
|
188
232
|
"settings.timeoutError": "下载超时",
|
|
189
233
|
"settings.networkError": "网络或数据源错误",
|
|
190
234
|
"settings.invalid_catalogError": "数据源没有有效的模型价格",
|
|
191
|
-
"settings.storageError": "价格缓存保存失败"
|
|
235
|
+
"settings.storageError": "价格缓存保存失败",
|
|
236
|
+
"widgets.overview.title": "Token 概览",
|
|
237
|
+
"widgets.time.title": "Token趋势",
|
|
238
|
+
"page.title": "Token 统计",
|
|
239
|
+
"page.viewModels": "查看模型统计",
|
|
240
|
+
"ui.refresh": "刷新",
|
|
241
|
+
"page.range": "时间范围",
|
|
242
|
+
"page.range.1h": "1 小时",
|
|
243
|
+
"page.range.12h": "12 小时",
|
|
244
|
+
"page.range.24h": "24 小时",
|
|
245
|
+
"page.updatedAt": "更新于",
|
|
246
|
+
"page.tokens": "Token",
|
|
247
|
+
"page.activity": "时间活动",
|
|
248
|
+
"page.composition": "Token 构成",
|
|
249
|
+
"page.cacheRead": "缓存读取",
|
|
250
|
+
"page.cacheWrite": "缓存写入",
|
|
251
|
+
"page.sort": "模型排序",
|
|
252
|
+
"page.sort.tokens": "Token",
|
|
253
|
+
"page.sort.cost": "费用",
|
|
254
|
+
"page.searchModels": "搜索模型",
|
|
255
|
+
"page.noModels": "没有匹配的模型",
|
|
256
|
+
"page.retry": "重试"
|
|
192
257
|
}
|
|
193
258
|
}
|
|
194
259
|
}
|
|
@@ -1,317 +1,49 @@
|
|
|
1
|
-
<script lang="ts" module>
|
|
2
|
-
/** Keep sparse chart ticks distinct across midnight and repeated clock hours at DST fall-back. */
|
|
3
|
-
export function timeAxisLabels(
|
|
4
|
-
starts: readonly [number, number, number], locale?: string, timeZone?: string,
|
|
5
|
-
): [string, string, string] {
|
|
6
|
-
const zone = timeZone ? { timeZone } : {};
|
|
7
|
-
const days = new Intl.DateTimeFormat(locale, { ...zone, year: 'numeric', month: '2-digit', day: '2-digit' });
|
|
8
|
-
const dates = new Intl.DateTimeFormat(locale, { ...zone, month: 'numeric', day: 'numeric' });
|
|
9
|
-
const clock = new Intl.DateTimeFormat(locale, { ...zone, hour: '2-digit', minute: '2-digit', hourCycle: 'h23' });
|
|
10
|
-
const offsets = new Intl.DateTimeFormat(locale, { ...zone, timeZoneName: 'shortOffset' });
|
|
11
|
-
const dayLabels = starts.map(start => days.format(start));
|
|
12
|
-
const zoneLabels = starts.map(start => offsets.formatToParts(start).find(part => part.type === 'timeZoneName')?.value ?? '');
|
|
13
|
-
const crossesDay = new Set(dayLabels).size > 1;
|
|
14
|
-
const changesOffset = new Set(zoneLabels).size > 1;
|
|
15
|
-
return starts.map((start, index) => `${crossesDay ? `${dates.format(start)} ` : ''}${clock.format(start)}${changesOffset ? ` ${zoneLabels[index]}` : ''}`)
|
|
16
|
-
as [string, string, string];
|
|
17
|
-
}
|
|
18
|
-
</script>
|
|
19
|
-
|
|
20
1
|
<script lang="ts">
|
|
21
2
|
import { onMount, untrack } from 'svelte';
|
|
22
|
-
import { isLoading
|
|
3
|
+
import { isLoading } from 'svelte-i18n';
|
|
4
|
+
import { _, type TimeRange } from '@bungee/plugin-sdk';
|
|
23
5
|
import { getPluginText } from '$utils/plugin-i18n';
|
|
24
6
|
import { LoadingIndicator } from '$components/industrial';
|
|
25
|
-
import
|
|
26
|
-
import
|
|
27
|
-
import {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
groupBy: View;
|
|
36
|
-
estimatedCostUsd: number | null;
|
|
37
|
-
bucketMs?: number;
|
|
38
|
-
data: ModelUsageRow[];
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
let { pluginName = 'token-stats', selectedRange = '24h' }: { pluginName?: string; selectedRange?: TimeRange } = $props();
|
|
42
|
-
let selectedView: View = $state('model');
|
|
43
|
-
let stats: StatsResponse | null = $state(null);
|
|
44
|
-
let loading = $state(true);
|
|
45
|
-
let error = $state('');
|
|
46
|
-
let refreshedAt = $state(Date.now());
|
|
47
|
-
let selectedBucketStart: number | null = $state(null);
|
|
48
|
-
const tooltipId = $props.id();
|
|
49
|
-
let tooltipAnchor: HTMLElement | null = $state(null);
|
|
50
|
-
let tooltipBucket: TimeBucket | undefined = $state();
|
|
51
|
-
let tooltipMode: 'hover' | 'focus' | 'touch' = 'hover';
|
|
52
|
-
let closeTimer: ReturnType<typeof setTimeout> | undefined;
|
|
53
|
-
let touchBucketWasOpen = false;
|
|
54
|
-
let generation = 0;
|
|
55
|
-
let requestKey = '';
|
|
56
|
-
let controller: AbortController | undefined;
|
|
57
|
-
|
|
7
|
+
import type { NativeWidgetHeaderChange } from '$components/native-widgets/widget-header';
|
|
8
|
+
import UsageTimeChart from './UsageTimeChart.svelte';
|
|
9
|
+
import { getStatsResource } from './stats';
|
|
10
|
+
import { usagePresentation } from './labels';
|
|
11
|
+
import type { StatsState, StatsResource } from './stats-resource';
|
|
12
|
+
|
|
13
|
+
let { pluginName = 'token-stats', selectedRange = '24h', onHeaderChange }:
|
|
14
|
+
{ pluginName?: string; selectedRange?: TimeRange; onHeaderChange?: NativeWidgetHeaderChange } = $props();
|
|
15
|
+
let state: StatsState = $state({ data: null, busy: false, error: '', refreshedAt: 0 });
|
|
16
|
+
let resource: StatsResource | undefined = $state();
|
|
58
17
|
const t = (key: string) => $isLoading ? '' : getPluginText(key, pluginName, (id, options) => $_(id, options));
|
|
59
|
-
const display = (value: unknown) => {
|
|
60
|
-
const number = tokenAmount(value);
|
|
61
|
-
return number === undefined ? '—' : new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format(number);
|
|
62
|
-
};
|
|
63
|
-
const rows = $derived(stats?.data ?? []);
|
|
64
|
-
const usage = $derived(usagePresentation(stats ?? { logicalRequests: 0, upstreamAttempts: 0 }));
|
|
65
|
-
const formattedCost = $derived(formatEstimatedUsd(stats?.estimatedCostUsd));
|
|
66
|
-
const models = $derived(stats?.groupBy === 'model' ? rankedModels(rows) : []);
|
|
67
|
-
const maxModelTokens = $derived(Math.max(0, ...models.map(model => model.tokens ?? 0)));
|
|
68
|
-
const cacheLabels = $derived($locale?.startsWith('zh')
|
|
69
|
-
? { read: '缓存读取', write: '缓存写入' }
|
|
70
|
-
: { read: 'Cache read', write: 'Cache write' });
|
|
71
|
-
const trend: TimeSeries = $derived(stats?.groupBy === 'time' && stats.bucketMs
|
|
72
|
-
? buildTimeSeries(rows, selectedRange as TokenRange, stats.bucketMs, refreshedAt)
|
|
73
|
-
: { buckets: [], models: [], maxTokens: 0 });
|
|
74
|
-
const axisTicks = $derived(trend.buckets.length
|
|
75
|
-
? timeAxisLabels([
|
|
76
|
-
trend.buckets[0].startMs,
|
|
77
|
-
trend.buckets[Math.floor(trend.buckets.length / 2)].startMs,
|
|
78
|
-
trend.buckets[trend.buckets.length - 1].startMs,
|
|
79
|
-
], $locale ?? undefined)
|
|
80
|
-
: ['', '', '']);
|
|
81
|
-
const selectedBucket = $derived(trend.buckets.find(bucket => bucket.startMs === selectedBucketStart));
|
|
82
18
|
$effect(() => {
|
|
83
|
-
|
|
84
|
-
|
|
19
|
+
const nextResource = getStatsResource(pluginName, selectedRange, 'time');
|
|
20
|
+
resource = nextResource;
|
|
21
|
+
return nextResource.subscribe(next => { state = next; });
|
|
85
22
|
});
|
|
86
|
-
const denseAxis = $derived(axisTicks.join('').length > 42);
|
|
87
|
-
const views = $derived($isLoading ? [] : ([
|
|
88
|
-
['model', t('dimension.model')], ['time', t('dimension.time')],
|
|
89
|
-
] as const));
|
|
90
|
-
const tones = ['bg-nexus-500', 'bg-sky-400', 'bg-emerald-400', 'bg-amber-400', 'bg-red-400'] as const;
|
|
91
|
-
|
|
92
|
-
function modelLabel(id: string): string {
|
|
93
|
-
return id === OTHER_MODEL ? t('ui.otherModels') : id === 'unknown' ? t('ui.unknownModel') : id;
|
|
94
|
-
}
|
|
95
|
-
function modelTone(id: string): string {
|
|
96
|
-
return id === OTHER_MODEL ? 'bg-carbon-500' : id === 'unknown' ? 'bg-zinc-500' : tones[modelColorIndex(id)];
|
|
97
|
-
}
|
|
98
|
-
function timeLabel(startMs: number, full = false): string {
|
|
99
|
-
return new Intl.DateTimeFormat($locale ?? undefined, {
|
|
100
|
-
...(full ? { month: '2-digit' as const, day: '2-digit' as const, timeZoneName: 'shortOffset' as const } : {}),
|
|
101
|
-
hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
|
|
102
|
-
}).format(startMs);
|
|
103
|
-
}
|
|
104
|
-
function bucketDescription(bucket: TimeBucket): string {
|
|
105
|
-
return `${timeLabel(bucket.startMs, true)} · ${bucket.details.length
|
|
106
|
-
? `${display(bucket.total)} Token · ${bucket.details.map(part => `${modelLabel(part.id)} ${display(part.tokens)}`).join(' · ')}`
|
|
107
|
-
: t('ui.noCountedTokens')}`;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function cancelClose() {
|
|
111
|
-
clearTimeout(closeTimer);
|
|
112
|
-
closeTimer = undefined;
|
|
113
|
-
}
|
|
114
|
-
function closeBucket() {
|
|
115
|
-
cancelClose();
|
|
116
|
-
selectedBucketStart = null;
|
|
117
|
-
tooltipAnchor = null;
|
|
118
|
-
}
|
|
119
|
-
function showBucket(startMs: number, element: HTMLElement, mode: 'hover' | 'focus' | 'touch') {
|
|
120
|
-
cancelClose();
|
|
121
|
-
tooltipMode = mode;
|
|
122
|
-
tooltipAnchor = element;
|
|
123
|
-
selectedBucketStart = startMs;
|
|
124
|
-
}
|
|
125
|
-
function leaveBucket() {
|
|
126
|
-
if (tooltipMode !== 'hover') return;
|
|
127
|
-
cancelClose();
|
|
128
|
-
// Allow the pointer to cross the small gap into the shared tooltip to read or scroll it.
|
|
129
|
-
closeTimer = setTimeout(closeBucket, 100);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
async function refresh() {
|
|
133
|
-
const current = ++generation;
|
|
134
|
-
controller?.abort();
|
|
135
|
-
controller = new AbortController();
|
|
136
|
-
const key = `${pluginName}:${selectedRange}:${selectedView}`;
|
|
137
|
-
if (requestKey !== key) {
|
|
138
|
-
stats = null;
|
|
139
|
-
closeBucket();
|
|
140
|
-
error = '';
|
|
141
|
-
requestKey = key;
|
|
142
|
-
}
|
|
143
|
-
loading = stats === null;
|
|
144
|
-
try {
|
|
145
|
-
const result = await requestPluginControl<StatsResponse>(
|
|
146
|
-
pluginName, `/stats?range=${encodeURIComponent(selectedRange)}&groupBy=${selectedView}`,
|
|
147
|
-
'GET', undefined, controller.signal,
|
|
148
|
-
);
|
|
149
|
-
if (current !== generation) return;
|
|
150
|
-
refreshedAt = Date.now();
|
|
151
|
-
stats = result;
|
|
152
|
-
error = '';
|
|
153
|
-
} catch (cause) {
|
|
154
|
-
if (current !== generation || controller.signal.aborted) return;
|
|
155
|
-
error = cause instanceof Error ? cause.message : t('ui.loadFailed');
|
|
156
|
-
} finally {
|
|
157
|
-
if (current === generation) loading = false;
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
23
|
$effect(() => {
|
|
162
|
-
|
|
163
|
-
|
|
24
|
+
const report = onHeaderChange;
|
|
25
|
+
const header = { summary: selectedRange, refresh: { label: t('ui.refresh'), busy: state.busy,
|
|
26
|
+
disabled: !resource, run: () => { void resource?.refresh(); } } };
|
|
27
|
+
untrack(() => report?.(header));
|
|
164
28
|
});
|
|
165
|
-
|
|
166
29
|
onMount(() => {
|
|
167
|
-
const
|
|
168
|
-
return () =>
|
|
30
|
+
const report = onHeaderChange;
|
|
31
|
+
return () => report?.(null);
|
|
169
32
|
});
|
|
170
33
|
</script>
|
|
171
34
|
|
|
172
|
-
<div class="flex h-full min-h-0
|
|
173
|
-
<
|
|
174
|
-
|
|
175
|
-
<button type="button" aria-pressed={selectedView === view}
|
|
176
|
-
class="nx-btn nx-btn-sm min-h-6 px-2 {selectedView === view ? 'nx-btn-primary' : 'nx-btn-ghost'}"
|
|
177
|
-
onclick={() => selectedView = view}>{label}</button>
|
|
178
|
-
{/each}
|
|
179
|
-
</div>
|
|
180
|
-
|
|
181
|
-
{#if loading && !stats}
|
|
35
|
+
<div class="flex h-full min-h-0 flex-col gap-2" data-testid="plugin-widget-token-stats">
|
|
36
|
+
{#if state.error}<p role="alert" class="shrink-0 text-xs text-red-300">{t('ui.loadFailed')}</p>{/if}
|
|
37
|
+
{#if state.busy && !state.data}
|
|
182
38
|
<LoadingIndicator size="sm" height="sm" label={t('ui.loading')} />
|
|
183
|
-
{:else if
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
{
|
|
187
|
-
<
|
|
39
|
+
{:else if state.data}
|
|
40
|
+
{#if usagePresentation(state.data).state === 'empty'}
|
|
41
|
+
<p class="flex flex-1 items-center justify-center text-sm text-zinc-400" data-testid="token-stats-empty">{t('ui.noData')}</p>
|
|
42
|
+
{:else if !usagePresentation(state.data).positive}
|
|
43
|
+
<p class="flex flex-1 items-center justify-center text-sm text-zinc-400">{t('ui.noBreakdown')}</p>
|
|
188
44
|
{:else}
|
|
189
|
-
<div class="
|
|
190
|
-
<div class="min-w-0 pr-2 sm:pr-3">
|
|
191
|
-
<div class="nx-label">{t('ui.input')}{#if estimatedTokens(stats, 'input') !== undefined}<span class="ml-1 text-zinc-400">· {t('ui.includesEstimate')}</span>{/if}</div>
|
|
192
|
-
<div class="nx-display truncate text-2xl text-zinc-50" title={usage.input === undefined ? t('ui.unknownValue') : display(usage.input)}>{display(usage.input)}</div>
|
|
193
|
-
</div>
|
|
194
|
-
<div class="min-w-0 border-l border-carbon-600 pl-2 sm:pl-3 sm:pr-3">
|
|
195
|
-
<div class="nx-label">{t('ui.output')}{#if estimatedTokens(stats, 'output') !== undefined}<span class="ml-1 text-zinc-400">· {t('ui.includesEstimate')}</span>{/if}</div>
|
|
196
|
-
<div class="nx-display truncate text-2xl text-zinc-50" title={usage.output === undefined ? t('ui.unknownValue') : display(usage.output)}>{display(usage.output)}</div>
|
|
197
|
-
</div>
|
|
198
|
-
<div class="col-span-2 flex min-w-0 items-baseline justify-between gap-2 border-t border-carbon-600 pt-2 sm:col-span-1 sm:block sm:border-l sm:border-t-0 sm:pl-3 sm:pt-0" data-testid="token-stats-cost">
|
|
199
|
-
<div class="nx-label shrink-0">{t('ui.estimatedCostUsd')}</div>
|
|
200
|
-
<div class="min-w-0 truncate font-display text-lg font-bold leading-none text-zinc-50 sm:text-2xl" title={formattedCost === '—' ? t('ui.unknownValue') : formattedCost}>{formattedCost}</div>
|
|
201
|
-
</div>
|
|
202
|
-
</div>
|
|
203
|
-
|
|
204
|
-
{#if selectedView === 'model' && models.length}
|
|
205
|
-
<!-- Keyboard focus lets users scroll the model list without moving the fixed summary. -->
|
|
206
|
-
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
207
|
-
<div class="min-h-0 flex-1 overflow-y-auto focus-visible:outline focus-visible:outline-1 focus-visible:outline-nexus-500" role="region" tabindex="0" aria-label={t('dimension.model')} data-testid="token-stats-model-scroll">
|
|
208
|
-
<ol class="min-w-0" data-testid="token-stats-model-list">
|
|
209
|
-
{#each models as model (model.id)}
|
|
210
|
-
<li class="border-b border-carbon-600/60 py-2 last:border-0" data-testid="token-stats-model-row">
|
|
211
|
-
<div class="flex items-start justify-between gap-2">
|
|
212
|
-
<span class="min-w-0 break-all font-mono text-xs leading-snug text-zinc-200">{modelLabel(model.id)}</span>
|
|
213
|
-
<span class="shrink-0 whitespace-nowrap text-right font-mono text-[10px] text-zinc-400" title={formatEstimatedUsd(model.estimatedCostUsd)}>USD <span class="text-zinc-200">{formatEstimatedUsd(model.estimatedCostUsd)}</span></span>
|
|
214
|
-
</div>
|
|
215
|
-
<div class="mt-1.5 grid grid-cols-2 gap-3 font-mono text-[11px]">
|
|
216
|
-
<div class="min-w-0">
|
|
217
|
-
<div class="flex items-baseline gap-1.5"><span class="nx-label">{t('ui.input')}</span><span class="font-display text-sm text-zinc-50">{display(model.input)}</span></div>
|
|
218
|
-
<div class="mt-1 flex flex-wrap items-baseline gap-x-3 gap-y-0.5 border-l border-carbon-500 pl-2">
|
|
219
|
-
<div class="inline-flex items-baseline gap-1.5 whitespace-nowrap"><span class="nx-label">{cacheLabels.read}</span><span class="font-display text-sm text-zinc-50">{display(model.cacheRead)}</span></div>
|
|
220
|
-
<div class="inline-flex items-baseline gap-1.5 whitespace-nowrap"><span class="nx-label">{cacheLabels.write}</span><span class="font-display text-sm text-zinc-50">{display(model.cacheWrite)}</span></div>
|
|
221
|
-
</div>
|
|
222
|
-
</div>
|
|
223
|
-
<div class="min-w-0 border-l border-carbon-600 pl-3">
|
|
224
|
-
<div class="flex items-baseline gap-1.5"><span class="nx-label">{t('ui.output')}</span><span class="font-display text-sm text-zinc-50">{display(model.output)}</span></div>
|
|
225
|
-
</div>
|
|
226
|
-
</div>
|
|
227
|
-
<div class="mt-1.5 h-1.5 bg-carbon-600/60" aria-hidden="true"><div class="h-full {modelTone(model.id)}" style:width={`${maxModelTokens > 0 ? (model.tokens ?? 0) / maxModelTokens * 100 : 0}%`}></div></div>
|
|
228
|
-
</li>
|
|
229
|
-
{/each}
|
|
230
|
-
</ol>
|
|
231
|
-
</div>
|
|
232
|
-
{:else if selectedView === 'time' && trend.maxTokens > 0}
|
|
233
|
-
<div class="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden" data-testid="token-stats-time-chart">
|
|
234
|
-
<div class="flex min-h-0 flex-1 items-end gap-1 border-b border-carbon-500" role="group" aria-label={t('ui.timeChart')}>
|
|
235
|
-
{#each trend.buckets as bucket, index (bucket.startMs)}
|
|
236
|
-
<button type="button" aria-pressed={selectedBucketStart === bucket.startMs}
|
|
237
|
-
aria-label={bucketDescription(bucket)} aria-describedby={selectedBucketStart === bucket.startMs ? tooltipId : undefined} data-token-stats-bucket-trigger
|
|
238
|
-
class="flex min-w-0 flex-1 self-stretch flex-col justify-end border-x border-transparent transition-colors hover:bg-carbon-700/40 focus-visible:outline focus-visible:outline-2 focus-visible:outline-nexus-500 {selectedBucketStart === bucket.startMs ? 'bg-carbon-700/40' : ''}"
|
|
239
|
-
onpointerenter={(event) => {
|
|
240
|
-
if (event.pointerType !== 'touch') showBucket(bucket.startMs, event.currentTarget, 'hover');
|
|
241
|
-
}}
|
|
242
|
-
onpointerleave={leaveBucket}
|
|
243
|
-
onfocus={(event) => showBucket(bucket.startMs, event.currentTarget, 'focus')}
|
|
244
|
-
onpointerdown={(event) => {
|
|
245
|
-
touchBucketWasOpen = event.pointerType === 'touch' && selectedBucketStart === bucket.startMs;
|
|
246
|
-
}}
|
|
247
|
-
onclick={(event) => {
|
|
248
|
-
if ('pointerType' in event && event.pointerType === 'touch') {
|
|
249
|
-
if (touchBucketWasOpen) closeBucket();
|
|
250
|
-
else showBucket(bucket.startMs, event.currentTarget, 'touch');
|
|
251
|
-
} else showBucket(bucket.startMs, event.currentTarget, event.detail === 0 ? 'focus' : 'hover');
|
|
252
|
-
}}
|
|
253
|
-
onblur={(event) => {
|
|
254
|
-
if (selectedBucketStart === bucket.startMs && !(event.relatedTarget instanceof HTMLElement && event.currentTarget.parentElement?.contains(event.relatedTarget))) closeBucket();
|
|
255
|
-
}}
|
|
256
|
-
onkeydown={(event) => {
|
|
257
|
-
if (event.key === 'Escape') closeBucket();
|
|
258
|
-
if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
|
|
259
|
-
event.preventDefault();
|
|
260
|
-
const next = event.currentTarget.parentElement?.children[index + (event.key === 'ArrowRight' ? 1 : -1)] as HTMLElement | undefined;
|
|
261
|
-
next?.focus();
|
|
262
|
-
}
|
|
263
|
-
}}>
|
|
264
|
-
{#if bucket.total > 0}
|
|
265
|
-
<span class="mx-auto flex w-full max-w-7 flex-col-reverse overflow-hidden sm:max-w-9" style:height={`${bucket.total / trend.maxTokens * 100}%`} aria-hidden="true">
|
|
266
|
-
{#each bucket.parts as part (part.id)}
|
|
267
|
-
<span class="block w-full {modelTone(part.id)}" style:height={`${part.tokens / bucket.total * 100}%`}></span>
|
|
268
|
-
{/each}
|
|
269
|
-
</span>
|
|
270
|
-
{/if}
|
|
271
|
-
</button>
|
|
272
|
-
{/each}
|
|
273
|
-
</div>
|
|
274
|
-
<Tooltip.SharedContent anchor={tooltipAnchor} open={selectedBucket !== undefined} id={tooltipId}
|
|
275
|
-
onclose={closeBucket} onpointerenter={cancelClose} onpointerleave={leaveBucket}
|
|
276
|
-
class="w-72 font-mono text-[11px]"
|
|
277
|
-
data-testid="token-stats-bucket-detail">
|
|
278
|
-
{#if tooltipBucket}
|
|
279
|
-
<div class="border-b border-carbon-600 pb-2 text-zinc-400">{timeLabel(tooltipBucket.startMs, true)}</div>
|
|
280
|
-
<div class="mt-2 flex items-baseline justify-between gap-3">
|
|
281
|
-
<span>Token</span><strong class="font-display text-sm text-zinc-50">{display(tooltipBucket.total)}</strong>
|
|
282
|
-
</div>
|
|
283
|
-
<div class="mt-2 space-y-1.5">
|
|
284
|
-
{#each tooltipBucket.details as part (part.id)}
|
|
285
|
-
<div class="flex items-start justify-between gap-3">
|
|
286
|
-
<span class="flex min-w-0 items-start gap-1.5">
|
|
287
|
-
<span class="mt-1 h-2 w-2 shrink-0 {modelTone(part.id)}" aria-hidden="true"></span>
|
|
288
|
-
<span class="break-all">{modelLabel(part.id)}</span>
|
|
289
|
-
</span>
|
|
290
|
-
<strong class="shrink-0 font-normal text-zinc-50">{display(part.tokens)}</strong>
|
|
291
|
-
</div>
|
|
292
|
-
{:else}
|
|
293
|
-
<span class="text-zinc-400">{t('ui.noCountedTokens')}</span>
|
|
294
|
-
{/each}
|
|
295
|
-
</div>
|
|
296
|
-
{/if}
|
|
297
|
-
</Tooltip.SharedContent>
|
|
298
|
-
<div class="mt-1 flex shrink-0 items-start justify-between gap-1 font-mono text-[10px] leading-tight text-zinc-400" aria-hidden="true">
|
|
299
|
-
<span class="shrink-0 whitespace-nowrap">{axisTicks[0]}</span>
|
|
300
|
-
<span class="min-w-0 whitespace-nowrap text-center {denseAxis ? 'hidden sm:inline' : ''}">{axisTicks[1]}</span>
|
|
301
|
-
<span class="shrink-0 whitespace-nowrap text-right">{axisTicks[2]}</span>
|
|
302
|
-
</div>
|
|
303
|
-
<div class="mt-2 flex shrink-0 flex-wrap gap-x-3 gap-y-1 font-mono text-[10px] text-zinc-300">
|
|
304
|
-
{#each trend.models as id (id)}
|
|
305
|
-
<span class="inline-flex min-w-0 items-center gap-1" title={modelLabel(id)}>
|
|
306
|
-
<span class="h-2 w-2 shrink-0 {modelTone(id)}" aria-hidden="true"></span>
|
|
307
|
-
<span class="max-w-28 truncate">{modelLabel(id)}</span>
|
|
308
|
-
</span>
|
|
309
|
-
{/each}
|
|
310
|
-
</div>
|
|
311
|
-
</div>
|
|
312
|
-
{:else if usage.positive}
|
|
313
|
-
<p class="text-xs text-zinc-400">{t('ui.noBreakdown')}</p>
|
|
314
|
-
{/if}
|
|
45
|
+
<div class="min-h-0 flex-1"><UsageTimeChart stats={state.data} range={selectedRange} refreshedAt={state.refreshedAt} {pluginName} /></div>
|
|
315
46
|
{/if}
|
|
316
47
|
{/if}
|
|
48
|
+
<a href={`/#/extensions/${pluginName}/statistics`} class="shrink-0 self-end text-[11px] text-zinc-400 hover:text-nexus-300 hover:underline">{t('page.viewModels')} →</a>
|
|
317
49
|
</div>
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount, untrack } from 'svelte';
|
|
3
|
+
import { isLoading } from 'svelte-i18n';
|
|
4
|
+
import { _, formatCompactNumber, type TimeRange } from '@bungee/plugin-sdk';
|
|
5
|
+
import { getPluginText } from '$utils/plugin-i18n';
|
|
6
|
+
import { LoadingIndicator } from '$components/industrial';
|
|
7
|
+
import type { NativeWidgetHeaderChange } from '$components/native-widgets/widget-header';
|
|
8
|
+
import { estimatedTokens, formatEstimatedUsd, formatTokenCount, usagePresentation } from './labels';
|
|
9
|
+
import { getStatsResource } from './stats';
|
|
10
|
+
import type { StatsState, StatsResource } from './stats-resource';
|
|
11
|
+
|
|
12
|
+
let { pluginName = 'token-stats', selectedRange = '24h', onHeaderChange }:
|
|
13
|
+
{ pluginName?: string; selectedRange?: TimeRange; onHeaderChange?: NativeWidgetHeaderChange } = $props();
|
|
14
|
+
let state: StatsState = $state({ data: null, busy: false, error: '', refreshedAt: 0 });
|
|
15
|
+
let resource: StatsResource | undefined = $state();
|
|
16
|
+
const t = (key: string) => $isLoading ? '' : getPluginText(key, pluginName, (id, options) => $_(id, options));
|
|
17
|
+
const usage = $derived(state.data ? usagePresentation(state.data) : null);
|
|
18
|
+
$effect(() => {
|
|
19
|
+
const nextResource = getStatsResource(pluginName, selectedRange, 'model');
|
|
20
|
+
resource = nextResource;
|
|
21
|
+
return nextResource.subscribe(next => { state = next; });
|
|
22
|
+
});
|
|
23
|
+
$effect(() => {
|
|
24
|
+
const report = onHeaderChange;
|
|
25
|
+
const header = { summary: selectedRange, refresh: { label: t('ui.refresh'), busy: state.busy,
|
|
26
|
+
disabled: !resource, run: () => { void resource?.refresh(); } } };
|
|
27
|
+
untrack(() => report?.(header));
|
|
28
|
+
});
|
|
29
|
+
onMount(() => {
|
|
30
|
+
const report = onHeaderChange;
|
|
31
|
+
return () => report?.(null);
|
|
32
|
+
});
|
|
33
|
+
</script>
|
|
34
|
+
|
|
35
|
+
<div class="kpi-metric-row w-full min-w-0" data-testid="token-stats-overview">
|
|
36
|
+
{#if state.busy && !state.data}
|
|
37
|
+
<LoadingIndicator size="sm" height="sm" label={t('ui.loading')} />
|
|
38
|
+
{:else if state.error && !state.data}
|
|
39
|
+
<p role="alert" class="text-xs text-red-300">{t('ui.loadFailed')}</p>
|
|
40
|
+
{:else}
|
|
41
|
+
<div class="kpi-metric-row overview-metrics">
|
|
42
|
+
{#each ['input', 'output', 'cost'] as metric}
|
|
43
|
+
{@const fullValue = metric === 'cost' ? formatEstimatedUsd(state.data?.estimatedCostUsd)
|
|
44
|
+
: formatTokenCount(metric === 'input' ? usage?.input : usage?.output)}
|
|
45
|
+
{@const value = metric === 'cost'
|
|
46
|
+
? (state.data?.estimatedCostUsd != null && Number.isFinite(state.data.estimatedCostUsd) && state.data.estimatedCostUsd >= 1000
|
|
47
|
+
? `$${formatCompactNumber(state.data.estimatedCostUsd)}` : fullValue)
|
|
48
|
+
: formatCompactNumber(metric === 'input' ? usage?.input : usage?.output)}
|
|
49
|
+
<div class="min-w-0" data-testid={`token-stats-metric-${metric}`}>
|
|
50
|
+
<div class="nx-label mb-2">{t(metric === 'cost' ? 'ui.estimatedCostUsd' : `ui.${metric}`)}</div>
|
|
51
|
+
<div class="overview-value nx-metric truncate text-zinc-50" title={fullValue} aria-label={fullValue}>{value}</div>
|
|
52
|
+
{#if metric !== 'cost' && state.data && (estimatedTokens(state.data, metric as 'input' | 'output') ?? 0) > 0}
|
|
53
|
+
<p class="mt-1 text-[10px] text-zinc-400">{t('ui.includesEstimate')}</p>
|
|
54
|
+
{/if}
|
|
55
|
+
</div>
|
|
56
|
+
{/each}
|
|
57
|
+
</div>
|
|
58
|
+
{#if state.error}<p role="alert" class="mt-2 text-xs text-red-300">{t('ui.loadFailed')}</p>
|
|
59
|
+
{:else if usage?.state === 'empty'}<p class="mt-2 text-[10px] text-zinc-400" data-testid="token-stats-empty">{t('ui.noData')}</p>{/if}
|
|
60
|
+
{/if}
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<style>
|
|
64
|
+
.overview-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
|
|
65
|
+
.overview-metrics > div + div { border-left: 1px solid var(--nx-edge); padding-left: 16px; }
|
|
66
|
+
.overview-value { font-size: clamp(20px, 2.3vw, 32px); }
|
|
67
|
+
@container (max-width: 380px) {
|
|
68
|
+
.overview-metrics { gap: 8px; }
|
|
69
|
+
.overview-metrics > div + div { padding-left: 8px; }
|
|
70
|
+
.overview-value { font-size: 20px; }
|
|
71
|
+
}
|
|
72
|
+
@container (max-width: 220px) {
|
|
73
|
+
.overview-metrics { grid-template-columns: 1fr; }
|
|
74
|
+
.overview-metrics > div + div { border-left: 0; padding-left: 0; }
|
|
75
|
+
}
|
|
76
|
+
</style>
|