@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 +1 -1
- package/src/client.js +43 -1
- package/src/index.js +74 -25
- package/test/client-bus-lifecycle.test.mjs +4 -2
- package/test/notify-poll.test.mjs +4 -2
- package/test/notify.route.test.mjs +2 -2
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
|
+
"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
|
-
|
|
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 注入(测试隔离)
|
|
47
|
-
const DATA_DIR = process.env.DSH_USAGE_PANEL_DATA_DIR
|
|
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
|
-
//
|
|
369
|
+
// 通知配置串行写链:读改写整体追加进链,合并读的总是前序写完成后的最新值;
|
|
370
|
+
// 任一失败不吞链(换行续链,失败只回报本次请求)
|
|
369
371
|
let notifyConfigWriteChain = Promise.resolve()
|
|
370
372
|
|
|
371
373
|
function persistNotifyPatch(patch) {
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
671
|
-
//
|
|
672
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
498
|
+
assert.equal(published.length >= 2, true, '两条事件入投影各发布一次')
|
|
499
499
|
})
|
|
500
500
|
|
|
501
501
|
test('快照: 投影已有事件时返回全量', async () => {
|