@kidli1412/dsh-token-heatmap 0.1.1 → 0.1.2
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/README.md +3 -3
- package/lib/client.js +129 -49
- package/lib/index.js +95 -36
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@ A DeepSeek Harness web plugin: a GitHub-style daily token-usage heatmap of the *
|
|
|
17
17
|
- ⚙️ **插件配置卡**(设置 → 插件 → 插件配置,随官方"插件配置"页签渲染):
|
|
18
18
|
- **显示热力图** 开关:关闭后新会话页面不再显示热力图卡片。
|
|
19
19
|
- **配色方案**:绿色 / 蓝色 / 橙色 / 红色 / 紫色 / 青色,六个色板按钮即时预览。
|
|
20
|
-
- 修改后需点"保存"(显示"未保存"徽标提示),"放弃修改"
|
|
20
|
+
- 修改后需点"保存"(显示"未保存"徽标提示),"放弃修改"可丢弃草稿;配置经 `token-heatmap` settings namespace 持久化到 `<DSH_HOME>/settings.yaml`(0.1.1 及更早版本存在 `<DSH_HOME>/storages/token-heatmap-config.json` 的旧配置会在启动时自动迁移)。
|
|
21
21
|
|
|
22
22
|
## 安装 / Install
|
|
23
23
|
|
|
@@ -49,8 +49,8 @@ dsh plugin --profile web remove @kidli1412/dsh-token-heatmap
|
|
|
49
49
|
|
|
50
50
|
## 工作原理 / How it works
|
|
51
51
|
|
|
52
|
-
- **服务端**(`lib/index.js` + `lib/usage.js` + `lib/config.js`):作为 profile bundle 挂载,增量折叠全部会话事件日志中的 token 用量样本(`assistant/chunk` 的 `usage` 与 `assistant/message` 的 `usage`;同 `(turn, step)` 的重复样本按"替换"语义处理,归属后一天),按天、按模型聚合,缓存到 `<DSH_HOME>/storages/token-heatmap-cache.json`,并通过回环受限端点 `GET /api/token-heatmap/usage` 提供;显示配置(开关 +
|
|
53
|
-
- **客户端**(`lib/client.js`):手写 `__ModuleLoader__` bundle,注册进会话 `conversation.input.dock` 列表插槽,仅当 `session.composerPhase === "blank"`(新会话 hero 屏)且配置开关开启时渲染。框架真正的"卡片下方"插槽 `conversation.composer.dock` 在 hero 屏被 `!hero` 门控禁用,因此本插件利用 `input.dock` 容器(flex 列)的 CSS `order` 把自己排到输入卡片**之后**。配置卡注册进官方 `settings.plugin.item` 插槽(设置 → 插件 →
|
|
52
|
+
- **服务端**(`lib/index.js` + `lib/usage.js` + `lib/config.js`):作为 profile bundle 挂载,增量折叠全部会话事件日志中的 token 用量样本(`assistant/chunk` 的 `usage` 与 `assistant/message` 的 `usage`;同 `(turn, step)` 的重复样本按"替换"语义处理,归属后一天),按天、按模型聚合,缓存到 `<DSH_HOME>/storages/token-heatmap-cache.json`,并通过回环受限端点 `GET /api/token-heatmap/usage` 提供;显示配置(开关 + 配色)由插件注册的 `token-heatmap` settings namespace 持有(settings.yaml),`GET/POST /api/token-heatmap/config` 作为回环兼容 API 读写同一 namespace,0.1.1 及更早的 `token-heatmap-config.json` 文档在启动时一次性迁移。
|
|
53
|
+
- **客户端**(`lib/client.js`):手写 `__ModuleLoader__` bundle,注册进会话 `conversation.input.dock` 列表插槽,仅当 `session.composerPhase === "blank"`(新会话 hero 屏)且配置开关开启时渲染。框架真正的"卡片下方"插槽 `conversation.composer.dock` 在 hero 屏被 `!hero` 门控禁用,因此本插件利用 `input.dock` 容器(flex 列)的 CSS `order` 把自己排到输入卡片**之后**。配置卡注册进官方 `settings.plugin.item` 插槽(设置 → 插件 → 插件配置页签),经 settings scope 读写 `token-heatmap` namespace(该 namespace 由本插件在服务端注册,官方页签只渲染"Host 实际 serve 的 namespace ∩ 已注册 key"的卡片)。
|
|
54
54
|
- 语义与 `dsh-token-meter` 的 `tokenUsage` 投影一致(参考插件 [dsh-usage-stats](https://github.com/Ychris12138/dsh-usage-stats),MIT)。
|
|
55
55
|
|
|
56
56
|
## 说明 / Notes
|
package/lib/client.js
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
* totals and a ‹year› year selector next to the stats. It also registers a
|
|
9
9
|
* settings card into the official `settings.plugin.item` seat
|
|
10
10
|
* (设置 → 插件 → 插件配置) with a display switch and a green/blue palette
|
|
11
|
-
* choice, persisted through the
|
|
11
|
+
* choice, persisted through the `token-heatmap` settings namespace (settings
|
|
12
|
+
* scope).
|
|
12
13
|
*
|
|
13
14
|
* The card is shown ONLY on the new-session (hero) screen — gated on
|
|
14
15
|
* `session.composerPhase === "blank"` — and is positioned visually BELOW the
|
|
@@ -327,25 +328,88 @@ window.__ModuleLoader__.load({
|
|
|
327
328
|
if (payload === null || typeof payload !== "object") throw new Error("unexpected response");
|
|
328
329
|
return payload;
|
|
329
330
|
}
|
|
331
|
+
//#endregion
|
|
330
332
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
333
|
+
//#region config store (settings-scope backed)
|
|
334
|
+
/**
|
|
335
|
+
* Display preferences (enabled + colorScheme), persisted through the
|
|
336
|
+
* Host `token-heatmap` settings namespace (settings.yaml) via the bound
|
|
337
|
+
* settings scope — NOT the legacy loopback config endpoint. The
|
|
338
|
+
* heatmap and the settings card read the store through
|
|
339
|
+
* useSyncExternalStore; while the scope is loading or unavailable the
|
|
340
|
+
* store falls back to the defaults (enabled / green), so the UI never
|
|
341
|
+
* blocks on the settings transport.
|
|
342
|
+
*/
|
|
343
|
+
const CONFIG_DEFAULTS = { enabled: true, colorScheme: "green" };
|
|
344
|
+
/** Coerce a scheme to a non-blank string, else the default. */
|
|
345
|
+
function sanitizeScheme(value) {
|
|
346
|
+
if (typeof value !== "string") return CONFIG_DEFAULTS.colorScheme;
|
|
347
|
+
const scheme = value.trim();
|
|
348
|
+
return scheme.length > 0 && scheme.length <= 32 ? scheme : CONFIG_DEFAULTS.colorScheme;
|
|
341
349
|
}
|
|
350
|
+
/**
|
|
351
|
+
* Reactive adapter over one bound settings scope: a
|
|
352
|
+
* useSyncExternalStore-compatible store whose snapshot is
|
|
353
|
+
* `{ status, enabled, colorScheme }` derived from the scope.
|
|
354
|
+
* `set(patch)` writes the touched fields through scope.set (async; the
|
|
355
|
+
* scope fences revisions and reloads on failure), and the snapshot
|
|
356
|
+
* only changes after the Host confirms. `reload()` re-reads from the
|
|
357
|
+
* Host; `dispose()` removes the subscription.
|
|
358
|
+
*/
|
|
359
|
+
function createConfigStore(scope) {
|
|
360
|
+
const listeners = [];
|
|
361
|
+
let snapshot = { status: "loading", ...CONFIG_DEFAULTS };
|
|
362
|
+
const compute = () => {
|
|
363
|
+
const s = scope.getSnapshot();
|
|
364
|
+
if (s === void 0 || s === null || s.status !== "ready" || s.value === void 0 || s.value === null) {
|
|
365
|
+
return { status: s === void 0 || s === null ? "unavailable" : s.status, ...CONFIG_DEFAULTS };
|
|
366
|
+
}
|
|
367
|
+
const v = s.value;
|
|
368
|
+
return {
|
|
369
|
+
status: s.status,
|
|
370
|
+
enabled: v.enabled !== false,
|
|
371
|
+
colorScheme: sanitizeScheme(v.colorScheme)
|
|
372
|
+
};
|
|
373
|
+
};
|
|
374
|
+
const notify = () => {
|
|
375
|
+
snapshot = compute();
|
|
376
|
+
for (const listener of listeners.slice()) {
|
|
377
|
+
try { listener(); } catch { /* contained: never break the fan-out */ }
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
const unsubscribe = scope.subscribe(notify);
|
|
381
|
+
snapshot = compute();
|
|
382
|
+
return {
|
|
383
|
+
getSnapshot: () => snapshot,
|
|
384
|
+
subscribe: (listener) => {
|
|
385
|
+
listeners.push(listener);
|
|
386
|
+
return () => {
|
|
387
|
+
const index = listeners.indexOf(listener);
|
|
388
|
+
if (index !== -1) listeners.splice(index, 1);
|
|
389
|
+
};
|
|
390
|
+
},
|
|
391
|
+
set: async (patch) => {
|
|
392
|
+
if (Object.hasOwn(patch, "enabled")) {
|
|
393
|
+
await scope.set("enabled", patch.enabled === true);
|
|
394
|
+
}
|
|
395
|
+
if (Object.hasOwn(patch, "colorScheme")) {
|
|
396
|
+
await scope.set("colorScheme", sanitizeScheme(patch.colorScheme));
|
|
397
|
+
}
|
|
398
|
+
},
|
|
399
|
+
reload: () => scope.load(),
|
|
400
|
+
dispose: () => unsubscribe()
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
/** Module-level store, created in apply() once the settings scope is bound. */
|
|
404
|
+
let configStore = null;
|
|
405
|
+
//#endregion
|
|
342
406
|
|
|
343
407
|
/**
|
|
344
408
|
* Cell palettes by scheme, level 0 (empty) → 4 (peak). Green is the
|
|
345
409
|
* classic GitHub scale; the others are ColorBrewer single-hue ramps
|
|
346
410
|
* (blue/orange/red/purple) plus a custom teal. The active scheme comes
|
|
347
|
-
* from the settings card (配置 → 配色方案), persisted
|
|
348
|
-
*
|
|
411
|
+
* from the settings card (配置 → 配色方案), persisted through the
|
|
412
|
+
* `token-heatmap` settings namespace.
|
|
349
413
|
*/
|
|
350
414
|
const COLOR_SCHEMES = {
|
|
351
415
|
green: ["rgba(128,128,128,0.15)", "#9be9a8", "#40c463", "#30a14e", "#216e39"],
|
|
@@ -440,7 +504,6 @@ window.__ModuleLoader__.load({
|
|
|
440
504
|
const dict = t !== void 0 ? t : (key) => zh[key] ?? key;
|
|
441
505
|
const translate = (key, params) => interpolate(dict(key), params);
|
|
442
506
|
const [data, setData] = react.useState(null);
|
|
443
|
-
const [config, setConfig] = react.useState(null);
|
|
444
507
|
const [error, setError] = react.useState(null);
|
|
445
508
|
const [loading, setLoading] = react.useState(false);
|
|
446
509
|
const [year, setYear] = react.useState(() => new Date().getFullYear());
|
|
@@ -450,13 +513,9 @@ window.__ModuleLoader__.load({
|
|
|
450
513
|
const id = loaderRef.current.start();
|
|
451
514
|
setLoading(true);
|
|
452
515
|
try {
|
|
453
|
-
const
|
|
454
|
-
fetchJson("/api/token-heatmap/usage"),
|
|
455
|
-
fetchJson("/api/token-heatmap/config")
|
|
456
|
-
]);
|
|
516
|
+
const payload = await fetchJson("/api/token-heatmap/usage");
|
|
457
517
|
if (loaderRef.current.isCurrent(id)) {
|
|
458
518
|
setData(payload);
|
|
459
|
-
setConfig(cfg);
|
|
460
519
|
setError(null);
|
|
461
520
|
}
|
|
462
521
|
} catch (err) {
|
|
@@ -478,12 +537,17 @@ window.__ModuleLoader__.load({
|
|
|
478
537
|
};
|
|
479
538
|
}, [load]);
|
|
480
539
|
|
|
540
|
+
// Display preferences come from the settings scope (auto-refreshed
|
|
541
|
+
// on settings/document-updated and connection/reset), not from a
|
|
542
|
+
// config fetch — the loopback endpoint remains as a back-compat API.
|
|
543
|
+
const config = react.useSyncExternalStore(configStore.subscribe, configStore.getSnapshot);
|
|
544
|
+
|
|
481
545
|
// New-session hero only: a blank session whose composer is still in
|
|
482
546
|
// the guidance phase. Conversed sessions hide the card.
|
|
483
547
|
if (session === void 0 || session === null || input === void 0 || input === null) return null;
|
|
484
548
|
if (session.composerPhase !== "blank") return null;
|
|
485
549
|
// Master switch from the settings card (设置 → 插件 → 插件配置).
|
|
486
|
-
if (config
|
|
550
|
+
if (config.enabled === false) return null;
|
|
487
551
|
|
|
488
552
|
const now = Date.now();
|
|
489
553
|
// Year-selector bounds: the current year at most (no future usage),
|
|
@@ -509,7 +573,7 @@ window.__ModuleLoader__.load({
|
|
|
509
573
|
const grid = buildGrid(dayMap, now, year);
|
|
510
574
|
const levelLegend = uiDict().levelLegend;
|
|
511
575
|
// Palette from the settings card; unknown schemes fall back to green.
|
|
512
|
-
const palette = COLOR_SCHEMES[config
|
|
576
|
+
const palette = COLOR_SCHEMES[config.colorScheme] ?? COLOR_SCHEMES.green;
|
|
513
577
|
|
|
514
578
|
const stats = [
|
|
515
579
|
{ label: dict("today"), value: todayTokens },
|
|
@@ -663,11 +727,12 @@ window.__ModuleLoader__.load({
|
|
|
663
727
|
* The plugin settings card, rendered inside the official
|
|
664
728
|
* 设置 → 插件 → 插件配置 tab (the `settings.plugin.item` seat).
|
|
665
729
|
*
|
|
666
|
-
* The
|
|
667
|
-
*
|
|
668
|
-
*
|
|
669
|
-
*
|
|
670
|
-
*
|
|
730
|
+
* The card edits the `token-heatmap` settings namespace through the
|
|
731
|
+
* bound settings scope (the tab only dispatches it while the Host
|
|
732
|
+
* serves that namespace) and stages a draft until the user saves,
|
|
733
|
+
* mirroring the official card chrome (disclosure header, unsaved
|
|
734
|
+
* badge, discard/save footer). The legacy loopback config endpoint
|
|
735
|
+
* remains as a back-compat API, but the card no longer uses it.
|
|
671
736
|
* @param props - `t` bound by the slot runtime locale seat.
|
|
672
737
|
*/
|
|
673
738
|
function TokenHeatmapSettingsCard({ t }) {
|
|
@@ -677,21 +742,15 @@ window.__ModuleLoader__.load({
|
|
|
677
742
|
const [draft, setDraft] = react.useState(null);
|
|
678
743
|
const [saving, setSaving] = react.useState(false);
|
|
679
744
|
const [saveFailed, setSaveFailed] = react.useState(false);
|
|
680
|
-
const
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
setLoaded(payload);
|
|
685
|
-
setDraft(payload);
|
|
686
|
-
setLoadFailed(false);
|
|
687
|
-
setSaveFailed(false);
|
|
688
|
-
} catch {
|
|
689
|
-
setLoadFailed(true);
|
|
690
|
-
}
|
|
691
|
-
}, []);
|
|
745
|
+
const config = react.useSyncExternalStore(configStore.subscribe, configStore.getSnapshot);
|
|
746
|
+
// Seed the staged form from the confirmed scope snapshot once (a
|
|
747
|
+
// later scope change is the card's own save confirmation; external
|
|
748
|
+
// edits land in loaded via the explicit sync in save()).
|
|
692
749
|
react.useEffect(() => {
|
|
693
|
-
|
|
694
|
-
|
|
750
|
+
if (config.status !== "ready" || loaded !== null) return;
|
|
751
|
+
setLoaded({ enabled: config.enabled, colorScheme: config.colorScheme });
|
|
752
|
+
setDraft({ enabled: config.enabled, colorScheme: config.colorScheme });
|
|
753
|
+
}, [config, loaded]);
|
|
695
754
|
|
|
696
755
|
const dirty = loaded !== null && draft !== null && (draft.enabled !== loaded.enabled || draft.colorScheme !== loaded.colorScheme);
|
|
697
756
|
const save = react.useCallback(async () => {
|
|
@@ -699,9 +758,14 @@ window.__ModuleLoader__.load({
|
|
|
699
758
|
setSaving(true);
|
|
700
759
|
setSaveFailed(false);
|
|
701
760
|
try {
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
761
|
+
await configStore.set({ enabled: draft.enabled, colorScheme: draft.colorScheme });
|
|
762
|
+
// The scope confirms the write and refreshes its snapshot
|
|
763
|
+
// synchronously before settling; sync the staged form.
|
|
764
|
+
const confirmed = configStore.getSnapshot();
|
|
765
|
+
if (confirmed.status === "ready") {
|
|
766
|
+
setLoaded({ enabled: confirmed.enabled, colorScheme: confirmed.colorScheme });
|
|
767
|
+
setDraft({ enabled: confirmed.enabled, colorScheme: confirmed.colorScheme });
|
|
768
|
+
}
|
|
705
769
|
} catch {
|
|
706
770
|
setSaveFailed(true);
|
|
707
771
|
} finally {
|
|
@@ -721,7 +785,7 @@ window.__ModuleLoader__.load({
|
|
|
721
785
|
const title = dict("settingsTitle");
|
|
722
786
|
|
|
723
787
|
let bodyContent;
|
|
724
|
-
if (
|
|
788
|
+
if (config.status === "unavailable") {
|
|
725
789
|
bodyContent = react_jsx_runtime.jsxs("p", {
|
|
726
790
|
className: S.settingsError,
|
|
727
791
|
children: [
|
|
@@ -730,7 +794,7 @@ window.__ModuleLoader__.load({
|
|
|
730
794
|
react_jsx_runtime.jsx("button", {
|
|
731
795
|
type: "button",
|
|
732
796
|
className: S.settingsRetry,
|
|
733
|
-
onClick:
|
|
797
|
+
onClick: () => configStore.reload(),
|
|
734
798
|
children: dict("settingsRetry")
|
|
735
799
|
})
|
|
736
800
|
]
|
|
@@ -871,23 +935,38 @@ window.__ModuleLoader__.load({
|
|
|
871
935
|
//#endregion
|
|
872
936
|
|
|
873
937
|
//#region plugin body
|
|
938
|
+
/**
|
|
939
|
+
* Settings namespace this plugin's card edits — must match the
|
|
940
|
+
* namespace the Host registers (lib/index.js). Spelled here rather
|
|
941
|
+
* than imported: a client package must not depend on a Host package.
|
|
942
|
+
*/
|
|
943
|
+
const SETTINGS_NS = "token-heatmap";
|
|
874
944
|
/** Services required by the client plugin body. */
|
|
875
|
-
const inject = ["slots", "locale"];
|
|
945
|
+
const inject = ["slots", "locale", "connection", "remote", "settingsScope"];
|
|
876
946
|
|
|
877
947
|
/**
|
|
878
948
|
* Client plugin body: register the dictionaries, the input-dock entry
|
|
879
949
|
* (heatmap below the composer card on the new-session screen) and the
|
|
880
|
-
* plugin settings card (设置 → 插件 → 插件配置)
|
|
950
|
+
* plugin settings card (设置 → 插件 → 插件配置), all backed by the
|
|
951
|
+
* `token-heatmap` settings namespace through a bound settings scope.
|
|
881
952
|
*
|
|
882
953
|
* Slot-kind contract: `conversation.input.dock` is a LIST slot, so its
|
|
883
954
|
* registration is keyed by `id`; `settings.plugin.item` is a KEYED slot,
|
|
884
955
|
* so its registration must carry `key` (the settings namespace the card
|
|
885
956
|
* edits) — registering with `id` instead throws
|
|
886
957
|
* `keyed slot "settings.plugin.item" requires options.key` and fails
|
|
887
|
-
* the whole plugin load.
|
|
958
|
+
* the whole plugin load. The card is only dispatched by the official
|
|
959
|
+
* 插件配置 tab when the Host actually SERVES that namespace, which is
|
|
960
|
+
* why this plugin registers it on the Host side too.
|
|
888
961
|
* @param ctx - client root context.
|
|
889
962
|
*/
|
|
890
963
|
function apply(ctx) {
|
|
964
|
+
// Bind the namespace scope on THIS plugin's fiber (settingsScope.bind
|
|
965
|
+
// registers the disposer itself); the scope auto-loads and refreshes
|
|
966
|
+
// on settings/document-updated and connection/reset. Requires the
|
|
967
|
+
// injected connection (transport) and remote (invalidation) services.
|
|
968
|
+
const scope = ctx.settingsScope.bind({ namespace: SETTINGS_NS });
|
|
969
|
+
configStore = createConfigStore(scope);
|
|
891
970
|
ctx.effect(() => ctx.locale.register(NS, { zh, en }), "token-heatmap: dictionaries");
|
|
892
971
|
ctx.slots.inject("conversation.input.dock", () => ctx.slots.register({
|
|
893
972
|
name: "conversation.input.dock",
|
|
@@ -897,7 +976,7 @@ window.__ModuleLoader__.load({
|
|
|
897
976
|
}, TokenHeatmap));
|
|
898
977
|
ctx.slots.inject("settings.plugin.item", () => ctx.slots.register({
|
|
899
978
|
name: "settings.plugin.item",
|
|
900
|
-
key:
|
|
979
|
+
key: SETTINGS_NS,
|
|
901
980
|
order: 30,
|
|
902
981
|
locale: NS
|
|
903
982
|
}, TokenHeatmapSettingsCard));
|
|
@@ -912,6 +991,7 @@ window.__ModuleLoader__.load({
|
|
|
912
991
|
exports.levelOf = levelOf;
|
|
913
992
|
exports.CELL_COLORS = CELL_COLORS;
|
|
914
993
|
exports.COLOR_SCHEMES = COLOR_SCHEMES;
|
|
994
|
+
exports.createConfigStore = createConfigStore;
|
|
915
995
|
return module.exports;
|
|
916
996
|
}
|
|
917
997
|
});
|
package/lib/index.js
CHANGED
|
@@ -11,6 +11,12 @@
|
|
|
11
11
|
* its own peer-socket loopback fence (the exact route bypasses the RPC trust
|
|
12
12
|
* fence); Host is checked only as an additional defense.
|
|
13
13
|
*
|
|
14
|
+
* Display settings (enabled + colorScheme) are owned by the plugin's
|
|
15
|
+
* `token-heatmap` settings namespace — the config card in
|
|
16
|
+
* 设置 → 插件 → 插件配置 reads/writes it through the settings scope, and the
|
|
17
|
+
* legacy `<DSH_HOME>/storages/token-heatmap-config.json` document is
|
|
18
|
+
* migrated into the namespace once at startup.
|
|
19
|
+
*
|
|
14
20
|
* Usage aggregation is INCREMENTAL: per-session fold state (day/model
|
|
15
21
|
* buckets plus the last usage sample) is cached in memory and persisted to
|
|
16
22
|
* `<DSH_HOME>/storages/token-heatmap-cache.json`. On each request only the
|
|
@@ -28,15 +34,43 @@
|
|
|
28
34
|
|
|
29
35
|
import { homedir } from "node:os";
|
|
30
36
|
import { join, dirname } from "node:path";
|
|
31
|
-
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
37
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
32
38
|
import { applyUsageDelta, createUsageState, mergeInto, renderUsage, zeroBuckets } from "./usage.js";
|
|
33
39
|
import { DEFAULT_CONFIG, parseConfig } from "./config.js";
|
|
40
|
+
import { settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
41
|
+
import z from "@deepseek-ai/schemastery";
|
|
34
42
|
|
|
35
43
|
/** Stable Cordis plugin name. */
|
|
36
44
|
const name = "token-heatmap";
|
|
37
45
|
|
|
38
46
|
/** Services required before this plugin activates. */
|
|
39
|
-
const inject = ["webServer", "sessions", "sessionPersistence"];
|
|
47
|
+
const inject = ["webServer", "sessions", "sessionPersistence", "settings"];
|
|
48
|
+
|
|
49
|
+
//#region settings namespace
|
|
50
|
+
/**
|
|
51
|
+
* Settings namespace owned by this plugin. Registering it makes the Host
|
|
52
|
+
* serve a `token-heatmap` section (resolved from schema defaults, then any
|
|
53
|
+
* composition `base`, then the user settings.yaml layer), which is exactly
|
|
54
|
+
* what the official 设置 → 插件 → 插件配置 tab dispatches on: it renders the
|
|
55
|
+
* card registered into `settings.plugin.item` whose `key` matches a served
|
|
56
|
+
* namespace. The card edits `enabled` + `colorScheme` through the settings
|
|
57
|
+
* scope; the legacy `<DSH_HOME>/storages/token-heatmap-config.json` document
|
|
58
|
+
* is migrated once at startup (see migrateLegacyConfig).
|
|
59
|
+
*/
|
|
60
|
+
const SETTINGS_NAMESPACE = settingsNamespace("token-heatmap");
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Durable display preferences; also the wire envelope the browser scope
|
|
64
|
+
* validates against. Scheme membership is deliberately NOT enforced (a newer
|
|
65
|
+
* client may know a palette the server does not — the client falls back to
|
|
66
|
+
* green); only the same shape bounds parseConfig applies: a short, non-blank
|
|
67
|
+
* string.
|
|
68
|
+
*/
|
|
69
|
+
const TokenHeatmapSettingsSchema = z.object({
|
|
70
|
+
enabled: z.boolean().default(true),
|
|
71
|
+
colorScheme: z.string().min(1).max(32).default("green")
|
|
72
|
+
});
|
|
73
|
+
//#endregion
|
|
40
74
|
|
|
41
75
|
const USAGE_PATH = "/api/token-heatmap/usage";
|
|
42
76
|
const CONFIG_PATH = "/api/token-heatmap/config";
|
|
@@ -273,51 +307,64 @@ function withLock(run) {
|
|
|
273
307
|
}
|
|
274
308
|
//#endregion
|
|
275
309
|
|
|
276
|
-
//#region config
|
|
277
|
-
/**
|
|
310
|
+
//#region config route + legacy migration
|
|
311
|
+
/** Legacy config file location under the dsh home (pre-0.1.2 storage). */
|
|
278
312
|
function configPath() {
|
|
279
313
|
const home = process.env.DSH_HOME ?? join(homedir(), ".dsh");
|
|
280
314
|
return join(home, "storages", "token-heatmap-config.json");
|
|
281
315
|
}
|
|
282
316
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
317
|
+
/**
|
|
318
|
+
* One-time migration from the legacy config document
|
|
319
|
+
* (`<DSH_HOME>/storages/token-heatmap-config.json`) into the registered
|
|
320
|
+
* settings namespace. Runs once per process, best-effort: when the user has
|
|
321
|
+
* no settings.yaml section yet, non-default values are imported through the
|
|
322
|
+
* settings write path; the legacy file is removed either way (the namespace
|
|
323
|
+
* becomes the single source of truth). A corrupt legacy document is dropped,
|
|
324
|
+
* never imported. Failures are logged and never fatal.
|
|
325
|
+
* @param ctx - plugin context carrying the settings service.
|
|
326
|
+
*/
|
|
327
|
+
async function migrateLegacyConfig(ctx) {
|
|
328
|
+
try {
|
|
329
|
+
const path = configPath();
|
|
330
|
+
let rawText;
|
|
290
331
|
try {
|
|
291
|
-
|
|
292
|
-
return parseConfig(JSON.parse(raw));
|
|
332
|
+
rawText = await readFile(path, "utf8");
|
|
293
333
|
} catch {
|
|
294
|
-
return
|
|
334
|
+
return; // no legacy document
|
|
295
335
|
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
336
|
+
let legacy;
|
|
337
|
+
try {
|
|
338
|
+
legacy = parseConfig(JSON.parse(rawText));
|
|
339
|
+
} catch {
|
|
340
|
+
await rm(path, { force: true });
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const descriptor = ctx.settings.describe().find((entry) => entry.ns === SETTINGS_NAMESPACE);
|
|
344
|
+
const userExists = descriptor !== void 0 && descriptor.user !== void 0;
|
|
345
|
+
if (!userExists) {
|
|
346
|
+
const patch = {};
|
|
347
|
+
if (legacy.enabled !== DEFAULT_CONFIG.enabled) patch.enabled = legacy.enabled;
|
|
348
|
+
if (legacy.colorScheme !== DEFAULT_CONFIG.colorScheme) patch.colorScheme = legacy.colorScheme;
|
|
349
|
+
if (Object.keys(patch).length > 0) await ctx.settings.update(SETTINGS_NAMESPACE, patch);
|
|
350
|
+
}
|
|
351
|
+
await rm(path, { force: true });
|
|
310
352
|
} catch (error) {
|
|
311
|
-
ctx.logger.warn(`token-heatmap:
|
|
312
|
-
throw error;
|
|
353
|
+
ctx.logger.warn(`token-heatmap: migrating legacy config failed: ${String(error)}`);
|
|
313
354
|
}
|
|
314
355
|
}
|
|
315
356
|
|
|
357
|
+
/** Serve the resolved settings section (schema defaults + user layer). */
|
|
358
|
+
function serveConfig(ctx) {
|
|
359
|
+
const section = ctx.settings.get(SETTINGS_NAMESPACE);
|
|
360
|
+
return { enabled: section?.enabled !== false, colorScheme: typeof section?.colorScheme === "string" && section.colorScheme.length > 0 ? section.colorScheme : DEFAULT_CONFIG.colorScheme };
|
|
361
|
+
}
|
|
362
|
+
|
|
316
363
|
async function handleConfig(ctx, req, res) {
|
|
317
364
|
if (rejectForeignConfigCaller(req, res)) return;
|
|
318
365
|
try {
|
|
319
366
|
if (req.method === "GET") {
|
|
320
|
-
json(res, 200, { ok: true, ...(
|
|
367
|
+
json(res, 200, { ok: true, ...serveConfig(ctx) });
|
|
321
368
|
return;
|
|
322
369
|
}
|
|
323
370
|
let raw;
|
|
@@ -327,9 +374,12 @@ async function handleConfig(ctx, req, res) {
|
|
|
327
374
|
json(res, 400, { ok: false, error: "bad-json", message: "request body must be a JSON object" });
|
|
328
375
|
return;
|
|
329
376
|
}
|
|
377
|
+
// parseConfig coerces the write the same way the legacy file path did
|
|
378
|
+
// (boolean check, scheme trimmed and shape-bounded, unknown schemes kept
|
|
379
|
+
// verbatim); the settings schema then validates the canonical shape.
|
|
330
380
|
const config = parseConfig(raw);
|
|
331
|
-
await
|
|
332
|
-
json(res, 200, { ok: true, ...
|
|
381
|
+
await ctx.settings.update(SETTINGS_NAMESPACE, config);
|
|
382
|
+
json(res, 200, { ok: true, ...serveConfig(ctx) });
|
|
333
383
|
} catch (error) {
|
|
334
384
|
ctx.logger.warn(`token-heatmap: config ${req.method} failed: ${String(error)}`);
|
|
335
385
|
json(res, 500, { ok: false, error: "internal", message: error instanceof Error ? error.message : String(error) });
|
|
@@ -455,10 +505,19 @@ async function handleUsage(ctx, req, res) {
|
|
|
455
505
|
}
|
|
456
506
|
|
|
457
507
|
/**
|
|
458
|
-
* Plugin body: register the usage and config routes
|
|
459
|
-
*
|
|
508
|
+
* Plugin body: register the usage and config routes, the settings namespace
|
|
509
|
+
* that backs the plugin configuration card (设置 → 插件 → 插件配置), and the
|
|
510
|
+
* one-time legacy-config migration.
|
|
511
|
+
* @param ctx - plugin context carrying webServer, sessions, sessionPersistence, and settings.
|
|
460
512
|
*/
|
|
461
513
|
function apply(ctx) {
|
|
514
|
+
// The registration is fiber-bound: disposing this plugin removes the
|
|
515
|
+
// namespace and its observers. `settings` is a hard dependency (inject),
|
|
516
|
+
// so ctx.settings is available here unconditionally.
|
|
517
|
+
ctx.settings.register(SETTINGS_NAMESPACE, TokenHeatmapSettingsSchema);
|
|
518
|
+
// Best-effort, fire-and-forget: import the pre-0.1.2 config document into
|
|
519
|
+
// the namespace and drop the file (see migrateLegacyConfig).
|
|
520
|
+
migrateLegacyConfig(ctx);
|
|
462
521
|
ctx.effect(() => ctx.webServer.register({
|
|
463
522
|
kind: "exact",
|
|
464
523
|
path: USAGE_PATH,
|
|
@@ -471,4 +530,4 @@ function apply(ctx) {
|
|
|
471
530
|
}), "token-heatmap: config route");
|
|
472
531
|
}
|
|
473
532
|
|
|
474
|
-
export { apply, inject, name, CONFIG_PATH, USAGE_PATH };
|
|
533
|
+
export { apply, inject, name, CONFIG_PATH, USAGE_PATH, SETTINGS_NAMESPACE, TokenHeatmapSettingsSchema, migrateLegacyConfig };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kidli1412/dsh-token-heatmap",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "DSH web plugin: GitHub-style daily token-usage heatmap on the new-session screen with a selectable calendar-year view, green/blue color schemes and a display switch (设置 → 插件 → 插件配置), plus today / this-month / all-time totals.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -16,6 +16,10 @@
|
|
|
16
16
|
"README.md",
|
|
17
17
|
"LICENSE"
|
|
18
18
|
],
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@deepseek-ai/dsh-settings": "^0.1.0-rc.7",
|
|
21
|
+
"@deepseek-ai/schemastery": "^3.18.1"
|
|
22
|
+
},
|
|
19
23
|
"dsh": {
|
|
20
24
|
"bundle": {
|
|
21
25
|
"patch": "./cordis.patch.yml"
|
|
@@ -31,7 +35,7 @@
|
|
|
31
35
|
},
|
|
32
36
|
"scripts": {
|
|
33
37
|
"check": "node --check lib/usage.js && node --check lib/config.js && node --check lib/index.js && node --check lib/client.js",
|
|
34
|
-
"test": "node scripts/smoke.mjs",
|
|
38
|
+
"test": "node scripts/smoke.mjs && node scripts/settings-smoke.mjs",
|
|
35
39
|
"prepublishOnly": "npm run check && npm test"
|
|
36
40
|
},
|
|
37
41
|
"license": "MIT",
|