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/LICENSE +77 -0
- package/README.md +104 -0
- package/cordis.patch.yml +5 -0
- package/lib/client.js +1491 -0
- package/lib/index.js +565 -0
- package/package.json +60 -0
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
|
+
}
|