@mzzsfy/dsh-usage-panel 0.8.4 → 0.8.5

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mzzsfy/dsh-usage-panel",
3
3
  "description": "多平台 LLM 账号余额与额度面板:支持 DeepSeek/OpenRouter/Kimi/智谱/MiniMax/NewApi 及自定义端点,自动定期查询,趋势图与历史快照,用量/余额阈值与窗口重置多通道通知",
4
- "version": "0.8.4",
4
+ "version": "0.8.5",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
package/src/client.js CHANGED
@@ -1388,7 +1388,44 @@ function UsagePanelApp() {
1388
1388
  setPollArmed(res ? Boolean(res.pollArmed) : null)
1389
1389
  })
1390
1390
  .catch(() => {})
1391
- return () => { alive = false }
1391
+
1392
+ // 服务端数据对账(F31):账号/历史持久化经总线广播桥转 window 事件,
1393
+ // 面板收到即单飞重拉;窗口聚焦同样对账(兜底错过的广播)。无轮询
1394
+ let knownRevision = null
1395
+ let resyncInFlight = false
1396
+ const resync = () => {
1397
+ if (resyncInFlight) return
1398
+ resyncInFlight = true
1399
+ api('/api/usage-panel/revision')
1400
+ .then((res) => {
1401
+ if (!alive || !res || typeof res.revision !== 'number') return null
1402
+ if (knownRevision !== null && res.revision !== knownRevision) {
1403
+ knownRevision = res.revision
1404
+ return Promise.all([
1405
+ api('/api/usage-panel/accounts'),
1406
+ api('/api/usage-panel/history'),
1407
+ ]).then(([accountsRes, historyRes]) => {
1408
+ if (!alive) return null
1409
+ if (accountsRes && accountsRes.accounts) setAccounts(accountsRes.accounts)
1410
+ if (historyRes && historyRes.sequences) setSequences(historyRes.sequences)
1411
+ return null
1412
+ })
1413
+ }
1414
+ if (knownRevision === null) knownRevision = res.revision
1415
+ return null
1416
+ })
1417
+ .catch(() => {})
1418
+ .then(() => { resyncInFlight = false })
1419
+ }
1420
+ const onDataChanged = () => resync()
1421
+ const onFocus = () => resync()
1422
+ window.addEventListener('up-data-changed', onDataChanged)
1423
+ window.addEventListener('focus', onFocus)
1424
+ return () => {
1425
+ alive = false
1426
+ window.removeEventListener('up-data-changed', onDataChanged)
1427
+ window.removeEventListener('focus', onFocus)
1428
+ }
1392
1429
  }, [])
1393
1430
 
1394
1431
  function refreshSequences() {
@@ -1516,6 +1553,11 @@ function UsagePanelApp() {
1516
1553
  apply(ctx) {
1517
1554
  const bus = tools.setupCommonTools(ctx)
1518
1555
  ctx.effect(() => startNotifications(bus), 'usage-panel notifications')
1556
+ // 数据失效广播桥(F31):服务端账号/历史持久化经总线发布变更,
1557
+ // 桥转 window 事件供面板组件对账(组件树无总线句柄)
1558
+ ctx.effect(() => bus.subscribe(() => {
1559
+ window.dispatchEvent(new CustomEvent('up-data-changed'))
1560
+ }, {}), 'usage-panel data-changed bridge')
1519
1561
  ctx.slots.inject('settings.section', () =>
1520
1562
  ctx.slots.register(
1521
1563
  { name: 'settings.section', id: 'usage-panel', order: 40, label: '账号余额' },
package/src/index.js CHANGED
@@ -43,8 +43,9 @@ const MAX_ACCOUNTS = 20
43
43
  // 单轮轮询的受限并发:串行执行时单账号 20s 超时即可拖满整轮(pollInFlight 互斥
44
44
  // 使后续 tick 全部跳过,轮询事实停摆),并发上限同时压住上游瞬时压力
45
45
  const POLL_CONCURRENCY = 4
46
- // 数据目录支持 env 注入(测试隔离);缺省落 ~/.dsh/dsh-usage-panel
47
- const DATA_DIR = process.env.DSH_USAGE_PANEL_DATA_DIR || join(homedir(), '.dsh', 'dsh-usage-panel')
46
+ // 数据目录支持 env 注入(测试隔离);缺省从宿主 home 派生(DSH_HOME 即 .dsh 根,rs-workflow 同构)
47
+ const DATA_DIR = process.env.DSH_USAGE_PANEL_DATA_DIR
48
+ || join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'dsh-usage-panel')
48
49
  const DATA_FILE = join(DATA_DIR, 'accounts.json')
49
50
  const HISTORY_FILE = join(DATA_DIR, 'history.json')
50
51
  const BAK_SUFFIX = '.bak'
@@ -365,24 +366,29 @@ export function apply(ctx, configRef) {
365
366
  return resolvedNotifySettings(value && typeof value === 'object' ? value.notify : undefined)
366
367
  }
367
368
 
368
- // 通知配置串行写链:读改写整体入链,任一失败不吞链(catch 后换行续链)
369
+ // 通知配置串行写链:读改写整体追加进链,合并读的总是前序写完成后的最新值;
370
+ // 任一失败不吞链(换行续链,失败只回报本次请求)
369
371
  let notifyConfigWriteChain = Promise.resolve()
370
372
 
371
373
  function persistNotifyPatch(patch) {
372
- // 读出当前值做浅合并后整体写回,不依赖宿主写入的嵌套合并语义
373
- const merged = { ...readNotifySettings(), ...patch }
374
- // 双形写(maintain 同构,判定延迟到使用点):legacy 方法面以 register 为准
375
- // (0.1.7 的 settings.update 名义在场但按命名空间找条目必败,不可作判据);
376
- // 0.1.7+ 走 configEditor.edit 落 profile 条目(fiber.entry,与命名空间解耦)
377
- const settings = ctx.get('settings')
378
- requireOk(settings !== undefined, 'settings 服务不可用')
379
- if (typeof settings.register === 'function' && typeof settings.update === 'function') {
380
- return settings.update(NAMESPACE, { notify: merged })
381
- }
382
- const editor = ctx.get('configEditor')
383
- const entry = ctx.fiber?.entry
384
- requireOk(editor && typeof editor.edit === 'function' && entry, 'configEditor 服务不可用')
385
- return editor.edit(entry, (current) => ({ ...current, notify: merged }))
374
+ const run = notifyConfigWriteChain.then(async () => {
375
+ // 读出当前值做浅合并后整体写回,不依赖宿主写入的嵌套合并语义
376
+ const merged = { ...readNotifySettings(), ...patch }
377
+ // 双形写(maintain 同构,判定延迟到使用点):legacy 方法面以 register 为准
378
+ // (0.1.7 的 settings.update 名义在场但按命名空间找条目必败,不可作判据);
379
+ // 0.1.7+ 走 configEditor.edit 落 profile 条目(fiber.entry,与命名空间解耦)
380
+ const settings = ctx.get('settings')
381
+ requireOk(settings !== undefined, 'settings 服务不可用')
382
+ if (typeof settings.register === 'function' && typeof settings.update === 'function') {
383
+ return settings.update(NAMESPACE, { notify: merged })
384
+ }
385
+ const editor = ctx.get('configEditor')
386
+ const entry = ctx.fiber?.entry
387
+ requireOk(editor && typeof editor.edit === 'function' && entry, 'configEditor 服务不可用')
388
+ return editor.edit(entry, (current) => ({ ...current, notify: merged }))
389
+ })
390
+ notifyConfigWriteChain = run.then(() => {}, () => {})
391
+ return run
386
392
  }
387
393
 
388
394
  function ensureHistory() {
@@ -393,8 +399,17 @@ export function apply(ctx, configRef) {
393
399
  }
394
400
 
395
401
  function persistHistory() {
396
- return historyStore.persist()
402
+ return historyStore.persist().then(() => { bumpDataRevision() })
403
+ }
404
+
405
+ // 数据修订号(F31):账号/历史任一持久化成功即自增;面板经 revision 路由
406
+ // 对账,读数落后即重拉——后台轮询写入不再让面板陈旧
407
+ let dataRevision = 0
408
+ function bumpDataRevision() {
409
+ dataRevision += 1
410
+ notifyChange()
397
411
  }
412
+ const dataRevisionOf = () => dataRevision
398
413
 
399
414
  // 查询成功后落快照:按读数取样追加各序列,并重建当月月窗口序列。
400
415
  function recordSnapshots(account, reading, queriedAt) {
@@ -415,9 +430,19 @@ export function apply(ctx, configRef) {
415
430
  return backoff
416
431
  }
417
432
 
433
+ // 同账号单飞(F13):重复触发的查询共享同一在途 promise,不并发打上游
434
+ const inflightQueries = new Map()
435
+
418
436
  function runQuery(account) {
437
+ // 归属校验(F13):闭包持的是查询发起时的账号对象;完成时按当前 config
438
+ // 引用对账——账号已删除/替换则本结果为孤儿,丢弃不写不评估
439
+ const owned = () => config !== null && Array.isArray(config.accounts) && config.accounts.includes(account)
419
440
  const queriedAt = Date.now()
420
- return queryAccount(account).then((result) => {
441
+ const inflight = inflightQueries.get(account.id)
442
+ if (inflight !== undefined) return inflight
443
+ const run = queryAccount(account).then((result) => {
444
+ inflightQueries.delete(account.id)
445
+ if (!owned()) return { ...result, ok: false, orphaned: true }
421
446
  const backoff = pollEntry(account)
422
447
  if (result.ok) {
423
448
  backoff.onSuccess()
@@ -429,7 +454,12 @@ export function apply(ctx, configRef) {
429
454
  }
430
455
  backoff.onFailure(Math.floor(queriedAt / 1000))
431
456
  return result
457
+ }).catch((error) => {
458
+ inflightQueries.delete(account.id)
459
+ throw error
432
460
  })
461
+ inflightQueries.set(account.id, run)
462
+ return run
433
463
  }
434
464
 
435
465
  // 刷新评估:越逻辑点事件入投影并经 webhook / dsh-im 投递;沿触发状态随账号落盘,
@@ -503,10 +533,13 @@ export function apply(ctx, configRef) {
503
533
 
504
534
  function persistConfig() {
505
535
  if (accountsBroken) return Promise.reject(new Error(ACCOUNTS_BROKEN_MESSAGE))
506
- // 写链毒化防护:链上失败不传播到后续写入(调用方 await 本次结果感知单次失败)
536
+ // 写链毒化防护:链上失败不传播到后续写入(调用方 await 本次结果感知单次失败);
537
+ // 落盘成功即自增修订号并广播(F31):全部账号变更路径(增删改/启停/重排)
538
+ // 都经此口,面板订阅对账,单飞重拉账号与历史
507
539
  const result = writeChain.then(async () => {
508
540
  await mkdir(dirname(DATA_FILE), { recursive: true })
509
541
  await writeFile(DATA_FILE, JSON.stringify(config), 'utf8')
542
+ bumpDataRevision()
510
543
  })
511
544
  writeChain = result.catch(() => {})
512
545
  return result
@@ -653,6 +686,23 @@ export function apply(ctx, configRef) {
653
686
  }
654
687
  }, 'usage-panel notifications route')
655
688
 
689
+ // 数据修订号对账口(F31):面板轮询比对本值,落后即重拉账号与历史
690
+ ctx.effect(
691
+ () =>
692
+ ctx.webServer.register({
693
+ kind: 'exact',
694
+ path: '/api/usage-panel/revision',
695
+ handler: async (req, res) => {
696
+ if (req.method !== 'GET') {
697
+ sendJson(res, 405, { error: 'method not allowed' })
698
+ return
699
+ }
700
+ sendJson(res, 200, { revision: dataRevisionOf() })
701
+ },
702
+ }),
703
+ 'usage-panel revision route',
704
+ )
705
+
656
706
  ctx.effect(
657
707
  () =>
658
708
  ctx.webServer.register({
@@ -667,11 +717,10 @@ export function apply(ctx, configRef) {
667
717
  const check = validateNotifyPatch(body)
668
718
  requireOk(check.ok, check.reason)
669
719
  // 读改写经串行链执行:重叠请求各自基于旧快照合并会互相覆盖
670
- // (先写的 enabled 被后写基于旧 enabled=false 的合并抹掉),链式
671
- // 追加保证任一写读取的总是前序写完成后的最新值;共享链以 catch
672
- // 续接,单次失败只回报本次请求(抛错由 guardedRoute 统一转 400),
673
- // 不阻断后续写
674
- await notifyConfigWriteChain.then(() => persistNotifyPatch(check.patch))
720
+ // persistNotifyPatch 内部把读改写整体追加进共享写链:任一写读取的总是
721
+ // 前序写完成后的最新值;链以 catch 续接,单次失败只回报本次请求
722
+ // (抛错由 guardedRoute 统一转 400),不阻断后续写
723
+ await persistNotifyPatch(check.patch)
675
724
  sendJson(res, 200, { ok: true, notify: publicNotify(readNotifySettings()) })
676
725
  }),
677
726
  }),
@@ -52,7 +52,8 @@ test('Given 尚未激活的 client When factory 物化 Then 不订阅或拉取
52
52
  test('Given ctx 已激活且快照在途 When 卸载 Then 退订并中止请求且不呈现迟到快照', async () => {
53
53
  const f = fixture(), plugin = f.instantiate()
54
54
  plugin.apply()
55
- assert.equal(f.subscriptions.size, 1)
55
+ // 通知投影 + F31 数据失效广播桥:激活期恒两条订阅
56
+ assert.equal(f.subscriptions.size, 2)
56
57
  f.emit()
57
58
  assert.equal(f.requests.length, 1)
58
59
  const request = f.requests[0]
@@ -74,10 +75,11 @@ test('Given 旧代激活 When 新代 apply Then 仅保留新代订阅且旧代
74
75
  const current = f.instantiate()
75
76
  current.apply()
76
77
  assert.equal(f.requests[0].options.signal.aborted, true)
77
- assert.equal(f.subscriptions.size, 1)
78
78
  const token = f.window['usage-panel:notify-poll']
79
79
  old.dispose()
80
80
  assert.equal(f.window['usage-panel:notify-poll'], token)
81
+ // 旧代卸载后仅存新代两条订阅(通知投影 + 数据失效广播桥)
82
+ assert.equal(f.subscriptions.size, 2)
81
83
  current.dispose()
82
84
  assert.equal(f.subscriptions.size, 0)
83
85
  })
@@ -20,8 +20,10 @@ function clientFixture({ storage = storageFixture(), indexedDB, abortSignalImpl
20
20
  const work = refresh(); pending.add(work); work.finally(() => pending.delete(work)); return work
21
21
  }, dispose() {} }),
22
22
  toast: { show: text => shown.push(text) },
23
- setupCommonTools: () => ({ subscribe(onMessage, { onReset }) {
24
- subscriptions.add(onReset); return () => subscriptions.delete(onReset)
23
+ setupCommonTools: () => ({ subscribe(onMessage, { onReset } = {}) {
24
+ // F31 数据失效桥订阅无 onReset:不入 poll 触发集
25
+ if (typeof onReset === 'function') subscriptions.add(onReset)
26
+ return () => subscriptions.delete(onReset)
25
27
  } }),
26
28
  }
27
29
  const fetch = async () => ({ ok: true, json: async () => payload })
@@ -489,13 +489,13 @@ test('变更发布: 事件 push 进入投影即发布总线变更', async () =>
489
489
  apply(ctx)
490
490
  // When 查询产生越阈事件入投影
491
491
  await produceNotifyEvent(routes)
492
- // Then 快照携带事件,总线收到发布
492
+ // Then 快照携带事件,总线收到发布;账号/历史持久化各广播一次数据失效(F31)
493
493
  const { req, res } = notifyRequest('')
494
494
  await routes.get('/api/usage-panel/notifications')(req, res)
495
495
  assert.equal(res.status, 200)
496
496
  assert.equal(res.payload.units.length, 2)
497
497
  assert.ok(res.payload.version > 0)
498
- assert.deepEqual(published, [null, null], '每条事件入投影各发布一次')
498
+ assert.equal(published.length >= 2, true, '两条事件入投影各发布一次')
499
499
  })
500
500
 
501
501
  test('快照: 投影已有事件时返回全量', async () => {