dsh-desktop-statusbar 2.0.2 → 2.1.1

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.
@@ -137,7 +137,9 @@ const adapters = [
137
137
  鉴权:Authorization: Bearer <api_key> —— 注意与 coding plan 配额的「裸 key」不同
138
138
  响应 { code, msg, data: { availableBalance, balance, rechargeAmount, totalSpendAmount } }
139
139
  code === 200 才算成功;可用余额 = availableBalance ?? balance,单位 CNY。 */
140
- keys: ['zhipu', 'glm', 'bigmodel'],
140
+ /* 'zai' 是 DSH 里 z.ai(智谱国际站)的 provider 名,模型为 glm-5.3-*(本机会话实测 2026-10-01);
141
+ 模型名本身也含 glm,这里补上 provider 名,免得模型改名后就匹配不到。 */
142
+ keys: ['zhipu', 'glm', 'bigmodel', 'zai'],
141
143
  refs: ['ZAI_API_KEY', 'ZHIPU_API_KEY', 'GLM_API_KEY', 'BIGMODEL_API_KEY'],
142
144
  request: (key) => ({
143
145
  /* 实测 2026-09-27:open.bigmodel.cn 与 api.z.ai 同后端,两个域名 + Bearer/裸 key 四种组合都返回 code=200 */
@@ -170,6 +172,11 @@ const adapters = [
170
172
  响应 { success, data: { level, limits: [{ type: TOKENS_LIMIT|CREDIT_LIMIT, percentage, ... }] } }
171
173
  percentage 就是配额使用率,直接当百分比显示,**不换算金额**。注意这是 coding plan
172
174
  配额,不是按量计费的现金余额 —— 现金余额只有控制台接口能给,需要网页登录态。 */
175
+ /* 它是 `zhipu` 的**第二条独立的路**,不是替代品:两者 keys 完全重叠,而 `adapterFor`
176
+ 按数组顺序命中即返回,所以配额这条永远选不中 —— 必须靠 `fallbackOf` 显式挂成
177
+ 链上的下一跳(见 adaptersFor),否则 coding plan 用户永远只看到现金余额那条路
178
+ 的结果(实测症状:余额显示 ¥- 或 ¥0.00)。 */
179
+ fallbackOf: 'zhipu',
173
180
  keys: ['zhipu', 'glm', 'bigmodel'],
174
181
  refs: ['ZHIPU_API_KEY', 'GLM_API_KEY', 'BIGMODEL_API_KEY'],
175
182
  request: (key, base) => ({
@@ -197,28 +204,219 @@ const adapters = [
197
204
  if (percent === null) return null;
198
205
  return { currency: '', total: percent.toFixed(2) + '%', parts: [], isPercent: true };
199
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
+ }
200
247
  }
201
248
  ];
202
249
 
203
- /** 会话线索(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
+ */
204
274
  function adapterFor(provider, model) {
205
- const text = (String(provider === undefined || provider === null ? '' : provider) + ' '
206
- + String(model === undefined || model === null ? '' : model)).toLowerCase();
207
- if (text.trim().length === 0) return null;
208
- for (let i = 0; i < adapters.length; i += 1) {
209
- const one = adapters[i];
210
- for (let k = 0; k < one.keys.length; k += 1) {
211
- 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
+ }
212
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;
213
327
  }
214
- return null;
328
+ return chain;
329
+ }
330
+
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;
215
364
  }
216
365
 
217
- /** 会话模型属于 DeepSeek 时,优先走官方账号通道(账号登录没有 API key 也能查)。 */
218
- function isDeepSeek(provider, model) {
219
- const text = (String(provider === undefined || provider === null ? '' : provider) + ' '
220
- + String(model === undefined || model === null ? '' : model)).toLowerCase();
221
- return text.includes('deepseek');
366
+ /**
367
+ * 本机路由响应 → 余额/配额。**只认明确命名的结构,绝不自由猜字段。**
368
+ *
369
+ * 为什么这条约束是硬性的:可行性调研实测过同族三个插件的返回结构 ——
370
+ * · workbuddy:顶层 `credits.total` **就是余额**
371
+ * · qoder :`usage.userQuota.total` 是**配额容量**,`remaining` 才是余额(语义正好相反)
372
+ * · trae :同时有 `total` / `available` / `workAvailable`
373
+ * 若写成"找任意名为 total 的数字",在 qoder 上会稳定显示一个**看起来合理的错数**。
374
+ * 本文件第 253 行那条实测教训(拿错平台的 key 去问余额,稳定 401)与
375
+ * "宁可不显示,也不显示错数"是同一个原则。
376
+ *
377
+ * 抽取器按优先级排列,命中即返回;全部不命中返回 null(调用方显示空)。
378
+ */
379
+ const LOCAL_SHAPE_EXTRACTORS = [
380
+ /* workbuddy(新 /usage 与老 /status 都是这个形状):顶层 credits.total 是余额 */
381
+ (data) => {
382
+ const credits = data !== null && data !== undefined ? data.credits : null;
383
+ if (credits === null || credits === undefined || typeof credits !== 'object') return null;
384
+ const total = Number(credits.total);
385
+ if (Number.isFinite(total) !== true) return null;
386
+ return { currency: '', total: formatCredits(total), parts: [], isCredits: true };
387
+ },
388
+ /* 配额百分比(智谱那条路的形状):明确带 limits 数组才算,避免把别的 total 当配额 */
389
+ (data) => {
390
+ const inner = data !== null && data !== undefined && data.data !== null && data.data !== undefined
391
+ ? data.data
392
+ : null;
393
+ if (inner === null || inner === undefined || Array.isArray(inner.limits) !== true) return null;
394
+ let percent = null;
395
+ for (let i = 0; i < inner.limits.length; i += 1) {
396
+ const one = inner.limits[i];
397
+ if (one === null || one === undefined) continue;
398
+ const kind = typeof one.type === 'string' ? one.type.toUpperCase() : '';
399
+ if (kind !== 'TOKENS_LIMIT' && kind !== 'CREDIT_LIMIT') continue;
400
+ const value = Number(one.percentage);
401
+ if (Number.isFinite(value) !== true) continue;
402
+ if (percent === null || value > percent) percent = value;
403
+ }
404
+ if (percent === null) return null;
405
+ return { currency: '', total: percent.toFixed(2) + '%', parts: [], isPercent: true };
406
+ }
407
+ ];
408
+
409
+ /**
410
+ * 本机路由文档 → 余额。抽取器全部不命中就给 null(**不猜**)。
411
+ * 导出是为了让测试能直接钉住"qoder 那种结构必须给 null"这条防线。
412
+ */
413
+ function balanceFromLocalDoc(data) {
414
+ if (data === null || data === undefined || typeof data !== 'object') return null;
415
+ for (let i = 0; i < LOCAL_SHAPE_EXTRACTORS.length; i += 1) {
416
+ const value = LOCAL_SHAPE_EXTRACTORS[i](data);
417
+ if (value !== null) return value;
418
+ }
419
+ return null;
222
420
  }
223
421
 
224
- export { adapters, adapterFor, isDeepSeek };
422
+ export { adapters, adapterFor, adaptersFor, pluginRoutesIn, balanceFromLocalDoc, LOCAL_READONLY_VERBS, LOCAL_NEVER_PROBE };