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/README.md +173 -7
- package/lib/client.js +858 -95
- package/lib/index.js +170 -10
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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)
|
|
298
|
-
|
|
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.
|
|
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",
|