@mzzsfy/dsh-usage-panel 0.7.1 → 0.8.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.
package/README.md CHANGED
@@ -130,3 +130,7 @@ node scripts/dev-link.mjs dsh-usage-panel # 仓库根执行:归一 profile 依
130
130
  ```
131
131
 
132
132
  工作副本以 junction 挂进 profile,改代码保存即热重载(host 半区)或刷新页面生效(client 半区),无需发版;规约与全仓归一见仓库根 `node scripts/dev-link.mjs all`。
133
+
134
+ ## dsh 版本兼容
135
+
136
+ 三版本(0.1.2-rc.1 / 0.1.5-rc.3 / 0.1.7-rc.1)全部通过:用量面板渲染(账号卡/通知规则折叠卡全控件)、API 面(pollArmed/通知投影)、激活 live。
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.7.1",
4
+ "version": "0.8.1",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
package/src/client.js CHANGED
@@ -565,6 +565,9 @@ function AccountCard(props) {
565
565
  const armed = props.deleteArmed === true
566
566
  const reading = account.last && account.last.ok ? account.last.reading : null
567
567
  const level = reading && (reading.level || reading.membership) ? levelLabel(reading.level || reading.membership) : null
568
+ const queriedAt = account.last && typeof account.last.queriedAt === 'number' && Number.isFinite(account.last.queriedAt)
569
+ ? account.last.queriedAt
570
+ : null
568
571
 
569
572
  return h('div', { className: 'up-card' },
570
573
  h('div', { className: 'up-card__row' },
@@ -572,6 +575,7 @@ function AccountCard(props) {
572
575
  h('span', { className: 'up-badge', 'data-type': account.type }, TYPE_LABELS[account.type] || account.type),
573
576
  level ? h('span', { className: 'up-badge', 'data-type': 'custom' }, level) : null,
574
577
  h('span', { className: 'up-spacer' }),
578
+ queriedAt !== null ? h('span', { className: 'up-meta', title: fmtTimeFull(queriedAt) }, '上次更新 ' + fmtTime(queriedAt)) : null,
575
579
  h('button', { className: 'up-btn', disabled: busy, onClick: props.onRefresh }, busy ? '查询中…' : '刷新'),
576
580
  h('button', { className: 'up-btn', disabled: busy, onClick: props.onEdit }, '编辑'),
577
581
  h('button', {
package/src/index.js CHANGED
@@ -66,6 +66,21 @@ const SETTINGS_SCHEMA = z.object({
66
66
  }).default({}),
67
67
  })
68
68
 
69
+ // 0.1.7 宿主以静态 Config 导出生成设置节表单(maintain 同构);legacy 宿主经
70
+ // settings.register 注册命名空间。根级 volatile 包装:整节 live 表单,节写经
71
+ // loader 原地热更;旧宿主 schemastery 无 volatile 方法,特性检测原样返回
72
+ export const Config = volatileWrap(SETTINGS_SCHEMA)
73
+
74
+ function volatileWrap(schema) {
75
+ return typeof schema.volatile === 'function' ? schema.volatile() : schema
76
+ }
77
+
78
+ // volatile ref 动态解包(get 协议):0.1.7 下 apply 入参 config 为整节单 ref;
79
+ // legacy 宿主 config 为普通对象原样透传
80
+ function unwrapVolatile(value) {
81
+ return typeof value?.get === 'function' ? value.get() : value
82
+ }
83
+
69
84
  const TYPE_DEEPSEEK = 'deepseek'
70
85
  const TYPE_OPENROUTER = 'openrouter'
71
86
  const TYPE_KIMI = 'kimi'
@@ -269,7 +284,7 @@ function normalizeAccounts(input) {
269
284
  ? lastSource.reading
270
285
  : null,
271
286
  error: typeof lastSource.error === 'string' ? lastSource.error : null,
272
- queriedAt: Number.isFinite(Number(lastSource.queriedAt)) ? Number(lastSource.queriedAt) : null,
287
+ queriedAt: typeof lastSource.queriedAt === 'number' && Number.isFinite(lastSource.queriedAt) ? lastSource.queriedAt : null,
273
288
  }
274
289
  : null
275
290
  const id = typeof raw.id === 'string' && raw.id.length > 0 ? raw.id : 'acct-' + String(index)
@@ -326,7 +341,7 @@ async function queryAccount(account) {
326
341
  // timer 为软依赖:不进 inject 声明,服务缺失时仅停用自动轮询,面板手动查询不受影响
327
342
  export const inject = ['webServer']
328
343
 
329
- export function apply(ctx) {
344
+ export function apply(ctx, configRef) {
330
345
  let config = null
331
346
  let loadPromise = null
332
347
  let writeChain = Promise.resolve()
@@ -340,7 +355,9 @@ export function apply(ctx) {
340
355
 
341
356
  function readNotifySettings() {
342
357
  const settings = ctx.get('settings')
343
- const value = settings ? settings.get(NAMESPACE) : undefined
358
+ // 双形读(maintain 同构):legacy settings 命名空间 get;0.1.7+ apply 入参
359
+ // config 整节解包。方法面守卫兼防:服务在但缺 get 时回落默认值,防路由 handler 抛错
360
+ const value = settings && typeof settings.get === 'function' ? settings.get(NAMESPACE) : unwrapVolatile(configRef)
344
361
  return resolvedNotifySettings(value && typeof value === 'object' ? value.notify : undefined)
345
362
  }
346
363
 
@@ -630,11 +647,21 @@ export function apply(ctx) {
630
647
  const body = JSON.parse(await readBody(req))
631
648
  const check = validateNotifyPatch(body)
632
649
  requireOk(check.ok, check.reason)
633
- // 读出当前值做浅合并后整体写回,不依赖 settings.update 的嵌套合并语义
650
+ // 读出当前值做浅合并后整体写回,不依赖宿主写入的嵌套合并语义
634
651
  const merged = { ...readNotifySettings(), ...check.patch }
652
+ // 双形写(maintain 同构,判定延迟到使用点):legacy 方法面以 register 为准
653
+ // (0.1.7 的 settings.update 名义在场但按命名空间找条目必败,不可作判据);
654
+ // 0.1.7+ 走 configEditor.edit 落 profile 条目(fiber.entry,与命名空间解耦)
635
655
  const settings = ctx.get('settings')
636
656
  requireOk(settings !== undefined, 'settings 服务不可用')
637
- await settings.update(NAMESPACE, { notify: merged })
657
+ if (typeof settings.register === 'function' && typeof settings.update === 'function') {
658
+ await settings.update(NAMESPACE, { notify: merged })
659
+ } else {
660
+ const editor = ctx.get('configEditor')
661
+ const entry = ctx.fiber?.entry
662
+ requireOk(editor && typeof editor.edit === 'function' && entry, 'configEditor 服务不可用')
663
+ await editor.edit(entry, (current) => ({ ...current, notify: merged }))
664
+ }
638
665
  sendJson(res, 200, { ok: true, notify: publicNotify(readNotifySettings()) })
639
666
  }),
640
667
  }),
@@ -765,6 +792,14 @@ export function apply(ctx) {
765
792
  })
766
793
 
767
794
  ctx.inject(['settings'], (settingsCtx) => {
795
+ // 方法面守卫:settings 服务缺 register 面(0.1.7 已移除)即走新形态分支;
796
+ // 0.1.7 关闭原生自动设置页,设置面由本包自带分区承担(maintain 同构)
797
+ if (typeof settingsCtx.settings.register !== 'function') {
798
+ if (typeof settingsCtx.settings?.configure === 'function') {
799
+ settingsCtx.effect(() => settingsCtx.settings.configure({ auto: false }, ctx.fiber))
800
+ }
801
+ return
802
+ }
768
803
  settingsCtx.settings.register(NAMESPACE, SETTINGS_SCHEMA)
769
804
  })
770
805
  }
package/src/notify.mjs CHANGED
@@ -6,6 +6,9 @@ export const PROJECTION_CAPACITY = 20
6
6
  export const PROJECTION_TTL_MS = 60 * 1000
7
7
  export const CLAIM_LOCK_TTL_MS = 30 * 1000
8
8
  export const WEBHOOK_TIMEOUT_MS = 10 * 1000
9
+ // 轮转判定容差:远小于最短真实窗口量级,远大于服务端毫秒级抖动;
10
+ // 容差内视为同窗口静默跟随
11
+ export const RESET_DRIFT_TOLERANCE_MS = 60 * 1000
9
12
 
10
13
  export const KIND_QUOTA = 'quota'
11
14
  export const KIND_BALANCE = 'balance'
@@ -63,6 +66,13 @@ export function normalizeNotifyState(raw) {
63
66
 
64
67
  const PERCENT_BASE = 100
65
68
 
69
+ // 窗口轮转判定:resetsAt 数值差超容差才算轮转;parse 失败无法证伪,保守按轮转。
70
+ // 容差内静默跟随,峰值与武装状态不重建
71
+ function windowRotated(prevResetsAt, resetsAt) {
72
+ const driftMs = Math.abs(Date.parse(resetsAt) - Date.parse(prevResetsAt))
73
+ return !(Number.isFinite(driftMs) && driftMs < RESET_DRIFT_TOLERANCE_MS)
74
+ }
75
+
66
76
  // 沿触发评估:刷新读数越过逻辑点(阈值穿越/窗口重置)时产出事件,返回新状态不改入参。
67
77
  // 仅在 last.ok 时由 host 调用;reading 缺失按无读数处理。
68
78
  export function evaluateAccount({ account, rule, state, seq, ts }) {
@@ -88,8 +98,9 @@ function evaluateQuota({ account, rule, state, reading, events, next }) {
88
98
  const prev = state.windows[label] || null
89
99
  let armed = prev === null ? true : prev.armed !== false
90
100
  let peak = prev === null ? null : prev.peak
91
- // 窗口轮转:两侧 resetsAt 均有效且值不同;上一窗口峰值随 reset 事件上报
92
- if (prev !== null && resetsAt !== null && prev.resetsAt !== null && prev.resetsAt !== resetsAt) {
101
+ // 窗口轮转:两侧 resetsAt 均有效、字符串不等且数值差超容差;上一窗口峰值随 reset 事件上报
102
+ if (prev !== null && resetsAt !== null && prev.resetsAt !== null && prev.resetsAt !== resetsAt
103
+ && windowRotated(prev.resetsAt, resetsAt)) {
93
104
  if (rule.resetNotice !== false && prev.peak !== null) {
94
105
  events.push({
95
106
  kind: KIND_RESET,
@@ -446,3 +446,105 @@ test('长轮询: cursor 落后当前版本时立即返回不挂起', async () =>
446
446
  assert.equal(res.payload.units.length, 2)
447
447
  assert.ok(res.payload.version > 0)
448
448
  })
449
+
450
+ // --- 0.1.7 settings 面双形适配(maintain a913c2c 同构) ---
451
+
452
+ test('Config 导出契约:根级 volatile 包装,validate 产整节单 ref,默认值对拍', async () => {
453
+ const mod = await import('../src/index.js')
454
+ assert.ok(mod.Config, 'Config 导出必须在场')
455
+ const resolved = mod.Config['~standard'].validate({})
456
+ assert.equal(resolved.issues, undefined)
457
+ const section = resolved.value.get()
458
+ assert.equal(section.notify.enabled, false)
459
+ assert.equal(section.notify.toast, true)
460
+ assert.equal(section.notify.imTargets.length, 0)
461
+ })
462
+
463
+ // 0.1.7 形态装配:settings 无 register/get(宿主已移除),configRef 为整节单 ref 桩
464
+ function makeRc17Ctx({ configStore = {}, configEditorAvailable = true } = {}) {
465
+ const routes = new Map()
466
+ const editCalls = []
467
+ const configureCalls = []
468
+ const disposers = []
469
+ const configRef = { get: () => ({ ...configStore }) }
470
+ const settingsService = { configure: (presentation, owner) => { configureCalls.push({ presentation, owner }); return () => {} } }
471
+ const configEditor = {
472
+ async edit(entry, change) {
473
+ editCalls.push(entry)
474
+ const next = change({ ...configStore }, {})
475
+ for (const key of Object.keys(next)) configStore[key] = next[key]
476
+ },
477
+ }
478
+
479
+ const ctx = {
480
+ get(name) {
481
+ if (name === 'settings') return settingsService
482
+ if (name === 'configEditor') return configEditorAvailable ? configEditor : undefined
483
+ return undefined
484
+ },
485
+ fiber: { entry: { options: { id: 'usage-panel', name: '@mzzsfy/dsh-usage-panel' } } },
486
+ effect(fn) { fn() },
487
+ inject(_deps, fn) {
488
+ fn({
489
+ settings: settingsService,
490
+ interval: () => {},
491
+ effect(stubEffect) {
492
+ const disposer = stubEffect()
493
+ if (typeof disposer === 'function') disposers.push(disposer)
494
+ },
495
+ })
496
+ },
497
+ webServer: {
498
+ register(route) {
499
+ routes.set(route.path, route.handler)
500
+ return () => routes.delete(route.path)
501
+ },
502
+ },
503
+ }
504
+ return { ctx, routes, configRef, configStore, editCalls, configureCalls, disposers }
505
+ }
506
+
507
+ test('0.1.7 形态:notify-config GET 读 configRef 节值,POST 经 configEditor.edit 落 fiber.entry', async () => {
508
+ // Given configRef 携带用户值 + configEditor 桩
509
+ const { ctx, routes, configRef, configStore, editCalls } = makeRc17Ctx({
510
+ configStore: { notify: { enabled: true, quotaThresholdPct: 88 } },
511
+ })
512
+ apply(ctx, configRef)
513
+ // When GET notify-config
514
+ const read = await call(routes, '/api/usage-panel/notify-config', makeReq('GET'))
515
+ // Then 返回 configRef 节值
516
+ assert.equal(read.status, 200)
517
+ assert.equal(read.payload.notify.enabled, true)
518
+ assert.equal(read.payload.notify.quotaThresholdPct, 88)
519
+ // When POST 部分补丁
520
+ const saved = await call(routes, '/api/usage-panel/notify-config', makeReq('POST', { enabled: true, balanceThreshold: 12 }))
521
+ // Then 整节合并落本条目
522
+ assert.equal(saved.status, 200)
523
+ assert.equal(saved.payload.ok, true)
524
+ assert.equal(editCalls.length, 1)
525
+ assert.equal(editCalls[0].options.id, 'usage-panel')
526
+ assert.equal(configStore.notify.enabled, true)
527
+ assert.equal(configStore.notify.balanceThreshold, 12)
528
+ assert.equal(configStore.notify.quotaThresholdPct, 88, '未提交字段整节保留')
529
+ })
530
+
531
+ test('0.1.7 形态:configEditor 缺失即 400 拒写,不崩溃', async () => {
532
+ // Given configEditor 服务缺席
533
+ const { ctx, routes, configRef } = makeRc17Ctx({ configEditorAvailable: false })
534
+ apply(ctx, configRef)
535
+ // When POST 写配置
536
+ const denied = await call(routes, '/api/usage-panel/notify-config', makeReq('POST', { enabled: true }))
537
+ // Then 400 错误响应,进程存活(测试走完即证)
538
+ assert.equal(denied.status, 400)
539
+ assert.ok(denied.payload.error)
540
+ })
541
+
542
+ test('0.1.7 形态:settings.configure 在场即关闭原生自动页,register 缺失不抛错', () => {
543
+ // Given settingsForms 带 configure 无 register
544
+ const { ctx, configRef, configureCalls } = makeRc17Ctx()
545
+ // When apply
546
+ apply(ctx, configRef)
547
+ // Then 关闭自动页且 owner 为本插件 fiber
548
+ assert.equal(configureCalls.length, 1)
549
+ assert.deepEqual(configureCalls[0].presentation, { auto: false })
550
+ })
@@ -23,6 +23,7 @@ import {
23
23
  KIND_QUOTA,
24
24
  KIND_BALANCE,
25
25
  KIND_RESET,
26
+ RESET_DRIFT_TOLERANCE_MS,
26
27
  } from '../src/notify.mjs'
27
28
 
28
29
  test('规则合并: 账号覆盖键生效, 其余继承全局', () => {
@@ -106,6 +107,50 @@ test('窗口重置: resetsAt 轮转产生 reset 事件报上一窗口峰值并
106
107
  assert.equal(again.events[0].kind, KIND_QUOTA)
107
108
  })
108
109
 
110
+ test('窗口重置: 毫秒级服务端抖动视为同窗口不发事件且峰值保留', () => {
111
+ // Given 阈值 90, 首轮 utilization 40 以 ISO 时刻建立基线
112
+ const rule = { quotaThresholdPct: 90, balanceThreshold: null, resetNotice: true }
113
+ let state = createNotifyState()
114
+ state = evaluateAccount({ account: quotaAccount(quotaReading(40, '2026-10-17T09:10:04.999Z')), rule, state, seq: 1, ts: 1000 }).state
115
+ // When 次轮 resetsAt 仅漂移 1ms(智谱 TIME_LIMIT 实测抖动), utilization 回落
116
+ const next = evaluateAccount({ account: quotaAccount(quotaReading(30, '2026-10-17T09:10:04.998Z')), rule, state, seq: 2, ts: 2000 })
117
+ // Then 无事件, 峰值保留 40 不误报, 基线静默跟随新值
118
+ assert.equal(next.events.length, 0)
119
+ assert.equal(next.state.windows['5小时'].peak, 40)
120
+ assert.equal(next.state.windows['5小时'].resetsAt, '2026-10-17T09:10:04.998Z')
121
+ })
122
+
123
+ test('窗口重置: 超容差的 resetsAt 变化仍判定为轮转', () => {
124
+ // Given 阈值 90, 首轮 92 建立基线
125
+ const rule = { quotaThresholdPct: 90, balanceThreshold: null, resetNotice: true }
126
+ let state = createNotifyState()
127
+ state = evaluateAccount({ account: quotaAccount(quotaReading(92, '2026-10-17T09:10:04.999Z')), rule, state, seq: 1, ts: 1000 }).state
128
+ // When resetsAt 前移 1 小时(真实轮转)
129
+ const next = evaluateAccount({ account: quotaAccount(quotaReading(5, '2026-10-17T10:10:04.999Z')), rule, state, seq: 2, ts: 2000 })
130
+ // Then 产生 reset 事件报上一窗口峰值 92
131
+ assert.equal(next.events.length, 1)
132
+ assert.equal(next.events[0].kind, KIND_RESET)
133
+ assert.equal(next.events[0].text, '[dsh] 账号A 5小时窗口已重置,上一窗口峰值用量 92%')
134
+ })
135
+
136
+ test('窗口重置: 漂移恰好容差内静默, 达到容差即轮转', () => {
137
+ // Given 阈值 90, 基线 resetsAt 为固定时刻
138
+ const rule = { quotaThresholdPct: 90, balanceThreshold: null, resetNotice: true }
139
+ const baseMs = Date.parse('2026-10-17T09:10:04.999Z')
140
+ const isoOf = (ms) => new Date(ms).toISOString()
141
+ const baselineState = evaluateAccount({ account: quotaAccount(quotaReading(40, isoOf(baseMs))), rule, state: createNotifyState(), seq: 1, ts: 1000 }).state
142
+ // When 漂移为容差减 1ms(容差内)
143
+ const inside = evaluateAccount({ account: quotaAccount(quotaReading(40, isoOf(baseMs + RESET_DRIFT_TOLERANCE_MS - 1))), rule, state: baselineState, seq: 2, ts: 2000 })
144
+ // Then 无事件, 基线跟随
145
+ assert.equal(inside.events.length, 0)
146
+ assert.equal(inside.state.windows['5小时'].resetsAt, isoOf(baseMs + RESET_DRIFT_TOLERANCE_MS - 1))
147
+ // When 漂移恰好等于容差(边界,严格小于判定不吸收,独立从原始基线评估)
148
+ const boundary = evaluateAccount({ account: quotaAccount(quotaReading(40, isoOf(baseMs + RESET_DRIFT_TOLERANCE_MS))), rule, state: baselineState, seq: 3, ts: 3000 })
149
+ // Then 判定轮转产生 reset 事件
150
+ assert.equal(boundary.events.length, 1)
151
+ assert.equal(boundary.events[0].kind, KIND_RESET)
152
+ })
153
+
109
154
  test('窗口重置: resetNotice 关闭时不产生 reset 事件但仍重建基线并重新武装', () => {
110
155
  // Given 阈值 90 已触发, 重置通知关闭
111
156
  const rule = { quotaThresholdPct: 90, balanceThreshold: null, resetNotice: false }