@duke-dsh-plugins/dsh-token-stats 1.3.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/client.js ADDED
@@ -0,0 +1,677 @@
1
+ /**
2
+ * dsh-token-stats — Client half (web bundle).
3
+ *
4
+ * Rendered by the DSH web shell via `window.__ModuleLoader__.load`. Registers
5
+ * a "Token 统计" page in the Settings panel (`settings.section`) with:
6
+ * - two tabs (近 7 天 / 近 30 天, both include today),
7
+ * - a stacked bar chart (per-model daily consumption over the range),
8
+ * - a donut + legend (per-model total share over the range),
9
+ * - a GitHub-contributions-style heatmap whose day range adapts to the
10
+ * container width (capped at one year / 365 days).
11
+ *
12
+ * Host communication goes through the `tokenStats` Remote namespace
13
+ * (`ctx.remote.tokenStats.getStats()`), published by the Host half in
14
+ * `index.js`.
15
+ */
16
+ window.__ModuleLoader__.load({
17
+ id: "dsh-token-stats",
18
+ factory: (require) => {
19
+ var module = { exports: {} };
20
+ var exports = module.exports;
21
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
22
+ const React = require("react");
23
+
24
+ // ---- CSS (package-owned, mirrors DSH design tokens) --------------------
25
+ const CSS = `
26
+ .ts-page{max-width:720px;width:100%;box-sizing:border-box;color:var(--dsw-alias-label-primary);display:flex;flex-direction:column;gap:14px}
27
+ .ts-head{display:flex;align-items:center;gap:10px}
28
+ .ts-head-title{font-size:16px;font-weight:500;line-height:24px;margin:0;flex:1}
29
+ .ts-refresh{box-sizing:border-box;height:28px;font:inherit;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);border-radius:14px;background:transparent;color:var(--dsw-alias-label-secondary);padding:0 12px;font-size:12px;line-height:26px}
30
+ .ts-refresh:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l1)}
31
+ .ts-status{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px;min-height:18px}
32
+ .ts-tabs{display:inline-flex;gap:4px;padding:3px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:10px;align-self:flex-start}
33
+ .ts-tab{box-sizing:border-box;height:26px;font:inherit;cursor:pointer;border:none;background:transparent;color:var(--dsw-alias-label-secondary);border-radius:7px;padding:0 14px;font-size:13px;line-height:26px}
34
+ .ts-tab-active{background:var(--dsw-alias-bg-overlay);color:var(--dsw-alias-label-primary);box-shadow:0 1px 2px rgba(0,0,0,0.08)}
35
+ .ts-body{display:flex;flex-direction:column;gap:16px}
36
+ .ts-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:10px}
37
+ .ts-card{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:12px;padding:12px 14px;display:flex;flex-direction:column;gap:4px}
38
+ .ts-card-label{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
39
+ .ts-card-value{font-size:18px;font-weight:600;line-height:26px}
40
+ .ts-block{display:flex;flex-direction:column;gap:8px}
41
+ .ts-block-title{color:var(--dsw-alias-label-secondary);font-size:13px;font-weight:500;line-height:20px}
42
+ .ts-bar-legend{display:flex;flex-wrap:wrap;gap:6px 14px}
43
+ .ts-legend-chip{display:inline-flex;align-items:center;gap:6px;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
44
+ .ts-bar-legend .ts-legend-name{color:var(--dsw-alias-label-primary)}
45
+ .ts-legend-dot{width:10px;height:10px;border-radius:3px;flex:none;display:inline-block}
46
+ .ts-pie-wrap{display:flex;flex-wrap:wrap;gap:20px;align-items:center}
47
+ .ts-legend{display:flex;flex-direction:column;gap:8px;min-width:180px;flex:1}
48
+ .ts-legend-row{display:flex;align-items:center;gap:8px;font-size:12px;line-height:18px}
49
+ .ts-legend-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
50
+ .ts-legend-val{color:var(--dsw-alias-label-secondary);white-space:nowrap}
51
+ .ts-heat{stroke:var(--dsw-alias-border-l1);stroke-width:0.5}
52
+ .ts-heat-0{fill:var(--dsw-alias-bg-layer-2);fill:color-mix(in srgb,var(--dsw-alias-border-l1) 45%,transparent);background:var(--dsw-alias-bg-layer-2);background:color-mix(in srgb,var(--dsw-alias-border-l1) 45%,transparent)}
53
+ .ts-heat-1{fill:var(--dsw-alias-state-success-primary);background:var(--dsw-alias-state-success-primary);opacity:0.3}
54
+ .ts-heat-2{fill:var(--dsw-alias-state-success-primary);background:var(--dsw-alias-state-success-primary);opacity:0.5}
55
+ .ts-heat-3{fill:var(--dsw-alias-state-success-primary);background:var(--dsw-alias-state-success-primary);opacity:0.75}
56
+ .ts-heat-4{fill:var(--dsw-alias-state-success-primary);background:var(--dsw-alias-state-success-primary);opacity:1}
57
+ .ts-heat-legend{display:flex;align-items:center;gap:3px}
58
+ .ts-heat-legend-cell{width:10px;height:10px;border-radius:2px}
59
+ .ts-heat-legend-label{color:var(--dsw-alias-label-secondary);font-size:11px;margin:0 6px}
60
+ .ts-empty{color:var(--dsw-alias-label-secondary);font-size:13px;padding:24px 0}
61
+ .ts-error{color:var(--dsw-alias-state-error-primary)}
62
+ .ts-loading{display:flex;align-items:center;gap:10px;color:var(--dsw-alias-label-secondary);font-size:13px;padding:24px 0}
63
+ .ts-spinner{width:16px;height:16px;border-radius:50%;border:2px solid var(--dsw-alias-border-l1);border-top-color:var(--dsw-alias-brand-primary);animation:ts-spin .8s linear infinite;flex:none}
64
+ @keyframes ts-spin{to{transform:rotate(360deg)}}
65
+
66
+ /* Settings nav icon: DSH 0.1.x settings.section only projects id/order/
67
+ label, and the settings shell paints a generic gear for every external
68
+ section (client-ui-settings-general's navIcon()). registerSettingsNavIcon
69
+ marks our own nav row; hide the shell's gear and draw the chart-column
70
+ Lucide glyph as a currentColor mask so it follows the native nav
71
+ hover/active colors without changing the shell's 16px icon rhythm. */
72
+ [data-dsh-token-stats-settings-nav]>svg:first-child{display:none}
73
+ [data-dsh-token-stats-settings-nav]::before{content:'';flex:none;width:16px;height:16px;background:currentColor;-webkit-mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 3v16a2 2 0 0 0 2 2h16'/%3E%3Cpath d='M18 17V9'/%3E%3Cpath d='M13 17V5'/%3E%3Cpath d='M8 17v-3'/%3E%3C/svg%3E") center/contain no-repeat;mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 3v16a2 2 0 0 0 2 2h16'/%3E%3Cpath d='M18 17V9'/%3E%3Cpath d='M13 17V5'/%3E%3Cpath d='M8 17v-3'/%3E%3C/svg%3E") center/contain no-repeat}
74
+ `;
75
+
76
+ // ---- Settings nav icon --------------------------------------------------
77
+ // DSH 0.1.x does not yet carry an icon through the settings.section
78
+ // registration contract: its shell projects only id/order/label and
79
+ // paints a generic gear for every external section. Mark only this
80
+ // plugin's localized nav row so the CSS above can replace the fallback
81
+ // gear; the disposer clears the marker for HMR / plugin disable.
82
+ const SETTINGS_LABEL = "Token 统计";
83
+ const SETTINGS_NAV_MARKER = "data-dsh-token-stats-settings-nav";
84
+
85
+ function registerSettingsNavIcon(label) {
86
+ let disposed = false;
87
+ const sync = function () {
88
+ if (disposed) return;
89
+ const currentLabel = String(label).trim();
90
+ const buttons = document.querySelectorAll('[role="dialog"] nav button');
91
+ for (let i = 0; i < buttons.length; i++) {
92
+ const button = buttons[i];
93
+ const text = button.textContent ? button.textContent.trim() : "";
94
+ if (currentLabel.length > 0 && text === currentLabel) {
95
+ button.setAttribute(SETTINGS_NAV_MARKER, "");
96
+ } else {
97
+ button.removeAttribute(SETTINGS_NAV_MARKER);
98
+ }
99
+ }
100
+ };
101
+ sync();
102
+ const observer = new MutationObserver(sync);
103
+ observer.observe(document.body, { childList: true, subtree: true, characterData: true });
104
+ return function () {
105
+ disposed = true;
106
+ observer.disconnect();
107
+ const marked = document.querySelectorAll("[" + SETTINGS_NAV_MARKER + "]");
108
+ for (let i = 0; i < marked.length; i++) marked[i].removeAttribute(SETTINGS_NAV_MARKER);
109
+ };
110
+ }
111
+
112
+ // ---- Client Remote contribution ----------------------------------------
113
+ // The browser-side `remote.tokenStats` service only exists after this
114
+ // module mounts its namespace via ctx.remote.$mount(): dsh-api-remotes'
115
+ // client assembly mounts only the five official namespaces, so a plugin
116
+ // must mount its own. Mirrors the invocation in typert.host.js (id,
117
+ // service/namespace/method). zod is not requirable in the browser module
118
+ // loader, so codecs use passthrough schemas — the runtime contract only
119
+ // requires typeSymbol + schema.parse().
120
+ const passthrough = () => ({ parse: (v) => v });
121
+ const CLIENT_REMOTE = {
122
+ package: "dsh-token-stats",
123
+ descriptors: [
124
+ {
125
+ id: "dsh-token-stats#tokenStats/getStats",
126
+ service: "tokenStats",
127
+ namespace: "tokenStats",
128
+ method: "getStats",
129
+ invocation: { kind: "direct" },
130
+ parameters: [],
131
+ result: {
132
+ mode: "strict",
133
+ typeSymbol: "dsh-token-stats#TokenStatsResult",
134
+ schema: passthrough(),
135
+ },
136
+ },
137
+ ],
138
+ };
139
+
140
+ async function apply(ctx) {
141
+ // Mount the tokenStats namespace before anything touches it; the mount's
142
+ // lifetime is bound to this plugin's context by $mount itself.
143
+ await ctx.remote.$mount(CLIENT_REMOTE);
144
+
145
+ const styleTag = document.createElement("style");
146
+ styleTag.textContent = CSS;
147
+ document.head.appendChild(styleTag);
148
+ ctx.effect(() => () => styleTag.remove());
149
+
150
+ // Mark our settings-nav row so the CSS above replaces the shell's
151
+ // fallback gear (no icon field exists in settings.section yet).
152
+ ctx.effect(() => registerSettingsNavIcon(SETTINGS_LABEL));
153
+
154
+ // ctx.get() reads the service without the property-accessor inject guard.
155
+ const remote = ctx.get("remote.tokenStats");
156
+
157
+ const PALETTE = [
158
+ "#5b8cff", "#f26d7a", "#3ecf8e", "#f5b942", "#a78bfa", "#38bdf8",
159
+ "#fb923c", "#34d399", "#f472b6", "#60a5fa", "#fbbf24", "#c084fc",
160
+ "#2dd4bf", "#f87171",
161
+ ];
162
+ const MONTHS = ["1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月", "9月", "10月", "11月", "12月"];
163
+
164
+ function fmtCompact(n) {
165
+ if (n >= 1e9) {
166
+ const v = n / 1e9;
167
+ return (v >= 100 ? Math.round(v) : v.toFixed(1)) + "B";
168
+ }
169
+ if (n >= 1e6) {
170
+ const v = n / 1e6;
171
+ return (v >= 100 ? Math.round(v) : v.toFixed(1)) + "M";
172
+ }
173
+ if (n >= 1e3) {
174
+ const v = n / 1e3;
175
+ return (v >= 100 ? Math.round(v) : v.toFixed(1)) + "K";
176
+ }
177
+ return String(n);
178
+ }
179
+
180
+ function keyOf(d) {
181
+ return (
182
+ d.getFullYear() +
183
+ "-" +
184
+ String(d.getMonth() + 1).padStart(2, "0") +
185
+ "-" +
186
+ String(d.getDate()).padStart(2, "0")
187
+ );
188
+ }
189
+
190
+ function dayOffset(offset) {
191
+ const d = new Date();
192
+ d.setDate(d.getDate() + offset);
193
+ return keyOf(d);
194
+ }
195
+
196
+ function heatLevel(v, max) {
197
+ if (!v || v <= 0 || !max || max <= 0) return 0;
198
+ const r = v / max;
199
+ if (r >= 0.75) return 4;
200
+ if (r >= 0.5) return 3;
201
+ if (r >= 0.25) return 2;
202
+ return 1;
203
+ }
204
+
205
+ function buildRange(data, rangeDays) {
206
+ const byDate = {};
207
+ for (const d of data.days) byDate[d.date] = d;
208
+ const start = new Date(dayOffset(-(rangeDays - 1)) + "T00:00:00");
209
+ const dayList = [];
210
+ for (let i = 0; i < rangeDays; i++) {
211
+ const d = new Date(start.getFullYear(), start.getMonth(), start.getDate() + i);
212
+ const k = keyOf(d);
213
+ const entry = byDate[k];
214
+ dayList.push({
215
+ date: k,
216
+ label: d.getMonth() + 1 + "/" + d.getDate(),
217
+ total: entry ? entry.total : 0,
218
+ models: entry ? entry.models : [],
219
+ });
220
+ }
221
+ const totals = new Map();
222
+ for (const day of dayList) {
223
+ for (const m of day.models) {
224
+ let agg = totals.get(m.key);
225
+ if (!agg) {
226
+ agg = { total: 0, name: m.name };
227
+ totals.set(m.key, agg);
228
+ }
229
+ agg.total += m.total;
230
+ }
231
+ }
232
+ const rangeModels = [];
233
+ for (const [key, agg] of totals) rangeModels.push({ key, total: agg.total, name: agg.name });
234
+ rangeModels.sort((a, b) => b.total - a.total);
235
+ const sum = { total: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
236
+ for (const day of dayList) {
237
+ sum.total += day.total;
238
+ for (const m of day.models) {
239
+ sum.input += m.input;
240
+ sum.output += m.output;
241
+ sum.cacheRead += m.cacheRead;
242
+ sum.cacheWrite += m.cacheWrite;
243
+ }
244
+ }
245
+ return { dayList, rangeModels, sum };
246
+ }
247
+
248
+ function BarChart(props) {
249
+ const dayList = props.dayList;
250
+ const rangeModels = props.rangeModels;
251
+ const width = props.width;
252
+ const n = dayList.length;
253
+ const plotLeft = 44;
254
+ const plotRight = 6;
255
+ const plotTop = 10;
256
+ const plotBottom = 26;
257
+ const plotW = Math.max(10, width - plotLeft - plotRight);
258
+ const plotH = 176;
259
+ const maxTotal = Math.max(1, ...dayList.map((d) => d.total));
260
+ const step = plotW / n;
261
+ const barW = Math.max(2, step * 0.66);
262
+ const colorOf = {};
263
+ rangeModels.forEach((m, i) => {
264
+ colorOf[m.key] = PALETTE[i % PALETTE.length];
265
+ });
266
+ const els = [];
267
+ const gridR = [1, 0.75, 0.5, 0.25, 0];
268
+ gridR.forEach((r, gi) => {
269
+ const y = plotTop + plotH - r * plotH;
270
+ els.push(
271
+ React.createElement("line", {
272
+ key: "g" + gi,
273
+ x1: plotLeft, y1: y, x2: plotLeft + plotW, y2: y,
274
+ stroke: "var(--dsw-alias-border-l1)", strokeWidth: 1,
275
+ }),
276
+ );
277
+ els.push(
278
+ React.createElement("text", {
279
+ key: "gt" + gi,
280
+ x: plotLeft - 5, y: y + 3, textAnchor: "end", fontSize: 10,
281
+ fill: "var(--dsw-alias-label-secondary)",
282
+ }, fmtCompact(maxTotal * r)),
283
+ );
284
+ });
285
+ dayList.forEach((day, i) => {
286
+ const x = plotLeft + i * step + (step - barW) / 2;
287
+ const byKey = {};
288
+ for (const m of day.models) byKey[m.key] = m;
289
+ let y = plotTop + plotH;
290
+ for (const m of rangeModels) {
291
+ const rec = byKey[m.key];
292
+ if (!rec || rec.total <= 0) continue;
293
+ const h = Math.max(1, (rec.total / maxTotal) * plotH);
294
+ y -= h;
295
+ els.push(
296
+ React.createElement(
297
+ "rect",
298
+ { key: "b" + i + "_" + m.key, x, y, width: barW, height: h, fill: colorOf[m.key] },
299
+ React.createElement("title", null, day.label + " · " + rec.name + ": " + fmtCompact(rec.total)),
300
+ ),
301
+ );
302
+ }
303
+ });
304
+ const labelEvery = n > 12 ? Math.ceil(n / 8) : 1;
305
+ dayList.forEach((day, i) => {
306
+ if (i % labelEvery !== 0 && i !== n - 1) return;
307
+ const x = plotLeft + i * step + step / 2;
308
+ els.push(
309
+ React.createElement("text", {
310
+ key: "xl" + i, x, y: plotTop + plotH + 16, textAnchor: "middle", fontSize: 10,
311
+ fill: "var(--dsw-alias-label-secondary)",
312
+ }, day.label),
313
+ );
314
+ });
315
+ return React.createElement("svg", {
316
+ width, height: plotTop + plotH + plotBottom,
317
+ viewBox: "0 0 " + width + " " + (plotTop + plotH + plotBottom),
318
+ }, els);
319
+ }
320
+
321
+ function BarLegend(props) {
322
+ const rows = props.rangeModels.map((m, i) =>
323
+ React.createElement(
324
+ "span", { key: m.key, className: "ts-legend-chip", title: m.key },
325
+ React.createElement("span", { className: "ts-legend-dot", style: { background: PALETTE[i % PALETTE.length] } }),
326
+ React.createElement("span", { className: "ts-legend-name" }, m.name),
327
+ React.createElement("span", { className: "ts-legend-val" }, fmtCompact(m.total)),
328
+ ),
329
+ );
330
+ return React.createElement("div", { className: "ts-bar-legend" }, rows);
331
+ }
332
+
333
+ function PieChart(props) {
334
+ const rangeModels = props.rangeModels;
335
+ const sum = props.sum;
336
+ const size = props.size;
337
+ const total = sum.total || 0;
338
+ const cx = size / 2;
339
+ const cy = size / 2;
340
+ const rOuter = size / 2 - 8;
341
+ const rInner = rOuter * 0.58;
342
+ const rMid = (rOuter + rInner) / 2;
343
+ const strokeWidth = rOuter - rInner;
344
+ const C = 2 * Math.PI * rMid;
345
+ const els = [];
346
+ if (total <= 0) {
347
+ els.push(
348
+ React.createElement("circle", {
349
+ key: "e", cx, cy, r: rMid, fill: "none",
350
+ stroke: "var(--dsw-alias-border-l1)", strokeWidth: 1,
351
+ }),
352
+ );
353
+ els.push(
354
+ React.createElement("text", {
355
+ key: "et", x: cx, y: cy + 4, textAnchor: "middle", fontSize: 12,
356
+ fill: "var(--dsw-alias-label-secondary)",
357
+ }, "暂无数据"),
358
+ );
359
+ } else {
360
+ els.push(
361
+ React.createElement("circle", {
362
+ key: "track", cx, cy, r: rMid, fill: "none",
363
+ stroke: "var(--dsw-alias-bg-layer-2)", strokeWidth,
364
+ }),
365
+ );
366
+ let offset = 0;
367
+ rangeModels.forEach((m, i) => {
368
+ if (m.total <= 0) return;
369
+ const dash = (m.total / total) * C;
370
+ els.push(
371
+ React.createElement(
372
+ "circle",
373
+ {
374
+ key: "w" + i, cx, cy, r: rMid, fill: "none",
375
+ stroke: PALETTE[i % PALETTE.length],
376
+ strokeWidth,
377
+ strokeDasharray: dash + " " + (C - dash),
378
+ strokeDashoffset: -offset,
379
+ transform: "rotate(-90 " + cx + " " + cy + ")",
380
+ },
381
+ React.createElement("title", null, m.name + ": " + fmtCompact(m.total) + " (" + ((m.total / total) * 100).toFixed(1) + "%)"),
382
+ ),
383
+ );
384
+ offset += dash;
385
+ });
386
+ els.push(
387
+ React.createElement("text", {
388
+ key: "c", x: cx, y: cy - 2, textAnchor: "middle", fontSize: 18, fontWeight: 600,
389
+ fill: "var(--dsw-alias-label-primary)",
390
+ }, fmtCompact(total)),
391
+ );
392
+ els.push(
393
+ React.createElement("text", {
394
+ key: "cl", x: cx, y: cy + 14, textAnchor: "middle", fontSize: 10,
395
+ fill: "var(--dsw-alias-label-secondary)",
396
+ }, "Tokens"),
397
+ );
398
+ }
399
+ return React.createElement("svg", { width: size, height: size, viewBox: "0 0 " + size + " " + size }, els);
400
+ }
401
+
402
+ function PieLegend(props) {
403
+ const total = props.total;
404
+ const rows = props.rangeModels.map((m, i) =>
405
+ React.createElement(
406
+ "div", { key: m.key, className: "ts-legend-row" },
407
+ React.createElement("span", { className: "ts-legend-dot", style: { background: PALETTE[i % PALETTE.length] } }),
408
+ React.createElement("span", { className: "ts-legend-name", title: m.key }, m.name),
409
+ React.createElement(
410
+ "span", { className: "ts-legend-val" },
411
+ fmtCompact(m.total) + " · " + (total > 0 ? ((m.total / total) * 100).toFixed(1) : "0") + "%",
412
+ ),
413
+ ),
414
+ );
415
+ return React.createElement("div", { className: "ts-legend" }, rows);
416
+ }
417
+
418
+ function Heatmap(props) {
419
+ const daysMap = props.daysMap;
420
+ const width = props.width;
421
+ const cell = 10;
422
+ const gap = 2;
423
+ const padLeft = 34;
424
+ const padTop = 22;
425
+ const padRight = 10;
426
+ const availWeeks = Math.max(1, Math.floor((width - padLeft - padRight) / (cell + gap)));
427
+ const weeks = Math.min(availWeeks, 53);
428
+ const totalDays = Math.min(365, weeks * 7);
429
+ const today = new Date();
430
+ const start = new Date(today.getFullYear(), today.getMonth(), today.getDate() - (totalDays - 1));
431
+ let max = 0;
432
+ for (let i = 0; i < totalDays; i++) {
433
+ const d = new Date(start.getFullYear(), start.getMonth(), start.getDate() + i);
434
+ const v = daysMap[keyOf(d)] || 0;
435
+ if (v > max) max = v;
436
+ }
437
+ const els = [];
438
+ let prevMonth = -1;
439
+ let lastLabelX = -100;
440
+ for (let i = 0; i < totalDays; i++) {
441
+ const d = new Date(start.getFullYear(), start.getMonth(), start.getDate() + i);
442
+ const v = daysMap[keyOf(d)] || 0;
443
+ const w = Math.floor(i / 7);
444
+ const r = i % 7;
445
+ const x = padLeft + w * (cell + gap);
446
+ const y = padTop + r * (cell + gap);
447
+ els.push(
448
+ React.createElement(
449
+ "rect",
450
+ {
451
+ key: "c" + i, x, y, width: cell, height: cell, rx: 2,
452
+ className: "ts-heat ts-heat-" + heatLevel(v, max),
453
+ },
454
+ React.createElement("title", null, (d.getMonth() + 1) + "/" + d.getDate() + " · " + fmtCompact(v) + " tokens"),
455
+ ),
456
+ );
457
+ const m = d.getMonth();
458
+ if (m !== prevMonth && x - lastLabelX >= 22) {
459
+ prevMonth = m;
460
+ lastLabelX = x;
461
+ els.push(
462
+ React.createElement("text", {
463
+ key: "ml" + i, x: x + 2, y: 12, fontSize: 10,
464
+ fill: "var(--dsw-alias-label-secondary)",
465
+ }, MONTHS[m]),
466
+ );
467
+ }
468
+ }
469
+ const weekdays = [["Mon", 1], ["Wed", 3], ["Fri", 5]];
470
+ weekdays.forEach((wd) => {
471
+ const y = padTop + wd[1] * (cell + gap) + cell / 2 + 3;
472
+ els.push(
473
+ React.createElement("text", {
474
+ key: "wl" + wd[0], x: padLeft - 6, y, textAnchor: "end", fontSize: 9,
475
+ fill: "var(--dsw-alias-label-secondary)",
476
+ }, wd[0]),
477
+ );
478
+ });
479
+ const gridH = padTop + 7 * (cell + gap) - gap + 6;
480
+ const fullW = padLeft + weeks * (cell + gap) - gap + padRight;
481
+ return React.createElement("svg", { width, height: gridH, viewBox: "0 0 " + fullW + " " + gridH }, els);
482
+ }
483
+
484
+ function HeatLegend() {
485
+ const cells = [];
486
+ for (let i = 0; i < 5; i++) {
487
+ cells.push(React.createElement("span", { key: i, className: "ts-heat ts-heat-" + i + " ts-heat-legend-cell" }));
488
+ }
489
+ return React.createElement(
490
+ "div", { className: "ts-heat-legend" },
491
+ React.createElement("span", { className: "ts-heat-legend-label" }, "少"),
492
+ cells,
493
+ React.createElement("span", { className: "ts-heat-legend-label" }, "多"),
494
+ );
495
+ }
496
+
497
+ function SummaryCards(props) {
498
+ const sum = props.sum;
499
+ const items = [
500
+ ["总 Tokens", fmtCompact(sum.total)],
501
+ ["输入(含缓存)", fmtCompact(sum.input + sum.cacheRead + sum.cacheWrite)],
502
+ ["输出", fmtCompact(sum.output)],
503
+ ];
504
+ return React.createElement(
505
+ "div", { className: "ts-cards" },
506
+ items.map((it, i) =>
507
+ React.createElement(
508
+ "div", { key: i, className: "ts-card" },
509
+ React.createElement("div", { className: "ts-card-label" }, it[0]),
510
+ React.createElement("div", { className: "ts-card-value" }, it[1]),
511
+ ),
512
+ ),
513
+ );
514
+ }
515
+
516
+ function TokenStatsPage(props) {
517
+ const [data, setData] = React.useState(null);
518
+ const [tab, setTab] = React.useState(7);
519
+ const [error, setError] = React.useState(null);
520
+ const [width, setWidth] = React.useState(0);
521
+ const rootRef = React.useRef(null);
522
+
523
+ const load = React.useCallback(async () => {
524
+ try {
525
+ const res = await remote.getStats();
526
+ if (res && res.ok) {
527
+ // The Remote gateway returns `res.value` = the Host method's full
528
+ // `{ ok, value }` envelope; unwrap it (tolerate both shapes).
529
+ const v = res.value;
530
+ const payload =
531
+ v && typeof v === "object" && v.ok === true && v.value && typeof v.value === "object"
532
+ ? v.value
533
+ : v;
534
+ if (payload && typeof payload === "object" && Array.isArray(payload.days)) {
535
+ setData(payload);
536
+ setError(null);
537
+ } else {
538
+ setError("统计数据格式异常");
539
+ }
540
+ } else {
541
+ const err = res && res.error;
542
+ setError((err && err.message) || (err && err.code) || "获取统计数据失败");
543
+ }
544
+ } catch (e) {
545
+ setError(e instanceof Error ? e.message : String(e));
546
+ }
547
+ }, []);
548
+
549
+ React.useEffect(() => {
550
+ load();
551
+ const t = setInterval(() => load(), 30000);
552
+ return () => clearInterval(t);
553
+ }, [load]);
554
+
555
+ React.useEffect(() => {
556
+ if (data && !data.ready) {
557
+ const t = setTimeout(() => load(), 2000);
558
+ return () => clearTimeout(t);
559
+ }
560
+ return undefined;
561
+ }, [data, load]);
562
+
563
+ React.useEffect(() => {
564
+ const el = rootRef.current;
565
+ if (!el) return undefined;
566
+ const measure = () => setWidth(el.clientWidth);
567
+ measure();
568
+ if (typeof ResizeObserver !== "undefined") {
569
+ const ro = new ResizeObserver(measure);
570
+ ro.observe(el);
571
+ return () => ro.disconnect();
572
+ }
573
+ return undefined;
574
+ }, []);
575
+
576
+ const tabs = React.createElement(
577
+ "div", { className: "ts-tabs" },
578
+ React.createElement(
579
+ "button", { className: "ts-tab" + (tab === 7 ? " ts-tab-active" : ""), onClick: () => setTab(7) },
580
+ "近 7 天",
581
+ ),
582
+ React.createElement(
583
+ "button", { className: "ts-tab" + (tab === 30 ? " ts-tab-active" : ""), onClick: () => setTab(30) },
584
+ "近 30 天",
585
+ ),
586
+ );
587
+
588
+ let body;
589
+ if (error) {
590
+ body = React.createElement("div", { className: "ts-empty ts-error" }, "加载失败:" + error);
591
+ } else if (!data) {
592
+ body = React.createElement(
593
+ "div", { className: "ts-loading" },
594
+ React.createElement("span", { className: "ts-spinner", "aria-hidden": true }),
595
+ React.createElement("span", null, "正在加载统计数据…"),
596
+ );
597
+ } else {
598
+ let range = null;
599
+ const daysMap = {};
600
+ let renderError = null;
601
+ try {
602
+ range = buildRange(data, tab);
603
+ for (const d of data.days) daysMap[d.date] = d.total;
604
+ } catch (e) {
605
+ renderError = e instanceof Error ? e.message : String(e);
606
+ }
607
+ if (renderError || !range) {
608
+ body = React.createElement(
609
+ "div", { className: "ts-empty ts-error" },
610
+ "数据渲染失败:" + (renderError || "未知错误"),
611
+ );
612
+ } else {
613
+ const chartWidth = Math.min(width, 720);
614
+ body = React.createElement(
615
+ "div", { className: "ts-body" },
616
+ React.createElement(SummaryCards, { sum: range.sum }),
617
+ React.createElement(
618
+ "div", { className: "ts-block" },
619
+ React.createElement("div", { className: "ts-block-title" }, "每日消耗 · 按模型"),
620
+ width > 0
621
+ ? React.createElement(BarChart, { dayList: range.dayList, rangeModels: range.rangeModels, width: chartWidth })
622
+ : null,
623
+ React.createElement(BarLegend, { rangeModels: range.rangeModels }),
624
+ ),
625
+ React.createElement(
626
+ "div", { className: "ts-block" },
627
+ React.createElement("div", { className: "ts-block-title" }, "模型总消耗占比"),
628
+ React.createElement(
629
+ "div", { className: "ts-pie-wrap" },
630
+ React.createElement(PieChart, { rangeModels: range.rangeModels, sum: range.sum, size: 190 }),
631
+ React.createElement(PieLegend, { rangeModels: range.rangeModels, total: range.sum.total }),
632
+ ),
633
+ ),
634
+ React.createElement(
635
+ "div", { className: "ts-block" },
636
+ React.createElement("div", { className: "ts-block-title" }, "每日活跃 · 近一年(随宽度自适应)"),
637
+ width > 0 ? React.createElement(Heatmap, { daysMap, width: chartWidth }) : null,
638
+ React.createElement(HeatLegend, null),
639
+ ),
640
+ );
641
+ }
642
+ }
643
+
644
+ const status =
645
+ data && !data.ready
646
+ ? "正在统计历史记录… (" + data.progress.done + "/" + data.progress.total + ")"
647
+ : data && data.ready
648
+ ? "统计范围:近 " + tab + " 天(含今天)"
649
+ : "";
650
+
651
+ return React.createElement(
652
+ "div", { className: "ts-page", ref: rootRef },
653
+ React.createElement(
654
+ "div", { className: "ts-head" },
655
+ React.createElement("div", { className: "ts-head-title" }, "Token 用量统计"),
656
+ React.createElement("button", { className: "ts-refresh", onClick: () => load() }, "刷新"),
657
+ ),
658
+ React.createElement("div", { className: "ts-status" }, status),
659
+ tabs,
660
+ body,
661
+ );
662
+ }
663
+
664
+ // Settings entry: a full page under the sidebar Settings panel.
665
+ ctx.slots.inject("settings.section", () =>
666
+ ctx.slots.register(
667
+ { name: "settings.section", id: "token-stats", order: 25, label: () => SETTINGS_LABEL },
668
+ TokenStatsPage,
669
+ ),
670
+ );
671
+ }
672
+
673
+ exports.apply = apply;
674
+ exports.inject = ["slots", "remote"];
675
+ return module.exports;
676
+ },
677
+ });
@@ -0,0 +1,4 @@
1
+ # dsh bundle patch: inserts this plugin into a profile's layer stack.
2
+ - insert:
3
+ - id: token-stats
4
+ name: 'dsh-token-stats'