dsh-consumption-badge 1.2.2 → 1.3.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/lib/index.js CHANGED
@@ -27,6 +27,41 @@ let balanceCache = { at: 0, value: null }
27
27
  /** dsh-plugin-personalization 是否同时安装(null = 尚未探测)。 */
28
28
  let personalizationProbe = null
29
29
 
30
+ /**
31
+ * 可选显示币种(报价表本身始终是人民币,浏览器半边只做展示换算)。
32
+ * 顺序即设置页下拉里的顺序。
33
+ */
34
+ const CURRENCY_CODES = ['USD', 'EUR', 'GBP', 'JPY', 'HKD', 'TWD', 'KRW', 'SGD', 'AUD', 'CAD', 'CHF', 'INR', 'RUB', 'BRL', 'THB', 'MXN']
35
+
36
+ /**
37
+ * 内置兜底汇率(1 CNY = n 目标币种),取自 2026-11-02 的 open.er-api.com。
38
+ * 只在取不到实时汇率(离线/被墙/接口改版)时使用,返回值里会标 `stale: true`。
39
+ */
40
+ const FALLBACK_RATES = {
41
+ USD: 0.150131,
42
+ EUR: 0.134567,
43
+ GBP: 0.112169,
44
+ JPY: 23.670366,
45
+ HKD: 1.177898,
46
+ TWD: 4.889976,
47
+ KRW: 200.722601,
48
+ SGD: 0.191923,
49
+ AUD: 0.213732,
50
+ CAD: 0.2098,
51
+ CHF: 0.123863,
52
+ INR: 14.290818,
53
+ RUB: 12.180268,
54
+ BRL: 0.807624,
55
+ THB: 5.054541,
56
+ MXN: 2.816108,
57
+ }
58
+ const RATES_TTL_MS = 6 * 60 * 60 * 1000
59
+ const RATES_SOURCES = [
60
+ 'https://open.er-api.com/v6/latest/CNY',
61
+ 'https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/cny.json',
62
+ ]
63
+ let ratesCache = { at: 0, value: null }
64
+
30
65
  /**
31
66
  * 探测 dsh-plugin-personalization 是否与本插件共存于同一 profile 的 node_modules:
32
67
  * 只 import 它那个空的 host 入口(不调用 apply),失败即视为未安装。
@@ -69,12 +104,15 @@ function sanitizeKey(value) {
69
104
 
70
105
  /**
71
106
  * 折叠会话日志:assistant/message 上的 usage 按 turn 归并,轮次上下文取当时生效的路由。
107
+ * 同一遍扫描里再按 `message.id` 留一份**逐条消息**记录 —— 徽章要显示「这条回复自己」的用量,
108
+ * 而 turn 会合并同轮多步(多步一问一答时两者不相等)。
72
109
  * @param events 会话事件数组(SessionEvent[])。
73
- * @returns 按 turn 升序的逐轮记录。
110
+ * @returns {{turns: object[], messages: object[]}} turns 按 turn 升序;messages 按日志顺序。
74
111
  */
75
- function foldTurns(events) {
112
+ function foldEvents(events) {
76
113
  const byTurn = new Map()
77
114
  const starts = new Map()
115
+ const messages = []
78
116
  let model = ''
79
117
  let provider = ''
80
118
  for (let i = 0; i < events.length; i += 1) {
@@ -119,6 +157,19 @@ function foldTurns(events) {
119
157
  rec.out += num(u.outputTokens)
120
158
  rec.steps += 1
121
159
  if (!rec.model && model) rec.model = model
160
+ // 逐条消息记录:id 来自 assistant/message 的 message.id(= 槽位给的 messageId)。
161
+ const message = d.message
162
+ const id = message && typeof message.id === 'string' ? message.id : ''
163
+ messages.push({
164
+ id: id,
165
+ turn: turn,
166
+ step: typeof d.step === 'number' ? d.step : 0,
167
+ hit: num(u.cacheReadTokens),
168
+ miss: num(u.inputTokens) + num(u.cacheWriteTokens),
169
+ out: num(u.outputTokens),
170
+ time: 0,
171
+ model: model || '',
172
+ })
122
173
  }
123
174
  const keys = []
124
175
  byTurn.forEach(function (_v, k) { keys.push(k) })
@@ -130,7 +181,12 @@ function foldTurns(events) {
130
181
  rec.time = typeof t0 === 'number' ? t0 : 0
131
182
  turns.push(rec)
132
183
  }
133
- return turns
184
+ // 时间统一按「该轮请求开始的时刻」归属峰谷(与 turns 同口径)。
185
+ for (let i = 0; i < messages.length; i += 1) {
186
+ const t0 = starts.get(messages[i].turn)
187
+ messages[i].time = typeof t0 === 'number' ? t0 : 0
188
+ }
189
+ return { turns: turns, messages: messages }
134
190
  }
135
191
 
136
192
  /** 归一化 /user/balance 响应。 */
@@ -290,18 +346,22 @@ export function apply(ctx) {
290
346
 
291
347
  /** 逐轮用量(带 length 版本缓存,日志没变就不重折)。 */
292
348
  async function handleTurns(sessionId) {
293
- if (!sessionId) return { ok: false, code: 'missing-session-id', error: 'missing sessionId', turns: [] }
349
+ if (!sessionId) return { ok: false, code: 'missing-session-id', error: 'missing sessionId', turns: [], messages: [] }
294
350
  const events = await readEvents(sessionId)
295
- if (events === null) return { ok: false, code: 'log-unavailable', error: 'session log unavailable', turns: [] }
351
+ if (events === null) return { ok: false, code: 'log-unavailable', error: 'session log unavailable', turns: [], messages: [] }
296
352
  const cached = turnCache.get(sessionId)
297
- if (cached && cached.length === events.length) return { ok: true, cached: true, turns: cached.turns }
298
- const turns = foldTurns(events)
353
+ if (cached && cached.length === events.length) {
354
+ return { ok: true, cached: true, turns: cached.turns, messages: cached.messages }
355
+ }
356
+ const folded = foldEvents(events)
357
+ const turns = folded.turns
358
+ const messages = folded.messages
299
359
  if (turnCache.size >= TURN_CACHE_LIMIT && !turnCache.has(sessionId)) {
300
360
  const firstKey = turnCache.keys().next()
301
361
  if (!firstKey.done) turnCache.delete(firstKey.value)
302
362
  }
303
- turnCache.set(sessionId, { length: events.length, turns: turns })
304
- return { ok: true, cached: false, turns: turns }
363
+ turnCache.set(sessionId, { length: events.length, turns: turns, messages: messages })
364
+ return { ok: true, cached: false, turns: turns, messages: messages }
305
365
  }
306
366
 
307
367
  /** 当前模型(计价用),读不到就让浏览器回退 flash。 */
@@ -338,6 +398,93 @@ export function apply(ctx) {
338
398
  return out
339
399
  }
340
400
 
401
+ /**
402
+ * 取一次实时汇率(1 CNY = n 目标币种)。只取 CURRENCY_CODES 里那几种,避免整包塞给浏览器。
403
+ * 通道与本插件的余额请求一致(curl),另用全局 fetch 作为第二通道;
404
+ * 两个源都失败时回内置兜底表并标 `stale: true`(界面会说明这是兜底汇率)。
405
+ * @returns {{rates: object, source: string, fetchedAt: number, stale: boolean, error: string}}
406
+ */
407
+ async function fetchRates() {
408
+ const subprocess = ctx.get('subprocess')
409
+ let lastError = ''
410
+ for (let i = 0; i < RATES_SOURCES.length; i += 1) {
411
+ const url = RATES_SOURCES[i]
412
+ let text = ''
413
+ if (subprocess && typeof subprocess.spawn === 'function') {
414
+ try {
415
+ const handle = subprocess.spawn({
416
+ argv: ['curl', '-sS', '--max-time', '12', url],
417
+ cwd: process.cwd(),
418
+ stdio: { stdin: { data: '' }, stdout: { maxBytes: 262144 }, stderr: { maxBytes: 4096 } },
419
+ graceMs: 3000,
420
+ })
421
+ await handle.done
422
+ const collected = handle.collected || {}
423
+ const outRead = collected.stdout ? collected.stdout.readFrom(0) : null
424
+ text = outRead && typeof outRead.text === 'string' ? outRead.text.trim() : ''
425
+ } catch (e) {
426
+ lastError = errText(e)
427
+ text = ''
428
+ }
429
+ }
430
+ if (!text && typeof fetch === 'function') {
431
+ try {
432
+ const response = await fetch(url)
433
+ if (response && response.ok !== false) text = String(await response.text()).trim()
434
+ } catch (e) {
435
+ lastError = errText(e)
436
+ text = ''
437
+ }
438
+ }
439
+ if (!text) continue
440
+ let json = null
441
+ try {
442
+ json = JSON.parse(text)
443
+ } catch (e) {
444
+ lastError = 'bad rates payload'
445
+ continue
446
+ }
447
+ if (!json || typeof json !== 'object') continue
448
+ const raw = json.rates && typeof json.rates === 'object'
449
+ ? json.rates
450
+ : (json.cny && typeof json.cny === 'object' ? json.cny : null)
451
+ if (!raw) continue
452
+ const rates = {}
453
+ let found = 0
454
+ for (let k = 0; k < CURRENCY_CODES.length; k += 1) {
455
+ const code = CURRENCY_CODES[k]
456
+ const v = typeof raw[code] === 'number' ? raw[code] : raw[code.toLowerCase()]
457
+ if (typeof v === 'number' && Number.isFinite(v) && v > 0) {
458
+ rates[code] = v
459
+ found += 1
460
+ }
461
+ }
462
+ if (found === 0) continue
463
+ const stamp = typeof json.time_last_update_unix === 'number'
464
+ ? json.time_last_update_unix * 1000
465
+ : (typeof json.date === 'string' ? Date.parse(json.date) : 0)
466
+ return {
467
+ rates: rates,
468
+ source: url.indexOf('er-api') >= 0 ? 'open.er-api.com' : 'currency-api',
469
+ fetchedAt: Number.isFinite(stamp) && stamp > 0 ? stamp : 0,
470
+ stale: false,
471
+ error: '',
472
+ }
473
+ }
474
+ return { rates: FALLBACK_RATES, source: 'builtin', fetchedAt: 0, stale: true, error: lastError || 'live rates unavailable' }
475
+ }
476
+
477
+ /** 汇率入口:进程内缓存 6h,`?force=1` 强制刷新。 */
478
+ async function handleRates(force) {
479
+ const now = Date.now()
480
+ if (!force && ratesCache.value && now - ratesCache.at < RATES_TTL_MS) {
481
+ return Object.assign({ ok: true, base: 'CNY', codes: CURRENCY_CODES, cached: true }, ratesCache.value)
482
+ }
483
+ const fetched = await fetchRates()
484
+ ratesCache = { at: Date.now(), value: fetched }
485
+ return Object.assign({ ok: true, base: 'CNY', codes: CURRENCY_CODES, cached: false }, fetched)
486
+ }
487
+
341
488
  disposers.push(ctx.webServer.register({
342
489
  kind: 'exact',
343
490
  path: ROUTE_BASE + '/turns',
@@ -364,6 +511,19 @@ export function apply(ctx) {
364
511
  },
365
512
  }))
366
513
 
514
+ disposers.push(ctx.webServer.register({
515
+ kind: 'exact',
516
+ path: ROUTE_BASE + '/rates',
517
+ handler: async function (req, res) {
518
+ try {
519
+ const url = new URL(req.url || '/', 'http://127.0.0.1')
520
+ sendJson(res, 200, await handleRates(url.searchParams.get('force') === '1'))
521
+ } catch (e) {
522
+ sendJson(res, 500, { ok: false, error: errText(e) })
523
+ }
524
+ },
525
+ }))
526
+
367
527
  disposers.push(ctx.webServer.register({
368
528
  kind: 'exact',
369
529
  path: ROUTE_BASE + '/balance',
@@ -395,4 +555,4 @@ export function apply(ctx) {
395
555
  disposers.length = 0
396
556
  }
397
557
  })
398
- }
558
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-consumption-badge",
3
- "version": "1.2.2",
3
+ "version": "1.3.2",
4
4
  "description": "DSH Web UI plugin: a per-message consumption badge (tokens | estimated cost | peak/valley) plus a session detail modal with cumulative tokens, estimated cost, Beijing-time peak/off-peak pricing, a per-turn dual-axis chart, and the DeepSeek account balance",
5
5
  "keywords": [
6
6
  "deepseek-harness",