@kidli1412/dsh-token-heatmap 0.1.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/lib/client.js ADDED
@@ -0,0 +1,908 @@
1
+ /**
2
+ * dsh-token-heatmap — browser half.
3
+ *
4
+ * Hand-written `__ModuleLoader__` bundle (no build step): registers into the
5
+ * conversation `input.dock` list slot and renders a GitHub-style heatmap of a
6
+ * SELECTABLE CALENDAR YEAR (Jan 1 – Dec 31; columns = weeks, rows = Mon..Sun,
7
+ * all seven weekday labels on the left) plus today / this-month / all-time
8
+ * totals and a ‹year› year selector next to the stats. It also registers a
9
+ * settings card into the official `settings.plugin.item` seat
10
+ * (设置 → 插件 → 插件配置) with a display switch and a green/blue palette
11
+ * choice, persisted through the server half's loopback config endpoint.
12
+ *
13
+ * The card is shown ONLY on the new-session (hero) screen — gated on
14
+ * `session.composerPhase === "blank"` — and is positioned visually BELOW the
15
+ * composer input card via a flex `order` on the dock wrapper (the dock's DOM
16
+ * site sits above the bar, but the composer stack is a flex column and the
17
+ * framework's true below-card seat `composer.dock` is disabled on the hero
18
+ * screen).
19
+ *
20
+ * Cell colors use ABSOLUTE token thresholds (not window-relative ranks), so
21
+ * a huge day is always dark regardless of its neighbours:
22
+ * level 0: 0 level 1: <1M level 2: 1M–10M
23
+ * level 3: 10M–100M level 4: ≥100M
24
+ *
25
+ * Data comes from the server half's loopback-only endpoint via same-origin
26
+ * fetch: GET /api/token-heatmap/usage.
27
+ */
28
+ window.__ModuleLoader__.load({
29
+ id: "@kidli1412/dsh-token-heatmap",
30
+ factory: (require) => {
31
+ var module = { exports: {} };
32
+ var exports = module.exports;
33
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
34
+
35
+ let react = require("react");
36
+ let react_jsx_runtime = require("react/jsx-runtime");
37
+
38
+ //#region css
39
+ const css = [
40
+ ".thm_dock{box-sizing:border-box;width:100%;max-width:var(--dsh-composer-card-max-width,780px);margin:0 auto;padding:0 16px 2px}",
41
+ ".thm_card{box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-base);border-radius:12px;padding:10px 12px 12px;display:flex;flex-direction:column;gap:8px}",
42
+ ".thm_head{flex-wrap:wrap;align-items:baseline;gap:4px 12px;display:flex}",
43
+ ".thm_title{color:var(--dsw-alias-label-primary);font-size:12px;font-weight:600;line-height:18px}",
44
+ ".thm_stat{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px}",
45
+ ".thm_statLabel{margin-right:4px}",
46
+ ".thm_statValue{color:var(--dsw-alias-label-primary);font-size:12px;font-weight:600;line-height:18px;font-variant-numeric:tabular-nums}",
47
+ ".thm_yearNav{align-items:center;gap:2px;display:inline-flex}",
48
+ ".thm_yearBtn{cursor:pointer;color:var(--dsw-alias-label-tertiary);background:0 0;border:none;border-radius:4px;padding:0 5px;font-size:13px;line-height:18px}",
49
+ ".thm_yearBtn:hover:not(:disabled){color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-interactive-bg-hover)}",
50
+ ".thm_yearBtn:disabled{opacity:.35;cursor:default}",
51
+ ".thm_yearLabel{color:var(--dsw-alias-label-secondary);font-size:11px;line-height:16px;min-width:36px;text-align:center;font-variant-numeric:tabular-nums}",
52
+ ".thm_refresh{cursor:pointer;color:var(--dsw-alias-label-tertiary);background:0 0;border:none;border-radius:6px;margin-left:auto;padding:2px 6px;font:inherit;font-size:11px;line-height:16px}",
53
+ ".thm_refresh:hover{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-interactive-bg-hover)}",
54
+ ".thm_refresh:disabled{opacity:.5;cursor:default}",
55
+ ".thm_gridWrap{overflow-x:auto}",
56
+ ".thm_grid{width:max-content;display:flex;flex-direction:column;gap:2px}",
57
+ ".thm_months{margin-left:16px;display:flex}",
58
+ ".thm_monthLabel{color:var(--dsw-alias-label-tertiary);flex:none;font-size:10px;line-height:14px;white-space:nowrap;overflow:hidden}",
59
+ ".thm_row{display:flex;gap:2px}",
60
+ ".thm_rowLabel{color:var(--dsw-alias-label-caption);width:16px;flex:none;font-size:9px;line-height:12px;text-align:center}",
61
+ ".thm_cell{box-sizing:border-box;width:10px;height:10px;border-radius:2px;flex:none}",
62
+ ".thm_legend{align-items:center;gap:3px;margin-left:auto;font-size:10px;line-height:14px;display:inline-flex;color:var(--dsw-alias-label-tertiary)}",
63
+ ".thm_legendCell{box-sizing:border-box;width:9px;height:9px;border-radius:2px}",
64
+ ".thm_error{color:var(--dsw-alias-state-error-primary);background:var(--dsw-alias-interactive-bg-hover-danger);border-radius:8px;padding:7px 8px;font-size:11px;line-height:16px;display:flex;justify-content:space-between;align-items:center;gap:8px}",
65
+ ".thm_retry{color:inherit;font:inherit;cursor:pointer;background:0 0;border:none;flex:none;padding:0}",
66
+ ".thm_loading{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px;padding:6px 2px}",
67
+ ".thms_card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none;transition:border-color .16s,background .16s}",
68
+ ".thms_card:hover{border-color:var(--dsw-alias-label-dimmed)}",
69
+ ".thms_cardOpen{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}",
70
+ ".thms_header{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;align-items:center;gap:12px;padding:14px 16px;display:flex}",
71
+ ".thms_headText{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}",
72
+ ".thms_name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}",
73
+ ".thms_desc{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}",
74
+ ".thms_chevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}",
75
+ ".thms_chevronOpen{transform:rotate(180deg)}",
76
+ ".thms_pending{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;flex:none;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}",
77
+ ".thms_body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding:12px 0 8px;display:flex;flex-direction:column;gap:12px}",
78
+ ".thms_loading{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.5;margin:0}",
79
+ ".thms_error{color:var(--dsw-alias-label-error);font-size:12px;line-height:1.5;margin:0}",
80
+ ".thms_retryBtn{color:inherit;font:inherit;cursor:pointer;background:0 0;border:none;flex:none;padding:0;text-decoration:underline}",
81
+ ".thms_field{display:flex;flex-direction:column;gap:4px}",
82
+ ".thms_fieldLabel{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:500;line-height:1.5}",
83
+ ".thms_fieldHint{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.5;margin:0}",
84
+ ".thms_switchRow{display:flex;align-items:center;gap:10px}",
85
+ ".thms_switch{position:relative;width:36px;height:20px;flex:none;border-radius:999px;border:0;padding:0;cursor:pointer;background:var(--dsw-alias-interactive-bg-hover-solid);transition:background .15s}",
86
+ ".thms_switch:disabled{opacity:.5;cursor:default}",
87
+ ".thms_switchOn{background:var(--dsw-alias-state-business-primary)}",
88
+ ".thms_thumb{position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}",
89
+ ".thms_switchOn .thms_thumb{transform:translateX(16px)}",
90
+ ".thms_swatches{display:flex;flex-wrap:wrap;gap:8px}",
91
+ ".thms_swatch{appearance:none;font:inherit;cursor:pointer;align-items:center;gap:8px;border:1px solid var(--dsw-alias-border-l2);background:0 0;border-radius:8px;padding:6px 10px;display:flex}",
92
+ ".thms_swatch:hover:not(:disabled){border-color:var(--dsw-alias-label-dimmed)}",
93
+ ".thms_swatch:disabled{opacity:.5;cursor:default}",
94
+ ".thms_swatch[data-active=true]{border-color:var(--dsw-alias-state-business-primary);box-shadow:0 0 0 1px var(--dsw-alias-state-business-primary) inset}",
95
+ ".thms_swatchCells{display:flex;gap:2px}",
96
+ ".thms_swatchCell{width:10px;height:10px;border-radius:2px}",
97
+ ".thms_swatchLabel{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.5}",
98
+ ".thms_footer{border-top:1px solid var(--dsw-alias-border-l2);justify-content:flex-end;align-items:center;gap:8px;padding:12px 0 4px;display:flex}",
99
+ ".thms_footerError{min-width:0;color:var(--dsw-alias-label-error);flex:1;margin:0;font-size:12px;line-height:1.5}",
100
+ ".thms_btn{appearance:none;font:inherit;cursor:pointer;border:1px solid #0000;border-radius:8px;padding:5px 14px;font-size:13px;line-height:1.5}",
101
+ ".thms_btn:disabled{opacity:.4;cursor:default}",
102
+ ".thms_discard{border-color:var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);background:0 0}",
103
+ ".thms_discard:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed)}",
104
+ ".thms_save{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}"
105
+ ].join("");
106
+ const tagId = "@kidli1412/dsh-token-heatmap/TokenHeatmap.module.css";
107
+ if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
108
+ const tag = document.createElement("style");
109
+ tag.dataset.plugin = "@kidli1412/dsh-token-heatmap";
110
+ tag.dataset.pluginCss = tagId;
111
+ tag.textContent = css;
112
+ document.head.appendChild(tag);
113
+ }
114
+ const S = {
115
+ dock: "thm_dock",
116
+ card: "thm_card",
117
+ head: "thm_head",
118
+ title: "thm_title",
119
+ stat: "thm_stat",
120
+ statLabel: "thm_statLabel",
121
+ statValue: "thm_statValue",
122
+ yearNav: "thm_yearNav",
123
+ yearBtn: "thm_yearBtn",
124
+ yearLabel: "thm_yearLabel",
125
+ refresh: "thm_refresh",
126
+ gridWrap: "thm_gridWrap",
127
+ grid: "thm_grid",
128
+ months: "thm_months",
129
+ monthLabel: "thm_monthLabel",
130
+ row: "thm_row",
131
+ rowLabel: "thm_rowLabel",
132
+ cell: "thm_cell",
133
+ legend: "thm_legend",
134
+ legendCell: "thm_legendCell",
135
+ error: "thm_error",
136
+ retry: "thm_retry",
137
+ loading: "thm_loading",
138
+ settingsCard: "thms_card",
139
+ settingsCardOpen: "thms_cardOpen",
140
+ settingsHeader: "thms_header",
141
+ settingsHeadText: "thms_headText",
142
+ settingsName: "thms_name",
143
+ settingsDesc: "thms_desc",
144
+ settingsChevron: "thms_chevron",
145
+ settingsChevronOpen: "thms_chevronOpen",
146
+ settingsPending: "thms_pending",
147
+ settingsBody: "thms_body",
148
+ settingsLoading: "thms_loading",
149
+ settingsError: "thms_error",
150
+ settingsRetry: "thms_retryBtn",
151
+ settingsField: "thms_field",
152
+ settingsFieldLabel: "thms_fieldLabel",
153
+ settingsFieldHint: "thms_fieldHint",
154
+ settingsSwitchRow: "thms_switchRow",
155
+ settingsSwitch: "thms_switch",
156
+ settingsSwitchOn: "thms_switchOn",
157
+ settingsThumb: "thms_thumb",
158
+ settingsSwatches: "thms_swatches",
159
+ settingsSwatch: "thms_swatch",
160
+ settingsSwatchCells: "thms_swatchCells",
161
+ settingsSwatchCell: "thms_swatchCell",
162
+ settingsSwatchLabel: "thms_swatchLabel",
163
+ settingsFooter: "thms_footer",
164
+ settingsFooterError: "thms_footerError",
165
+ settingsBtn: "thms_btn",
166
+ settingsDiscard: "thms_discard",
167
+ settingsSave: "thms_save"
168
+ };
169
+ //#endregion
170
+
171
+ //#region locale
172
+ /** Locale namespace and dictionaries. */
173
+ const NS = "tokenHeatmap";
174
+ const zh = {
175
+ title: "Token 用量",
176
+ refresh: "刷新",
177
+ loading: "加载中…",
178
+ error: "Token 统计暂不可用",
179
+ errorHint: "服务端未加载或请求失败",
180
+ retry: "重试",
181
+ today: "今日",
182
+ month: "本月",
183
+ total: "累计",
184
+ prevYear: "上一年",
185
+ nextYear: "下一年",
186
+ less: "少",
187
+ more: "多",
188
+ cellTokens: "{date} · {tokens} tokens",
189
+ weekdayLabel: ["一", "二", "三", "四", "五", "六", "日"],
190
+ monthName: ["1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月", "9月", "10月", "11月", "12月"],
191
+ levelLegend: ["0", "<1M", "1M–10M", "10M–100M", "≥100M"],
192
+ settingsTitle: "Token 用量热力图",
193
+ settingsDescription: "新会话页面的 Token 用量热力图显示与配色。",
194
+ settingsEnabled: "显示热力图",
195
+ settingsEnabledHint: "关闭后,新会话输入框下方的 Token 用量热力图不再显示。",
196
+ settingsScheme: "配色方案",
197
+ settingsSchemeHint: "热力图格子的颜色主题:绿色为经典 GitHub 风格,其余为新增配色。",
198
+ settingsGreen: "绿色",
199
+ settingsBlue: "蓝色",
200
+ settingsOrange: "橙色",
201
+ settingsRed: "红色",
202
+ settingsPurple: "紫色",
203
+ settingsTeal: "青色",
204
+ settingsSave: "保存",
205
+ settingsSaving: "保存中…",
206
+ settingsDiscard: "放弃修改",
207
+ settingsUnsaved: "未保存",
208
+ settingsSaveFailed: "保存失败,修改已保留,请重试。",
209
+ settingsLoadFailed: "配置加载失败。",
210
+ settingsRetry: "重试",
211
+ settingsExpand: "展开设置",
212
+ settingsCollapse: "收起设置"
213
+ };
214
+ const en = {
215
+ title: "Token usage",
216
+ refresh: "Refresh",
217
+ loading: "Loading…",
218
+ error: "Token stats unavailable",
219
+ errorHint: "server half not loaded or request failed",
220
+ retry: "Retry",
221
+ today: "Today",
222
+ month: "This month",
223
+ total: "All time",
224
+ prevYear: "Previous year",
225
+ nextYear: "Next year",
226
+ less: "Less",
227
+ more: "More",
228
+ cellTokens: "{date} · {tokens} tokens",
229
+ weekdayLabel: ["M", "T", "W", "T", "F", "S", "S"],
230
+ monthName: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
231
+ levelLegend: ["0", "<1M", "1M–10M", "10M–100M", "≥100M"],
232
+ settingsTitle: "Token usage heatmap",
233
+ settingsDescription: "Display and colors of the token heatmap on the new-session screen.",
234
+ settingsEnabled: "Show heatmap",
235
+ settingsEnabledHint: "When off, the token heatmap below the composer is hidden.",
236
+ settingsScheme: "Color scheme",
237
+ settingsSchemeHint: "Cell palette: green is the classic GitHub style; the rest are new themes.",
238
+ settingsGreen: "Green",
239
+ settingsBlue: "Blue",
240
+ settingsOrange: "Orange",
241
+ settingsRed: "Red",
242
+ settingsPurple: "Purple",
243
+ settingsTeal: "Teal",
244
+ settingsSave: "Save",
245
+ settingsSaving: "Saving…",
246
+ settingsDiscard: "Discard",
247
+ settingsUnsaved: "Unsaved",
248
+ settingsSaveFailed: "Save failed; your changes were kept.",
249
+ settingsLoadFailed: "Failed to load configuration.",
250
+ settingsRetry: "Retry",
251
+ settingsExpand: "Show settings",
252
+ settingsCollapse: "Hide settings"
253
+ };
254
+ //#endregion
255
+
256
+ //#region helpers
257
+ /** Locale-safe template interpolation: `t("key", {a})` replaces `{a}`. */
258
+ function interpolate(template, params) {
259
+ if (params === void 0) return template;
260
+ return template.replace(/\{(\w+)\}/g, (match, key) => (Object.hasOwn(params, key) ? String(params[key]) : match));
261
+ }
262
+
263
+ /** Group thousands. */
264
+ function fmt(n) {
265
+ return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
266
+ }
267
+
268
+ /** Compact form: 1234 → "1.2k"; 61M → "61m"; 4.2B → "4.2b". */
269
+ function fmtCompact(n) {
270
+ if (n < 1000) return String(n);
271
+ if (n < 1000000) return `${(n / 1000).toFixed(n < 10000 ? 1 : 0)}k`;
272
+ if (n < 1000000000) return `${(n / 1000000).toFixed(n < 10000000 ? 1 : 0)}m`;
273
+ return `${(n / 1000000000).toFixed(n < 10000000000 ? 1 : 0)}b`;
274
+ }
275
+
276
+ /** Local-calendar `YYYY-MM-DD` key. */
277
+ function dayKey(timeMs) {
278
+ const date = new Date(timeMs);
279
+ const month = String(date.getMonth() + 1).padStart(2, "0");
280
+ const day = String(date.getDate()).padStart(2, "0");
281
+ return `${date.getFullYear()}-${month}-${day}`;
282
+ }
283
+
284
+ /** Local-calendar `YYYY-MM` key. */
285
+ function monthKey(timeMs) {
286
+ const date = new Date(timeMs);
287
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
288
+ }
289
+
290
+ /** `YYYY-MM-DD` → `YYYY-MM` without any timezone parsing. */
291
+ function monthOfDateKey(key) {
292
+ return key.slice(0, 7);
293
+ }
294
+
295
+ /**
296
+ * Pick the display dictionary for locale-shaped helpers (month names,
297
+ * weekday labels, date formatting). The locale plugin resolves the UI
298
+ * language from `navigator.languages`, so mirror that probe here.
299
+ */
300
+ function uiDict() {
301
+ const candidates = typeof navigator !== "undefined"
302
+ ? [...(navigator.languages ?? []), navigator.language].filter(Boolean)
303
+ : [];
304
+ return candidates.some((tag) => tag.toLowerCase().startsWith("zh")) ? zh : en;
305
+ }
306
+
307
+ /** Display date "8月13日" / "Aug 13". */
308
+ function dateLabel(key) {
309
+ const [year, month, day] = key.split("-").map(Number);
310
+ const names = uiDict().monthName;
311
+ return `${names[month - 1]} ${day}日`;
312
+ }
313
+
314
+ /** Per-request staleness guard: only the most recent start may `isCurrent()`. */
315
+ function createLoader() {
316
+ let current = 0;
317
+ return {
318
+ start: () => ++current,
319
+ isCurrent: (id) => id === current
320
+ };
321
+ }
322
+
323
+ async function fetchJson(path) {
324
+ const response = await fetch(path, { headers: { accept: "application/json" } });
325
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
326
+ const payload = await response.json();
327
+ if (payload === null || typeof payload !== "object") throw new Error("unexpected response");
328
+ return payload;
329
+ }
330
+
331
+ async function postJson(path, body) {
332
+ const response = await fetch(path, {
333
+ method: "POST",
334
+ headers: { "content-type": "application/json" },
335
+ body: JSON.stringify(body)
336
+ });
337
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
338
+ const payload = await response.json();
339
+ if (payload === null || typeof payload !== "object") throw new Error("unexpected response");
340
+ return payload;
341
+ }
342
+
343
+ /**
344
+ * Cell palettes by scheme, level 0 (empty) → 4 (peak). Green is the
345
+ * classic GitHub scale; the others are ColorBrewer single-hue ramps
346
+ * (blue/orange/red/purple) plus a custom teal. The active scheme comes
347
+ * from the settings card (配置 → 配色方案), persisted by the server
348
+ * half's config endpoint.
349
+ */
350
+ const COLOR_SCHEMES = {
351
+ green: ["rgba(128,128,128,0.15)", "#9be9a8", "#40c463", "#30a14e", "#216e39"],
352
+ blue: ["rgba(128,128,128,0.15)", "#c6dbef", "#6baed6", "#3182bd", "#08519c"],
353
+ orange: ["rgba(128,128,128,0.15)", "#fdd0a2", "#fdae6b", "#fd8d3c", "#d94801"],
354
+ red: ["rgba(128,128,128,0.15)", "#fcbba1", "#fc9272", "#fb6a4a", "#cb181d"],
355
+ purple: ["rgba(128,128,128,0.15)", "#bcbddc", "#9e9ac8", "#807dba", "#6a51a3"],
356
+ teal: ["rgba(128,128,128,0.15)", "#99f6e4", "#2dd4bf", "#14b8a6", "#0f766e"]
357
+ };
358
+ /** Backward-compatible alias: the default (green) palette. */
359
+ const CELL_COLORS = COLOR_SCHEMES.green;
360
+
361
+ /**
362
+ * ABSOLUTE token thresholds per day (not relative ranks), so a large
363
+ * day is always dark no matter what its neighbours look like:
364
+ * level 0: 0 tokens
365
+ * level 1: 1 .. 1M
366
+ * level 2: 1M .. 10M
367
+ * level 3: 10M .. 100M
368
+ * level 4: ≥ 100M
369
+ * e.g. 61M/day is level 3 (#30a14e, dark-ish green).
370
+ */
371
+ function levelOf(tokens) {
372
+ if (tokens <= 0) return 0;
373
+ if (tokens < 1e6) return 1;
374
+ if (tokens < 1e7) return 2;
375
+ if (tokens < 1e8) return 3;
376
+ return 4;
377
+ }
378
+
379
+ /**
380
+ * Build the GitHub-style grid for a CALENDAR YEAR (Jan 1 – Dec 31,
381
+ * local time). Columns are weeks (Mon-first), aligned so the first
382
+ * column starts on the Monday of the week containing Jan 1; cells
383
+ * outside the year are null, and days after today render as empty
384
+ * level-0 cells so the full year layout stays visible (for past years
385
+ * no day is in the future, so every in-year cell shows real or empty
386
+ * data). Month labels span the columns they cover (Jan..Dec).
387
+ * @param dayMap - date key → tokens.
388
+ * @param now - reference timestamp.
389
+ * @param year - calendar year to render; defaults to the year of `now`.
390
+ * @returns `{ columns, monthStarts }` where each column is
391
+ * `{ month, cells: Array<{key, tokens, level} | null> }`.
392
+ */
393
+ function buildGrid(dayMap, now, year) {
394
+ const nowDate = new Date(now);
395
+ const gridYear = year ?? nowDate.getFullYear();
396
+ const yearStart = new Date(gridYear, 0, 1);
397
+ const yearEnd = new Date(gridYear, 11, 31);
398
+ const startKey = dayKey(yearStart.getTime());
399
+ const endKey = dayKey(yearEnd.getTime());
400
+ const todayKey = dayKey(nowDate.getTime());
401
+ const firstColumn = new Date(yearStart.getFullYear(), yearStart.getMonth(), yearStart.getDate() - ((yearStart.getDay() + 6) % 7));
402
+ const columns = [];
403
+ const monthStarts = [];
404
+ const cursor = new Date(firstColumn);
405
+ while (cursor.getTime() <= yearEnd.getTime()) {
406
+ const columnStartKey = dayKey(cursor.getTime());
407
+ const cells = [];
408
+ for (let d = 0; d < 7; d += 1) {
409
+ const key = dayKey(cursor.getTime());
410
+ if (key < startKey || key > endKey) {
411
+ cells.push(null);
412
+ } else {
413
+ const future = key > todayKey;
414
+ const tokens = future ? 0 : (dayMap.get(key) ?? 0);
415
+ cells.push({ key, tokens, level: levelOf(tokens) });
416
+ }
417
+ cursor.setDate(cursor.getDate() + 1);
418
+ }
419
+ // Label the column by the month of its first in-year day; the
420
+ // very first column may start in December of the previous year.
421
+ const columnMonth = monthOfDateKey(cells.find((cell) => cell !== null)?.key ?? columnStartKey);
422
+ columns.push({ month: columnMonth, cells });
423
+ if (monthStarts.length === 0 || monthStarts[monthStarts.length - 1].month !== columnMonth) {
424
+ monthStarts.push({ month: columnMonth, index: columns.length - 1 });
425
+ }
426
+ }
427
+ return { columns, monthStarts };
428
+ }
429
+ //#endregion
430
+
431
+ //#region TokenHeatmap
432
+ /**
433
+ * The heatmap card. Shown only on the new-session hero screen
434
+ * (`composerPhase === "blank"`); a flex `order` on the wrapper places
435
+ * it below the composer input card.
436
+ * @param props - `session`/`input` from the InputZone owner share, `t`
437
+ * bound by the slot runtime locale seat.
438
+ */
439
+ function TokenHeatmap({ session, input, t }) {
440
+ const dict = t !== void 0 ? t : (key) => zh[key] ?? key;
441
+ const translate = (key, params) => interpolate(dict(key), params);
442
+ const [data, setData] = react.useState(null);
443
+ const [config, setConfig] = react.useState(null);
444
+ const [error, setError] = react.useState(null);
445
+ const [loading, setLoading] = react.useState(false);
446
+ const [year, setYear] = react.useState(() => new Date().getFullYear());
447
+ const loaderRef = react.useRef(null);
448
+ if (loaderRef.current === null) loaderRef.current = createLoader();
449
+ const load = react.useCallback(async () => {
450
+ const id = loaderRef.current.start();
451
+ setLoading(true);
452
+ try {
453
+ const [payload, cfg] = await Promise.all([
454
+ fetchJson("/api/token-heatmap/usage"),
455
+ fetchJson("/api/token-heatmap/config")
456
+ ]);
457
+ if (loaderRef.current.isCurrent(id)) {
458
+ setData(payload);
459
+ setConfig(cfg);
460
+ setError(null);
461
+ }
462
+ } catch (err) {
463
+ if (loaderRef.current.isCurrent(id)) setError(err instanceof Error ? err.message : String(err));
464
+ } finally {
465
+ if (loaderRef.current.isCurrent(id)) setLoading(false);
466
+ }
467
+ }, []);
468
+ react.useEffect(() => {
469
+ load();
470
+ const timer = window.setInterval(load, 5 * 60 * 1000);
471
+ const onVisible = () => {
472
+ if (document.visibilityState === "visible") load();
473
+ };
474
+ document.addEventListener("visibilitychange", onVisible);
475
+ return () => {
476
+ window.clearInterval(timer);
477
+ document.removeEventListener("visibilitychange", onVisible);
478
+ };
479
+ }, [load]);
480
+
481
+ // New-session hero only: a blank session whose composer is still in
482
+ // the guidance phase. Conversed sessions hide the card.
483
+ if (session === void 0 || session === null || input === void 0 || input === null) return null;
484
+ if (session.composerPhase !== "blank") return null;
485
+ // Master switch from the settings card (设置 → 插件 → 插件配置).
486
+ if (config !== null && config.enabled === false) return null;
487
+
488
+ const now = Date.now();
489
+ // Year-selector bounds: the current year at most (no future usage),
490
+ // and at least the earliest year present in the data so the selector
491
+ // cannot wander into guaranteed-empty territory.
492
+ const nowYear = new Date(now).getFullYear();
493
+ let minYear = nowYear;
494
+ for (const day of data?.days ?? []) {
495
+ const y = Number(day.date.slice(0, 4));
496
+ if (y < minYear) minYear = y;
497
+ }
498
+ const maxYear = nowYear;
499
+ const todayTokens = data?.days?.find((day) => day.date === dayKey(now))?.tokens ?? 0;
500
+ const monthPrefix = monthKey(now);
501
+ let monthTokens = 0;
502
+ for (const day of data?.days ?? []) {
503
+ if (day.date.startsWith(monthPrefix)) monthTokens += day.tokens;
504
+ }
505
+ const totalTokens = data?.total ?? 0;
506
+
507
+ const dayMap = new Map();
508
+ for (const day of data?.days ?? []) dayMap.set(day.date, day.tokens);
509
+ const grid = buildGrid(dayMap, now, year);
510
+ const levelLegend = uiDict().levelLegend;
511
+ // Palette from the settings card; unknown schemes fall back to green.
512
+ const palette = COLOR_SCHEMES[config?.colorScheme] ?? COLOR_SCHEMES.green;
513
+
514
+ const stats = [
515
+ { label: dict("today"), value: todayTokens },
516
+ { label: dict("month"), value: monthTokens },
517
+ { label: dict("total"), value: totalTokens }
518
+ ];
519
+
520
+ let body;
521
+ if (error !== null) {
522
+ body = react_jsx_runtime.jsxs("div", {
523
+ className: S.error,
524
+ children: [
525
+ react_jsx_runtime.jsx("span", {
526
+ children: `${dict("error")}(${dict("errorHint")})`
527
+ }),
528
+ react_jsx_runtime.jsx("button", {
529
+ type: "button",
530
+ className: S.retry,
531
+ onClick: load,
532
+ children: dict("retry")
533
+ })
534
+ ]
535
+ });
536
+ } else if (data === null) {
537
+ body = react_jsx_runtime.jsx("div", {
538
+ className: S.loading,
539
+ children: dict("loading")
540
+ });
541
+ } else {
542
+ body = react_jsx_runtime.jsxs("div", {
543
+ className: S.card,
544
+ children: [
545
+ react_jsx_runtime.jsxs("div", {
546
+ className: S.head,
547
+ children: [
548
+ react_jsx_runtime.jsx("span", { className: S.title, children: dict("title") }),
549
+ ...stats.map((stat) => react_jsx_runtime.jsxs("span", {
550
+ className: S.stat,
551
+ title: `${stat.label}: ${fmt(stat.value)}`,
552
+ children: [
553
+ react_jsx_runtime.jsx("span", { className: S.statLabel, children: stat.label }),
554
+ react_jsx_runtime.jsx("span", { className: S.statValue, children: fmtCompact(stat.value) })
555
+ ]
556
+ }, stat.label)),
557
+ react_jsx_runtime.jsxs("span", {
558
+ className: S.yearNav,
559
+ children: [
560
+ react_jsx_runtime.jsx("button", {
561
+ type: "button",
562
+ className: S.yearBtn,
563
+ disabled: year <= minYear,
564
+ onClick: () => setYear(year - 1),
565
+ "aria-label": dict("prevYear"),
566
+ children: "\u2039"
567
+ }),
568
+ react_jsx_runtime.jsx("span", { className: S.yearLabel, children: String(year) }),
569
+ react_jsx_runtime.jsx("button", {
570
+ type: "button",
571
+ className: S.yearBtn,
572
+ disabled: year >= maxYear,
573
+ onClick: () => setYear(year + 1),
574
+ "aria-label": dict("nextYear"),
575
+ children: "\u203a"
576
+ })
577
+ ]
578
+ }),
579
+ react_jsx_runtime.jsx("button", {
580
+ type: "button",
581
+ className: S.refresh,
582
+ disabled: loading,
583
+ onClick: load,
584
+ children: dict("refresh")
585
+ })
586
+ ]
587
+ }),
588
+ react_jsx_runtime.jsx("div", {
589
+ className: S.gridWrap,
590
+ children: react_jsx_runtime.jsxs("div", {
591
+ className: S.grid,
592
+ children: [
593
+ react_jsx_runtime.jsx("div", {
594
+ className: S.months,
595
+ children: grid.monthStarts.map((entry, index) => {
596
+ const span = index + 1 < grid.monthStarts.length ? grid.monthStarts[index + 1].index - entry.index : grid.columns.length - entry.index;
597
+ // One column = 10px cell + 2px gap = 12px; the label
598
+ // box must span exactly N columns (no -2: there is no
599
+ // gap between month labels, so a short box would drift
600
+ // left by 2px per month).
601
+ return react_jsx_runtime.jsx("span", {
602
+ className: S.monthLabel,
603
+ style: { width: span * 12 },
604
+ children: uiDict().monthName[Number(entry.month.slice(5)) - 1]
605
+ }, entry.month);
606
+ })
607
+ }),
608
+ ...Array.from({ length: 7 }, (_, row) => {
609
+ const label = uiDict().weekdayLabel[row] ?? "";
610
+ return react_jsx_runtime.jsxs("div", {
611
+ className: S.row,
612
+ children: [
613
+ react_jsx_runtime.jsx("span", { className: S.rowLabel, children: label }),
614
+ ...grid.columns.map((column) => {
615
+ const cell = column.cells[row];
616
+ if (cell === null) {
617
+ return react_jsx_runtime.jsx("span", { className: S.cell }, `${column.month}-${row}-null`);
618
+ }
619
+ return react_jsx_runtime.jsx("span", {
620
+ className: S.cell,
621
+ style: { background: palette[cell.level] },
622
+ title: translate("cellTokens", { date: dateLabel(cell.key), tokens: fmt(cell.tokens) })
623
+ }, cell.key);
624
+ })
625
+ ]
626
+ }, `row-${row}`);
627
+ }),
628
+ react_jsx_runtime.jsx("div", {
629
+ className: S.row,
630
+ children: [
631
+ react_jsx_runtime.jsx("span", { className: S.rowLabel, children: "" }),
632
+ react_jsx_runtime.jsx("span", {
633
+ className: S.legend,
634
+ children: [
635
+ dict("less"),
636
+ ...palette.map((color, level) => react_jsx_runtime.jsx("span", {
637
+ className: S.legendCell,
638
+ style: { background: color },
639
+ title: levelLegend[level] ?? ""
640
+ }, `lvl-${level}`)),
641
+ dict("more")
642
+ ]
643
+ })
644
+ ]
645
+ })
646
+ ]
647
+ })
648
+ })
649
+ ]
650
+ });
651
+ }
652
+
653
+ return react_jsx_runtime.jsx("div", {
654
+ className: S.dock,
655
+ style: { order: 99 },
656
+ children: body
657
+ });
658
+ }
659
+ //#endregion
660
+
661
+ //#region TokenHeatmapSettingsCard
662
+ /**
663
+ * The plugin settings card, rendered inside the official
664
+ * 设置 → 插件 → 插件配置 tab (the `settings.plugin.item` seat).
665
+ *
666
+ * The Host only exposes its own allowlisted settings namespaces to the
667
+ * Web client, so this card does NOT bind a settingsScope; instead it
668
+ * reads/writes the plugin's own loopback config endpoint and stages a
669
+ * draft until the user saves, mirroring the official card chrome
670
+ * (disclosure header, unsaved badge, discard/save footer).
671
+ * @param props - `t` bound by the slot runtime locale seat.
672
+ */
673
+ function TokenHeatmapSettingsCard({ t }) {
674
+ const dict = t !== void 0 ? t : (key) => zh[key] ?? key;
675
+ const [open, setOpen] = react.useState(false);
676
+ const [loaded, setLoaded] = react.useState(null);
677
+ const [draft, setDraft] = react.useState(null);
678
+ const [saving, setSaving] = react.useState(false);
679
+ const [saveFailed, setSaveFailed] = react.useState(false);
680
+ const [loadFailed, setLoadFailed] = react.useState(false);
681
+ const load = react.useCallback(async () => {
682
+ try {
683
+ const payload = await fetchJson("/api/token-heatmap/config");
684
+ setLoaded(payload);
685
+ setDraft(payload);
686
+ setLoadFailed(false);
687
+ setSaveFailed(false);
688
+ } catch {
689
+ setLoadFailed(true);
690
+ }
691
+ }, []);
692
+ react.useEffect(() => {
693
+ load();
694
+ }, [load]);
695
+
696
+ const dirty = loaded !== null && draft !== null && (draft.enabled !== loaded.enabled || draft.colorScheme !== loaded.colorScheme);
697
+ const save = react.useCallback(async () => {
698
+ if (draft === null || saving) return;
699
+ setSaving(true);
700
+ setSaveFailed(false);
701
+ try {
702
+ const payload = await postJson("/api/token-heatmap/config", draft);
703
+ setLoaded(payload);
704
+ setDraft(payload);
705
+ } catch {
706
+ setSaveFailed(true);
707
+ } finally {
708
+ setSaving(false);
709
+ }
710
+ }, [draft, saving]);
711
+ const discard = () => {
712
+ if (loaded !== null) setDraft(loaded);
713
+ setSaveFailed(false);
714
+ };
715
+ const toggleEnabled = () => {
716
+ if (draft !== null) setDraft({ ...draft, enabled: !draft.enabled });
717
+ };
718
+ const chooseScheme = (scheme) => {
719
+ if (draft !== null) setDraft({ ...draft, colorScheme: scheme });
720
+ };
721
+ const title = dict("settingsTitle");
722
+
723
+ let bodyContent;
724
+ if (loadFailed) {
725
+ bodyContent = react_jsx_runtime.jsxs("p", {
726
+ className: S.settingsError,
727
+ children: [
728
+ dict("settingsLoadFailed"),
729
+ " ",
730
+ react_jsx_runtime.jsx("button", {
731
+ type: "button",
732
+ className: S.settingsRetry,
733
+ onClick: load,
734
+ children: dict("settingsRetry")
735
+ })
736
+ ]
737
+ });
738
+ } else if (loaded === null || draft === null) {
739
+ bodyContent = react_jsx_runtime.jsx("p", {
740
+ className: S.settingsLoading,
741
+ children: dict("loading")
742
+ });
743
+ } else {
744
+ bodyContent = react_jsx_runtime.jsxs(react_jsx_runtime.Fragment, {
745
+ children: [
746
+ react_jsx_runtime.jsx("div", {
747
+ className: S.settingsField,
748
+ children: react_jsx_runtime.jsxs("div", {
749
+ className: S.settingsSwitchRow,
750
+ children: [
751
+ react_jsx_runtime.jsx("button", {
752
+ type: "button",
753
+ role: "switch",
754
+ "aria-checked": draft.enabled,
755
+ "aria-label": dict("settingsEnabled"),
756
+ disabled: saving,
757
+ className: draft.enabled ? `${S.settingsSwitch} ${S.settingsSwitchOn}` : S.settingsSwitch,
758
+ onClick: toggleEnabled,
759
+ children: react_jsx_runtime.jsx("span", { className: S.settingsThumb })
760
+ }),
761
+ react_jsx_runtime.jsx("span", { className: S.settingsFieldLabel, children: dict("settingsEnabled") })
762
+ ]
763
+ })
764
+ }),
765
+ react_jsx_runtime.jsx("p", {
766
+ className: S.settingsFieldHint,
767
+ children: dict("settingsEnabledHint")
768
+ }),
769
+ react_jsx_runtime.jsxs("div", {
770
+ className: S.settingsField,
771
+ children: [
772
+ react_jsx_runtime.jsx("span", { className: S.settingsFieldLabel, children: dict("settingsScheme") }),
773
+ react_jsx_runtime.jsx("div", {
774
+ className: S.settingsSwatches,
775
+ children: Object.keys(COLOR_SCHEMES).map((scheme) => react_jsx_runtime.jsx("button", {
776
+ type: "button",
777
+ disabled: saving,
778
+ className: S.settingsSwatch,
779
+ "data-active": draft.colorScheme === scheme,
780
+ onClick: () => chooseScheme(scheme),
781
+ children: [
782
+ react_jsx_runtime.jsx("span", {
783
+ className: S.settingsSwatchCells,
784
+ children: COLOR_SCHEMES[scheme].slice(1).map((color, index) => react_jsx_runtime.jsx("span", {
785
+ className: S.settingsSwatchCell,
786
+ style: { background: color }
787
+ }, `cell-${index}`))
788
+ }),
789
+ react_jsx_runtime.jsx("span", {
790
+ className: S.settingsSwatchLabel,
791
+ children: dict(`settings${scheme[0].toUpperCase()}${scheme.slice(1)}`)
792
+ })
793
+ ]
794
+ }, scheme))
795
+ })
796
+ ]
797
+ }),
798
+ react_jsx_runtime.jsx("p", {
799
+ className: S.settingsFieldHint,
800
+ children: dict("settingsSchemeHint")
801
+ }),
802
+ react_jsx_runtime.jsxs("div", {
803
+ className: S.settingsFooter,
804
+ children: [
805
+ saveFailed ? react_jsx_runtime.jsx("p", {
806
+ className: S.settingsFooterError,
807
+ children: dict("settingsSaveFailed")
808
+ }) : null,
809
+ react_jsx_runtime.jsx("button", {
810
+ type: "button",
811
+ className: `${S.settingsBtn} ${S.settingsDiscard}`,
812
+ disabled: !dirty || saving,
813
+ onClick: discard,
814
+ children: dict("settingsDiscard")
815
+ }),
816
+ react_jsx_runtime.jsx("button", {
817
+ type: "button",
818
+ className: `${S.settingsBtn} ${S.settingsSave}`,
819
+ disabled: !dirty || saving,
820
+ onClick: save,
821
+ children: dict(saving ? "settingsSaving" : "settingsSave")
822
+ })
823
+ ]
824
+ })
825
+ ]
826
+ });
827
+ }
828
+
829
+ return react_jsx_runtime.jsxs("li", {
830
+ className: open ? `${S.settingsCard} ${S.settingsCardOpen}` : S.settingsCard,
831
+ children: [
832
+ react_jsx_runtime.jsxs("button", {
833
+ type: "button",
834
+ className: S.settingsHeader,
835
+ "aria-expanded": open,
836
+ "aria-label": `${dict(open ? "settingsCollapse" : "settingsExpand")}: ${title}`,
837
+ onClick: () => setOpen(!open),
838
+ children: [
839
+ react_jsx_runtime.jsxs("span", {
840
+ className: S.settingsHeadText,
841
+ children: [
842
+ react_jsx_runtime.jsx("span", { className: S.settingsName, children: title }),
843
+ react_jsx_runtime.jsx("span", { className: S.settingsDesc, children: dict("settingsDescription") })
844
+ ]
845
+ }),
846
+ dirty ? react_jsx_runtime.jsx("span", {
847
+ className: S.settingsPending,
848
+ children: dict("settingsUnsaved")
849
+ }) : null,
850
+ react_jsx_runtime.jsx("svg", {
851
+ width: 14,
852
+ height: 14,
853
+ className: open ? `${S.settingsChevron} ${S.settingsChevronOpen}` : S.settingsChevron,
854
+ viewBox: "0 0 14 14",
855
+ fill: "none",
856
+ xmlns: "http://www.w3.org/2000/svg",
857
+ children: react_jsx_runtime.jsx("path", {
858
+ d: "M11.8486 5.5L11.4238 5.92383L8.69727 8.65137C8.44157 8.90706 8.21562 9.13382 8.01172 9.29785C7.79912 9.46883 7.55595 9.61756 7.25 9.66602C7.08435 9.69222 6.91565 9.69222 6.75 9.66602C6.44405 9.61756 6.20088 9.46883 5.98828 9.29785C5.78438 9.13382 5.55843 8.90706 5.30273 8.65137L2.57617 5.92383L2.15137 5.5L3 4.65137L3.42383 5.07617L6.15137 7.80273C6.42595 8.07732 6.59876 8.24849 6.74023 8.3623C6.87291 8.46904 6.92272 8.47813 6.9375 8.48047C6.97895 8.48703 7.02105 8.48703 7.0625 8.48047C7.07728 8.47813 7.12709 8.46904 7.25977 8.3623C7.40124 8.24849 7.57405 8.07732 7.84863 7.80273L10.5762 5.07617L11 4.65137L11.8486 5.5Z",
859
+ fill: "currentColor"
860
+ })
861
+ })
862
+ ]
863
+ }),
864
+ open ? react_jsx_runtime.jsx("div", {
865
+ className: S.settingsBody,
866
+ children: bodyContent
867
+ }) : null
868
+ ]
869
+ });
870
+ }
871
+ //#endregion
872
+
873
+ //#region plugin body
874
+ /** Services required by the client plugin body. */
875
+ const inject = ["slots", "locale"];
876
+
877
+ /**
878
+ * Client plugin body: register the dictionaries and the dock entry.
879
+ * @param ctx - client root context.
880
+ */
881
+ function apply(ctx) {
882
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), "token-heatmap: dictionaries");
883
+ ctx.slots.inject("conversation.input.dock", () => ctx.slots.register({
884
+ name: "conversation.input.dock",
885
+ id: "token-heatmap",
886
+ order: 10,
887
+ locale: NS
888
+ }, TokenHeatmap));
889
+ ctx.slots.inject("settings.plugin.item", () => ctx.slots.register({
890
+ name: "settings.plugin.item",
891
+ id: "token-heatmap",
892
+ order: 30,
893
+ locale: NS
894
+ }, TokenHeatmapSettingsCard));
895
+ }
896
+ //#endregion
897
+
898
+ exports.apply = apply;
899
+ exports.inject = inject;
900
+ exports.TokenHeatmap = TokenHeatmap;
901
+ exports.TokenHeatmapSettingsCard = TokenHeatmapSettingsCard;
902
+ exports.buildGrid = buildGrid;
903
+ exports.levelOf = levelOf;
904
+ exports.CELL_COLORS = CELL_COLORS;
905
+ exports.COLOR_SCHEMES = COLOR_SCHEMES;
906
+ return module.exports;
907
+ }
908
+ });