dsh-desktop-statusbar 2.1.0 → 2.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.en.md CHANGED
@@ -99,10 +99,32 @@ Peak hours are **09:00–12:00 and 14:00–18:00 Beijing time, Monday to Friday*
99
99
 
100
100
  ## Development
101
101
 
102
+ Every test is a standalone script meant to be run with plain node — no test framework.
103
+
102
104
  ```powershell
103
- node tools/test.cjs
105
+ npm test # runs every verify*.js under tests/, then checks project vs installed plugin dir
106
+ npm run sync # copies lib/ into the installed plugin dir
104
107
  ```
105
108
 
109
+ Development scripts under `tools/` ship with the package (the `files` field includes them),
110
+ so both a git clone and an npm install get them:
111
+
112
+ | Script | Purpose |
113
+ |---|---|
114
+ | `tools/test.cjs` | full test run + consistency guard |
115
+ | `tools/sync-to-plugin.cjs` | project `lib/` → installed plugin dir |
116
+ | `tools/sync-from-plugin.cjs` | installed plugin dir → project `lib/` |
117
+ | `tools/plugin-dir.cjs` | locates the plugin dir |
118
+ | `tools/shot.cjs` | regenerates the README screenshots |
119
+
120
+ They are only used while developing; the plugin never loads them at runtime.
121
+ `npm test` needs the repository's `tests/` directory, so an npm-installed copy can only run
122
+ `sync-*` and `shot`.
123
+
124
+ The plugin directory is located automatically: environment variable `DSH_STATUS_BAR_PLUGIN_DIR` →
125
+ known layouts under `~/.dsh` (`local-plugins/`, each `profiles/*/node_modules/`) → derived from the
126
+ `tools/` location. No code changes needed when switching machine or user name.
127
+
106
128
  ## Licence
107
129
 
108
130
  [MIT](LICENSE): free to use, modify and redistribute; keep the copyright and licence notice.
package/README.md CHANGED
@@ -37,7 +37,7 @@
37
37
  | 余额 | 充值余额 / 赠金余额 / 剩余配额 |
38
38
 
39
39
  - **会话数据下钻**:上下文压缩逐次标注发生节点,技能注入统计当前会话注入的技能,工具调用按次数列出会话使用的所有工具;点击标题返回上一层
40
- - **版本自检与更新**:DSH启动时自动检测最新版本,发现新版时在「状态栏」导航项旁显示提示,并在设置页提供一键更新按钮
40
+ - **版本自检与更新**:DSH 启动时自动检测最新版本,发现新版时在「状态栏」导航项旁显示提示,并在设置页提供一键更新按钮
41
41
  - **费用估算**:按官方峰谷口径计价,每条调用按其发生时刻与所用模型分别计算
42
42
  - **价格库**:自定义单价、峰谷分档、CNY / USD 计价单位
43
43
  - **GitHub支持**:用户的支持是开发者持续开发的最大动力!点击按钮转跳GitHub项目页,帮忙点亮Star支持一下,感谢各位!
@@ -96,13 +96,33 @@ npm view dsh-desktop-statusbar version
96
96
 
97
97
  - 最快 / 最慢首字与速度来自 host 新增的投影,**升级后需完全退出并重启 DSH** 才开始统计
98
98
  - 价格库中缺少单价的模型不计费,补充单价后自动补算
99
+ - 一键更新依赖 pnpm 可执行文件:桌面端用宿主随包提供的 pnpm,命令行 / 网页端用 PATH 上的 pnpm。两者都找不到时会明确提示「找不到 pnpm」,而不是静默失败
99
100
 
100
101
  ## 开发
101
102
 
103
+ 每个测试都是独立脚本,直接用 node 跑,不需要任何测试框架。
104
+
102
105
  ```powershell
103
- node tools/test.cjs
106
+ npm test # runs every verify*.js under tests/, then checks project vs installed plugin dir
107
+ npm run sync # copies lib/ into the installed plugin dir
104
108
  ```
105
109
 
110
+ `tools/` 下的开发脚本随包一起发布(`files` 里有它),克隆仓库或装 npm 包都能拿到:
111
+
112
+ | 脚本 | 作用 |
113
+ |---|---|
114
+ | `tools/test.cjs` | 全量测试 + 一致性守卫 |
115
+ | `tools/sync-to-plugin.cjs` | 项目 `lib/` → 已安装插件目录 |
116
+ | `tools/sync-from-plugin.cjs` | 已安装插件目录 → 项目 `lib/` |
117
+ | `tools/plugin-dir.cjs` | 自动定位插件目录 |
118
+ | `tools/shot.cjs` | 生成 README 截图 |
119
+
120
+ 它们只在开发时用,插件运行时不会加载。`npm test` 需要仓库里的 `tests/` 目录,
121
+ 所以从 npm 装的包只能跑 `sync-*` / `shot` 这几个。
122
+
123
+ 插件目录的位置自动探测,顺序是:环境变量 `DSH_STATUS_BAR_PLUGIN_DIR` → `~/.dsh` 下的已知安装布局
124
+ (`local-plugins/`、各 `profiles/*/node_modules/`)→ 由 `tools/` 位置推导。换机器、换用户名都不用改代码。
125
+
106
126
  ## 许可
107
127
 
108
128
  [MIT](LICENSE):可自由使用、修改与再分发;保留版权与许可声明即可。
Binary file
package/docs/pricing.png CHANGED
Binary file
Binary file
package/docs/settings.png CHANGED
Binary file
@@ -172,6 +172,11 @@ const adapters = [
172
172
  响应 { success, data: { level, limits: [{ type: TOKENS_LIMIT|CREDIT_LIMIT, percentage, ... }] } }
173
173
  percentage 就是配额使用率,直接当百分比显示,**不换算金额**。注意这是 coding plan
174
174
  配额,不是按量计费的现金余额 —— 现金余额只有控制台接口能给,需要网页登录态。 */
175
+ /* 它是 `zhipu` 的**第二条独立的路**,不是替代品:两者 keys 完全重叠,而 `adapterFor`
176
+ 按数组顺序命中即返回,所以配额这条永远选不中 —— 必须靠 `fallbackOf` 显式挂成
177
+ 链上的下一跳(见 adaptersFor),否则 coding plan 用户永远只看到现金余额那条路
178
+ 的结果(实测症状:余额显示 ¥- 或 ¥0.00)。 */
179
+ fallbackOf: 'zhipu',
175
180
  keys: ['zhipu', 'glm', 'bigmodel'],
176
181
  refs: ['ZHIPU_API_KEY', 'GLM_API_KEY', 'BIGMODEL_API_KEY'],
177
182
  request: (key, base) => ({
@@ -199,28 +204,184 @@ const adapters = [
199
204
  if (percent === null) return null;
200
205
  return { currency: '', total: percent.toFixed(2) + '%', parts: [], isPercent: true };
201
206
  }
207
+ },
208
+ {
209
+ id: 'workbuddy',
210
+ label: 'WorkBuddy',
211
+ /* WorkBuddy / WorkBuddy AI 的余额是**积分**,且不在这里查:凭据由 connect 类插件
212
+ 解密持有,上游鉴权与计费解析都由它实现,它把结论挂在本机 Web 路由上。
213
+ 所以本适配器不发外部请求,只读那条本机路由(local: true),本插件不碰凭据、
214
+ 也不重复实现上游协议。
215
+ 实测 2026-10-07:国际版路由返回 credits.total = 177。
216
+ 实测 2026-10-12:`credits.total` 这个口径在**新旧两个插件上一致**
217
+ (新 dsh-connect-workbuddy 的 /usage 与老 dsh-workbuddy-connect 的 /status
218
+ 都是顶层 credits.total),所以解析器一份就够,变的只是**路径**。 */
219
+ local: true,
220
+ keys: ['workbuddy', 'codebuddy'],
221
+ refs: [],
222
+ /* 候选路由**按新旧插件依次尝试**(见 pathsOf):第一个能解析出结果的胜出。
223
+ 为什么不能只留一条:两个插件是不同作者的两套实现,用户可能同时装着、
224
+ 也可能只用其中一个(实测本机同时装着,当前生效的是新插件)。
225
+ 为什么新插件要带 region:它的路由按区域分账号(cn / global),
226
+ 不带参数默认 cn,区域给错会拿到另一个区域的账号余额。 */
227
+ pathsOf: (provider) => {
228
+ const text = String(provider === undefined || provider === null ? '' : provider).toLowerCase()
229
+ /* 国际版:新插件用 region=global,老插件用 /ai/status;
230
+ 实测 provider 名有三种形态:workbuddy(国内)、workbuddy-ai、workbuddy-global */
231
+ const global = text.includes('-ai') || text.includes('global')
232
+ const region = global ? 'global' : 'cn'
233
+ return [
234
+ '/plugins/dsh-connect-workbuddy/usage?region=' + region,
235
+ global ? '/plugins/dsh-workbuddy-connect/ai/status' : '/plugins/dsh-workbuddy-connect/status'
236
+ ]
237
+ },
238
+ parse: (data) => {
239
+ const credits = data !== null && data !== undefined && data.credits !== undefined ? data.credits : null;
240
+ if (credits === null) return null;
241
+ const total = Number(credits.total);
242
+ if (Number.isFinite(total) !== true) return null;
243
+ /* 积分不是货币:currency 留空让客户端不拼货币符号;不给 parts,
244
+ 气泡因此只显示总数、不列套餐构成。 */
245
+ return { currency: '', total: formatCredits(total), parts: [], isCredits: true };
246
+ }
202
247
  }
203
248
  ];
204
249
 
205
- /** 会话线索(provider + 模型名)里出现这些词就认为是哪一家。 */
250
+ /**
251
+ * 积分数值的显示文本:整数按千分位分组,非整数保留两位小数。
252
+ * 积分不带货币单位,所以这里不拼 ¥ / $(与 formatBalanceAmount 的区别就在这)。
253
+ */
254
+ function formatCredits(value) {
255
+ const n = Number(value);
256
+ if (Number.isFinite(n) !== true) return '0';
257
+ const rounded = Number.isInteger(n) ? n : Math.round(n * 100) / 100;
258
+ const text = String(rounded);
259
+ const dot = text.indexOf('.');
260
+ const whole = dot < 0 ? text : text.slice(0, dot);
261
+ const rest = dot < 0 ? '' : text.slice(dot);
262
+ return whole.replace(/\B(?=(\d{3})+(?!\d))/g, ',') + rest;
263
+ }
264
+
265
+ /**
266
+ * 会话线索 → 平台适配器。
267
+ *
268
+ * **provider 优先于模型名**:先只用 provider 匹配一轮,没命中才把模型名并进来匹配。
269
+ * 为什么必须分两轮:模型名常常带别家厂商的词,混在一起匹配会让会话被认成错的平台 ——
270
+ * 实测 2026-10-07,WorkBuddy AI 会话(provider=workbuddy-ai)跑 deepseek-v4.1-flash-sg,
271
+ * 旧写法把 "deepseek" 从模型名里匹到,于是拿 DeepSeek 的 API key 去问官方余额接口,
272
+ * 稳定拿到 HTTP 401,底栏余额永远是横杠。provider 是会话实际走的计费通道,以它为准。
273
+ */
206
274
  function adapterFor(provider, model) {
207
- const text = (String(provider === undefined || provider === null ? '' : provider) + ' '
208
- + String(model === undefined || model === null ? '' : model)).toLowerCase();
209
- if (text.trim().length === 0) return null;
210
- for (let i = 0; i < adapters.length; i += 1) {
211
- const one = adapters[i];
212
- for (let k = 0; k < one.keys.length; k += 1) {
213
- if (text.includes(one.keys[k])) return one;
275
+ const providerText = String(provider === undefined || provider === null ? '' : provider).toLowerCase().trim();
276
+ const modelText = String(model === undefined || model === null ? '' : model).toLowerCase().trim();
277
+ const pick = (text) => {
278
+ if (text.length === 0) return null;
279
+ for (let i = 0; i < adapters.length; i += 1) {
280
+ const one = adapters[i];
281
+ for (let k = 0; k < one.keys.length; k += 1) {
282
+ if (text.includes(one.keys[k])) return one;
283
+ }
214
284
  }
285
+ return null;
286
+ };
287
+ const byProvider = pick(providerText);
288
+ if (byProvider !== null) return byProvider;
289
+ /* provider 还没上报(或名字认不出)时才退回模型名,保持对自定义 provider 名的兼容 */
290
+ return pick(modelText);
291
+ }
292
+
293
+ /**
294
+ * 会话线索 → **要按顺序依次尝试的适配器链**(第一个是主选,其后是回退)。
295
+ *
296
+ * 为什么需要"链"而不是单个适配器:智谱有两种**互相独立**的计费通道,接口与响应结构
297
+ * 完全不同,而且 keys 完全重叠(都是 zhipu/glm/bigmodel):
298
+ * · 按量计费 → `zhipu`(现金余额,控制台接口,Bearer)
299
+ * · coding plan → `zhipu-quota`(配额百分比,monitor 接口,裸 key)
300
+ * 单选的 `adapterFor` 按数组顺序命中即返回,`zhipu-quota` 永远选不中 —— 于是 coding plan
301
+ * 用户永远拿不到配额,客户端 `isPercent` 分支恒不可达(用户实测:智谱 coding plan 显示"余额 ¥-")。
302
+ * 用户明确要求**按实际调用的模型/凭据决定显示余额还是配额**,两种是独立的路、不能二选一。
303
+ *
304
+ * 实现就是"两个端点都试、按返回结构决定显示什么":调用方按这个顺序逐个请求,**第一个
305
+ * 解析出结果的胜出**(余额接口对 coding plan 返回不了 code=200 的余额 → parse 给 null →
306
+ * 自动落到配额;反过来按量计费账号在配额接口上拿不到 limits → 也给 null)。
307
+ * 判定完全交给各家自己的 `parse`(那是已经核实过上游协议的**唯一**权威),不在这里另立规则。
308
+ *
309
+ * 非智谱平台没有 `fallbackOf`,链长恒为 1 —— 行为与旧版逐字节一致。
310
+ */
311
+ function adaptersFor(provider, model) {
312
+ const first = adapterFor(provider, model);
313
+ if (first === null) return [];
314
+ const chain = [first];
315
+ /* 跟着 `fallbackOf` 往下走:它的语义是"**我是**某条适配器的后备"
316
+ (`zhipu-quota.fallbackOf === 'zhipu'` = 配额是余额的后备),
317
+ 所以这里找的是"谁声明了以当前这条为后备",而不是当前这条的后备。
318
+ visited 兜住配置写错造成的环(否则会转不出来)。 */
319
+ const visited = new Set([first.id]);
320
+ let current = first;
321
+ for (;;) {
322
+ const next = adapters.filter((one) => one.fallbackOf === current.id && visited.has(one.id) !== true)[0];
323
+ if (next === undefined) break;
324
+ visited.add(next.id);
325
+ chain.push(next);
326
+ current = next;
215
327
  }
216
- return null;
328
+ return chain;
217
329
  }
218
330
 
219
- /** 会话模型属于 DeepSeek 时,优先走官方账号通道(账号登录没有 API key 也能查)。 */
220
- function isDeepSeek(provider, model) {
221
- const text = (String(provider === undefined || provider === null ? '' : provider) + ' '
222
- + String(model === undefined || model === null ? '' : model)).toLowerCase();
223
- return text.includes('deepseek');
331
+ /* ------------------------------------------------- connect 类插件的通用发现 */
332
+
333
+ /**
334
+ * 允许探测的**动词白名单**。只探"读余额/状态"这一类,且都是只读语义。
335
+ *
336
+ * 为什么不放开:可行性调研实测到同族插件还有 `refresh` / `probe` / `checkin` /
337
+ * `transfer` / `oauth` / `__save` / `pool` 等路由,其中多数是**写操作**
338
+ * (签到会真的领奖励、probe 会真的花积分、__save 会写配置)。
339
+ * "GET 无副作用"是路由所有者的选择、不是保证 —— 所以这里只列明确只读的三条。
340
+ */
341
+ const LOCAL_READONLY_VERBS = ['usage', 'status', 'account-credits'];
342
+
343
+ /** 永不探测的路径:`/plugins/events` 是 SSE,GET 会挂住不返回(实测 15s 超时)。 */
344
+ const LOCAL_NEVER_PROBE = ['/plugins/events'];
345
+
346
+ /**
347
+ * 从一张路由表(Map)里挑出"形如 `/plugins/<id>/<动词>` 且动词在白名单里"的路径。
348
+ *
349
+ * 只做筛选、不发请求;调用方负责确认可达性。返回按插件 id 去重、保留全部动词候选。
350
+ * `table` 不是 Map(宿主改实现 / 字段被改名)时返回空数组 —— 调用方据此回退声明表。
351
+ */
352
+ function pluginRoutesIn(table) {
353
+ const out = [];
354
+ if (table === null || table === undefined || typeof table.forEach !== 'function') return out;
355
+ table.forEach((value, key) => {
356
+ if (typeof key !== 'string') return;
357
+ if (LOCAL_NEVER_PROBE.indexOf(key) >= 0) return;
358
+ const hit = /^\/plugins\/([^/]+)\/([^/]+)$/.exec(key);
359
+ if (hit === null) return;
360
+ if (LOCAL_READONLY_VERBS.indexOf(hit[2]) < 0) return;
361
+ out.push({ pluginId: hit[1], verb: hit[2], path: key });
362
+ });
363
+ return out;
224
364
  }
225
365
 
226
- export { adapters, adapterFor, isDeepSeek };
366
+ /**
367
+ * 本机路由响应 → 余额/配额。**只认明确命名的结构,绝不自由猜字段。**
368
+ *
369
+ * 为什么这条约束是硬性的:可行性调研实测过同族三个插件的返回结构 ——
370
+ * · workbuddy:顶层 `credits.total` **就是余额**
371
+ * · qoder :`usage.userQuota.total` 是**配额容量**,`remaining` 才是余额(语义正好相反)
372
+ * · trae :同时有 `total` / `available` / `workAvailable`
373
+ * 若写成"找任意名为 total 的数字",在 qoder 上会稳定显示一个**看起来合理的错数**。
374
+ * 与 workbuddy 适配器注释里那条实测教训(拿错平台的 key 去问余额,稳定 401)是同一个原则:
375
+ * 宁可不显示,也不显示错数。
376
+ *
377
+ * 这条防线**唯一的实现处就是各适配器自己的 `parse`**:`index.js` 的 `queryLocalAdapter`
378
+ * 拿到本机路由响应后直接调 `adapter.parse(data)` —— workbuddy 只认 `credits.total`、
379
+ * 智谱配额只认带 `limits` 数组的形状,认不出一律给 null(既不猜字段,也不硬凑)。
380
+ * 回归测试(tests/verify-balance-api.js)打的就是这条生产路径。
381
+ *
382
+ * 历史上这里还有一套 `LOCAL_SHAPE_EXTRACTORS` + `balanceFromLocalDoc`:它只被测试引用、
383
+ * 生产从未调用(2026-10 删除)。测试打生产 `parse` 才是真防线 —— 打只给测试用的旁路函数,
384
+ * 绿了也不代表线上安全。
385
+ */
386
+
387
+ export { adapters, adapterFor, adaptersFor, pluginRoutesIn, LOCAL_READONLY_VERBS, LOCAL_NEVER_PROBE };