@kidli1412/dsh-token-heatmap 0.4.2 → 0.5.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/README.md +14 -7
- package/lib/client.js +32 -30
- package/lib/index.js +168 -39
- package/package.json +23 -14
package/README.md
CHANGED
|
@@ -36,11 +36,11 @@ GitHub 风格自然年热力图:覆盖所选自然年 1月–12月(`‹ 2026
|
|
|
36
36
|
- ⚙️ **设置就在卡片上**:点标题行最右端的 **⚙**(`刷新 [⚙]`)弹出**悬浮设置面板** —— 位置在 ⚙ 正上方 8px、水平居中对齐、贴边留 12px,超出视口会自动钳制;点面板外的任意位置、按 **Esc**、或再点一次 ⚙ 都会收起。**插件不再往 DSH 设置(设置 → 插件 → 插件配置)里注册任何卡片**,所以那里看不到本插件。
|
|
37
37
|
- **配色方案**:六个色板按钮,**点击即时生效**(不需要"保存");**默认视图**:年 / 月,决定新会话页面首次打开时显示哪个视图(当次会话手动切换只影响当前页面)。面板底部是阈值图例(悬停看各档范围)与一行说明。
|
|
38
38
|
- 写入失败时面板底部会红字提示"保存失败,已回到服务端的值"(settings scope 复核后回滚乐观值)。
|
|
39
|
-
-
|
|
39
|
+
- 配置作为**本插件的 profile 配置**持久化:DSH 0.2.0 用插件导出的 `Config` schema 生成设置表单,表单以 **profile entry id**(`token-heatmap`)为键,值写进 profile patch(旧的 `<DSH_HOME>/settings.yaml` 段与 0.1.1 及更早的 `<DSH_HOME>/storages/token-heatmap-config.json` 都会在启动时**一次性迁移/抢救**回配置)。
|
|
40
40
|
|
|
41
41
|
## 安装 / Install
|
|
42
42
|
|
|
43
|
-
需要 `web` profile 与 `pnpm`。DSH 兼容版本见下方「兼容性 / Compatibility」;运行于 `@deepseek-ai/dsh
|
|
43
|
+
需要 `web` profile 与 `pnpm`。DSH 兼容版本见下方「兼容性 / Compatibility」;运行于 `@deepseek-ai/dsh ^0.2.0-rc.2`(0.2.0 版本线;0.1.2 线请用 0.4.2)。
|
|
44
44
|
|
|
45
45
|
从 npm 安装:
|
|
46
46
|
|
|
@@ -68,8 +68,8 @@ dsh plugin --profile web remove @kidli1412/dsh-token-heatmap
|
|
|
68
68
|
|
|
69
69
|
## 工作原理 / How it works
|
|
70
70
|
|
|
71
|
-
- **服务端**(`lib/index.js` + `lib/usage.js` + `lib/config.js`):作为 profile bundle 挂载,**实时折叠会话事件**(监听官方 `session/event`,每个 `assistant/chunk`/`assistant/message` 的 `usage` 事件即时写入缓存,不依赖 hero 屏挂载);启动时一次性补折叠已存在的 live 会话(如 resumed 会话);请求时 `collectUsage` 再做一次增量同步兜底,并枚举 **已归档(stored)会话**补齐历史——两种 `sessionPersistence` 接口都支持:0.1.2 线的 `listSnapshots()` + `readFrom()`,以及 0.1.3 起取代它们的 `list()` + `open()`/`handle.read()`。同 `(turn, step)` 的重复样本按"替换"语义处理,归属后一天;**fork(`isSeeded`)会话从它的继承切点开始折叠**——日志开头那批属于父会话的事件在父会话侧已折叠,跳过它们才不会把同一批 token 计两次;按天、按模型聚合,缓存到 `<DSH_HOME>/storages/token-heatmap-cache.json`。通过回环受限端点 `GET /api/token-heatmap/usage` 提供;显示配置(配色 +
|
|
72
|
-
- **客户端**(`lib/client.js`):手写 `__ModuleLoader__` bundle,注册进会话 `conversation.input.dock` 列表插槽,仅在 `session.blank`(新会话 hero 屏;旧宿主回退 `composerPhase === "blank"`)时渲染——卡片没有显示开关,hero 屏上始终显示。框架真正的"卡片下方"插槽 `conversation.composer.dock` 在 hero 屏被 `!hero` 门控禁用,因此本插件利用 `input.dock` 容器(flex 列)的 CSS `order` 把自己排到输入卡片**之后**。同一份数据由 `buildGrid()`(年,53 列 × 7 行)与 `buildMonthGrid()`(月,7 列 × 5–6 行,带日号)两个纯函数分别铺格,共用 `levelOf()` 的绝对阈值分档与 `palette` 配色;`‹ ›` 按钮按当前视图步进年或月,边界取"当前年/月"与"数据里最早的月",年/月分段按钮紧跟在步进器后面,**⚙ 设置面板**在卡片底部展开(内嵌面板,动作:⚙ 切换 / × / Esc / 焦点移出)。**不注册 `settings.plugin.item`**(官方"插件配置"页签只渲染"Host 实际 serve 的 namespace ∩ 客户端已注册 key"
|
|
71
|
+
- **服务端**(`lib/index.js` + `lib/usage.js` + `lib/config.js`):作为 profile bundle 挂载,**实时折叠会话事件**(监听官方 `session/event`,每个 `assistant/chunk`/`assistant/message` 的 `usage` 事件即时写入缓存,不依赖 hero 屏挂载);启动时一次性补折叠已存在的 live 会话(如 resumed 会话);请求时 `collectUsage` 再做一次增量同步兜底,并枚举 **已归档(stored)会话**补齐历史——两种 `sessionPersistence` 接口都支持:0.1.2 线的 `listSnapshots()` + `readFrom()`,以及 0.1.3 起取代它们的 `list()` + `open()`/`handle.read()`。同 `(turn, step)` 的重复样本按"替换"语义处理,归属后一天;**fork(`isSeeded`)会话从它的继承切点开始折叠**——日志开头那批属于父会话的事件在父会话侧已折叠,跳过它们才不会把同一批 token 计两次;按天、按模型聚合,缓存到 `<DSH_HOME>/storages/token-heatmap-cache.json`。通过回环受限端点 `GET /api/token-heatmap/usage` 提供;显示配置(配色 + 默认视图)由**导出的 `Config` schema** 持有(0.2.0 的设置模型:以 entry id `token-heatmap` 为键的表单,值写进 profile patch),`GET/POST /api/token-heatmap/config` 作为回环兼容 API 读写同一份配置(读经 `settings.describe()`,写经 `settings.update()`;0.1.x 的 `enabled` 开关已废弃,该字段只作为常量 `true` 回给旧客户端),0.1.1 及更早的 `token-heatmap-config.json` 文档在启动时一次性迁移。
|
|
72
|
+
- **客户端**(`lib/client.js`):手写 `__ModuleLoader__` bundle,注册进会话 `conversation.input.dock` 列表插槽,仅在 `session.blank`(新会话 hero 屏;旧宿主回退 `composerPhase === "blank"`)时渲染——卡片没有显示开关,hero 屏上始终显示。框架真正的"卡片下方"插槽 `conversation.composer.dock` 在 hero 屏被 `!hero` 门控禁用,因此本插件利用 `input.dock` 容器(flex 列)的 CSS `order` 把自己排到输入卡片**之后**。同一份数据由 `buildGrid()`(年,53 列 × 7 行)与 `buildMonthGrid()`(月,7 列 × 5–6 行,带日号)两个纯函数分别铺格,共用 `levelOf()` 的绝对阈值分档与 `palette` 配色;`‹ ›` 按钮按当前视图步进年或月,边界取"当前年/月"与"数据里最早的月",年/月分段按钮紧跟在步进器后面,**⚙ 设置面板**在卡片底部展开(内嵌面板,动作:⚙ 切换 / × / Esc / 焦点移出)。**不注册 `settings.plugin.item`**(官方"插件配置"页签只渲染"Host 实际 serve 的 namespace ∩ 客户端已注册 key"的卡片,本插件不再占这个位置),配置经 `ctx.configForms.get("token-heatmap")` 读写本插件配置(0.2.0 的设置模型,取代 0.1.x 的 settings scope)。
|
|
73
73
|
- 语义与 `dsh-token-meter` 的 `tokenUsage` 投影一致(参考插件 [dsh-usage-stats](https://github.com/Ychris12138/dsh-usage-stats),MIT)。
|
|
74
74
|
|
|
75
75
|
## 说明 / Notes
|
|
@@ -80,10 +80,17 @@ dsh plugin --profile web remove @kidli1412/dsh-token-heatmap
|
|
|
80
80
|
|
|
81
81
|
## 兼容性 / Compatibility
|
|
82
82
|
|
|
83
|
-
- **DSH**:manifest 通过 `dsh.compatibility.dshReleases`
|
|
83
|
+
- **DSH**:manifest 通过 `dsh.compatibility.dshReleases` 将当前版本线 `0.2.0-rc.2` 声明为 `compatible`(DSH STORE 的精确逐版本兼容证据;仅范围声明不会恢复上架)。插件使用的客户端注入(`dsh-api-remotes` / `dsh-client-connection` / `dsh-client-locale` / `dsh-client-ui-conversation` / `dsh-client-ui-settings`)、客户端服务(`slots` / `locale` / `configForms`)与 Host 服务(`webServer` 精确路由、`settings.describe` / `settings.update`、`sessions`、`sessionPersistence`)在 0.2.0 版本线上均已在真实安装中核对。
|
|
84
84
|
- **Node**:`^22.19.0 || >=24.0.0`(与 DSH 一致)。
|
|
85
|
-
- **宿主要求(dsh-market 显示)**:`engines.dsh: ^0.
|
|
86
|
-
- **依赖**:`@deepseek-ai/
|
|
85
|
+
- **宿主要求(dsh-market 显示)**:`engines.dsh: ^0.2.0-rc.2`,并将运行时依赖的 lockstep 宿主包声明为 `peerDependencies`(`dsh-host-webserver` / `dsh-session` / `dsh-session-persistence` / `dsh-settings` 与客户端模块 `dsh-api-remotes` / `dsh-client-connection` / `dsh-client-locale` / `dsh-client-ui-conversation` / `dsh-client-ui-settings`,均为 `^0.2.0-rc.2`);插件市场会据此显示"宿主要求"并判断与当前 DSH 是否匹配。这些包由 DSH 运行时提供、本插件**从不 import**,所以同时标为 `peerDependenciesMeta.optional`——npm/pnpm 不再把它们装进依赖树(0.2.0 起它们彼此还有 peer 关系,沿用旧的自动安装会直接解析冲突),而"宿主要求"的语义不受影响。
|
|
86
|
+
- **依赖**:`@deepseek-ai/schemastery ^3.18.2`(唯一的真实依赖;`dsh-settings` 自 0.2.0 起不再需要作为 peer 之外的运行时依赖)。
|
|
87
|
+
- **0.5.0(DSH 0.2.0 适配)**:DSH 0.2.0 换了三套契约,宿主兼容闸门会**直接拒绝挂载**声明不符的 bundle(现象:插件在市场里显示"不兼容"、`rows: []`、hero 屏上什么都没有)——
|
|
88
|
+
1. **宿主要求必须落在 0.2.0 线上**:闸门逐条比对 `peerDependencies` 里的 `@deepseek-ai/dsh*` 与运行时版本(`semver.satisfies(..., { includePrerelease: true })`),`^0.1.2-rc.1` 不满足 `0.2.0-rc.2`,因此本版把 `engines.dsh` 与 9 个宿主 peer 一起提到 `^0.2.0-rc.2`(并把宿主包标为 optional peer,见上)。
|
|
89
|
+
2. **设置模型重做**:0.1.x 的"插件自建 settings namespace + `settings.register(ns, schema)`"没了,`settings.get(ns)` 也没了。现在**插件导出的 `Config`(schemastery)就是设置表单**,以 **profile entry id**(bundle patch 里的 `id: token-heatmap`)为键;Host 侧读别人用 `settings.describe()`(`serveConfig()` 已改,否则配置端点会静默回默认值)、写自己用 `settings.update(ns, patch)`,浏览器端用 `ctx.configForms.get("token-heatmap")`(`getSnapshot` / `subscribe` / `set`),旧的 `ctx.settingsScope.bind({namespace})` 已从客户端消失。卡片 ⚙ 面板的 UI 与"点击即时生效"完全不变。
|
|
90
|
+
3. **弹层皮肤同步**:0.2.0 的原生 stat dialog 改用 `border-radius:var(--dsw-radius-lg)` 并加 `backdrop-filter:var(--dsw-menu-backdrop-filter)`,本插件的 ⚙ 悬浮面板跟着改(带 fallback,旧宿主仍渲染 12px 圆角)。
|
|
91
|
+
> 升级时会**自动抢救旧配置**:DSH 自己的 `settings.yaml` 导入按 entry id 进行,而当时本插件正因上面的 peer 不符被拒挂载,导入失败后值只留在 `~/.dsh/settings.yaml.imported` 里。本版 Host 在启动后(等 Loader settle)读一次该文档,若条目还没有用户值就把非默认的 `colorScheme` / `defaultView` 写进配置——一次性、只读、失败即跳过。(`enabled` 字段自 0.3.0 起已废弃,不参与抢救。)
|
|
92
|
+
> 另外提醒:0.2.0 给 `dsh web` 的每个请求加了**签名 cookie 鉴权**,插件的两个端点因此只有带 cookie 的同源页面能到达;用 node/curl 直接探端点拿到 401 是 DSH 的鉴权层,**不代表插件没挂载**。Host 侧的 loopback 精确路由 fence 保留为第二道防线。
|
|
93
|
+
- **0.4.0(设置搬进卡片)**:不再注册官方 `settings.plugin.item` 插槽——设置页(设置 → 插件 → 插件配置)里不再有本插件的卡片,配色与默认视图改在卡片自己的 ⚙ 面板里改,**点击即时生效**(去掉了草稿/保存/放弃那套)。当时 Host 侧的 `settings.register("token-heatmap", schema)` 保留下来当 settings.yaml 的校验与持久化管道;**该注册在 0.5.0 已随 0.2.0 的设置模型一起移除**(导出的 `Config` 就是管道)。升级只影响设置入口位置,已有配置不动。
|
|
87
94
|
- **0.1.4(DSH 0.1.2 适配)**:rc.1 起 live session 不再携带 `.events` 数组(改用 `session.seq` + `session.eventAt(seq)`,与官方 `dsh-token-meter` 相同),新会话判断从 `composerPhase === "blank"` 改为布尔 `session.blank`;`sessionPersistence` 的 stored 会话枚举在 0.1.3-alpha.2 被替换(`listSnapshots`/`readFrom` → `list()` + `open()`/`handle.read()`),两条接口见 0.1.6 条目。客户端注入模块列表同步为新架构模块(见上)。
|
|
88
95
|
- **0.1.6(session/event 实时折叠 + stored 会话枚举修复)**:`apply()` 注册官方 `session/event` 监听器,每个 usage 事件即时折叠进缓存,解决 live 会话仅在 hero 屏挂载时才折叠而漏计同一日其他会话用量的问题(表现为当日总量偏小、历史天数丢失);启动时一次性补折叠已存在的 live 会话(如 resumed 会话)。**stored 会话枚举修复**:0.1.3-alpha.2 起 `sessionPersistence` 移除了 `readFrom()` 与 `listSnapshots()`,只保留 `list()` + `open()`/`handle.read()`;旧实现只探测 `list`/`listSnapshots` 却无条件调用 `readFrom`,导致每个 stored 会话抛错并被吞成一条 warn —— 表现为热力图只剩进程内 live 的几天。现在两条接口都支持(`list()` 的 `revision` 同样用于跳过未变更的日志,增量仍按 `seq` 去重与连续性校验),stored 会话可完整补齐历史;两者都不可用时不再误判为"日志被截断",而是保留已折叠天数并告警。token 口径与 `dsh-token-meter` 一致(input + output + cacheRead + cacheWrite,不含 reasoningTokens)。
|
|
89
96
|
- **0.2.0(月视图 + 默认视图设置)**:新增 `buildMonthGrid()` 月视图(周一起、5–6 行、日号 + 当日 token 数)与 年/月 分段切换,`‹ ›` 按当前视图步进年或月;settings namespace 新增 `defaultView`(`"year" | "month"`)字段——与 `colorScheme` 的"只约束 shape"不同,**`defaultView` 是枚举校验**(未知视图没有可回退的渲染器),旧 Host 上该字段会被 schema 丢弃、旧客户端读到未知值时回退为"年"。0.1.x 的 `settings.yaml` 无需迁移(缺字段即取默认 `year`)。
|
package/lib/client.js
CHANGED
|
@@ -7,9 +7,10 @@
|
|
|
7
7
|
* columns = weeks, rows = Mon..Sun, all seven weekday labels on the left) or a
|
|
8
8
|
* single-month calendar, switchable from the header, plus today / this-month /
|
|
9
9
|
* all-time totals and a ‹period› stepper. The card configures ITSELF through a
|
|
10
|
-
* ⚙ panel (palette + default view) persisted in
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* ⚙ panel (palette + default view) persisted in this plugin's profile CONFIG —
|
|
11
|
+
* DSH 0.2.0 derives the form from the Host's exported `Config` and the card
|
|
12
|
+
* reads/writes it through `ctx.configForms.get("token-heatmap")`; there is no
|
|
13
|
+
* `settings.plugin.item` card and no settings scope any more, so all of its
|
|
13
14
|
* settings live on the card.
|
|
14
15
|
*
|
|
15
16
|
* The card is shown ONLY on the new-session (hero) screen — gated on the
|
|
@@ -99,8 +100,10 @@ window.__ModuleLoader__.load({
|
|
|
99
100
|
// The settings panel mirrors DSH's own pill dialogs: portaled to
|
|
100
101
|
// document.body, position:fixed (so the card can never clip it),
|
|
101
102
|
// measure-then-place above the ⚙ and clamped to the viewport, with
|
|
102
|
-
// the shipped dialog surface + elevation tokens.
|
|
103
|
-
|
|
103
|
+
// the shipped dialog surface + elevation tokens. DSH 0.2.0's stat
|
|
104
|
+
// dialog moved to `--dsw-radius-lg` and a backdrop filter, so the
|
|
105
|
+
// surface follows (the fallbacks keep it rendering on an older host).
|
|
106
|
+
".thm_panel{position:fixed;z-index:1100;box-sizing:border-box;background:var(--dsw-specific-menu,#fff);--dsw-elevation-stroke-color:var(--dsw-alias-border-l1);width:400px;max-width:calc(100vw - 24px);backdrop-filter:var(--dsw-menu-backdrop-filter);box-shadow:var(--dsw-elevation-prominent,0 8px 32px rgba(0,0,0,.16));color:var(--dsw-alias-label-secondary);cursor:default;border:0;border-radius:var(--dsw-radius-lg,12px);padding:16px;font-size:12px;line-height:18px;text-align:left;font-weight:400}",
|
|
104
107
|
".thm_panelHead{color:var(--dsw-alias-label-primary);justify-content:space-between;align-items:center;gap:16px;margin-bottom:8px;font-weight:500;display:flex}",
|
|
105
108
|
".thm_panelTitleLabel{align-items:center;gap:6px;display:inline-flex}",
|
|
106
109
|
".thm_panelTitleLabel svg{flex:none;width:14px;height:14px}",
|
|
@@ -406,13 +409,14 @@ window.__ModuleLoader__.load({
|
|
|
406
409
|
return VIEW_MODES.includes(view) ? view : CONFIG_DEFAULTS.defaultView;
|
|
407
410
|
}
|
|
408
411
|
/**
|
|
409
|
-
* Reactive adapter over one
|
|
410
|
-
* useSyncExternalStore-compatible store whose snapshot is
|
|
411
|
-
* `{ status, colorScheme, defaultView }` derived from the
|
|
412
|
-
* `set(patch)` writes the touched fields through
|
|
413
|
-
*
|
|
414
|
-
* only changes after the Host confirms. `
|
|
415
|
-
*
|
|
412
|
+
* Reactive adapter over one config form (`ctx.configForms.get(entryId)`):
|
|
413
|
+
* a useSyncExternalStore-compatible store whose snapshot is
|
|
414
|
+
* `{ status, colorScheme, defaultView }` derived from the form.
|
|
415
|
+
* `set(patch)` writes the touched fields through `form.set(field, value)`
|
|
416
|
+
* (async; the form fences revisions and reloads on failure), and the
|
|
417
|
+
* snapshot only changes after the Host confirms. `dispose()` removes the
|
|
418
|
+
* subscription. (The pre-0.2.0 `reload()` wrapper is gone: the shared
|
|
419
|
+
* form re-reads on its own, and its API has no `load()`.)
|
|
416
420
|
*/
|
|
417
421
|
function createConfigStore(scope) {
|
|
418
422
|
const listeners = [];
|
|
@@ -454,7 +458,6 @@ window.__ModuleLoader__.load({
|
|
|
454
458
|
await scope.set("defaultView", sanitizeView(patch.defaultView));
|
|
455
459
|
}
|
|
456
460
|
},
|
|
457
|
-
reload: () => scope.load(),
|
|
458
461
|
dispose: () => unsubscribe()
|
|
459
462
|
};
|
|
460
463
|
}
|
|
@@ -1194,34 +1197,33 @@ window.__ModuleLoader__.load({
|
|
|
1194
1197
|
|
|
1195
1198
|
//#region plugin body
|
|
1196
1199
|
/**
|
|
1197
|
-
*
|
|
1198
|
-
* the
|
|
1199
|
-
*
|
|
1200
|
+
* Profile entry id this plugin's config form is keyed by. DSH 0.2.0
|
|
1201
|
+
* replaced the plugin-owned settings namespace with schema-derived plugin
|
|
1202
|
+
* CONFIG: the Host exports `Config`, the form is keyed by the profile
|
|
1203
|
+
* entry id — the `id` of the row our bundle patch inserts, which is what
|
|
1204
|
+
* the browser asks for here. Spelled as a literal rather than imported: a
|
|
1205
|
+
* client package must not depend on a Host package.
|
|
1200
1206
|
*/
|
|
1201
1207
|
const SETTINGS_NS = "token-heatmap";
|
|
1202
1208
|
/** Services required by the client plugin body. */
|
|
1203
|
-
const inject = ["slots", "locale", "
|
|
1209
|
+
const inject = ["slots", "locale", "configForms"];
|
|
1204
1210
|
|
|
1205
1211
|
/**
|
|
1206
1212
|
* Client plugin body: register the dictionaries and the input-dock entry
|
|
1207
|
-
* (the heatmap below the composer card on the new-session screen)
|
|
1208
|
-
*
|
|
1209
|
-
*
|
|
1210
|
-
*
|
|
1211
|
-
* 设置 → 插件 → 插件配置 card seat) any more — the Host still serves the
|
|
1212
|
-
* namespace, it just has no configuration card to dispatch.
|
|
1213
|
+
* (the heatmap below the composer card on the new-session screen). The
|
|
1214
|
+
* card configures itself through its own ⚙ panel, which reads and writes
|
|
1215
|
+
* the plugin's config form (`ctx.configForms.get("token-heatmap")`) —
|
|
1216
|
+
* the shared form owns transport, revisions, and recovery.
|
|
1213
1217
|
*
|
|
1214
1218
|
* Slot-kind contract: `conversation.input.dock` is a LIST slot, so its
|
|
1215
|
-
* registration is keyed by `id
|
|
1216
|
-
* would instead need `key`, the settings namespace the card edits).
|
|
1219
|
+
* registration is keyed by `id`.
|
|
1217
1220
|
* @param ctx - client root context.
|
|
1218
1221
|
*/
|
|
1219
1222
|
function apply(ctx) {
|
|
1220
|
-
//
|
|
1221
|
-
//
|
|
1222
|
-
//
|
|
1223
|
-
|
|
1224
|
-
const scope = ctx.settingsScope.bind({ namespace: SETTINGS_NS });
|
|
1223
|
+
// The shared form for our entry: `getSnapshot()`/`subscribe()` for
|
|
1224
|
+
// reads, `set(field, value)` for writes. `createConfigStore` is
|
|
1225
|
+
// duck-typed over exactly that surface, so the ⚙ panel is unchanged.
|
|
1226
|
+
const scope = ctx.configForms.get(SETTINGS_NS);
|
|
1225
1227
|
configStore = createConfigStore(scope);
|
|
1226
1228
|
ctx.effect(() => ctx.locale.register(NS, { zh, en }), "token-heatmap: dictionaries");
|
|
1227
1229
|
ctx.slots.inject("conversation.input.dock", () => ctx.slots.register({
|
package/lib/index.js
CHANGED
|
@@ -11,11 +11,13 @@
|
|
|
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 (
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
14
|
+
* Display settings (colorScheme + defaultView) are owned by this plugin's
|
|
15
|
+
* profile CONFIG — DSH 0.2.0 derives the settings form from the exported
|
|
16
|
+
* `Config` schema, keys it by the profile entry id (`token-heatmap`, the id of
|
|
17
|
+
* the row our bundle patch inserts), and the browser edits it through
|
|
18
|
+
* `ctx.configForms.get("token-heatmap")`. The plugin's own ⚙ panel writes the
|
|
19
|
+
* same values; the legacy `<DSH_HOME>/storages/token-heatmap-config.json`
|
|
20
|
+
* document is migrated into it once at startup.
|
|
19
21
|
*
|
|
20
22
|
* Usage aggregation is INCREMENTAL: per-session fold state (day/model
|
|
21
23
|
* buckets plus the last usage sample) is cached in memory and persisted to
|
|
@@ -33,6 +35,7 @@
|
|
|
33
35
|
*/
|
|
34
36
|
|
|
35
37
|
import { homedir } from "node:os";
|
|
38
|
+
import { readFileSync } from "node:fs";
|
|
36
39
|
import { join, dirname } from "node:path";
|
|
37
40
|
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
38
41
|
import { applyUsageDelta, createUsageState, mergeInto, renderUsage, zeroBuckets } from "./usage.js";
|
|
@@ -45,24 +48,17 @@ const name = "token-heatmap";
|
|
|
45
48
|
/** Services required before this plugin activates. */
|
|
46
49
|
const inject = ["webServer", "sessions", "sessionPersistence", "settings"];
|
|
47
50
|
|
|
48
|
-
//#region
|
|
51
|
+
//#region plugin config
|
|
49
52
|
/**
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
* card registered into `settings.plugin.item` whose `key` matches a served
|
|
55
|
-
* namespace. The card edits `enabled` + `colorScheme` through the settings
|
|
56
|
-
* scope; the legacy `<DSH_HOME>/storages/token-heatmap-config.json` document
|
|
57
|
-
* is migrated once at startup (see migrateLegacyConfig).
|
|
58
|
-
* DSH 0.1.2 起 `@deepseek-ai/dsh-settings` 不再导出 settingsNamespace 帮助函数:
|
|
59
|
-
* namespace 直接以字面量传入(register/get/update 内部仍按
|
|
60
|
-
* /^[a-z][a-z0-9-]*$/ 校验)。
|
|
53
|
+
* Profile entry id this plugin's config form is keyed by: the `id` of the row
|
|
54
|
+
* the bundle patch inserts, which is also what the browser asks
|
|
55
|
+
* `ctx.configForms.get(...)` for. DSH 0.2.0 derives the form from the exported
|
|
56
|
+
* `Config` schema, so no registration call exists (nor does `settings.get`).
|
|
61
57
|
*/
|
|
62
58
|
const SETTINGS_NAMESPACE = "token-heatmap";
|
|
63
59
|
|
|
64
60
|
/**
|
|
65
|
-
* Durable display preferences; also the wire envelope the browser
|
|
61
|
+
* Durable display preferences; also the wire envelope the browser form
|
|
66
62
|
* validates against. Scheme membership is deliberately NOT enforced (a newer
|
|
67
63
|
* client may know a palette the server does not — the client falls back to
|
|
68
64
|
* green); only the same shape bounds parseConfig applies: a short, non-blank
|
|
@@ -72,10 +68,12 @@ const SETTINGS_NAMESPACE = "token-heatmap";
|
|
|
72
68
|
* the hero screen, so the field is neither accepted nor served (an old
|
|
73
69
|
* settings.yaml keeps its dead `enabled` key, and nothing reads it).
|
|
74
70
|
*/
|
|
75
|
-
const
|
|
71
|
+
const Config = z.object({
|
|
76
72
|
colorScheme: z.string().min(1).max(32).default("green"),
|
|
77
73
|
defaultView: z.union(VIEW_MODES.map((mode) => z.const(mode))).default("year")
|
|
78
74
|
});
|
|
75
|
+
/** @deprecated 0.1.x name; the schema IS the config now. Kept for old importers. */
|
|
76
|
+
const TokenHeatmapSettingsSchema = Config;
|
|
79
77
|
//#endregion
|
|
80
78
|
|
|
81
79
|
const USAGE_PATH = "/api/token-heatmap/usage";
|
|
@@ -329,14 +327,127 @@ function configPath() {
|
|
|
329
327
|
return join(home, "storages", "token-heatmap-config.json");
|
|
330
328
|
}
|
|
331
329
|
|
|
330
|
+
/** DSH home resolution, mirroring `@deepseek-ai/dsh-home-paths` (no extra peer). */
|
|
331
|
+
function dshHomeDirectory(env = process.env) {
|
|
332
|
+
const configured = env.DSH_HOME;
|
|
333
|
+
if (typeof configured === "string" && configured.trim() !== "") return configured.trim();
|
|
334
|
+
return join(homedir(), ".dsh");
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* One profile entry's descriptor from the settings service, or undefined when
|
|
339
|
+
* the service is absent or the entry is not live. DSH 0.2.0 dropped the old
|
|
340
|
+
* `settings.get(ns)` accessor — a plugin's settings ARE its profile entry
|
|
341
|
+
* config now — so reads go through the same `describe()` view the settings UI
|
|
342
|
+
* renders (its `value` is the resolved section, its `user` the raw user layer).
|
|
343
|
+
* @param ctx - plugin context.
|
|
344
|
+
* @param entryId - profile entry id (`token-heatmap`).
|
|
345
|
+
* @returns the descriptor, or undefined.
|
|
346
|
+
*/
|
|
347
|
+
function entryDescriptor(ctx, entryId) {
|
|
348
|
+
const settings = typeof ctx.get === "function" ? ctx.get("settings") : ctx.settings;
|
|
349
|
+
if (settings === null || settings === void 0 || typeof settings.describe !== "function") return void 0;
|
|
350
|
+
try {
|
|
351
|
+
return settings.describe().find((row) => row.ns === entryId);
|
|
352
|
+
} catch {
|
|
353
|
+
return void 0;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* One profile entry's resolved config section, read through the settings
|
|
359
|
+
* service.
|
|
360
|
+
* @param ctx - plugin context.
|
|
361
|
+
* @param entryId - profile entry id (`token-heatmap`).
|
|
362
|
+
* @returns the resolved section, or undefined when the entry is absent.
|
|
363
|
+
*/
|
|
364
|
+
function entryConfig(ctx, entryId) {
|
|
365
|
+
const value = entryDescriptor(ctx, entryId)?.value;
|
|
366
|
+
return value !== null && typeof value === "object" ? value : void 0;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Best-effort read of one scalar out of the pre-0.2.0 `settings.yaml` section
|
|
371
|
+
* for this entry. DSH's own importer moves those sections into the profile
|
|
372
|
+
* once, keyed by entry id — and skipped ours while the bundle was refused for
|
|
373
|
+
* incompatible peers, leaving the value only in the renamed document. This is
|
|
374
|
+
* a deliberately narrow scan (one top-level section, one scalar) rather than a
|
|
375
|
+
* YAML dependency: anything it cannot read is simply left for the user.
|
|
376
|
+
* @param text - contents of `settings.yaml` or `settings.yaml.imported`.
|
|
377
|
+
* @param section - top-level section name (`token-heatmap`).
|
|
378
|
+
* @param field - scalar key inside it.
|
|
379
|
+
* @returns the raw trimmed string value, or null.
|
|
380
|
+
*/
|
|
381
|
+
function legacySettingFromDocument(text, section, field) {
|
|
382
|
+
if (typeof text !== "string") return null;
|
|
383
|
+
const lines = text.split(/\r?\n/);
|
|
384
|
+
const inSection = /^[^\s#][^:]*:/;
|
|
385
|
+
let inside = false;
|
|
386
|
+
for (const line of lines) {
|
|
387
|
+
if (inSection.test(line)) {
|
|
388
|
+
inside = line.trim() === `${section}:` && !line.startsWith(" ");
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
if (!inside) continue;
|
|
392
|
+
const match = /^\s+([A-Za-z0-9_-]+):\s*(.*?)\s*$/.exec(line);
|
|
393
|
+
if (match === null || match[1] !== field) continue;
|
|
394
|
+
return match[2].replace(/\s+#.*$/, "").replace(/^["']|["']$/g, "");
|
|
395
|
+
}
|
|
396
|
+
return null;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** Files the legacy value may still live in, newest name first. */
|
|
400
|
+
const LEGACY_SETTINGS_FILES = ["settings.yaml.imported", "settings.yaml"];
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* One-time rescue of display preferences from the removed settings document.
|
|
404
|
+
*
|
|
405
|
+
* DSH 0.2.0 imported the old `settings.yaml` sections per entry id, but this
|
|
406
|
+
* plugin was refused (incompatible peers) at that moment, so its section —
|
|
407
|
+
* `colorScheme` / `defaultView`, plus the dead `enabled` — only survives in
|
|
408
|
+
* the renamed document. Runs only when the entry has no user value yet, only
|
|
409
|
+
* for non-default fields, best effort, never blocking.
|
|
410
|
+
* @param ctx - plugin context carrying the settings service.
|
|
411
|
+
*/
|
|
412
|
+
async function rescueRemovedSettingsDocument(ctx) {
|
|
413
|
+
try {
|
|
414
|
+
const descriptor = entryDescriptor(ctx, SETTINGS_NAMESPACE);
|
|
415
|
+
if (descriptor === void 0) return;
|
|
416
|
+
const settings = typeof ctx.get === "function" ? ctx.get("settings") : ctx.settings;
|
|
417
|
+
const user = descriptor.user;
|
|
418
|
+
if (user !== null && typeof user === "object" && (Object.hasOwn(user, "colorScheme") || Object.hasOwn(user, "defaultView"))) return;
|
|
419
|
+
const home = dshHomeDirectory();
|
|
420
|
+
let text = null;
|
|
421
|
+
for (const file of LEGACY_SETTINGS_FILES) {
|
|
422
|
+
try {
|
|
423
|
+
text = readFileSync(join(home, file), "utf8");
|
|
424
|
+
} catch {
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
if (text !== null) break;
|
|
428
|
+
}
|
|
429
|
+
if (text === null) return;
|
|
430
|
+
const patch = {};
|
|
431
|
+
const scheme = legacySettingFromDocument(text, SETTINGS_NAMESPACE, "colorScheme");
|
|
432
|
+
if (scheme !== null && scheme !== DEFAULT_CONFIG.colorScheme) patch.colorScheme = scheme;
|
|
433
|
+
const view = legacySettingFromDocument(text, SETTINGS_NAMESPACE, "defaultView");
|
|
434
|
+
if (view !== null && VIEW_MODES.includes(view) && view !== DEFAULT_CONFIG.defaultView) patch.defaultView = view;
|
|
435
|
+
if (Object.keys(patch).length === 0) return;
|
|
436
|
+
await settings.update(SETTINGS_NAMESPACE, patch);
|
|
437
|
+
ctx.logger.info(`token-heatmap: carried ${JSON.stringify(patch)} over from the removed settings.yaml`);
|
|
438
|
+
} catch (error) {
|
|
439
|
+
ctx.logger.warn(`token-heatmap: legacy settings rescue skipped: ${String(error)}`);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
332
443
|
/**
|
|
333
444
|
* One-time migration from the legacy config document
|
|
334
|
-
* (`<DSH_HOME>/storages/token-heatmap-config.json`) into the
|
|
335
|
-
*
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
*
|
|
339
|
-
*
|
|
445
|
+
* (`<DSH_HOME>/storages/token-heatmap-config.json`) into the plugin config.
|
|
446
|
+
* Runs once per process, best-effort: when the user has no config value yet,
|
|
447
|
+
* non-default values are imported through the settings write path; the legacy
|
|
448
|
+
* file is removed either way (the config entry becomes the single source of
|
|
449
|
+
* truth). A corrupt legacy document is dropped, never imported. Failures are
|
|
450
|
+
* logged and never fatal.
|
|
340
451
|
* @param ctx - plugin context carrying the settings service.
|
|
341
452
|
*/
|
|
342
453
|
async function migrateLegacyConfig(ctx) {
|
|
@@ -355,15 +466,16 @@ async function migrateLegacyConfig(ctx) {
|
|
|
355
466
|
await rm(path, { force: true });
|
|
356
467
|
return;
|
|
357
468
|
}
|
|
358
|
-
const descriptor = ctx
|
|
469
|
+
const descriptor = entryDescriptor(ctx, SETTINGS_NAMESPACE);
|
|
470
|
+
const settings = typeof ctx.get === "function" ? ctx.get("settings") : ctx.settings;
|
|
359
471
|
const userExists = descriptor !== void 0 && descriptor.user !== void 0;
|
|
360
|
-
if (!userExists) {
|
|
472
|
+
if (!userExists && settings !== void 0 && typeof settings.update === "function") {
|
|
361
473
|
const patch = {};
|
|
362
474
|
// The legacy document's `enabled` switch died with 0.2.0: import only
|
|
363
475
|
// what the card still owns.
|
|
364
476
|
if (legacy.colorScheme !== DEFAULT_CONFIG.colorScheme) patch.colorScheme = legacy.colorScheme;
|
|
365
477
|
if (legacy.defaultView !== DEFAULT_CONFIG.defaultView) patch.defaultView = legacy.defaultView;
|
|
366
|
-
if (Object.keys(patch).length > 0) await
|
|
478
|
+
if (Object.keys(patch).length > 0) await settings.update(SETTINGS_NAMESPACE, patch);
|
|
367
479
|
}
|
|
368
480
|
await rm(path, { force: true });
|
|
369
481
|
} catch (error) {
|
|
@@ -372,13 +484,13 @@ async function migrateLegacyConfig(ctx) {
|
|
|
372
484
|
}
|
|
373
485
|
|
|
374
486
|
/**
|
|
375
|
-
* Serve the resolved
|
|
487
|
+
* Serve the resolved config section (schema defaults + user layer).
|
|
376
488
|
* `enabled` stays in the payload as a constant true: the legacy loopback
|
|
377
489
|
* endpoint is a back-compat API, and a pre-0.2.0 client that reads it must not
|
|
378
490
|
* lose the card over a switch this version no longer has.
|
|
379
491
|
*/
|
|
380
492
|
function serveConfig(ctx) {
|
|
381
|
-
const section = ctx
|
|
493
|
+
const section = entryConfig(ctx, SETTINGS_NAMESPACE);
|
|
382
494
|
const scheme = typeof section?.colorScheme === "string" && section.colorScheme.length > 0 ? section.colorScheme : DEFAULT_CONFIG.colorScheme;
|
|
383
495
|
const view = typeof section?.defaultView === "string" && VIEW_MODES.includes(section.defaultView) ? section.defaultView : DEFAULT_CONFIG.defaultView;
|
|
384
496
|
return { enabled: true, colorScheme: scheme, defaultView: view };
|
|
@@ -687,19 +799,22 @@ async function handleUsage(ctx, req, res) {
|
|
|
687
799
|
}
|
|
688
800
|
|
|
689
801
|
/**
|
|
690
|
-
* Plugin body: register the usage and config routes
|
|
691
|
-
*
|
|
692
|
-
*
|
|
802
|
+
* Plugin body: register the usage and config routes and run the two one-time
|
|
803
|
+
* legacy migrations. DSH 0.2.0 needs no namespace registration — the exported
|
|
804
|
+
* `Config` is the form — so nothing here touches the settings document beyond
|
|
805
|
+
* the rescues.
|
|
693
806
|
* @param ctx - plugin context carrying webServer, sessions, sessionPersistence, and settings.
|
|
694
807
|
*/
|
|
695
808
|
function apply(ctx) {
|
|
696
|
-
// The registration is fiber-bound: disposing this plugin removes the
|
|
697
|
-
// namespace and its observers. `settings` is a hard dependency (inject),
|
|
698
|
-
// so ctx.settings is available here unconditionally.
|
|
699
|
-
ctx.settings.register(SETTINGS_NAMESPACE, TokenHeatmapSettingsSchema);
|
|
700
809
|
// Best-effort, fire-and-forget: import the pre-0.1.2 config document into
|
|
701
|
-
// the
|
|
810
|
+
// the config entry and drop the file (see migrateLegacyConfig), then rescue
|
|
811
|
+
// whatever the removed settings.yaml still holds. The rescue waits for the
|
|
812
|
+
// Loader to settle, exactly like DSH's own importer did, so the entry it
|
|
813
|
+
// inspects is the live one.
|
|
702
814
|
migrateLegacyConfig(ctx);
|
|
815
|
+
const loader = ctx.root?.loader;
|
|
816
|
+
const settled = typeof loader?.await === "function" ? Promise.resolve(loader.await()).catch(() => void 0) : Promise.resolve();
|
|
817
|
+
settled.then(() => rescueRemovedSettingsDocument(ctx)).catch(() => void 0);
|
|
703
818
|
// Real-time fold: listen to session/event and fold each usage event into
|
|
704
819
|
// the cache immediately, so live session usage is captured regardless of
|
|
705
820
|
// whether the hero screen is mounted (the client polls the usage endpoint
|
|
@@ -779,4 +894,18 @@ function apply(ctx) {
|
|
|
779
894
|
}), "token-heatmap: config route");
|
|
780
895
|
}
|
|
781
896
|
|
|
782
|
-
export {
|
|
897
|
+
export {
|
|
898
|
+
apply,
|
|
899
|
+
inject,
|
|
900
|
+
name,
|
|
901
|
+
Config,
|
|
902
|
+
CONFIG_PATH,
|
|
903
|
+
USAGE_PATH,
|
|
904
|
+
SETTINGS_NAMESPACE,
|
|
905
|
+
TokenHeatmapSettingsSchema,
|
|
906
|
+
migrateLegacyConfig,
|
|
907
|
+
entryConfig,
|
|
908
|
+
dshHomeDirectory,
|
|
909
|
+
legacySettingFromDocument,
|
|
910
|
+
rescueRemovedSettingsDocument
|
|
911
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kidli1412/dsh-token-heatmap",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "DSH web plugin: GitHub-style daily token-usage heatmap on the new-session screen with switchable year/month views and six color schemes. The card configures itself through a floating ⚙ panel (palette + default view) — no DSH settings card — and shows today / this-month / all-time totals.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -20,15 +20,26 @@
|
|
|
20
20
|
"@deepseek-ai/schemastery": "^3.18.2"
|
|
21
21
|
},
|
|
22
22
|
"peerDependencies": {
|
|
23
|
-
"@deepseek-ai/dsh-api-remotes": "^0.
|
|
24
|
-
"@deepseek-ai/dsh-client-connection": "^0.
|
|
25
|
-
"@deepseek-ai/dsh-client-locale": "^0.
|
|
26
|
-
"@deepseek-ai/dsh-client-ui-conversation": "^0.
|
|
27
|
-
"@deepseek-ai/dsh-client-ui-settings": "^0.
|
|
28
|
-
"@deepseek-ai/dsh-host-webserver": "^0.
|
|
29
|
-
"@deepseek-ai/dsh-session": "^0.
|
|
30
|
-
"@deepseek-ai/dsh-session-persistence": "^0.
|
|
31
|
-
"@deepseek-ai/dsh-settings": "^0.
|
|
23
|
+
"@deepseek-ai/dsh-api-remotes": "^0.2.0-rc.2",
|
|
24
|
+
"@deepseek-ai/dsh-client-connection": "^0.2.0-rc.2",
|
|
25
|
+
"@deepseek-ai/dsh-client-locale": "^0.2.0-rc.2",
|
|
26
|
+
"@deepseek-ai/dsh-client-ui-conversation": "^0.2.0-rc.2",
|
|
27
|
+
"@deepseek-ai/dsh-client-ui-settings": "^0.2.0-rc.2",
|
|
28
|
+
"@deepseek-ai/dsh-host-webserver": "^0.2.0-rc.2",
|
|
29
|
+
"@deepseek-ai/dsh-session": "^0.2.0-rc.2",
|
|
30
|
+
"@deepseek-ai/dsh-session-persistence": "^0.2.0-rc.2",
|
|
31
|
+
"@deepseek-ai/dsh-settings": "^0.2.0-rc.2"
|
|
32
|
+
},
|
|
33
|
+
"peerDependenciesMeta": {
|
|
34
|
+
"@deepseek-ai/dsh-api-remotes": { "optional": true },
|
|
35
|
+
"@deepseek-ai/dsh-client-connection": { "optional": true },
|
|
36
|
+
"@deepseek-ai/dsh-client-locale": { "optional": true },
|
|
37
|
+
"@deepseek-ai/dsh-client-ui-conversation": { "optional": true },
|
|
38
|
+
"@deepseek-ai/dsh-client-ui-settings": { "optional": true },
|
|
39
|
+
"@deepseek-ai/dsh-host-webserver": { "optional": true },
|
|
40
|
+
"@deepseek-ai/dsh-session": { "optional": true },
|
|
41
|
+
"@deepseek-ai/dsh-session-persistence": { "optional": true },
|
|
42
|
+
"@deepseek-ai/dsh-settings": { "optional": true }
|
|
32
43
|
},
|
|
33
44
|
"dsh": {
|
|
34
45
|
"bundle": {
|
|
@@ -46,9 +57,7 @@
|
|
|
46
57
|
},
|
|
47
58
|
"compatibility": {
|
|
48
59
|
"dshReleases": {
|
|
49
|
-
"0.
|
|
50
|
-
"0.1.2-alpha.5": "compatible",
|
|
51
|
-
"0.1.2-rc.1": "compatible"
|
|
60
|
+
"0.2.0-rc.2": "compatible"
|
|
52
61
|
}
|
|
53
62
|
}
|
|
54
63
|
},
|
|
@@ -60,7 +69,7 @@
|
|
|
60
69
|
"license": "MIT",
|
|
61
70
|
"engines": {
|
|
62
71
|
"node": "^22.19.0 || >=24.0.0",
|
|
63
|
-
"dsh": "^0.
|
|
72
|
+
"dsh": "^0.2.0-rc.2"
|
|
64
73
|
},
|
|
65
74
|
"repository": {
|
|
66
75
|
"type": "git",
|