dsh-desktop-statusbar 1.0.0

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 ADDED
@@ -0,0 +1,565 @@
1
+ /**
2
+ * dsh-desktop-statusbar — host 侧。
3
+ *
4
+ * 提供客户端拿不到的东西:
5
+ * 1. `sessionModel` 投影 — 最近一条 assistant 消息实际使用的 provider/model。
6
+ * 2. `sessionUsage` 投影 — 按模型聚合的 token 用量(总费用),外加 `last`
7
+ * (最近一条 assistant 消息的模型与用量,"本次费用"段用)。
8
+ * 3. `sessionTimeRange` 投影 — 活跃时长:累加每个 step 的墙钟区间。
9
+ * (sessionStats 的 llmMs/toolMs 是各次调用耗时之和,同一 step 内并行调用会
10
+ * 重复计入,因此可能大于墙钟时间;两者口径不同,不该相互比较。)
11
+ * 4. `/dsh-desktop-statusbar/api/balance` — DeepSeek 账户余额。
12
+ *
13
+ * 凭据来源按可靠性依次尝试:环境变量 DEEPSEEK_API_KEY → `<DSH_HOME>/.credentials.yaml`
14
+ * → `ctx.credentials`(本部署里 records 只有 client-connection 记录,故常为空)。
15
+ * key 只在本进程内存中用于调用官方余额接口,不落盘、不转发、不写日志。
16
+ *
17
+ * @module dsh-desktop-statusbar
18
+ */
19
+ import { z } from 'zod'
20
+ import { readFileSync } from 'node:fs'
21
+ import { join } from 'node:path'
22
+ import { homedir } from 'node:os'
23
+
24
+ export const name = 'dsh-desktop-statusbar'
25
+
26
+ export const inject = ['sessionProjections', 'webServer', 'credentials']
27
+
28
+ /** 投影 apply 拿不到 ctx,这里留一份引用给日志用(apply 时赋值)。 */
29
+ let ctxRef = null
30
+ let wireLogAt = -1
31
+
32
+ /* ------------------------------------------------------------------ 日志 */
33
+
34
+ function logHost(ctx, message) {
35
+ try {
36
+ if (ctx.logger !== undefined && ctx.logger !== null && typeof ctx.logger.info === 'function') {
37
+ ctx.logger.info('[dsh-desktop-statusbar] ' + message)
38
+ return
39
+ }
40
+ if (typeof ctx.logger === 'function') {
41
+ const scoped = ctx.logger('dsh-desktop-statusbar')
42
+ if (scoped !== undefined && scoped !== null && typeof scoped.info === 'function') {
43
+ scoped.info(message)
44
+ return
45
+ }
46
+ }
47
+ if (typeof process !== 'undefined' && process.stderr !== undefined && typeof process.stderr.write === 'function') {
48
+ process.stderr.write('[dsh-desktop-statusbar] ' + message + '\n')
49
+ }
50
+ } catch (error) {
51
+ /* 日志失败不影响功能 */
52
+ }
53
+ }
54
+
55
+ /* ------------------------------------------------------------ sessionModel */
56
+
57
+ const sessionModelSchema = z.object({
58
+ provider: z.string().nullable(),
59
+ model: z.string().nullable(),
60
+ updatedAt: z.number().nullable(),
61
+ }).strict()
62
+
63
+ const sessionModelProjection = {
64
+ key: 'desktopStatusbarModel',
65
+ stateSchema: sessionModelSchema,
66
+ init: () => ({ provider: null, model: null, updatedAt: null }),
67
+ apply: (state, event) => {
68
+ if (event.type !== 'assistant/message') return state
69
+ const source = event.data.message.source
70
+ if (source.kind !== 'model') return state
71
+ const { provider, model } = source
72
+ if (provider === state.provider && model === state.model) return state
73
+ return { provider, model, updatedAt: event.time }
74
+ },
75
+ wire: { viewSchema: sessionModelSchema, view: (state) => state },
76
+ stateVersion: 1,
77
+ }
78
+
79
+ /* ------------------------------------------------------------ sessionUsage */
80
+
81
+ const usageBucket = z.object({
82
+ input: z.number(),
83
+ cacheRead: z.number(),
84
+ cacheWrite: z.number(),
85
+ output: z.number(),
86
+ }).strict()
87
+
88
+ /**
89
+ * 从一条 assistant 事件里取 usage —— 与官方 usageSampleOf 同口径:
90
+ * data.usage 只是兜底,stream 里 type==='usage' 的 chunk 才是权威值(后者会覆盖前者)。
91
+ */
92
+ function findUsageInStream(stream) {
93
+ if (stream === null || stream === undefined) return undefined
94
+ let found
95
+ const visit = (node) => {
96
+ if (node === null || node === undefined) return
97
+ if (Array.isArray(node)) {
98
+ for (const item of node) visit(item)
99
+ return
100
+ }
101
+ if (typeof node !== 'object') return
102
+ if (node.chunk !== null && node.chunk !== undefined && typeof node.chunk === 'object') {
103
+ if (node.chunk.type === 'usage' && node.chunk.usage !== undefined) found = node.chunk.usage
104
+ return
105
+ }
106
+ if (node.type === 'usage' && node.usage !== undefined) found = node.usage
107
+ }
108
+ visit(stream)
109
+ return found
110
+ }
111
+
112
+ function usageOf(event) {
113
+ if (event.type !== 'assistant/message' && event.type !== 'assistant/attempt') return undefined
114
+ const data = event.data === null || event.data === undefined ? {} : event.data
115
+ let usage = event.type === 'assistant/message' ? data.usage : undefined
116
+ const fromStream = findUsageInStream(data.stream)
117
+ if (fromStream !== undefined) usage = fromStream
118
+ return usage
119
+ }
120
+
121
+ /** 取数值字段,兼容几种命名;取不到返回 0。 */
122
+ const numOf = (source, names) => {
123
+ for (const name of names) {
124
+ const value = source[name]
125
+ if (typeof value === 'number' && Number.isFinite(value)) return value
126
+ }
127
+ return 0
128
+ }
129
+
130
+ /**
131
+ * 把内核给的 TokenUsage 归一成计价分量。
132
+ * inputTokens 是含缓存的总输入,未缓存部分要减掉 cacheRead / cacheWrite(与官方 tokenUsage 投影同口径)。
133
+ */
134
+ function usageParts(usage) {
135
+ const cacheRead = numOf(usage, ['cacheReadTokens', 'cachedReadTokens', 'cacheRead', 'cachedTokens'])
136
+ const cacheWrite = numOf(usage, ['cacheWriteTokens', 'cacheWrite', 'cacheCreationTokens'])
137
+ const totalInput = numOf(usage, ['inputTokens', 'promptTokens', 'input'])
138
+ const output = numOf(usage, ['outputTokens', 'completionTokens', 'output'])
139
+ return { input: Math.max(0, totalInput - cacheRead - cacheWrite), cacheRead, cacheWrite, output }
140
+ }
141
+
142
+ /** 一次模型调用的分量与发生时间,峰谷计价按它逐条判定。 */
143
+ const usageCall = z.object({
144
+ at: z.number(),
145
+ model: z.string(),
146
+ input: z.number(),
147
+ cacheRead: z.number(),
148
+ cacheWrite: z.number(),
149
+ output: z.number(),
150
+ }).strict()
151
+
152
+ const lastUsage = z.object({
153
+ provider: z.string(),
154
+ model: z.string(),
155
+ time: z.number(),
156
+ input: z.number(),
157
+ cacheRead: z.number(),
158
+ cacheWrite: z.number(),
159
+ output: z.number(),
160
+ calls: z.array(usageCall),
161
+ }).strict()
162
+
163
+ const MAX_CALLS = 4000
164
+ const appendCalls = (calls, more) =>
165
+ calls.length + more.length <= MAX_CALLS ? calls.concat(more) : calls.slice(calls.length + more.length - MAX_CALLS).concat(more)
166
+
167
+ const sessionUsageSchema = z.object({
168
+ models: z.record(z.string(), usageBucket),
169
+ calls: z.array(usageCall),
170
+ turn: z.number().nullable(),
171
+ current: lastUsage.nullable(),
172
+ last: lastUsage.nullable(),
173
+ }).strict()
174
+
175
+ const sessionUsageProjection = {
176
+ key: 'desktopStatusbarUsage',
177
+ stateSchema: sessionUsageSchema,
178
+ init: () => ({ models: {}, calls: [], current: null, last: null, turn: null }),
179
+ apply: (state, event) => {
180
+ const calls = Array.isArray(state.calls) ? state.calls : []
181
+ const turnOf = (event) => {
182
+ const value = event.data === null || event.data === undefined ? undefined : event.data.turn
183
+ return typeof value === 'number' ? value : null
184
+ }
185
+ const stateTurn = typeof state.turn === 'number' ? state.turn : null
186
+ // 新一轮:上一轮结算进 last,本轮清空(不依赖 turn/start 事件)
187
+ if (event.type === 'turn/start') {
188
+ const started = turnOf(event)
189
+ if (state.current === null) return { ...state, turn: started }
190
+ return { ...state, calls, current: null, last: state.current, turn: started }
191
+ }
192
+ const usage = usageOf(event)
193
+ if (usage === undefined || usage === null) return state
194
+ const source = event.data.message.source
195
+ if (source.kind !== 'model') return state
196
+ const model = source.model
197
+ const currentModel = state.models[model]
198
+ const parts = usageParts(usage)
199
+ const bucket = {
200
+ input: (currentModel === undefined ? 0 : currentModel.input) + parts.input,
201
+ cacheRead: (currentModel === undefined ? 0 : currentModel.cacheRead) + parts.cacheRead,
202
+ cacheWrite: (currentModel === undefined ? 0 : currentModel.cacheWrite) + parts.cacheWrite,
203
+ output: (currentModel === undefined ? 0 : currentModel.output) + parts.output,
204
+ }
205
+ const one = {
206
+ at: event.time,
207
+ model,
208
+ input: parts.input,
209
+ cacheRead: parts.cacheRead,
210
+ cacheWrite: parts.cacheWrite,
211
+ output: parts.output,
212
+ }
213
+ const turn = turnOf(event)
214
+ if (calls.length < 5) {
215
+ logHost(ctxRef, 'usage sample: turn=' + String(turn) + ' model=' + String(model)
216
+ + ' parts=' + JSON.stringify(parts)
217
+ + ' keys=' + (usage !== null && typeof usage === 'object' ? Object.keys(usage).join('|') : typeof usage))
218
+ }
219
+ // 轮次变化就重开本轮汇总:不能假设一定有 turn/start
220
+ const sameTurn = state.current !== null && Array.isArray(state.current.calls)
221
+ && (turn === null || stateTurn === null || turn === stateTurn)
222
+ const prev = sameTurn ? state.current : null
223
+ const callsForTurn = prev === null ? [] : prev.calls
224
+ const summary = {
225
+ provider: source.provider,
226
+ model,
227
+ time: event.time,
228
+ input: (prev === null ? 0 : prev.input) + one.input,
229
+ cacheRead: (prev === null ? 0 : prev.cacheRead) + one.cacheRead,
230
+ cacheWrite: (prev === null ? 0 : prev.cacheWrite) + one.cacheWrite,
231
+ output: (prev === null ? 0 : prev.output) + one.output,
232
+ }
233
+ const current = { ...summary, calls: callsForTurn.concat([one]) }
234
+ return {
235
+ ...state,
236
+ turn: turn === null ? stateTurn : turn,
237
+ calls: appendCalls(calls, [one]),
238
+ current,
239
+ // 轮次变化时把上一轮结算进 last
240
+ last: prev === null && state.current !== null ? state.current : state.last,
241
+ models: { ...state.models, [model]: bucket },
242
+ }
243
+ },
244
+ wire: {
245
+ viewSchema: sessionUsageSchema,
246
+ view: (state) => {
247
+ if (state !== null && state !== undefined) {
248
+ const calls = Array.isArray(state.calls) ? state.calls : []
249
+ // 只在条数变化时打一行,避免每次推送都刷日志
250
+ if (calls.length !== wireLogAt) {
251
+ wireLogAt = calls.length
252
+ const last = calls.length === 0 ? null : calls[calls.length - 1]
253
+ logHost(ctxRef, 'wire view: calls=' + String(calls.length)
254
+ + ' modelsAgg=' + JSON.stringify(state.models)
255
+ + ' lastCall=' + JSON.stringify(last)
256
+ + ' currentCalls=' + String(state.current === null || state.current === undefined || !Array.isArray(state.current.calls) ? 0 : state.current.calls.length))
257
+ }
258
+ }
259
+ return state
260
+ },
261
+ },
262
+ // schema 加了 current(本轮累计),提升版本以重建旧状态。
263
+ stateVersion: 3,
264
+ }
265
+
266
+ /* -------------------------------------------------------- sessionTimeRange */
267
+
268
+ const sessionTimeRangeSchema = z.object({
269
+ turns: z.number(),
270
+ since: z.number().nullable(),
271
+ steps: z.number(),
272
+ stepSince: z.number().nullable(),
273
+ }).strict()
274
+
275
+ /**
276
+ * 总用时 = 各轮墙钟时长之和,与官方"本轮总用时"同一口径。
277
+ * 一轮 = turn/start → turn/end,包含轮内各 step 之间的间隙(工具调用、排队等),
278
+ * 所以它必然大于等于各 step 时长之和。
279
+ * 另外单独累加一份 step 之和作兜底,以防某个内核不发 turn 事件。
280
+ */
281
+ const sessionTimeRangeProjection = {
282
+ key: 'desktopStatusbarActiveTime',
283
+ stateSchema: sessionTimeRangeSchema,
284
+ init: () => ({ turns: 0, since: null, steps: 0, stepSince: null }),
285
+ apply: (state, event) => {
286
+ if (event.type === 'turn/start') {
287
+ if (state.since !== null) return state
288
+ return { turns: state.turns, since: event.time, steps: state.steps, stepSince: state.stepSince }
289
+ }
290
+ if (event.type === 'turn/end') {
291
+ if (state.since === null) return state
292
+ return {
293
+ turns: state.turns + Math.max(0, event.time - state.since),
294
+ since: null,
295
+ steps: state.steps,
296
+ stepSince: state.stepSince,
297
+ }
298
+ }
299
+ if (event.type === 'step/start') {
300
+ if (state.stepSince !== null) return state
301
+ return { turns: state.turns, since: state.since, steps: state.steps, stepSince: event.time }
302
+ }
303
+ if (event.type === 'step/end') {
304
+ if (state.stepSince === null) return state
305
+ return {
306
+ turns: state.turns,
307
+ since: state.since,
308
+ steps: state.steps + Math.max(0, event.time - state.stepSince),
309
+ stepSince: null,
310
+ }
311
+ }
312
+ return state
313
+ },
314
+ wire: { viewSchema: sessionTimeRangeSchema, view: (state) => state },
315
+ // 字段变了(active → turns + steps),提升版本重建旧状态
316
+ stateVersion: 2,
317
+ }
318
+
319
+ /* ---------------------------------------------------------------- 余额查询 */
320
+
321
+ const BALANCE_TTL_MS = 60 * 1000
322
+ let balanceCache = { at: 0, value: null }
323
+
324
+ /** 最近一次由底栏上报的会话模型(设置页是全局槽,读不到会话投影,只能这么拿)。 */
325
+ let activeModel = { provider: null, model: null, at: 0 }
326
+
327
+ function sendJson(res, status, body) {
328
+ const text = JSON.stringify(body)
329
+ res.writeHead(status, {
330
+ 'content-type': 'application/json; charset=utf-8',
331
+ 'cache-control': 'no-store',
332
+ connection: 'close',
333
+ })
334
+ res.end(text)
335
+ }
336
+
337
+ /** 读一小段 JSON 请求体(只用于本插件自己的上报接口,超过 4KB 直接断开)。 */
338
+ function readJsonBody(req) {
339
+ return new Promise((resolve) => {
340
+ let raw = ''
341
+ let settled = false
342
+ const finish = (value) => {
343
+ if (settled) return
344
+ settled = true
345
+ resolve(value)
346
+ }
347
+ req.on('data', (chunk) => {
348
+ raw += chunk
349
+ if (raw.length > 4096) {
350
+ req.destroy()
351
+ finish(null)
352
+ }
353
+ })
354
+ req.on('end', () => {
355
+ try {
356
+ const parsed = JSON.parse(raw)
357
+ finish(parsed !== null && typeof parsed === 'object' ? parsed : null)
358
+ } catch (error) {
359
+ finish(null)
360
+ }
361
+ })
362
+ req.on('error', () => finish(null))
363
+ })
364
+ }
365
+
366
+ /** 环境变量是最高优先级的来源。 */
367
+ function keyFromEnv() {
368
+ const value = process.env.DEEPSEEK_API_KEY
369
+ return typeof value === 'string' && value.length > 10 ? value : null
370
+ }
371
+
372
+ /**
373
+ * `<DSH_HOME>/.credentials.yaml`。该文件的 records 在本部署里只有
374
+ * client-connection 记录,真正的 key 由 refs 段的 DEEPSEEK_API_KEY 引用,
375
+ * 因此直接按名字匹配取值,必要时退回 records 里的 secret。
376
+ */
377
+ function keyFromCredentialsFile() {
378
+ try {
379
+ const home = process.env.DSH_HOME !== undefined && process.env.DSH_HOME.length > 0
380
+ ? process.env.DSH_HOME
381
+ : join(homedir(), '.dsh')
382
+ const text = readFileSync(join(home, '.credentials.yaml'), 'utf8')
383
+ const patterns = [
384
+ /DEEPSEEK_API_KEY\s*:\s*["']?([^"'\s#]+)["']?/,
385
+ /SECRET\s*:\s*["']?([^"'\s#]+)["']?/i,
386
+ /secret\s*:\s*["']?([^"'\s#]+)["']?/,
387
+ ]
388
+ for (const pattern of patterns) {
389
+ const match = text.match(pattern)
390
+ if (match !== null && typeof match[1] === 'string' && match[1].length > 10) return match[1]
391
+ }
392
+ } catch (error) {
393
+ /* 文件缺失或不可读 */
394
+ }
395
+ return null
396
+ }
397
+
398
+ function keyFromCredentialStore(ctx) {
399
+ const store = ctx.credentials
400
+ if (store === undefined || store === null) return null
401
+ const ids = []
402
+ try {
403
+ for (const id of Object.keys(store)) ids.push(id)
404
+ } catch (error) {
405
+ /* ignore */
406
+ }
407
+ if (ids.length === 0) ids.push('deepseek-official', 'deepseek')
408
+ for (const id of ids) {
409
+ let credential
410
+ try {
411
+ credential = typeof store.get === 'function' ? store.get(id) : undefined
412
+ if (credential === undefined && typeof store.read === 'function') credential = store.read(id)
413
+ } catch (error) {
414
+ continue
415
+ }
416
+ if (credential === undefined || credential === null) continue
417
+ const payload = credential.payload !== undefined && credential.payload !== null ? credential.payload : credential
418
+ const found = payload.secret ?? payload.apiKey ?? payload.api_key ?? payload.key ?? payload.token
419
+ if (typeof found === 'string' && found.length > 10) return found
420
+ }
421
+ return null
422
+ }
423
+
424
+ function findApiKey(ctx) {
425
+ const fromEnv = keyFromEnv()
426
+ if (fromEnv !== null) return { key: fromEnv, source: 'env' }
427
+ const fromFile = keyFromCredentialsFile()
428
+ if (fromFile !== null) return { key: fromFile, source: 'credentials.yaml' }
429
+ const fromStore = keyFromCredentialStore(ctx)
430
+ if (fromStore !== null) return { key: fromStore, source: 'credentials-service' }
431
+ return { key: null, source: null }
432
+ }
433
+
434
+ async function queryBalance(ctx, force) {
435
+ const now = Date.now()
436
+ if (force !== true && balanceCache.value !== null && now - balanceCache.at < BALANCE_TTL_MS) {
437
+ return balanceCache.value
438
+ }
439
+
440
+ const found = findApiKey(ctx)
441
+ if (found.key === null) {
442
+ const missing = { ok: false, at: now, reason: 'no-credential' }
443
+ logHost(ctx, 'balance: 环境变量 / credentials.yaml / credentials 服务都没取到 key')
444
+ balanceCache = { at: now, value: missing }
445
+ return missing
446
+ }
447
+
448
+ logHost(ctx, 'balance: 使用来源 ' + String(found.source) + ' 的 key 查询余额')
449
+
450
+ try {
451
+ const response = await fetch('https://api.deepseek.com/user/balance', {
452
+ method: 'GET',
453
+ headers: { authorization: 'Bearer ' + found.key, accept: 'application/json' },
454
+ signal: AbortSignal.timeout(10000),
455
+ })
456
+ if (!response.ok) {
457
+ const failed = { ok: false, at: now, reason: 'http-' + String(response.status), source: found.source }
458
+ logHost(ctx, 'balance: HTTP ' + String(response.status))
459
+ balanceCache = { at: now, value: failed }
460
+ return failed
461
+ }
462
+ const data = await response.json()
463
+ const infos = Array.isArray(data.balance_infos) ? data.balance_infos : []
464
+ const picked = infos.filter((info) => info.currency === 'CNY')[0] ?? infos[0]
465
+ if (picked === undefined) {
466
+ const empty = { ok: false, at: now, reason: 'no-balance-info', source: found.source }
467
+ logHost(ctx, 'balance: 响应里没有 balance_infos')
468
+ balanceCache = { at: now, value: empty }
469
+ return empty
470
+ }
471
+ const value = {
472
+ ok: true,
473
+ at: now,
474
+ at: now,
475
+ source: found.source,
476
+ currency: picked.currency ?? 'CNY',
477
+ total: picked.total_balance ?? null,
478
+ granted: picked.granted_balance ?? null,
479
+ toppedUp: picked.topped_up_balance ?? null,
480
+ available: data.is_available === true,
481
+ }
482
+ logHost(ctx, 'balance: OK ' + String(value.total) + ' ' + String(value.currency))
483
+ balanceCache = { at: now, value }
484
+ return value
485
+ } catch (error) {
486
+ const detail = error !== null && error !== undefined && typeof error.message === 'string' ? error.message : 'request-failed'
487
+ const failed = { ok: false, at: now, reason: 'request-failed', detail, source: found.source }
488
+ logHost(ctx, 'balance: 请求异常 ' + detail)
489
+ balanceCache = { at: now, value: failed }
490
+ return failed
491
+ }
492
+ }
493
+
494
+ /* ------------------------------------------------------------------ apply */
495
+
496
+ export function apply(ctx) {
497
+ ctxRef = ctx
498
+ ctx.effect(
499
+ () => ctx.sessionProjections.register(sessionModelProjection),
500
+ 'dsh-desktop-statusbar: sessionModel projection',
501
+ )
502
+ ctx.effect(
503
+ () => ctx.sessionProjections.register(sessionUsageProjection),
504
+ 'dsh-desktop-statusbar: sessionUsage projection',
505
+ )
506
+ ctx.effect(
507
+ () => ctx.sessionProjections.register(sessionTimeRangeProjection),
508
+ 'dsh-desktop-statusbar: sessionTimeRange projection',
509
+ )
510
+
511
+ ctx.effect(() => ctx.webServer.register({
512
+ kind: 'prefix',
513
+ path: '/dsh-desktop-statusbar/api',
514
+ handler: async (req, res) => {
515
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1')
516
+ if (url.pathname === '/dsh-desktop-statusbar/api/prices') {
517
+ sendJson(res, 200, {
518
+ models: {
519
+ 'deepseek-flash': {
520
+ peak: { input: 2, cacheRead: 0.04, cacheWrite: 0, output: 8 },
521
+ offPeak: { input: 1, cacheRead: 0.02, cacheWrite: 0, output: 4 },
522
+ },
523
+ 'deepseek-v4-pro': {
524
+ peak: { input: 9, cacheRead: 0.3, cacheWrite: 0, output: 27 },
525
+ offPeak: { input: 4.5, cacheRead: 0.15, cacheWrite: 0, output: 13.5 },
526
+ },
527
+ },
528
+ note: 'CNY per 1M tokens, 高峰=北京时间周一至周五 9-12、14-18',
529
+ })
530
+ return
531
+ }
532
+ if (url.pathname === '/dsh-desktop-statusbar/api/active-model') {
533
+ if (req.method === 'POST') {
534
+ const body = await readJsonBody(req)
535
+ const model = body !== null && typeof body.model === 'string' ? body.model.trim() : ''
536
+ if (model.length === 0) {
537
+ sendJson(res, 400, { ok: false, reason: 'model-required' })
538
+ return
539
+ }
540
+ activeModel = {
541
+ provider: typeof body.provider === 'string' && body.provider.length > 0 ? body.provider : null,
542
+ model: model,
543
+ at: Date.now(),
544
+ }
545
+ sendJson(res, 200, { ok: true })
546
+ return
547
+ }
548
+ sendJson(res, 200, {
549
+ ok: true,
550
+ provider: activeModel.provider,
551
+ model: activeModel.model,
552
+ at: activeModel.at,
553
+ })
554
+ return
555
+ }
556
+ if (url.pathname !== '/dsh-desktop-statusbar/api/balance') {
557
+ sendJson(res, 404, { ok: false, reason: 'not-found' })
558
+ return
559
+ }
560
+ const force = url.searchParams.get('force') === '1'
561
+ const value = await queryBalance(ctx, force)
562
+ sendJson(res, 200, value)
563
+ },
564
+ }), 'dsh-desktop-statusbar: balance api')
565
+ }
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "dsh-desktop-statusbar",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "把 DSH 桌面端对话区底部的统计行换成可配置状态栏:10 个字段可选可排序,按官方峰谷口径估算费用,支持自定义模型单价与账户余额。",
6
+ "license": "PolyForm-Noncommercial-1.0.0",
7
+ "author": "北浮森屿 (https://github.com/Rapheal-Y7)",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/Rapheal-Y7/dsh-desktop-statusbar.git"
11
+ },
12
+ "homepage": "https://github.com/Rapheal-Y7/dsh-desktop-statusbar#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/Rapheal-Y7/dsh-desktop-statusbar/issues"
15
+ },
16
+ "keywords": [
17
+ "dsh",
18
+ "dsh-plugin",
19
+ "deepseek-harness",
20
+ "statusbar",
21
+ "token-usage",
22
+ "cost"
23
+ ],
24
+ "main": "./lib/index.js",
25
+ "exports": {
26
+ ".": {
27
+ "default": "./lib/index.js"
28
+ },
29
+ "./client": {
30
+ "default": "./lib/client.js"
31
+ },
32
+ "./package.json": "./package.json"
33
+ },
34
+ "files": [
35
+ "lib",
36
+ "cordis.patch.yml",
37
+ "README.md",
38
+ "LICENSE"
39
+ ],
40
+ "scripts": {
41
+ "test": "node tools/test.cjs"
42
+ },
43
+ "dependencies": {
44
+ "zod": "^4.4.0"
45
+ },
46
+ "dsh": {
47
+ "bundle": {
48
+ "patch": "./cordis.patch.yml"
49
+ },
50
+ "client": {
51
+ "inject": [
52
+ "@deepseek-ai/dsh-client-runtime",
53
+ "@deepseek-ai/dsh-client-ui-conversation",
54
+ "@deepseek-ai/dsh-client-ui-slots",
55
+ "@deepseek-ai/dsh-client-locale"
56
+ ],
57
+ "platform": "web"
58
+ }
59
+ }
60
+ }