dsh-desktop-statusbar 2.0.2 → 2.1.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/LICENSE +21 -77
- package/README.en.md +108 -96
- package/README.md +108 -96
- package/docs/pricing.en.png +0 -0
- package/docs/pricing.png +0 -0
- package/docs/settings.en.png +0 -0
- package/docs/settings.png +0 -0
- package/docs/statusbar.en.png +0 -0
- package/docs/statusbar.png +0 -0
- package/icon.svg +10 -0
- package/lib/balance-api.js +214 -16
- package/lib/client.js +2781 -333
- package/lib/index.js +2728 -158
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +66 -62
package/lib/index.js
CHANGED
|
@@ -11,22 +11,39 @@
|
|
|
11
11
|
* 4. `sessionTiming` 投影 — 逐次模型调用的首字延迟与输出速度极值(最快/最慢)。
|
|
12
12
|
* 官方 sessionStats 只有累计值(ttftMs/ttftSteps、decodeMs/decodeTokens),
|
|
13
13
|
* 单次极值推不出来,所以这里按同一套事件口径自己折一份。
|
|
14
|
-
* 5. `/dsh-desktop-statusbar/api/balance` —
|
|
15
|
-
*
|
|
14
|
+
* 5. `/dsh-desktop-statusbar/api/balance` — 当前会话实际计费通道的账户余额。
|
|
15
|
+
* DeepSeek 有两条通道:`deepseek-account`(登录账号额度)与 `deepseek-official`
|
|
16
|
+
* (模型页那把 API key 的账户),按会话实际用的 provider 选,互不顶替;
|
|
17
|
+
* 其它平台(GLM、Kimi、OpenRouter 等)按其适配器的端点与字段查。
|
|
18
|
+
* 6. `/dsh-desktop-statusbar/api/daily-usage` — 本周(周一 00:00 起)每天的 token 用量。
|
|
19
|
+
* 来源是一份本地台账(`<DSH_HOME>/dsh-status-bar/usage-ledger.json`):启动时把会话日志里
|
|
20
|
+
* 新产生的用量并进去,以后只在打开活跃总览面板时(带 force=1)现扫一次,30 秒内复用缓存。
|
|
21
|
+
* 台账只增不减:删掉会话或整个工作区,已记下的数字都不缩水;当时没有单价的用量记成
|
|
22
|
+
* "欠账",补上单价后只结算欠账,不重算已经算好的费用。只取 token 数字,不读正文。
|
|
23
|
+
* 7. `/dsh-desktop-statusbar/api/holidays` — 抓国务院的放假通知,解析出放假日。
|
|
16
24
|
* (客户端跨域抓不到 gov.cn,只能由 host 代抓;正文解析在 lib/holiday.js。)
|
|
25
|
+
* 8. `/dsh-desktop-statusbar/api/subagent-usage` — 按 `parentSession` 聚合子智能体
|
|
26
|
+
* (Agent Teams 的队友)的用量。子智能体是**独立会话**,它的消耗不在本会话的投影里,
|
|
27
|
+
* 底栏「总计」「本轮」要算上就只能由 host 读会话日志补出来。可选参数 `since`(毫秒)
|
|
28
|
+
* 只统计该时刻之后的用量:客户端传了就按它(`sinceSource='client'`),没传(或非法:
|
|
29
|
+
* 非有限数 / 负数 / 未来时刻)则由 host 读**父会话日志**推起点(`sinceSource='host'`),
|
|
30
|
+
* 连父会话都没有就本轮不并队友(`sinceSource='none'`);要整会话显式传 `since=0`。
|
|
31
|
+
* 返回里 `total.stats` 是后代**整会话**的首字/解码累计(与窗口无关),给底栏
|
|
32
|
+
* 「首字平均 / 综合速度」并进会话级数值用。只读,绝不写台账。
|
|
17
33
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* key
|
|
34
|
+
* 凭据来源:DeepSeek 走 `ctx.credentials`(模型页写入的权威来源),退回环境变量
|
|
35
|
+
* DEEPSEEK_API_KEY 与 `<DSH_HOME>/.credentials.yaml`;其它平台按适配器的 refs 逐个解析。
|
|
36
|
+
* key 只在本进程内存中用于调用对应平台的余额接口,不落盘、不转发、不写日志。
|
|
21
37
|
*
|
|
22
38
|
* @module dsh-desktop-statusbar
|
|
23
39
|
*/
|
|
24
40
|
import { z } from 'zod'
|
|
25
|
-
import { adapterFor } from './balance-api.js'
|
|
41
|
+
import { adapterFor, adaptersFor, pluginRoutesIn } from './balance-api.js'
|
|
26
42
|
import { holidayHtmlToText, parseHolidayNotice } from './holiday.js'
|
|
27
|
-
import { readFileSync } from 'node:fs'
|
|
28
|
-
import { join } from 'node:path'
|
|
43
|
+
import { appendFileSync, closeSync, copyFileSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, statSync, writeFileSync } from 'node:fs'
|
|
44
|
+
import { basename, dirname, join } from 'node:path'
|
|
29
45
|
import { homedir } from 'node:os'
|
|
46
|
+
import * as zlib from 'node:zlib'
|
|
30
47
|
|
|
31
48
|
export const name = 'dsh-desktop-statusbar'
|
|
32
49
|
|
|
@@ -72,8 +89,10 @@ const sessionModelProjection = {
|
|
|
72
89
|
init: () => ({ provider: null, model: null, updatedAt: null }),
|
|
73
90
|
apply: (state, event) => {
|
|
74
91
|
if (event.type !== 'assistant/message') return state
|
|
75
|
-
const source = event.data.message.
|
|
76
|
-
|
|
92
|
+
const source = event.data === undefined || event.data === null || event.data.message === undefined || event.data.message === null
|
|
93
|
+
? null
|
|
94
|
+
: event.data.message.source
|
|
95
|
+
if (source === null || source === undefined || source.kind !== 'model') return state
|
|
77
96
|
const { provider, model } = source
|
|
78
97
|
if (provider === state.provider && model === state.model) return state
|
|
79
98
|
return { provider, model, updatedAt: event.time }
|
|
@@ -82,6 +101,198 @@ const sessionModelProjection = {
|
|
|
82
101
|
stateVersion: 1,
|
|
83
102
|
}
|
|
84
103
|
|
|
104
|
+
/* ------------------------------------------------------------ sessionCapacity */
|
|
105
|
+
|
|
106
|
+
const sessionCapacitySchema = z.object({
|
|
107
|
+
contextWindow: z.number().nullable(),
|
|
108
|
+
maxOutputTokens: z.number().nullable(),
|
|
109
|
+
updatedAt: z.number().nullable(),
|
|
110
|
+
}).strict()
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 上下文压缩的触发点要两个入参,它们只出现在请求事件里:
|
|
114
|
+
* request/header → data.header.config.maxTokens(本次请求的输出上限,随推理档位浮动)
|
|
115
|
+
* request/context → data.contextWindow(只在 provider/model/窗口变化时才记,比 header 稀疏得多)
|
|
116
|
+
* 两者都得折:少一个客户端就算不出压缩点,圆环上就不开缺口。
|
|
117
|
+
*/
|
|
118
|
+
const sessionCapacityProjection = {
|
|
119
|
+
key: 'desktopStatusbarCapacity',
|
|
120
|
+
stateSchema: sessionCapacitySchema,
|
|
121
|
+
init: () => ({ contextWindow: null, maxOutputTokens: null, updatedAt: null }),
|
|
122
|
+
apply: (state, event) => {
|
|
123
|
+
const data = event.data === undefined || event.data === null ? {} : event.data
|
|
124
|
+
let contextWindow = state.contextWindow
|
|
125
|
+
let maxOutputTokens = state.maxOutputTokens
|
|
126
|
+
if (event.type === 'request/context') {
|
|
127
|
+
if (typeof data.contextWindow !== 'number') return state
|
|
128
|
+
contextWindow = data.contextWindow
|
|
129
|
+
} else if (event.type === 'request/header') {
|
|
130
|
+
const config = data.header === undefined || data.header === null ? null : data.header.config
|
|
131
|
+
if (config === null || typeof config.maxTokens !== 'number') return state
|
|
132
|
+
maxOutputTokens = config.maxTokens
|
|
133
|
+
} else {
|
|
134
|
+
return state
|
|
135
|
+
}
|
|
136
|
+
if (contextWindow === state.contextWindow && maxOutputTokens === state.maxOutputTokens) return state
|
|
137
|
+
return { contextWindow, maxOutputTokens, updatedAt: event.time }
|
|
138
|
+
},
|
|
139
|
+
wire: { viewSchema: sessionCapacitySchema, view: (state) => state },
|
|
140
|
+
stateVersion: 1,
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/* -------------------------------------------------------- sessionProgress */
|
|
144
|
+
|
|
145
|
+
/** 一次压缩落在哪一轮哪一步(压缩事件只带轮号,步号取压缩前最后一步;取不到给 null)。 */
|
|
146
|
+
const compactMark = z.object({
|
|
147
|
+
turn: z.number(),
|
|
148
|
+
step: z.number().nullable(),
|
|
149
|
+
}).strict()
|
|
150
|
+
|
|
151
|
+
const sessionProgressSchema = z.object({
|
|
152
|
+
turn: z.number(),
|
|
153
|
+
turnSteps: z.number(),
|
|
154
|
+
toolCalls: z.number(),
|
|
155
|
+
/* 工具名 → 调用次数:气泡里「工具调用」那一行点开要逐个列出来 */
|
|
156
|
+
toolCounts: z.record(z.string(), z.number()),
|
|
157
|
+
skills: z.array(z.string()),
|
|
158
|
+
/* 注入进会话的全局提示词文件名(~/.dsh/AGENTS.md → 'AGENTS.md');没注入给 null */
|
|
159
|
+
globalInstruction: z.string().nullable(),
|
|
160
|
+
compactCount: z.number(),
|
|
161
|
+
compactTurn: z.number().nullable(),
|
|
162
|
+
compactStep: z.number().nullable(),
|
|
163
|
+
/* 每一次压缩的落点,按发生顺序排:气泡里「上下文压缩」那一行点开逐条列 */
|
|
164
|
+
compacts: z.array(compactMark),
|
|
165
|
+
}).strict()
|
|
166
|
+
|
|
167
|
+
/** 从 skill 工具的参数里取技能名;参数是 JSON 字符串,坏掉就当没这个名字。 */
|
|
168
|
+
function skillNameOf(data) {
|
|
169
|
+
let args = data.arguments
|
|
170
|
+
if (typeof args === 'string') {
|
|
171
|
+
try {
|
|
172
|
+
args = JSON.parse(args)
|
|
173
|
+
} catch (error) {
|
|
174
|
+
return null
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (args === null || args === undefined || typeof args !== 'object') return null
|
|
178
|
+
return typeof args.name === 'string' && args.name.length > 0 ? args.name : null
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* 技能还有一条斜杠命令注入的路:/i-have-adhd 这类不产生 skill 工具调用,
|
|
183
|
+
* 只补一条 user/message,source 形如 { kind: 'skill-invocation', name: 'i-have-adhd', form: 'instructions' }。
|
|
184
|
+
* skill-catalog(可用技能清单,每次会话都注入)不是「这次注入的技能」,必须排除。
|
|
185
|
+
*/
|
|
186
|
+
function skillInvocationOf(data) {
|
|
187
|
+
if (data === null || data === undefined) return null
|
|
188
|
+
const source = data.source
|
|
189
|
+
if (source === null || source === undefined || source.kind !== 'skill-invocation') return null
|
|
190
|
+
return typeof source.name === 'string' && source.name.length > 0 ? source.name : null
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* 全局提示词是靠 agent-instructions 注入的一条 user/message 进来的,source 形如:
|
|
195
|
+
* { kind: 'agent-instructions', form: 'instructions', changes: [{ action: 'set',
|
|
196
|
+
* scope: 'user-global\u0000AGENTS.md', path: '~/.dsh/AGENTS.md', digest: '…' }] }
|
|
197
|
+
* 只认 user-global 作用域(项目的 AGENTS.md 不算「全局提示词」),名字取路径的文件名。
|
|
198
|
+
*/
|
|
199
|
+
function globalInstructionOf(data) {
|
|
200
|
+
if (data === null || data === undefined) return null
|
|
201
|
+
const source = data.source
|
|
202
|
+
if (source === null || source === undefined || source.kind !== 'agent-instructions') return null
|
|
203
|
+
const changes = Array.isArray(source.changes) ? source.changes : []
|
|
204
|
+
for (const change of changes) {
|
|
205
|
+
if (change === null || change === undefined) continue
|
|
206
|
+
const scope = typeof change.scope === 'string' ? change.scope : ''
|
|
207
|
+
/* 作用域是「作用域\u0000文件名」,只认 user-global 那一段 */
|
|
208
|
+
if (scope.split('\u0000')[0] !== 'user-global') continue
|
|
209
|
+
const path = typeof change.path === 'string' ? change.path : ''
|
|
210
|
+
const name = path.split(/[\\/]/).pop()
|
|
211
|
+
if (typeof name === 'string' && name.length > 0) return name
|
|
212
|
+
}
|
|
213
|
+
return null
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* 轮次项气泡要的几个数官方 sessionStats 一个都没有(它只有 turns / steps 与各类耗时),
|
|
218
|
+
* 所以在 host 侧按事件折一份:
|
|
219
|
+
* step/start → 本轮走到第几步(轮号一变就归零,step 是轮内序号不是会话累计)
|
|
220
|
+
* tool/call → 整个会话的工具调用次数 + 每个工具名各自的次数;
|
|
221
|
+
* 工具名是 skill 的那种再把技能名按注入顺序记进 skills
|
|
222
|
+
* user/message → 全局提示词(agent-instructions 注入的那种)的文件名;
|
|
223
|
+
* 斜杠命令注入的技能(skill-invocation)也补进 skills
|
|
224
|
+
* compaction/start → 压缩次数 + 这一次的落点;压缩事件只带轮号,
|
|
225
|
+
* 压缩发生在哪一步取压缩前最后一条同轮 step/start
|
|
226
|
+
* 这几种事件在真实日志里都齐(75 个会话:11208 次工具调用、22 个压缩事件)。
|
|
227
|
+
*/
|
|
228
|
+
const sessionProgressProjection = {
|
|
229
|
+
key: 'desktopStatusbarProgress',
|
|
230
|
+
stateSchema: sessionProgressSchema,
|
|
231
|
+
init: () => ({
|
|
232
|
+
turn: 0,
|
|
233
|
+
turnSteps: 0,
|
|
234
|
+
toolCalls: 0,
|
|
235
|
+
toolCounts: {},
|
|
236
|
+
skills: [],
|
|
237
|
+
globalInstruction: null,
|
|
238
|
+
compactCount: 0,
|
|
239
|
+
compactTurn: null,
|
|
240
|
+
compactStep: null,
|
|
241
|
+
compacts: [],
|
|
242
|
+
}),
|
|
243
|
+
apply: (state, event) => {
|
|
244
|
+
if (event.type === 'step/start') {
|
|
245
|
+
const data = event.data === undefined || event.data === null ? {} : event.data
|
|
246
|
+
if (typeof data.step !== 'number') return state
|
|
247
|
+
const turn = typeof data.turn === 'number' ? data.turn : state.turn
|
|
248
|
+
const turnSteps = turn === state.turn ? Math.max(state.turnSteps, data.step) : data.step
|
|
249
|
+
if (turn === state.turn && turnSteps === state.turnSteps) return state
|
|
250
|
+
return { ...state, turn: turn, turnSteps: turnSteps }
|
|
251
|
+
}
|
|
252
|
+
if (event.type === 'user/message') {
|
|
253
|
+
const skill = skillInvocationOf(event.data)
|
|
254
|
+
if (skill !== null) return { ...state, skills: state.skills.concat([skill]) }
|
|
255
|
+
const name = globalInstructionOf(event.data)
|
|
256
|
+
if (name === null || state.globalInstruction === name) return state
|
|
257
|
+
return { ...state, globalInstruction: name }
|
|
258
|
+
}
|
|
259
|
+
if (event.type === 'tool/call') {
|
|
260
|
+
const data = event.data === undefined || event.data === null ? {} : event.data
|
|
261
|
+
const name = typeof data.name === 'string' && data.name.length > 0 ? data.name : null
|
|
262
|
+
const skill = name === 'skill' ? skillNameOf(data) : null
|
|
263
|
+
const counts = name === null ? state.toolCounts : {
|
|
264
|
+
...state.toolCounts,
|
|
265
|
+
[name]: (state.toolCounts[name] === undefined ? 0 : state.toolCounts[name]) + 1,
|
|
266
|
+
}
|
|
267
|
+
return {
|
|
268
|
+
...state,
|
|
269
|
+
toolCalls: state.toolCalls + 1,
|
|
270
|
+
toolCounts: counts,
|
|
271
|
+
/* 技能有两种注入来源:调 skill 工具(这里)与斜杠命令(user/message 分支);
|
|
272
|
+
名字按注入顺序排,重复注入也照记(数量 = 数组长度) */
|
|
273
|
+
skills: skill === null ? state.skills : state.skills.concat([skill]),
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (event.type === 'compaction/start') {
|
|
277
|
+
const data = event.data === undefined || event.data === null ? {} : event.data
|
|
278
|
+
const turn = typeof data.turn === 'number' ? data.turn : state.turn
|
|
279
|
+
/* 压缩前没有同轮的步(压缩落在轮首)时给 null,客户端那一格显示横杠 */
|
|
280
|
+
const step = turn === state.turn && state.turnSteps > 0 ? state.turnSteps : null
|
|
281
|
+
return {
|
|
282
|
+
...state,
|
|
283
|
+
compactCount: state.compactCount + 1,
|
|
284
|
+
compactTurn: turn,
|
|
285
|
+
compactStep: step,
|
|
286
|
+
compacts: state.compacts.concat([{ turn: turn, step: step }]),
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return state
|
|
290
|
+
},
|
|
291
|
+
wire: { viewSchema: sessionProgressSchema, view: (state) => state },
|
|
292
|
+
// 字段变了(加 globalInstruction、技能多一条斜杠命令来源),提升版本重建旧状态
|
|
293
|
+
stateVersion: 6,
|
|
294
|
+
}
|
|
295
|
+
|
|
85
296
|
/* ------------------------------------------------------------ sessionUsage */
|
|
86
297
|
|
|
87
298
|
const usageBucket = z.object({
|
|
@@ -124,6 +335,27 @@ function usageOf(event) {
|
|
|
124
335
|
return usage
|
|
125
336
|
}
|
|
126
337
|
|
|
338
|
+
/**
|
|
339
|
+
* 这两类事件带着"这次请求用的是哪个模型":`session/title-llm-request` 的 route、
|
|
340
|
+
* `model/selection` 的 model。assistant/attempt 只有 stream、没有 message,取不到型号,
|
|
341
|
+
* 靠投影里记下的最近一次请求补上 —— 否则那条事件会让整条投影抛错并永久停摆。
|
|
342
|
+
*/
|
|
343
|
+
function routedModelOf(event) {
|
|
344
|
+
const data = event.data === null || event.data === undefined ? null : event.data
|
|
345
|
+
if (data === null || typeof data !== 'object') return null
|
|
346
|
+
if (event.type === 'session/title-llm-request') {
|
|
347
|
+
const route = data.route
|
|
348
|
+
if (route === null || route === undefined || typeof route !== 'object') return null
|
|
349
|
+
if (typeof route.model !== 'string' || route.model.length === 0) return null
|
|
350
|
+
return { model: route.model, provider: typeof route.provider === 'string' ? route.provider : '' }
|
|
351
|
+
}
|
|
352
|
+
if (event.type === 'model/selection') {
|
|
353
|
+
if (typeof data.model !== 'string' || data.model.length === 0) return null
|
|
354
|
+
return { model: data.model, provider: typeof data.provider === 'string' ? data.provider : '' }
|
|
355
|
+
}
|
|
356
|
+
return null
|
|
357
|
+
}
|
|
358
|
+
|
|
127
359
|
/** 取数值字段,兼容几种命名;取不到返回 0。 */
|
|
128
360
|
const numOf = (source, names) => {
|
|
129
361
|
for (const name of names) {
|
|
@@ -178,12 +410,16 @@ const sessionUsageSchema = z.object({
|
|
|
178
410
|
turn: z.number().nullable(),
|
|
179
411
|
current: lastUsage.nullable(),
|
|
180
412
|
last: lastUsage.nullable(),
|
|
413
|
+
/* 最近一次请求用的模型(见 routedModelOf):不带 message 的 assistant/attempt 靠它补型号。
|
|
414
|
+
可空且可选 —— 万一某条路径没带上这两个键,也不至于让整个 state 过不了校验。 */
|
|
415
|
+
routeModel: z.string().nullable().optional(),
|
|
416
|
+
routeProvider: z.string().nullable().optional(),
|
|
181
417
|
}).strict()
|
|
182
418
|
|
|
183
419
|
const sessionUsageProjection = {
|
|
184
420
|
key: 'desktopStatusbarUsage',
|
|
185
421
|
stateSchema: sessionUsageSchema,
|
|
186
|
-
init: () => ({ models: {}, calls: [], current: null, last: null, turn: null }),
|
|
422
|
+
init: () => ({ models: {}, calls: [], current: null, last: null, turn: null, routeModel: null, routeProvider: null }),
|
|
187
423
|
apply: (state, event) => {
|
|
188
424
|
const calls = Array.isArray(state.calls) ? state.calls : []
|
|
189
425
|
const turnOf = (event) => {
|
|
@@ -197,12 +433,26 @@ const sessionUsageProjection = {
|
|
|
197
433
|
if (state.current === null) return { ...state, turn: started }
|
|
198
434
|
return { ...state, calls, current: null, last: state.current, turn: started }
|
|
199
435
|
}
|
|
436
|
+
// 先记下"这次请求用的是哪个模型":没有 message 的事件(assistant/attempt)靠它补型号
|
|
437
|
+
const routed = routedModelOf(event)
|
|
438
|
+
const base = routed === null ? state : {
|
|
439
|
+
...state,
|
|
440
|
+
routeModel: routed.model,
|
|
441
|
+
routeProvider: routed.provider,
|
|
442
|
+
}
|
|
200
443
|
const usage = usageOf(event)
|
|
201
|
-
if (usage === undefined || usage === null) return
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
const
|
|
205
|
-
const
|
|
444
|
+
if (usage === undefined || usage === null) return base
|
|
445
|
+
// 型号两条路:常规事件从 message.source 取;没有 message 的用上面记下的最近一次请求
|
|
446
|
+
const message = event.data === null || event.data === undefined ? null : event.data.message
|
|
447
|
+
const source = message === null || message === undefined ? null : message.source
|
|
448
|
+
const fromMessage = source !== null && source.kind === 'model' && typeof source.model === 'string'
|
|
449
|
+
const model = fromMessage ? source.model : (typeof base.routeModel === 'string' ? base.routeModel : '')
|
|
450
|
+
// 两条路都拿不到型号:放弃这一笔,好过抛错把整条投影(以及同一次驱动的其它投影)带停
|
|
451
|
+
if (model.length === 0) return base
|
|
452
|
+
const provider = fromMessage && typeof source.provider === 'string'
|
|
453
|
+
? source.provider
|
|
454
|
+
: (typeof base.routeProvider === 'string' ? base.routeProvider : '')
|
|
455
|
+
const currentModel = base.models[model]
|
|
206
456
|
const parts = usageParts(usage)
|
|
207
457
|
const bucket = {
|
|
208
458
|
input: (currentModel === undefined ? 0 : currentModel.input) + parts.input,
|
|
@@ -220,12 +470,12 @@ const sessionUsageProjection = {
|
|
|
220
470
|
}
|
|
221
471
|
const turn = turnOf(event)
|
|
222
472
|
// 轮次变化就重开本轮汇总:不能假设一定有 turn/start
|
|
223
|
-
const sameTurn =
|
|
473
|
+
const sameTurn = base.current !== null && Array.isArray(base.current.calls)
|
|
224
474
|
&& (turn === null || stateTurn === null || turn === stateTurn)
|
|
225
|
-
const prev = sameTurn ?
|
|
475
|
+
const prev = sameTurn ? base.current : null
|
|
226
476
|
const callsForTurn = prev === null ? [] : prev.calls
|
|
227
477
|
const summary = {
|
|
228
|
-
provider
|
|
478
|
+
provider,
|
|
229
479
|
model,
|
|
230
480
|
time: event.time,
|
|
231
481
|
input: (prev === null ? 0 : prev.input) + one.input,
|
|
@@ -235,13 +485,13 @@ const sessionUsageProjection = {
|
|
|
235
485
|
}
|
|
236
486
|
const current = { ...summary, calls: callsForTurn.concat([one]) }
|
|
237
487
|
return {
|
|
238
|
-
...
|
|
488
|
+
...base,
|
|
239
489
|
turn: turn === null ? stateTurn : turn,
|
|
240
490
|
calls: appendCalls(calls, [one]),
|
|
241
491
|
current,
|
|
242
492
|
// 轮次变化时把上一轮结算进 last
|
|
243
|
-
last: prev === null &&
|
|
244
|
-
models: { ...
|
|
493
|
+
last: prev === null && base.current !== null ? base.current : base.last,
|
|
494
|
+
models: { ...base.models, [model]: bucket },
|
|
245
495
|
}
|
|
246
496
|
},
|
|
247
497
|
wire: {
|
|
@@ -251,7 +501,9 @@ const sessionUsageProjection = {
|
|
|
251
501
|
// schema 加了 current(本轮累计),提升版本以重建旧状态。
|
|
252
502
|
// 3 → 4:历史缓存里有按旧口径算出来的逐条/本轮用量(未缓存输入少掉一截),
|
|
253
503
|
// 界面上的「本轮」因此与「总计」对不上,提升版本强制从事件日志重放。
|
|
254
|
-
|
|
504
|
+
// 4 → 5:旧缓存里缺「会话起标题」那几笔 —— attempt 事件没有 message,取不到型号时
|
|
505
|
+
// 整条投影抛错、该会话的账从此停摆。提升版本强制重放,把这些笔补回账本。
|
|
506
|
+
stateVersion: 5,
|
|
255
507
|
}
|
|
256
508
|
|
|
257
509
|
/* -------------------------------------------------------- sessionTimeRange */
|
|
@@ -433,8 +685,10 @@ const sessionTimingProjection = {
|
|
|
433
685
|
}),
|
|
434
686
|
apply: (state, event) => {
|
|
435
687
|
if (event.type === 'step/start') {
|
|
688
|
+
const data = event.data === undefined || event.data === null ? {} : event.data
|
|
689
|
+
if (typeof data.turn !== 'number') return state
|
|
436
690
|
/* 换轮就把本轮累计清零:气泡里的"本轮平均…"只算当前这一轮 */
|
|
437
|
-
const turn =
|
|
691
|
+
const turn = data.turn
|
|
438
692
|
const sameTurn = state.turn === turn
|
|
439
693
|
return {
|
|
440
694
|
...state,
|
|
@@ -444,7 +698,7 @@ const sessionTimingProjection = {
|
|
|
444
698
|
turnDecodeMs: sameTurn ? state.turnDecodeMs : 0,
|
|
445
699
|
turnDecodeTokens: sameTurn ? state.turnDecodeTokens : 0,
|
|
446
700
|
openStep: {
|
|
447
|
-
turn:
|
|
701
|
+
turn: data.turn, step: data.step, startTime: event.time,
|
|
448
702
|
firstTokenTime: null, firstTokenTool: false,
|
|
449
703
|
},
|
|
450
704
|
}
|
|
@@ -513,8 +767,6 @@ const sessionTimingProjection = {
|
|
|
513
767
|
|
|
514
768
|
/* 不缓存余额:每次请求都问一次官方账号服务,和设置页「账号与余额」打开时的取值节奏一致。
|
|
515
769
|
* 之前这里是 60 秒缓存,叠上客户端 60 秒轮询,底栏最坏会落后近两分钟。 */
|
|
516
|
-
const BALANCE_TTL_MS = 0
|
|
517
|
-
let balanceCache = { at: 0, value: null }
|
|
518
770
|
|
|
519
771
|
/* 诊断:account 服务的探测结论,随 /api/balance 一起返回(客户端不读它,只用来查为什么回退)。 */
|
|
520
772
|
let accountProbeNote = '未探测'
|
|
@@ -583,48 +835,43 @@ function readJsonBody(req) {
|
|
|
583
835
|
})
|
|
584
836
|
}
|
|
585
837
|
|
|
586
|
-
/**
|
|
587
|
-
function keyFromEnv() {
|
|
588
|
-
const
|
|
838
|
+
/** 环境变量是最高优先级的来源;refName 为空时按 DeepSeek 的引用名。 */
|
|
839
|
+
function keyFromEnv(refName) {
|
|
840
|
+
const name = typeof refName === 'string' && refName.length > 0 ? refName : 'DEEPSEEK_API_KEY'
|
|
841
|
+
const value = process.env[name]
|
|
589
842
|
return typeof value === 'string' && value.length > 10 ? value : null
|
|
590
843
|
}
|
|
591
844
|
|
|
592
845
|
/**
|
|
593
|
-
* `<DSH_HOME>/.credentials.yaml
|
|
594
|
-
*
|
|
595
|
-
*
|
|
846
|
+
* `<DSH_HOME>/.credentials.yaml` 的 refs 段按引用名取值(模型页写入的 key 就落在那里)。
|
|
847
|
+
* 只认引用名本身:records 里的 `secret` 是 client-connection 的授权口令,不是任何平台的
|
|
848
|
+
* API key —— 先前拿它兜底,会让没配 key 的平台把这个口令发给对方接口(实测换回 401)。
|
|
596
849
|
*/
|
|
597
|
-
function keyFromCredentialsFile() {
|
|
850
|
+
function keyFromCredentialsFile(refName) {
|
|
851
|
+
const name = typeof refName === 'string' && refName.length > 0 ? refName : 'DEEPSEEK_API_KEY'
|
|
598
852
|
try {
|
|
599
853
|
const home = process.env.DSH_HOME !== undefined && process.env.DSH_HOME.length > 0
|
|
600
854
|
? process.env.DSH_HOME
|
|
601
855
|
: join(homedir(), '.dsh')
|
|
602
856
|
const text = readFileSync(join(home, '.credentials.yaml'), 'utf8')
|
|
603
|
-
const
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
]
|
|
608
|
-
for (const pattern of patterns) {
|
|
609
|
-
const match = text.match(pattern)
|
|
610
|
-
if (match !== null && typeof match[1] === 'string' && match[1].length > 10) return match[1]
|
|
611
|
-
}
|
|
857
|
+
const quoted = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
858
|
+
const pattern = new RegExp('(?:^|[\\s{,])["\']?' + quoted + '["\']?\\s*:\\s*["\']?([^"\'\\s#}]+)')
|
|
859
|
+
const match = text.match(pattern)
|
|
860
|
+
if (match !== null && typeof match[1] === 'string' && match[1].length > 10) return match[1]
|
|
612
861
|
} catch (error) {
|
|
613
862
|
/* 文件缺失或不可读 */
|
|
614
863
|
}
|
|
615
864
|
return null
|
|
616
865
|
}
|
|
617
866
|
|
|
618
|
-
function keyFromCredentialStore(ctx) {
|
|
867
|
+
function keyFromCredentialStore(ctx, refName) {
|
|
619
868
|
const store = ctx.credentials
|
|
620
869
|
if (store === undefined || store === null) return null
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
}
|
|
627
|
-
if (ids.length === 0) ids.push('deepseek-official', 'deepseek')
|
|
870
|
+
/* 只按引用名取;没有引用名时才回落到 DeepSeek 的历史 id。
|
|
871
|
+
逐个遍历 store 的键会串到别的凭据上,第三方平台拿到的就不是自己的 key。 */
|
|
872
|
+
const ids = typeof refName === 'string' && refName.length > 0
|
|
873
|
+
? [refName]
|
|
874
|
+
: ['deepseek-official', 'deepseek']
|
|
628
875
|
for (const id of ids) {
|
|
629
876
|
let credential
|
|
630
877
|
try {
|
|
@@ -660,8 +907,8 @@ async function resolveAdapterKey(ctx, adapter) {
|
|
|
660
907
|
}
|
|
661
908
|
}
|
|
662
909
|
}
|
|
663
|
-
/* 没挂凭据服务时的旧路径:环境变量 / .credentials.yaml */
|
|
664
|
-
const fallback = findApiKey(ctx)
|
|
910
|
+
/* 没挂凭据服务时的旧路径:环境变量 / .credentials.yaml(都按该适配器的引用名取) */
|
|
911
|
+
const fallback = findApiKey(ctx, adapter.refs[0])
|
|
665
912
|
return { key: fallback.key, source: fallback.source }
|
|
666
913
|
}
|
|
667
914
|
|
|
@@ -717,12 +964,12 @@ async function queryWithAdapter(ctx, adapter, now) {
|
|
|
717
964
|
}
|
|
718
965
|
}
|
|
719
966
|
|
|
720
|
-
function findApiKey(ctx) {
|
|
721
|
-
const fromEnv = keyFromEnv()
|
|
967
|
+
function findApiKey(ctx, refName) {
|
|
968
|
+
const fromEnv = keyFromEnv(refName)
|
|
722
969
|
if (fromEnv !== null) return { key: fromEnv, source: 'env' }
|
|
723
|
-
const fromFile = keyFromCredentialsFile()
|
|
970
|
+
const fromFile = keyFromCredentialsFile(refName)
|
|
724
971
|
if (fromFile !== null) return { key: fromFile, source: 'credentials.yaml' }
|
|
725
|
-
const fromStore = keyFromCredentialStore(ctx)
|
|
972
|
+
const fromStore = keyFromCredentialStore(ctx, refName)
|
|
726
973
|
if (fromStore !== null) return { key: fromStore, source: 'credentials-service' }
|
|
727
974
|
return { key: null, source: null }
|
|
728
975
|
}
|
|
@@ -730,7 +977,7 @@ function findApiKey(ctx) {
|
|
|
730
977
|
/**
|
|
731
978
|
* 官方账号服务里的余额(设置页「账号与余额」用的就是它):
|
|
732
979
|
* getBalance 返回充值钱包 value[] 与赠金钱包 bonusWallets[],两者相加正是底栏要的"充值 + 赠送"合计。
|
|
733
|
-
*
|
|
980
|
+
* 只在会话没有 API key、纯账号登录模式时采用;取不到、账号未登录或调用抛错一律返回 null。
|
|
734
981
|
*/
|
|
735
982
|
async function queryAccountBalance(ctx) {
|
|
736
983
|
if (ctx === null || ctx === undefined || typeof ctx.get !== 'function') {
|
|
@@ -807,33 +1054,260 @@ async function queryAccountBalance(ctx) {
|
|
|
807
1054
|
}
|
|
808
1055
|
}
|
|
809
1056
|
|
|
1057
|
+
/**
|
|
1058
|
+
* 本机路由取余额的适配器(当前只有 WorkBuddy 积分)。
|
|
1059
|
+
*
|
|
1060
|
+
* 为什么不直接调上游:WorkBuddy 的凭据由 connect 类插件解密持有,
|
|
1061
|
+
* 上游的鉴权头、计费接口与响应解析也都在它那边。它把这些结论挂在本机 Web 路由上,
|
|
1062
|
+
* 这里只读那条路由 —— 本插件既不碰凭据,也不用把上游协议再实现一遍。
|
|
1063
|
+
*
|
|
1064
|
+
* 连的是 loopback,Host 头是 127.0.0.1:port,不带 Origin,正好过对方路由的 loopback 校验。
|
|
1065
|
+
*/
|
|
1066
|
+
|
|
1067
|
+
/** 从 webServer 服务上取本机端口;取不到返回 null。 */
|
|
1068
|
+
function localWebPort(ctx) {
|
|
1069
|
+
/* inject 里声明过 webServer,所以 ctx.webServer 与 ctx.get('webServer') 是同一个对象;
|
|
1070
|
+
两条都试,取第一个真带端口号的,免得宿主哪天改注册方式、或某个入口没挂服务时就取不到。 */
|
|
1071
|
+
const candidates = []
|
|
1072
|
+
if (ctx !== null && ctx !== undefined) {
|
|
1073
|
+
if (ctx.webServer !== undefined && ctx.webServer !== null) candidates.push(ctx.webServer)
|
|
1074
|
+
if (typeof ctx.get === 'function') {
|
|
1075
|
+
try {
|
|
1076
|
+
const viaGet = ctx.get('webServer')
|
|
1077
|
+
if (viaGet !== undefined && viaGet !== null) candidates.push(viaGet)
|
|
1078
|
+
} catch (error) {
|
|
1079
|
+
/* 服务没注册时 get 可能抛错,忽略 */
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
const withPort = candidates.filter((one) => typeof one.port === 'number' && one.port > 0)[0]
|
|
1084
|
+
const port = withPort !== undefined ? withPort.port : Number(process.env.DSH_WEB_PORT)
|
|
1085
|
+
return Number.isFinite(port) && port > 0 ? port : null
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
/**
|
|
1089
|
+
* 探测一条本机路由:返回 `{ reachable, data }`。
|
|
1090
|
+
*
|
|
1091
|
+
* `reachable` 的判据来自可行性调研的实测(这一步很关键,否则会误判):
|
|
1092
|
+
* · 200 + JSON → 可达,`data` 是解析好的文档
|
|
1093
|
+
* · 405 / 403 / 400 + JSON → **路由存在**(只是方法/来源/参数不对)⇒ 可达但不可读
|
|
1094
|
+
* · 404 **且**无 Content-Type **且**空体 → 宿主兜底「这条路由没注册」⇒ 不可达
|
|
1095
|
+
* · 其它(网络、超时、5xx) → 环境问题 ⇒ 不可达,且调用方**不该**据此换下一条路径
|
|
1096
|
+
* 为什么 404 要连 Content-Type 与空体一起判:插件自己的 handler 也会返回 404+JSON
|
|
1097
|
+
* (本插件的 `/api` 就是),只看状态码会把「插件在、但没这条数据」当成「插件不在」。
|
|
1098
|
+
*/
|
|
1099
|
+
async function probeLocalRoute(url) {
|
|
1100
|
+
try {
|
|
1101
|
+
const response = await fetch(url, {
|
|
1102
|
+
method: 'GET',
|
|
1103
|
+
headers: { accept: 'application/json' },
|
|
1104
|
+
signal: AbortSignal.timeout(10000),
|
|
1105
|
+
})
|
|
1106
|
+
const type = response.headers === undefined || typeof response.headers.get !== 'function'
|
|
1107
|
+
? null
|
|
1108
|
+
: response.headers.get('content-type')
|
|
1109
|
+
if (response.status === 404 && (type === null || type.indexOf('json') < 0)) {
|
|
1110
|
+
return { reachable: false, data: null, reason: 'http-404' }
|
|
1111
|
+
}
|
|
1112
|
+
if (!response.ok) {
|
|
1113
|
+
return { reachable: true, data: null, reason: 'http-' + String(response.status) }
|
|
1114
|
+
}
|
|
1115
|
+
let data = null
|
|
1116
|
+
try {
|
|
1117
|
+
data = await response.json()
|
|
1118
|
+
} catch (error) {
|
|
1119
|
+
data = null
|
|
1120
|
+
}
|
|
1121
|
+
return { reachable: true, data: data, reason: null }
|
|
1122
|
+
} catch (error) {
|
|
1123
|
+
return { reachable: false, data: null, reason: 'network' }
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
/**
|
|
1128
|
+
* 候选路径:适配器自己声明的(新旧插件各一条)+ **通用发现**到的(同族插件)。
|
|
1129
|
+
*
|
|
1130
|
+
* 通用发现读的是宿主 webServer 的 `exact` / `prefixes` 两张路由表。它们是**未公开内部字段**
|
|
1131
|
+
* (全宿主没有任何消费方枚举过),所以整段必须 try/catch:拿不到就当"发现不可用",
|
|
1132
|
+
* 静默回退到声明表 —— 宿主哪天改名,这里只是少发现几个插件,不会让余额整块崩掉。
|
|
1133
|
+
*/
|
|
1134
|
+
function localCandidatePaths(ctx, adapter, provider) {
|
|
1135
|
+
const declared = typeof adapter.pathsOf === 'function'
|
|
1136
|
+
? adapter.pathsOf(provider)
|
|
1137
|
+
: [adapter.request(null, null, provider).path]
|
|
1138
|
+
const out = declared.slice()
|
|
1139
|
+
if (typeof pluginRoutesIn !== 'function') return out
|
|
1140
|
+
let found = []
|
|
1141
|
+
try {
|
|
1142
|
+
const server = ctx === null || ctx === undefined ? null : ctx.webServer
|
|
1143
|
+
const tables = server === null || server === undefined ? [] : [server.exact, server.prefixes]
|
|
1144
|
+
for (let i = 0; i < tables.length; i += 1) {
|
|
1145
|
+
found = found.concat(pluginRoutesIn(tables[i]))
|
|
1146
|
+
}
|
|
1147
|
+
} catch (error) {
|
|
1148
|
+
/* 未公开内部字段:拿不到就只用声明表(正常路径,不是错误) */
|
|
1149
|
+
return out
|
|
1150
|
+
}
|
|
1151
|
+
/* 通用发现只补**同族**(connect 类)的只读路由,且不重复声明过的。
|
|
1152
|
+
只认插件 id 里含本适配器 keys 的那些(workbuddy / codebuddy):
|
|
1153
|
+
这样换插件作者、换包名(dsh-workbuddy-connect → dsh-connect-workbuddy)时能自动跟上,
|
|
1154
|
+
而不会把无关插件的 /usage 也拉进来盲探 —— 那既慢(实测单次 0.5-0.7s,含上游读取),
|
|
1155
|
+
又可能碰到别人的私有路由。 */
|
|
1156
|
+
const keys = Array.isArray(adapter.keys) ? adapter.keys : []
|
|
1157
|
+
for (let i = 0; i < found.length; i += 1) {
|
|
1158
|
+
const one = found[i]
|
|
1159
|
+
if (out.indexOf(one.path) >= 0) continue
|
|
1160
|
+
const idText = String(one.pluginId).toLowerCase()
|
|
1161
|
+
const related = keys.some((key) => idText.indexOf(String(key).toLowerCase()) >= 0)
|
|
1162
|
+
if (related !== true) continue
|
|
1163
|
+
out.push(one.path)
|
|
1164
|
+
}
|
|
1165
|
+
return out
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
async function queryLocalAdapter(ctx, adapter, now, provider) {
|
|
1169
|
+
const port = localWebPort(ctx)
|
|
1170
|
+
if (port === null) {
|
|
1171
|
+
logHost(ctx, 'balance(' + adapter.id + '): 拿不到本机 Web 端口')
|
|
1172
|
+
return { ok: false, at: now, reason: 'no-local-port', provider: adapter.id }
|
|
1173
|
+
}
|
|
1174
|
+
const paths = localCandidatePaths(ctx, adapter, provider)
|
|
1175
|
+
let last = null
|
|
1176
|
+
for (let i = 0; i < paths.length; i += 1) {
|
|
1177
|
+
const path = paths[i]
|
|
1178
|
+
const url = 'http://127.0.0.1:' + String(port) + path
|
|
1179
|
+
const probed = await probeLocalRoute(url)
|
|
1180
|
+
/* 环境类失败(网络/超时)不换下一条:换路径不会变好,反而多发请求、
|
|
1181
|
+
还可能把真实的网络问题掩盖成"两个插件都没有"。 */
|
|
1182
|
+
if (probed.reachable !== true && probed.reason !== 'http-404') {
|
|
1183
|
+
logHost(ctx, 'balance(' + adapter.id + '): 本机路由请求失败 ' + String(probed.reason))
|
|
1184
|
+
return { ok: false, at: now, reason: probed.reason, provider: adapter.id }
|
|
1185
|
+
}
|
|
1186
|
+
if (probed.reachable !== true) {
|
|
1187
|
+
/* 这条路径没注册(裸 404):试下一条候选 */
|
|
1188
|
+
last = { ok: false, at: now, reason: 'http-404', provider: adapter.id }
|
|
1189
|
+
continue
|
|
1190
|
+
}
|
|
1191
|
+
if (probed.reason !== null) {
|
|
1192
|
+
/* 路由存在但当前读不了(405/403/400):记下来继续试下一条,最后如实报出 */
|
|
1193
|
+
logHost(ctx, 'balance(' + adapter.id + '): 本机路由 ' + path + ' 返回 ' + String(probed.reason))
|
|
1194
|
+
last = { ok: false, at: now, reason: probed.reason, provider: adapter.id }
|
|
1195
|
+
continue
|
|
1196
|
+
}
|
|
1197
|
+
const data = probed.data
|
|
1198
|
+
/* 未登录时对方给的是 status: 'signed-out',没有 credits 字段,解析自然落到失败分支 */
|
|
1199
|
+
if (data !== null && data !== undefined && data.creditsError !== undefined && data.creditsError !== null) {
|
|
1200
|
+
logHost(ctx, 'balance(' + adapter.id + '): 上游积分查询失败 ' + String(data.creditsError).slice(0, 120))
|
|
1201
|
+
last = { ok: false, at: now, reason: 'credits-error', provider: adapter.id }
|
|
1202
|
+
continue
|
|
1203
|
+
}
|
|
1204
|
+
const parsed = adapter.parse(data)
|
|
1205
|
+
if (parsed === null) {
|
|
1206
|
+
logHost(ctx, 'balance(' + adapter.id + '): 本机路由里没有积分(未登录或上游未返回)')
|
|
1207
|
+
last = { ok: false, at: now, reason: 'no-credits', provider: adapter.id }
|
|
1208
|
+
continue
|
|
1209
|
+
}
|
|
1210
|
+
logHost(ctx, 'balance(' + adapter.id + '): 积分 ' + String(parsed.total) + '(来自 ' + path + ')')
|
|
1211
|
+
return {
|
|
1212
|
+
ok: true,
|
|
1213
|
+
at: now,
|
|
1214
|
+
source: 'local-route',
|
|
1215
|
+
provider: adapter.id,
|
|
1216
|
+
currency: parsed.currency,
|
|
1217
|
+
total: parsed.total,
|
|
1218
|
+
isCredits: parsed.isCredits === true,
|
|
1219
|
+
isPercent: parsed.isPercent === true,
|
|
1220
|
+
parts: parsed.parts,
|
|
1221
|
+
toppedUp: partAmount(parsed.parts, 'toppedUp'),
|
|
1222
|
+
granted: partAmount(parsed.parts, 'granted'),
|
|
1223
|
+
spent: partAmount(parsed.parts, 'spent'),
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
return last !== null ? last : { ok: false, at: now, reason: 'no-credits', provider: adapter.id }
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
/* force 目前不改变行为(余额不缓存,每次请求都现查),但客户端点开气泡时会带 force=1
|
|
1230
|
+
表达"强制刷新",保留该形参以维持 HTTP 契约,避免为省一个参数去动客户端回调。 */
|
|
810
1231
|
async function queryBalance(ctx, force, provider, model) {
|
|
811
1232
|
const now = Date.now()
|
|
812
|
-
if (force !== true && balanceCache.value !== null && now - balanceCache.at < BALANCE_TTL_MS) {
|
|
813
|
-
return balanceCache.value
|
|
814
|
-
}
|
|
815
1233
|
|
|
816
1234
|
/* 当前会话用的是第三方平台就交给适配器:每个平台有自己的端点、鉴权与字段。
|
|
817
|
-
|
|
818
|
-
|
|
1235
|
+
走**适配器链**:智谱的"现金余额"与"coding plan 配额"是两条独立的路、keys 完全重叠,
|
|
1236
|
+
单选的 adapterFor 只会命中前者,于是 coding plan 用户永远拿不到配额(实测症状:
|
|
1237
|
+
底栏余额恒为 ¥-)。链上按顺序逐个请求,**第一个解析出结果的胜出** —— 判定交给
|
|
1238
|
+
各家自己的 parse(那是已核实上游协议的唯一权威),这里不另立规则。
|
|
1239
|
+
非智谱平台链长恒为 1,行为与旧版逐字节一致。 */
|
|
1240
|
+
const chain = adaptersFor(provider, model)
|
|
1241
|
+
const adapter = chain.length > 0 ? chain[0] : null
|
|
1242
|
+
if (adapter !== null && adapter.local === true) {
|
|
1243
|
+
const local = await queryLocalAdapter(ctx, adapter, now, provider)
|
|
1244
|
+
/* 这个平台的计量单位是积分,失败态也要带上:客户端据此决定不拼货币符号 */
|
|
1245
|
+
const value = local.ok === true ? local : Object.assign({}, local, { isCredits: true })
|
|
1246
|
+
return value
|
|
1247
|
+
}
|
|
819
1248
|
if (adapter !== null && adapter.id !== 'deepseek') {
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
1249
|
+
let last = null
|
|
1250
|
+
for (let i = 0; i < chain.length; i += 1) {
|
|
1251
|
+
const one = chain[i]
|
|
1252
|
+
/* 本地路由型适配器(积分)不走这条:它没有外部端点,且上面已经单独处理过 */
|
|
1253
|
+
if (one.local === true) continue
|
|
1254
|
+
const attempt = await queryWithAdapter(ctx, one, now)
|
|
1255
|
+
if (attempt.ok === true) {
|
|
1256
|
+
if (i > 0) {
|
|
1257
|
+
logHost(ctx, 'balance(' + one.id + '): 主适配器 ' + adapter.id + ' 没给出余额,回退到这条命中')
|
|
1258
|
+
}
|
|
1259
|
+
return attempt
|
|
1260
|
+
}
|
|
1261
|
+
last = attempt
|
|
1262
|
+
/* 只有"端点答了、但响应结构不是这一路"(no-balance-info)才值得换下一条:
|
|
1263
|
+
按量计费账号在配额接口上拿不到 limits,coding plan 在余额接口上拿不到 code=200
|
|
1264
|
+
—— 这两种正是要靠回退解决的。HTTP / 网络 / 缺凭据是**环境**问题,换端点不会
|
|
1265
|
+
变好,反而多发一次请求、还可能把真实的鉴权失败掩盖成"配额也没有"。 */
|
|
1266
|
+
if (attempt.reason !== 'no-balance-info') return attempt
|
|
1267
|
+
}
|
|
1268
|
+
return last !== null ? last : { ok: false, at: now, reason: 'unknown-platform' }
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
/* 会话明确在用别的平台、但这个平台认不出来时不硬凑:硬凑会把 DeepSeek 的余额
|
|
1272
|
+
当成对方的余额显示(比不显示更误导)。provider 还没上报(空)时才按 DeepSeek 处理。 */
|
|
1273
|
+
const hasProvider = typeof provider === 'string' && provider.trim().length > 0
|
|
1274
|
+
if (adapter === null && hasProvider) {
|
|
1275
|
+
const unknown = { ok: false, at: now, reason: 'unknown-platform' }
|
|
1276
|
+
logHost(ctx, 'balance: 认不出 ' + provider + ' 属于哪个平台,不显示余额')
|
|
1277
|
+
return unknown
|
|
823
1278
|
}
|
|
824
1279
|
|
|
825
|
-
/*
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
1280
|
+
/* DeepSeek 在 DSH 里有两条计费通道,余额必须跟当前会话实际走的那条:
|
|
1281
|
+
* deepseek-account → 登录账号的额度(官方账号服务)
|
|
1282
|
+
* deepseek-official → 模型页那把 API key 的账户(官方余额接口)
|
|
1283
|
+
* 判据只看 provider,与本机配没配 key 无关 —— 装了 key 的机器同样可能在跑账号模式的会话,
|
|
1284
|
+
* 反过来拿账号钱包顶替 API key 的账,就会出现「一直在花钱、余额却不动」。 */
|
|
1285
|
+
const usesAccountChannel = adapter !== null
|
|
1286
|
+
&& typeof provider === 'string'
|
|
1287
|
+
&& provider.toLowerCase().includes('account')
|
|
1288
|
+
if (usesAccountChannel) {
|
|
1289
|
+
const fromAccount = await queryAccountBalance(ctx)
|
|
1290
|
+
if (fromAccount !== null) {
|
|
1291
|
+
return fromAccount
|
|
1292
|
+
}
|
|
1293
|
+
const noAccount = { ok: false, at: now, reason: 'account-unavailable' }
|
|
1294
|
+
logHost(ctx, 'balance: 账号通道(' + provider + ')但账号服务不可用')
|
|
1295
|
+
return noAccount
|
|
830
1296
|
}
|
|
831
1297
|
|
|
832
|
-
|
|
1298
|
+
/* 其余 DeepSeek 会话按 API key 计费:余额取这把 key 的账户。
|
|
1299
|
+
* 模型页配了 key(credentials 服务 / 环境变量 / .credentials.yaml 任一处有值)就用它;
|
|
1300
|
+
* 确实没有 key 时才是"账号登录但 provider 名没带 account"的兜底,退回账号钱包。 */
|
|
1301
|
+
const deepseekAdapter = adapter === null ? adapterFor('deepseek', '') : adapter
|
|
1302
|
+
const found = await resolveAdapterKey(ctx, deepseekAdapter)
|
|
1303
|
+
|
|
833
1304
|
if (found.key === null) {
|
|
1305
|
+
const fromAccount = await queryAccountBalance(ctx)
|
|
1306
|
+
if (fromAccount !== null) {
|
|
1307
|
+
return fromAccount
|
|
1308
|
+
}
|
|
834
1309
|
const missing = { ok: false, at: now, reason: 'no-credential' }
|
|
835
|
-
logHost(ctx, 'balance: 环境变量 / credentials.yaml / credentials 服务都没取到 key')
|
|
836
|
-
balanceCache = { at: now, value: missing }
|
|
1310
|
+
logHost(ctx, 'balance: 环境变量 / credentials.yaml / credentials 服务都没取到 key,账号服务也不可用')
|
|
837
1311
|
return missing
|
|
838
1312
|
}
|
|
839
1313
|
|
|
@@ -848,7 +1322,6 @@ async function queryBalance(ctx, force, provider, model) {
|
|
|
848
1322
|
if (!response.ok) {
|
|
849
1323
|
const failed = { ok: false, at: now, reason: 'http-' + String(response.status), source: found.source }
|
|
850
1324
|
logHost(ctx, 'balance: HTTP ' + String(response.status))
|
|
851
|
-
balanceCache = { at: now, value: failed }
|
|
852
1325
|
return failed
|
|
853
1326
|
}
|
|
854
1327
|
const data = await response.json()
|
|
@@ -857,7 +1330,6 @@ async function queryBalance(ctx, force, provider, model) {
|
|
|
857
1330
|
if (picked === undefined) {
|
|
858
1331
|
const empty = { ok: false, at: now, reason: 'no-balance-info', source: found.source }
|
|
859
1332
|
logHost(ctx, 'balance: 响应里没有 balance_infos')
|
|
860
|
-
balanceCache = { at: now, value: empty }
|
|
861
1333
|
return empty
|
|
862
1334
|
}
|
|
863
1335
|
const value = {
|
|
@@ -873,107 +1345,2128 @@ async function queryBalance(ctx, force, provider, model) {
|
|
|
873
1345
|
available: data.is_available === true,
|
|
874
1346
|
}
|
|
875
1347
|
logHost(ctx, 'balance: OK ' + String(value.total) + ' ' + String(value.currency))
|
|
876
|
-
balanceCache = { at: now, value }
|
|
877
1348
|
return value
|
|
878
1349
|
} catch (error) {
|
|
879
1350
|
const detail = error !== null && error !== undefined && typeof error.message === 'string' ? error.message : 'request-failed'
|
|
880
1351
|
const failed = { ok: false, at: now, reason: 'request-failed', detail, source: found.source }
|
|
881
1352
|
logHost(ctx, 'balance: 请求异常 ' + detail)
|
|
882
|
-
balanceCache = { at: now, value: failed }
|
|
883
1353
|
return failed
|
|
884
1354
|
}
|
|
885
1355
|
}
|
|
886
1356
|
|
|
887
|
-
/* ------------------------------------------------------------
|
|
1357
|
+
/* ------------------------------------------------------------ 活跃总览 */
|
|
888
1358
|
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
1359
|
+
/**
|
|
1360
|
+
* 峰谷那个字段点开是「活跃总览」:左边看本周每天用了多少,右边看当月每天的活跃度,
|
|
1361
|
+
* 而 session 投影只覆盖当前会话,
|
|
1362
|
+
* 所以这里扫会话日志把用量收进一份本地台账(`<DSH_HOME>/dsh-status-bar/usage-ledger.json`)。
|
|
1363
|
+
*
|
|
1364
|
+
* 为什么必须落盘:会话一旦被删除,日志就没了,而周总计必须把已经发生的消耗留住。
|
|
1365
|
+
* 台账按「事件」去重(同一 turn/step 只算一次、assistant/message 优先),于是重复扫描不会
|
|
1366
|
+
* 重复计数,删掉会话也不会让已记下的数字缩水;窗口是自然周(周一 00:00 起)。
|
|
1367
|
+
* 全程只读 token 数字,不读正文,台账里也只有数字、没有对话内容。
|
|
1368
|
+
*/
|
|
893
1369
|
|
|
894
1370
|
/**
|
|
895
|
-
*
|
|
896
|
-
*
|
|
897
|
-
* 未发布、HTTP 失败、解析为空都返回 { ok: false, reason },交给客户端提示后重试。
|
|
1371
|
+
* 柱状窗口的起点:今天 00:00(北京时间)往前推 6 天,也就是「近 7 天」。
|
|
1372
|
+
* 这里用的是滚动窗口,不再对齐自然周 —— 用户看到的是最近七天,而不是本周一到现在。
|
|
898
1373
|
*/
|
|
899
|
-
|
|
900
|
-
const
|
|
901
|
-
const
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
search.searchParams.set('p', '1')
|
|
906
|
-
search.searchParams.set('n', '5')
|
|
907
|
-
search.searchParams.set('sort', 'score')
|
|
908
|
-
search.searchParams.set('sortType', '1')
|
|
909
|
-
const headers = { 'user-agent': BROWSER_UA }
|
|
910
|
-
/* 每次请求各自设超时:AbortSignal 不能复用 */
|
|
911
|
-
const timeout = () => AbortSignal.timeout(HOLIDAY_FETCH_TIMEOUT_MS)
|
|
1374
|
+
function recentStartMs(now) {
|
|
1375
|
+
const BEIJING = 8 * 3600 * 1000
|
|
1376
|
+
const shifted = new Date(now + BEIJING)
|
|
1377
|
+
const todayStart = Date.UTC(shifted.getUTCFullYear(), shifted.getUTCMonth(), shifted.getUTCDate()) - BEIJING
|
|
1378
|
+
return todayStart - 6 * 24 * 3600 * 1000
|
|
1379
|
+
}
|
|
912
1380
|
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
const
|
|
916
|
-
const
|
|
917
|
-
|
|
918
|
-
|
|
1381
|
+
/** 本月 1 号 00:00(北京时间)的毫秒时间戳。 */
|
|
1382
|
+
function monthStartMs(now) {
|
|
1383
|
+
const BEIJING = 8 * 3600 * 1000
|
|
1384
|
+
const shifted = new Date(now + BEIJING)
|
|
1385
|
+
return Date.UTC(shifted.getUTCFullYear(), shifted.getUTCMonth(), 1) - BEIJING
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
const padTwo = (value) => (value < 10 ? '0' + String(value) : String(value))
|
|
1389
|
+
|
|
1390
|
+
/** 当年 1 月 1 日 00:00(北京时间)的毫秒时间戳 —— 12 个月的统计要从这里扫起。 */
|
|
1391
|
+
function yearStartMs(now) {
|
|
1392
|
+
const BEIJING = 8 * 3600 * 1000
|
|
1393
|
+
const shifted = new Date(now + BEIJING)
|
|
1394
|
+
return Date.UTC(shifted.getUTCFullYear(), 0, 1) - BEIJING
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
/** 北京时间(UTC+8)的日期键 'YYYY-MM-DD' —— DeepSeek 的峰谷与账单都按北京时间。 */
|
|
1398
|
+
function localDateKey(ms) {
|
|
1399
|
+
const date = new Date(ms + 8 * 3600 * 1000)
|
|
1400
|
+
return date.getUTCFullYear() + '-' + padTwo(date.getUTCMonth() + 1) + '-' + padTwo(date.getUTCDate())
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
/* 官方参考价(CNY / 每百万 tokens),与客户端价格表同源、与官方定价页一致。
|
|
1404
|
+
只列有明确价格的 DeepSeek 模型;其它 provider(智谱、小米等)没有可靠价格,不计入花费。 */
|
|
1405
|
+
const TOKEN_PRICES = {
|
|
1406
|
+
peak: {
|
|
1407
|
+
'deepseek-flash': { input: 2, cacheRead: 0.04, cacheWrite: 0, output: 8 },
|
|
1408
|
+
'deepseek-v4-pro': { input: 9, cacheRead: 0.3, cacheWrite: 0, output: 27 },
|
|
1409
|
+
},
|
|
1410
|
+
offPeak: {
|
|
1411
|
+
'deepseek-flash': { input: 1, cacheRead: 0.02, cacheWrite: 0, output: 4 },
|
|
1412
|
+
'deepseek-v4-pro': { input: 4.5, cacheRead: 0.15, cacheWrite: 0, output: 13.5 },
|
|
1413
|
+
},
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
/**
|
|
1417
|
+
* 客户端上报的价格库(localStorage 里那份,用户可增删改)。
|
|
1418
|
+
*
|
|
1419
|
+
* 为什么 host 要收下它:活跃总览的 cost 是 host 扫会话日志时**当场算好写进台账**的,
|
|
1420
|
+
* 以前只认上面那张硬编码表 —— 用户在设置页加的模型(如 WorkBuddy 的
|
|
1421
|
+
* `Deepseek-V4.1-Flash`)在底栏能算,在活跃总览却恒为 0(实测 2026-10-07)。
|
|
1422
|
+
* 客户端启动/改价时 POST 过来,两边就用同一份价格。
|
|
1423
|
+
*
|
|
1424
|
+
* 形状:{ 模型名: { peak: {...}, offPeak: {...} } },与客户端 normalizePrice 之后一致。
|
|
1425
|
+
* 为 null 表示还没收到,此时回退到 TOKEN_PRICES。
|
|
1426
|
+
*/
|
|
1427
|
+
let clientPrices = null
|
|
1428
|
+
|
|
1429
|
+
/* 当前**真正生效**的那份价格库的指纹:只在 queueLedgerJob 里读写。
|
|
1430
|
+
价格上报靠它判断"这次上报是不是真的改了价" —— 快照若取在队列外,
|
|
1431
|
+
两个并排上报会各拿一份旧快照、双双报"已变更"(实测残留,只影响返回值与日志)。 */
|
|
1432
|
+
let activePriceFingerprint = null
|
|
1433
|
+
|
|
1434
|
+
/* ---------------------------------------- 模型名匹配(与客户端 lib/client.js 同规则) */
|
|
1435
|
+
|
|
1436
|
+
/**
|
|
1437
|
+
* 模型名切成词元:忽略大小写,分隔符(- . _ 空格等)都当边界。
|
|
1438
|
+
* `DeepSeek-V4.1-Flash-SG` → ['deepseek','v4','1','flash','sg']
|
|
1439
|
+
*/
|
|
1440
|
+
function modelNameTokens(name) {
|
|
1441
|
+
return typeof name === 'string'
|
|
1442
|
+
? name.toLowerCase().split(/[^a-z0-9]+/).filter((one) => one.length > 0)
|
|
919
1443
|
: []
|
|
920
|
-
|
|
921
|
-
标题可能带 <em> 高亮标签,比较前先剥掉。 */
|
|
922
|
-
const wanted = String(year) + '年部分节假日安排'
|
|
923
|
-
const plainTitle = (item) => String(item.title).replace(/<[^>]+>/g, '')
|
|
924
|
-
const hit = list.filter((item) => item !== null && typeof item === 'object'
|
|
925
|
-
&& typeof item.title === 'string' && plainTitle(item).indexOf(wanted) !== -1
|
|
926
|
-
&& typeof item.url === 'string')[0]
|
|
927
|
-
if (hit === undefined) return { ok: false, reason: 'not-found' }
|
|
1444
|
+
}
|
|
928
1445
|
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
if (days.length === 0) return { ok: false, reason: 'parse-empty' }
|
|
933
|
-
return { ok: true, year, days, title: plainTitle(hit), source: hit.url }
|
|
1446
|
+
/** 词元是否对得上:**完全相等**(大小写已经在切词时归一)。 */
|
|
1447
|
+
function tokenMatches(needle, hay) {
|
|
1448
|
+
return needle === hay
|
|
934
1449
|
}
|
|
935
1450
|
|
|
936
|
-
|
|
1451
|
+
/** 一条价格是不是"什么都没填":两档四项加起来是 0。用来把未定价的条目挡在匹配之外。 */
|
|
1452
|
+
function priceIsBlank(entry) {
|
|
1453
|
+
if (entry === null || entry === undefined || typeof entry !== 'object') return true
|
|
1454
|
+
const tier = (one) => (one === null || one === undefined ? 0
|
|
1455
|
+
: numOrZero(one.input) + numOrZero(one.cacheRead) + numOrZero(one.cacheWrite) + numOrZero(one.output))
|
|
1456
|
+
return tier(entry.peak) + tier(entry.offPeak) === 0
|
|
1457
|
+
}
|
|
937
1458
|
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
)
|
|
948
|
-
|
|
949
|
-
(
|
|
950
|
-
'
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
)
|
|
1459
|
+
/**
|
|
1460
|
+
* 这条价格里**有没有任何一格是数字**(= 用户真的填过)。八格全是 null(只在设置页点过
|
|
1461
|
+
* 「添加」还没填价就收起了)的条目**两轮匹配都不该算数**,否则第二轮兜底会把它当成
|
|
1462
|
+
* "明确设 0",该模型的费用被静默算成 ¥0。要区分 null 与 0,`sanitizePriceBook`
|
|
1463
|
+
* 必须把 null 原样带进来(不能压成 0)。
|
|
1464
|
+
*/
|
|
1465
|
+
function hasAnyPriceValue(entry) {
|
|
1466
|
+
if (entry === null || entry === undefined || typeof entry !== 'object') return false
|
|
1467
|
+
const tiers = [entry.peak, entry.offPeak]
|
|
1468
|
+
for (let i = 0; i < tiers.length; i += 1) {
|
|
1469
|
+
const one = tiers[i]
|
|
1470
|
+
if (one === null || one === undefined || typeof one !== 'object') continue
|
|
1471
|
+
if (typeof one.input === 'number' || typeof one.cacheRead === 'number'
|
|
1472
|
+
|| typeof one.cacheWrite === 'number' || typeof one.output === 'number') return true
|
|
1473
|
+
}
|
|
1474
|
+
return false
|
|
1475
|
+
}
|
|
956
1476
|
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
1477
|
+
/**
|
|
1478
|
+
* 一轮匹配:① 同名单独先行;②③(子串命中与词元全含命中)**合并评选**,
|
|
1479
|
+
* 按 weight = 词元数 * 1000 + 键名长度 取最具体的那个 —— 不能分档跑,
|
|
1480
|
+
* 否则模型名里含一个短键(如 flash)就会把更具体的长键挤掉。
|
|
1481
|
+
* 含数字的键只允许子串命中,不走词元档。
|
|
1482
|
+
* skipBlank=true 时跳过"还没定价"(整条全 0)的条目。
|
|
1483
|
+
*/
|
|
1484
|
+
function matchPriceKeyOnce(book, model, skipBlank) {
|
|
1485
|
+
const keys = Object.keys(book)
|
|
1486
|
+
const lower = model.toLowerCase()
|
|
1487
|
+
/* 空键不参选;八格全 null 的条目两轮都不参选;skipBlank 那轮再跳过"有数字但总和 0"的条目 */
|
|
1488
|
+
const usable = (key) => key.length > 0
|
|
1489
|
+
&& hasAnyPriceValue(book[key]) === true
|
|
1490
|
+
&& (skipBlank !== true || priceIsBlank(book[key]) !== true)
|
|
1491
|
+
/* ① 同名:单独先行,最高优先 */
|
|
1492
|
+
for (let i = 0; i < keys.length; i += 1) {
|
|
1493
|
+
if (keys[i].toLowerCase() === lower && usable(keys[i])) return keys[i]
|
|
1494
|
+
}
|
|
1495
|
+
/* ②③ 一次评选:子串命中的键与词元全含命中的键放在一起比 weight,取最具体的那个。
|
|
1496
|
+
不能按"先全部子串、再全部词元"两轮跑 —— 那样模型名里只要含一个短键(如 flash),
|
|
1497
|
+
就会把更具体的 deepseek-flash(只能靠词元档命中)挤掉。
|
|
1498
|
+
含数字的键(如 deepseek-v4-pro)只允许子串命中,不走词元档。 */
|
|
1499
|
+
const tokens = modelNameTokens(model)
|
|
1500
|
+
const weightOf = (key) => modelNameTokens(key).length * 1000 + key.length
|
|
1501
|
+
let best = null
|
|
1502
|
+
let bestWeight = -1
|
|
1503
|
+
for (let i = 0; i < keys.length; i += 1) {
|
|
1504
|
+
const key = keys[i]
|
|
1505
|
+
if (usable(key) !== true) continue
|
|
1506
|
+
let hit = lower.indexOf(key.toLowerCase()) !== -1
|
|
1507
|
+
if (hit !== true && /[0-9]/.test(key) !== true) {
|
|
1508
|
+
const want = modelNameTokens(key)
|
|
1509
|
+
if (want.length > 0) {
|
|
1510
|
+
hit = true
|
|
1511
|
+
for (let k = 0; k < want.length; k += 1) {
|
|
1512
|
+
let found = false
|
|
1513
|
+
for (let m = 0; m < tokens.length; m += 1) {
|
|
1514
|
+
if (tokenMatches(want[k], tokens[m]) === true) { found = true; break }
|
|
1515
|
+
}
|
|
1516
|
+
if (found !== true) { hit = false; break }
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
if (hit !== true) continue
|
|
1521
|
+
const weight = weightOf(key)
|
|
1522
|
+
if (weight > bestWeight) { bestWeight = weight; best = key }
|
|
1523
|
+
}
|
|
1524
|
+
return best
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
/**
|
|
1528
|
+
* 价格键 → 模型名:① 同名最优先;② 子串命中与③ 词元全含命中**合并评选**,取最具体的那个;
|
|
1529
|
+
* 名字里含数字的键(如 `deepseek-v4-pro`)只认子串,不做词元匹配 ——
|
|
1530
|
+
* 版本号不同的 pro 是另一个模型、另一个价,不该被旧版号吃掉。
|
|
1531
|
+
*
|
|
1532
|
+
* 为什么必须有词元档:默认键叫 `deepseek-flash`,而会话日志里记的是
|
|
1533
|
+
* `deepseek-v4.1-flash-sg` / `deepseek-v41-flash` —— 版本号插在中间,子串匹配命不中,
|
|
1534
|
+
* 那笔用量就会被当成"没有单价"(记欠账),默认价等于白配。
|
|
1535
|
+
*
|
|
1536
|
+
* **分两轮**:第一轮跳过"还没填价"(整条全 0)的条目 —— 用户加了条目但没填价,
|
|
1537
|
+
* 不该把该模型的费用算成 0;第一轮一无所获时才放开,让"明确把某模型定为 0 价"仍算得出来。
|
|
1538
|
+
* **必须与客户端 lib/client.js 的 pickPriceKey 逐条同规则**,否则底栏与活跃总览会算出两个价。
|
|
1539
|
+
*/
|
|
1540
|
+
function pickPriceKey(book, model) {
|
|
1541
|
+
if (book === null || book === undefined || typeof book !== 'object') return null
|
|
1542
|
+
if (typeof model !== 'string' || model.length === 0) return null
|
|
1543
|
+
const keys = Object.keys(book)
|
|
1544
|
+
if (keys.length === 0) return null
|
|
1545
|
+
const strict = matchPriceKeyOnce(book, model, true)
|
|
1546
|
+
if (strict !== null) return strict
|
|
1547
|
+
return matchPriceKeyOnce(book, model, false)
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
/**
|
|
1551
|
+
* 在一份价格库里按模型名查单价(匹配规则见 pickPriceKey)。
|
|
1552
|
+
*
|
|
1553
|
+
* 收 book 而不是直接读全局:迁移回扫要用**台账指纹还原出来的旧价目表**判断
|
|
1554
|
+
* "当时这个模型有没有价",与"现在有没有价"是两回事。
|
|
1555
|
+
* 只从 Object.keys 里取键,模型名撞上 'constructor' / 'toString' 这类原型属性也拿不到东西。
|
|
1556
|
+
*/
|
|
1557
|
+
function priceEntryIn(book, model) {
|
|
1558
|
+
const key = pickPriceKey(book, model)
|
|
1559
|
+
return key === null ? null : book[key]
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
/**
|
|
1563
|
+
* 回扫描专用:按**当时那套规则**判断"这个模型当时有没有价"。
|
|
1564
|
+
*
|
|
1565
|
+
* v1/v2 的计价只认精确键名(内置表 `TOKEN_PRICES.peak[model]`、客户端价格库 `book[model]`),
|
|
1566
|
+
* 到本次改造之前只多了一步子串匹配;词元匹配与"最具体评选"都是这次才加的。
|
|
1567
|
+
* 用新规则回扫,会把"当年因名字对不上而记 0"的用量误判成"当时有价"直接跳过 ——
|
|
1568
|
+
* 那批钱就永远补不回来,而它们恰恰是这次加词元匹配要救的那类模型(实测缺陷)。
|
|
1569
|
+
*/
|
|
1570
|
+
function legacyPriceEntryIn(book, model) {
|
|
1571
|
+
if (book === null || book === undefined || typeof book !== 'object') return null
|
|
1572
|
+
if (typeof model !== 'string' || model.length === 0) return null
|
|
1573
|
+
if (Object.prototype.hasOwnProperty.call(book, model) === true) return book[model]
|
|
1574
|
+
const lower = model.toLowerCase()
|
|
1575
|
+
const keys = Object.keys(book)
|
|
1576
|
+
for (let i = 0; i < keys.length; i += 1) {
|
|
1577
|
+
if (keys[i].length > 0 && lower.indexOf(keys[i].toLowerCase()) !== -1) return book[keys[i]]
|
|
1578
|
+
}
|
|
1579
|
+
return null
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
/** 当前生效价格库里的单价(记账与补算都走它)。 */
|
|
1583
|
+
function priceEntryFor(model) {
|
|
1584
|
+
return priceEntryIn(activePriceBook(), model)
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
/* 2026 年法定节假日(国务院通知,与 /api/holidays 抓到的是同一份):这些天全天按空闲价 */
|
|
1588
|
+
const CN_HOLIDAYS_2026 = [
|
|
1589
|
+
'2026-01-01', '2026-01-02', '2026-02-16', '2026-02-17', '2026-02-18', '2026-02-19', '2026-02-20',
|
|
1590
|
+
'2026-02-23', '2026-04-06', '2026-05-01', '2026-05-04', '2026-05-05', '2026-06-19', '2026-09-25',
|
|
1591
|
+
'2026-10-01', '2026-10-02', '2026-10-05', '2026-10-06', '2026-10-07',
|
|
1592
|
+
]
|
|
1593
|
+
|
|
1594
|
+
/** 高峰时段:北京时间周一至周五 9:00-12:00、14:00-18:00,法定节假日与周末全天按空闲价。 */
|
|
1595
|
+
const PEAK_WINDOWS = [[9, 12], [14, 18]]
|
|
1596
|
+
|
|
1597
|
+
/* 抓到的法定节假日会落到 <DSH_HOME>/dsh-status-bar/holidays.json。
|
|
1598
|
+
host 的时段判定必须与客户端用同一份假期表:内置表只覆盖 2026,客户端却能把新一年的
|
|
1599
|
+
假期存进配置 —— 两边不一致时,2027 年起同一天底栏按空闲价、活跃总览按高峰价,差一倍
|
|
1600
|
+
(实测缺陷,用户点「更新节假峰谷」也修不了 host)。 */
|
|
1601
|
+
function holidaysPath() {
|
|
1602
|
+
return join(dshHome(), 'dsh-status-bar', 'holidays.json')
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
let extraHolidays = null
|
|
1606
|
+
|
|
1607
|
+
/** 落盘过的法定节假日(带缓存);文件不存在或读不动就当没有这回事。 */
|
|
1608
|
+
function extraHolidayDays() {
|
|
1609
|
+
if (extraHolidays !== null) return extraHolidays
|
|
1610
|
+
const parsed = readLedgerFile(holidaysPath())
|
|
1611
|
+
extraHolidays = parsed !== null && Array.isArray(parsed.days)
|
|
1612
|
+
? parsed.days.filter((one) => typeof one === 'string')
|
|
1613
|
+
: []
|
|
1614
|
+
return extraHolidays
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
/** 把抓到/同步来的节假日并进落盘文件(按日期去重排序),下次启动仍然生效。 */
|
|
1618
|
+
function saveHolidayDays(days) {
|
|
1619
|
+
const merged = extraHolidayDays().slice()
|
|
1620
|
+
for (let i = 0; i < days.length; i += 1) {
|
|
1621
|
+
if (typeof days[i] === 'string' && merged.indexOf(days[i]) === -1) merged.push(days[i])
|
|
1622
|
+
}
|
|
1623
|
+
merged.sort()
|
|
1624
|
+
try {
|
|
1625
|
+
const dir = join(dshHome(), 'dsh-status-bar')
|
|
1626
|
+
const target = holidaysPath()
|
|
1627
|
+
const temp = target + '.tmp'
|
|
1628
|
+
mkdirSync(dir, { recursive: true })
|
|
1629
|
+
/* 原子写:直接覆盖时进程被中断会留下截断文件,下次启动读出空表,
|
|
1630
|
+
再保存就把原有假期永久丢掉(writeLedger 用的是同一套 temp+rename)。 */
|
|
1631
|
+
writeFileSync(temp, JSON.stringify({ updatedAt: Date.now(), days: merged }), 'utf8')
|
|
1632
|
+
renameSync(temp, target)
|
|
1633
|
+
} catch (error) {
|
|
1634
|
+
/* 写不进去不影响本次使用:内存里已经记下了 */
|
|
1635
|
+
}
|
|
1636
|
+
extraHolidays = merged
|
|
1637
|
+
return merged
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
/** 这一天是不是法定节假日:内置 2026 表 ∪ 抓取落盘的表。 */
|
|
1641
|
+
function isLegalHoliday(key) {
|
|
1642
|
+
if (CN_HOLIDAYS_2026.indexOf(key) >= 0) return true
|
|
1643
|
+
return extraHolidayDays().indexOf(key) >= 0
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
function isPeakTime(ms) {
|
|
1647
|
+
const date = new Date(ms + 8 * 3600 * 1000)
|
|
1648
|
+
const key = date.getUTCFullYear() + '-' + padTwo(date.getUTCMonth() + 1) + '-' + padTwo(date.getUTCDate())
|
|
1649
|
+
if (isLegalHoliday(key) === true) return false
|
|
1650
|
+
const weekday = date.getUTCDay()
|
|
1651
|
+
if (weekday === 0 || weekday === 6) return false
|
|
1652
|
+
const hour = date.getUTCHours()
|
|
1653
|
+
for (let i = 0; i < PEAK_WINDOWS.length; i += 1) {
|
|
1654
|
+
if (hour >= PEAK_WINDOWS[i][0] && hour < PEAK_WINDOWS[i][1]) return true
|
|
1655
|
+
}
|
|
1656
|
+
return false
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
/**
|
|
1660
|
+
* 内置参考价 → 价格库的形状({模型: {peak, offPeak}})。
|
|
1661
|
+
*
|
|
1662
|
+
* TOKEN_PRICES 是两张按档分开的表(`peak[模型]` / `offPeak[模型]`),而价格库是
|
|
1663
|
+
* 一条模型一份两档。两者混用会让查价静默失败 —— 内置模型也算不出钱、回扫时把
|
|
1664
|
+
* 已经计过价的用量当成"当时缺价"再补一遍(费用翻倍)。所以内置表必须先转一次。
|
|
1665
|
+
*/
|
|
1666
|
+
let builtinBookCache = null
|
|
1667
|
+
|
|
1668
|
+
function builtinPriceBook() {
|
|
1669
|
+
if (builtinBookCache !== null) return builtinBookCache
|
|
1670
|
+
const out = {}
|
|
1671
|
+
const names = Object.keys(TOKEN_PRICES.peak)
|
|
1672
|
+
for (let i = 0; i < names.length; i += 1) {
|
|
1673
|
+
const peak = TOKEN_PRICES.peak[names[i]]
|
|
1674
|
+
const offPeak = TOKEN_PRICES.offPeak[names[i]]
|
|
1675
|
+
if (peak === undefined && offPeak === undefined) continue
|
|
1676
|
+
out[names[i]] = {
|
|
1677
|
+
peak: peak !== undefined ? peak : offPeak,
|
|
1678
|
+
offPeak: offPeak !== undefined ? offPeak : peak,
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
builtinBookCache = out
|
|
1682
|
+
return out
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
/** 当前生效的价格库:收到过客户端上报就用它,否则回退到内置参考价(同一形状)。 */
|
|
1686
|
+
function activePriceBook() {
|
|
1687
|
+
return clientPrices !== null ? clientPrices : builtinPriceBook()
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
/** 把任意来源的金额压成有限数;给不出数就当 0。 */
|
|
1691
|
+
function numOrZero(value) {
|
|
1692
|
+
const n = Number(value)
|
|
1693
|
+
return Number.isFinite(n) ? n : 0
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
/**
|
|
1697
|
+
* 从客户端价格库里认出一份干净的 {模型: {peak, offPeak}}。
|
|
1698
|
+
* 客户端来的东西一律当不可信输入:只收形状对的条目,非数一律压成 0,别的字段丢掉。
|
|
1699
|
+
* 空库返回 null(视为"没有信息",不用它覆盖已有价格)。
|
|
1700
|
+
*/
|
|
1701
|
+
function sanitizePriceBook(raw) {
|
|
1702
|
+
if (raw === null || raw === undefined || typeof raw !== 'object') return null
|
|
1703
|
+
/* 空字符串 / null / undefined 一律保留成 null —— "没填"必须与"明确填 0"区分开,
|
|
1704
|
+
压成 0 的话匹配与回扫都会把空条目当成"用户把价定成了 0"。 */
|
|
1705
|
+
const keep = (value) => (value === null || value === undefined || value === '' ? null : numOrZero(value))
|
|
1706
|
+
const tier = (value) => (value !== null && value !== undefined && typeof value === 'object'
|
|
1707
|
+
? {
|
|
1708
|
+
input: keep(value.input),
|
|
1709
|
+
cacheRead: keep(value.cacheRead),
|
|
1710
|
+
cacheWrite: keep(value.cacheWrite),
|
|
1711
|
+
output: keep(value.output),
|
|
1712
|
+
}
|
|
1713
|
+
: null)
|
|
1714
|
+
const out = {}
|
|
1715
|
+
const names = Object.keys(raw)
|
|
1716
|
+
for (let i = 0; i < names.length; i += 1) {
|
|
1717
|
+
/* 空键会命中一切模型名(子串判定里空串人人都"包含"),直接丢掉 */
|
|
1718
|
+
if (names[i].length === 0) continue
|
|
1719
|
+
const entry = raw[names[i]]
|
|
1720
|
+
if (entry === null || entry === undefined || typeof entry !== 'object') continue
|
|
1721
|
+
const peak = tier(entry.peak)
|
|
1722
|
+
const offPeak = tier(entry.offPeak)
|
|
1723
|
+
if (peak === null && offPeak === null) continue
|
|
1724
|
+
/* 保留「是否峰谷计价」:没勾的条目是**全天一个价**,计价时两档都要取 peak,
|
|
1725
|
+
否则空闲时段会挑到那个没填的档、把费用算成 0(实测缺陷)。缺省按两档。 */
|
|
1726
|
+
const code = entry.currency === 'USD' ? 'USD' : (entry.currency === 'CNY' ? 'CNY' : null)
|
|
1727
|
+
out[names[i]] = {
|
|
1728
|
+
peak: peak !== null ? peak : offPeak,
|
|
1729
|
+
offPeak: offPeak !== null ? offPeak : peak,
|
|
1730
|
+
tiered: entry.tiered !== false,
|
|
1731
|
+
/* 条目币种:**带了就原样保留**(`CNY` / `USD`),没带就**不补**——
|
|
1732
|
+
host 算钱只认数值、不看币种(台账与历史都不受影响),币种是客户端用来
|
|
1733
|
+
决定"这条按哪种货币填的";缺省由客户端按它自己的默认币种解释,
|
|
1734
|
+
host 不替它决定(否则往返一次多出一个字段,深比较基准会被悄悄改掉)。
|
|
1735
|
+
注意:必须保留,不保留就等于清洗时把币种丢了。 */
|
|
1736
|
+
...(code === null ? {} : { currency: code }),
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
return Object.keys(out).length === 0 ? null : out
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
/**
|
|
1743
|
+
* 价格库指纹:把一份价格库压成一行文本。两个用途 ——
|
|
1744
|
+
* ① 判断"价格有没有变"(变了才去结算欠账);
|
|
1745
|
+
* ② 迁移回扫时把**当时那份价目表**还原出来(判断某个模型当时有没有价)。
|
|
1746
|
+
*
|
|
1747
|
+
* 它以前的作用是"不一致就把台账整本丢丢掉重扫",那会连已经算好的费用一起改掉,
|
|
1748
|
+
* 会话日志一旦被删,历史数字就再也算不回来。现在它只读不写、只用来触发补算。
|
|
1749
|
+
*/
|
|
1750
|
+
function fingerprintOf(book) {
|
|
1751
|
+
const names = Object.keys(book === null || book === undefined ? {} : book).sort()
|
|
1752
|
+
const parts = []
|
|
1753
|
+
for (let i = 0; i < names.length; i += 1) {
|
|
1754
|
+
const one = book[names[i]]
|
|
1755
|
+
const pick = (t) => (t === null || t === undefined ? 'x' : [numOrZero(t.input), numOrZero(t.cacheRead), numOrZero(t.cacheWrite), numOrZero(t.output)].join(','))
|
|
1756
|
+
parts.push(names[i] + ':' + pick(one.peak) + ':' + pick(one.offPeak))
|
|
1757
|
+
}
|
|
1758
|
+
return parts.join('|')
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
function priceFingerprint() {
|
|
1762
|
+
return fingerprintOf(activePriceBook())
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
/**
|
|
1766
|
+
* 指纹 → 价格库(fingerprintOf 的逆运算),迁移回扫用它还原"当时的价格库"。
|
|
1767
|
+
* 单条形状对不上(段数不是 3、数字不合法)就跳过那一条;整体为空的返回 null。
|
|
1768
|
+
*/
|
|
1769
|
+
function parseFingerprint(text) {
|
|
1770
|
+
if (typeof text !== 'string' || text.length === 0) return null
|
|
1771
|
+
const out = {}
|
|
1772
|
+
const segments = text.split('|')
|
|
1773
|
+
const tier = (raw) => {
|
|
1774
|
+
if (raw === 'x') return null
|
|
1775
|
+
const nums = raw.split(',')
|
|
1776
|
+
if (nums.length !== 4) return null
|
|
1777
|
+
const one = {
|
|
1778
|
+
input: Number(nums[0]),
|
|
1779
|
+
cacheRead: Number(nums[1]),
|
|
1780
|
+
cacheWrite: Number(nums[2]),
|
|
1781
|
+
output: Number(nums[3]),
|
|
1782
|
+
}
|
|
1783
|
+
if (Number.isFinite(one.input) !== true || Number.isFinite(one.cacheRead) !== true
|
|
1784
|
+
|| Number.isFinite(one.cacheWrite) !== true || Number.isFinite(one.output) !== true) return null
|
|
1785
|
+
return one
|
|
1786
|
+
}
|
|
1787
|
+
for (let i = 0; i < segments.length; i += 1) {
|
|
1788
|
+
const fields = segments[i].split(':')
|
|
1789
|
+
if (fields.length !== 3) continue
|
|
1790
|
+
const peak = tier(fields[1])
|
|
1791
|
+
const offPeak = tier(fields[2])
|
|
1792
|
+
if (peak === null && offPeak === null) continue
|
|
1793
|
+
out[fields[0]] = { peak: peak !== null ? peak : offPeak, offPeak: offPeak !== null ? offPeak : peak }
|
|
1794
|
+
}
|
|
1795
|
+
return Object.keys(out).length === 0 ? null : out
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
/** 一笔用量按单价折成人民币;模型没有价格表就返回 0(不计入花费)。 */
|
|
1799
|
+
function tokenCost(model, at, parts) {
|
|
1800
|
+
const entry = priceEntryFor(model)
|
|
1801
|
+
if (entry === null) return 0
|
|
1802
|
+
/* 没勾「峰谷计价」= 全天一个价:两档都取 peak,否则空闲时段会挑到没填的那档、算成 0 */
|
|
1803
|
+
const price = entry.tiered === false ? entry.peak : (isPeakTime(at) ? entry.peak : entry.offPeak)
|
|
1804
|
+
const use = price !== null && price !== undefined ? price : entry.peak
|
|
1805
|
+
if (use === null || use === undefined) return 0
|
|
1806
|
+
return (parts.input * numOrZero(use.input) + parts.cacheRead * numOrZero(use.cacheRead)
|
|
1807
|
+
+ parts.cacheWrite * numOrZero(use.cacheWrite) + parts.output * numOrZero(use.output)) / 1e6
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
/**
|
|
1811
|
+
* 这个模型的条目是按哪种货币填的(`CNY` / `USD`,缺省 `CNY`)。
|
|
1812
|
+
*
|
|
1813
|
+
* 只服务于"按币种分桶汇总":**算钱本身不看币种**(台账、历史数字都不受影响),
|
|
1814
|
+
* 客户端拿到分桶后按参考价比例折成底栏显示币种,混币时才能给出一个数字。
|
|
1815
|
+
*/
|
|
1816
|
+
function tokenCostCurrency(model) {
|
|
1817
|
+
const entry = priceEntryFor(model)
|
|
1818
|
+
return entry !== null && entry.currency === 'USD' ? 'USD' : 'CNY'
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
/** zstd 帧头(0x28 B5 2F FD):会话日志是多帧拼接,按它切开逐帧解。 */
|
|
1822
|
+
const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd])
|
|
1823
|
+
|
|
1824
|
+
/** 解出会话日志文本;运行时不带 zstd 时返回 null(客户端按空数据画)。 */
|
|
1825
|
+
function zstdText(buf) {
|
|
1826
|
+
if (typeof zlib.zstdDecompressSync !== 'function') return null
|
|
1827
|
+
const starts = []
|
|
1828
|
+
let at = 0
|
|
1829
|
+
while ((at = buf.indexOf(ZSTD_MAGIC, at)) >= 0) {
|
|
1830
|
+
starts.push(at)
|
|
1831
|
+
at += 4
|
|
1832
|
+
}
|
|
1833
|
+
if (starts.length === 0) return null
|
|
1834
|
+
const parts = []
|
|
1835
|
+
for (let i = 0; i < starts.length; i += 1) {
|
|
1836
|
+
const end = i + 1 < starts.length ? starts[i + 1] : buf.length
|
|
1837
|
+
try {
|
|
1838
|
+
parts.push(zlib.zstdDecompressSync(buf.subarray(starts[i], end)))
|
|
1839
|
+
} catch (error) {
|
|
1840
|
+
/* 坏帧跳过,其余帧照常 */
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
return parts.length === 0 ? null : Buffer.concat(parts).toString('utf8')
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
/** `<DSH_HOME>/sessions` —— 会话日志根目录。 */
|
|
1847
|
+
function sessionsRoot() {
|
|
1848
|
+
return join(dshHome(), 'sessions')
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
/** 递归收集会话日志文件(目录缺失时给空数组)。 */
|
|
1852
|
+
function collectSessionFiles(dir, out) {
|
|
1853
|
+
let entries
|
|
1854
|
+
try {
|
|
1855
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
1856
|
+
} catch (error) {
|
|
1857
|
+
return out
|
|
1858
|
+
}
|
|
1859
|
+
for (const entry of entries) {
|
|
1860
|
+
const full = join(dir, entry.name)
|
|
1861
|
+
if (entry.isDirectory()) collectSessionFiles(full, out)
|
|
1862
|
+
else if (entry.isFile() && entry.name.endsWith('.jsonl.zstd')) out.push(full)
|
|
1863
|
+
}
|
|
1864
|
+
return out
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
/* ------------------------------------------------- 会话头部(认父子关系用) */
|
|
1868
|
+
|
|
1869
|
+
/**
|
|
1870
|
+
* 只解压**第一帧**。会话日志是多帧拼接,头部事件一定落在第一帧里;
|
|
1871
|
+
* 而一个会话的日志可能几 MB —— 为了读一行 header 把整份解压一遍太贵。
|
|
1872
|
+
* 解不出来(帧被截断、运行时没有 zstd)返回 null,调用方退回全量解压。
|
|
1873
|
+
*/
|
|
1874
|
+
function firstFrameText(buf) {
|
|
1875
|
+
if (typeof zlib.zstdDecompressSync !== 'function') return null
|
|
1876
|
+
const start = buf.indexOf(ZSTD_MAGIC)
|
|
1877
|
+
if (start < 0) return null
|
|
1878
|
+
const next = buf.indexOf(ZSTD_MAGIC, start + 4)
|
|
1879
|
+
try {
|
|
1880
|
+
return zlib.zstdDecompressSync(buf.subarray(start, next < 0 ? buf.length : next)).toString('utf8')
|
|
1881
|
+
} catch (error) {
|
|
1882
|
+
return null
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
/**
|
|
1887
|
+
* 会话日志第一行的身份:`{ id, parentSession }`。
|
|
1888
|
+
* `parentSession` 只在**子智能体**的会话里出现(主会话没有这个字段),
|
|
1889
|
+
* 所以它就是"谁是谁的队友"的唯一线索。第一行不是头部事件、或 JSON 解不出来时返回 null。
|
|
1890
|
+
*/
|
|
1891
|
+
function sessionHeaderOf(text) {
|
|
1892
|
+
if (typeof text !== 'string' || text.length === 0) return null
|
|
1893
|
+
const end = text.indexOf('\n')
|
|
1894
|
+
const line = end < 0 ? text : text.slice(0, end)
|
|
1895
|
+
/* 先按子串筛一道:整行 JSON.parse 比 indexOf 贵得多,而绝大多数行都不是头部事件 */
|
|
1896
|
+
if (line.indexOf('"session"') < 0) return null
|
|
1897
|
+
let event
|
|
1898
|
+
try {
|
|
1899
|
+
event = JSON.parse(line)
|
|
1900
|
+
} catch (error) {
|
|
1901
|
+
return null
|
|
1902
|
+
}
|
|
1903
|
+
if (event === null || typeof event !== 'object' || event.type !== 'session') return null
|
|
1904
|
+
if (typeof event.id !== 'string' || event.id.length === 0) return null
|
|
1905
|
+
return {
|
|
1906
|
+
id: event.id,
|
|
1907
|
+
parentSession: typeof event.parentSession === 'string' && event.parentSession.length > 0
|
|
1908
|
+
? event.parentSession
|
|
1909
|
+
: null,
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
/* 读头部时先只看文件最前面这么多字节:第一帧几乎总在这一段里,
|
|
1914
|
+
而真实会话日志单个能到几 MB —— 为了读一行 header 把整份读进内存太浪费。 */
|
|
1915
|
+
const HEADER_HEAD_BYTES = 64 * 1024
|
|
1916
|
+
|
|
1917
|
+
/** 读文件开头的一小段(拿不到就返回 null,交给调用方退回全文读)。 */
|
|
1918
|
+
function readFileHead(file, bytes) {
|
|
1919
|
+
let fd = null
|
|
1920
|
+
try {
|
|
1921
|
+
fd = openSync(file, 'r')
|
|
1922
|
+
const buf = Buffer.allocUnsafe(bytes)
|
|
1923
|
+
const read = readSync(fd, buf, 0, bytes, 0)
|
|
1924
|
+
return read > 0 ? buf.subarray(0, read) : null
|
|
1925
|
+
} catch (error) {
|
|
1926
|
+
return null
|
|
1927
|
+
} finally {
|
|
1928
|
+
if (fd !== null) {
|
|
1929
|
+
try {
|
|
1930
|
+
closeSync(fd)
|
|
1931
|
+
} catch (error) {
|
|
1932
|
+
/* 关不上不影响已经读到的内容 */
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
/** 读一个会话日志文件的头部。读不出 / 解不出 / 没有头部一律返回 null,绝不抛。 */
|
|
1939
|
+
function sessionHeaderOfFile(file) {
|
|
1940
|
+
/* 快路径:只读前 64 KB。首帧完整落在这一段里时就直接拿到头部;否则退回全文读一遍。 */
|
|
1941
|
+
const head = readFileHead(file, HEADER_HEAD_BYTES)
|
|
1942
|
+
if (head !== null) {
|
|
1943
|
+
const fast = firstFrameText(head)
|
|
1944
|
+
const parsed = sessionHeaderOf(fast === null ? '' : fast)
|
|
1945
|
+
if (parsed !== null) return parsed
|
|
1946
|
+
}
|
|
1947
|
+
let buf
|
|
1948
|
+
try {
|
|
1949
|
+
buf = readFileSync(file)
|
|
1950
|
+
} catch (error) {
|
|
1951
|
+
return null
|
|
1952
|
+
}
|
|
1953
|
+
const text = zstdText(buf)
|
|
1954
|
+
return text === null ? null : sessionHeaderOf(text)
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
/**
|
|
1958
|
+
* 一条事件里的 token 用量:`data.usage` 是兜底,stream 里的 usage chunk 才是权威值
|
|
1959
|
+
* (与 sessionUsage 投影同口径)。assistant/attempt 这类事件只有 stream 里带 usage。
|
|
1960
|
+
*/
|
|
1961
|
+
function usageOfEvent(event) {
|
|
1962
|
+
const data = event.data
|
|
1963
|
+
if (data === null || data === undefined || typeof data !== 'object') return null
|
|
1964
|
+
let usage = data.usage !== undefined ? data.usage : event.usage
|
|
1965
|
+
if (Array.isArray(data.stream)) {
|
|
1966
|
+
for (const node of data.stream) {
|
|
1967
|
+
if (node === null || node === undefined) continue
|
|
1968
|
+
if (node.chunk !== undefined && node.chunk !== null && node.chunk.type === 'usage' && node.chunk.usage !== undefined) {
|
|
1969
|
+
usage = node.chunk.usage
|
|
1970
|
+
}
|
|
1971
|
+
if (node.type === 'usage' && node.usage !== undefined) usage = node.usage
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
if (usage === undefined || usage === null || typeof usage !== 'object') return null
|
|
1975
|
+
return usage
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
/**
|
|
1979
|
+
* 一笔消耗在日志里的身份:一次模型调用只该对应一个 key。
|
|
1980
|
+
* turn/step 是最自然的粒度(一个 step 一次模型调用);压缩摘要是独立调用,按 compactionId;
|
|
1981
|
+
* 两者都没有时退回 seq。
|
|
1982
|
+
*/
|
|
1983
|
+
function usageEventKey(event) {
|
|
1984
|
+
const data = event.data !== null && event.data !== undefined && typeof event.data === 'object' ? event.data : {}
|
|
1985
|
+
if (event.type === 'compaction/summary') {
|
|
1986
|
+
return 'compact:' + String(data.compactionId !== undefined && data.compactionId !== null ? data.compactionId : event.seq)
|
|
1987
|
+
}
|
|
1988
|
+
if (typeof data.turn === 'number' && typeof data.step === 'number') {
|
|
1989
|
+
return 'turn:' + String(data.turn) + ':' + String(data.step)
|
|
1990
|
+
}
|
|
1991
|
+
return 'seq:' + String(event.seq)
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
/**
|
|
1995
|
+
* 从一段日志文本里挑出窗口内的用量事件。
|
|
1996
|
+
* 同一个 key 只留一条,且 `assistant/message` 优先 —— 这样重试的 attempt 与最终消息
|
|
1997
|
+
* 不会被算两次,而「压缩摘要」这类独立消耗照常计入。
|
|
1998
|
+
*/
|
|
1999
|
+
function pickUsageEvents(text, fromMs) {
|
|
2000
|
+
const picked = new Map()
|
|
2001
|
+
for (const line of text.split('\n')) {
|
|
2002
|
+
if (line.indexOf('"usage"') < 0) continue
|
|
2003
|
+
let event
|
|
2004
|
+
try {
|
|
2005
|
+
event = JSON.parse(line)
|
|
2006
|
+
} catch (error) {
|
|
2007
|
+
continue
|
|
2008
|
+
}
|
|
2009
|
+
if (event === null || typeof event !== 'object') continue
|
|
2010
|
+
if (typeof event.time !== 'number' || event.time < fromMs) continue
|
|
2011
|
+
const usage = usageOfEvent(event)
|
|
2012
|
+
if (usage === null) continue
|
|
2013
|
+
const parts = usageParts(usage)
|
|
2014
|
+
/* 被中断的尝试会留下一条全 0 的 usage:没花钱,也不该占一次调用 */
|
|
2015
|
+
if (parts.input === 0 && parts.cacheRead === 0 && parts.cacheWrite === 0 && parts.output === 0) continue
|
|
2016
|
+
const isMessage = event.type === 'assistant/message'
|
|
2017
|
+
const key = usageEventKey(event)
|
|
2018
|
+
const prev = picked.get(key)
|
|
2019
|
+
if (prev === undefined || (prev.isMessage !== true && isMessage)) {
|
|
2020
|
+
const data = event.data !== null && event.data !== undefined && typeof event.data === 'object' ? event.data : {}
|
|
2021
|
+
const source = data.message !== null && data.message !== undefined && typeof data.message === 'object'
|
|
2022
|
+
&& data.message.source !== null && data.message.source !== undefined && typeof data.message.source === 'object'
|
|
2023
|
+
? data.message.source
|
|
2024
|
+
: null
|
|
2025
|
+
/* 模型名的两个来源:assistant/message 在 message.source.model,
|
|
2026
|
+
而 compaction/summary(压缩摘要本身是一次真实模型调用)把模型放在顶层 data.model ——
|
|
2027
|
+
只认前者会让整笔摘要用量落进「(未知模型)」欠账,永远结算不出来。 */
|
|
2028
|
+
const model = source !== null && typeof source.model === 'string'
|
|
2029
|
+
? source.model
|
|
2030
|
+
: (typeof data.model === 'string' && data.model.length > 0 ? data.model : null)
|
|
2031
|
+
picked.set(key, {
|
|
2032
|
+
seq: typeof event.seq === 'number' ? event.seq : Math.round(event.time),
|
|
2033
|
+
time: event.time,
|
|
2034
|
+
parts: parts,
|
|
2035
|
+
isMessage: isMessage,
|
|
2036
|
+
/* 计价要用模型名(不同模型单价不同) */
|
|
2037
|
+
model: model,
|
|
2038
|
+
})
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
2041
|
+
return picked
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
/* ------------------------------------------- 子智能体用量(「总计」要算上队友) */
|
|
2045
|
+
|
|
2046
|
+
/* 后代上限:**不是产品限制**,只用来防畸形数据(例如被污染成上千个会话目录的 sessions 树)
|
|
2047
|
+
把后台拖死 —— 用户明确要求"所有队友的对话都要算",所以这个值取得远大于任何真实
|
|
2048
|
+
Agent Teams 树(真实场景是几十个会话),正常情况永远不触发;真触发了才带 truncated: true。 */
|
|
2049
|
+
const SUBAGENT_MAX_DESCENDANTS = 2000
|
|
2050
|
+
|
|
2051
|
+
/* file → `{ mtimeMs, size, header }`(header 为 null = 这个文件读不出头部)。
|
|
2052
|
+
**失败也缓存**:坏文件、非日志文件、正在写的文件不该每次请求都重读两遍(64KB 快路径 + 全文退回)。
|
|
2053
|
+
靠 mtime+size 判断"还是那一份":文件一变(正常写入都会让 size/mtime 变)就自动重试 ——
|
|
2054
|
+
刚建好、header 还没落盘的文件下一次请求就能认出来,而真正的坏文件不会一直被反复读。
|
|
2055
|
+
头部里的 id / parentSession 是会话创建时写死的,所以读成功的那份不会失效,只有 stat 变了才重读。
|
|
2056
|
+
上限 SUBAGENT_HEADER_CACHE_MAX 条,超出丢最久没被重新写入的(Map 的插入顺序)。 */
|
|
2057
|
+
const SUBAGENT_HEADER_CACHE_MAX = 512
|
|
2058
|
+
const subagentHeaders = new Map()
|
|
2059
|
+
|
|
2060
|
+
/** 两份同名会话文件谁更新:先比 mtime,一样再比 size(更大的一般更完整)。 */
|
|
2061
|
+
function headerEntryNewer(candidate, current) {
|
|
2062
|
+
if (candidate.mtimeMs !== current.mtimeMs) return candidate.mtimeMs > current.mtimeMs
|
|
2063
|
+
return candidate.size > current.size
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
/**
|
|
2067
|
+
* 会话文件 → `parentSession → [子会话]` 邻接表(外加 `duplicates` 计数,只用来打日志)。
|
|
2068
|
+
*
|
|
2069
|
+
* 为什么要拆成"先建索引、再算用量":索引只需要读每个文件的**第一帧**(很便宜),
|
|
2070
|
+
* 而用量要把后代日志整份解压(很贵)。索引按 `文件 + mtimeMs + size` 增量维护(失败也缓存),
|
|
2071
|
+
* 用量那份按 `(父会话, 参与文件 mtime+size, 窗口, 价格指纹)` 缓存,两层各管一层失效。
|
|
2072
|
+
*/
|
|
2073
|
+
async function subagentIndexSnapshot(files) {
|
|
2074
|
+
const live = new Set(files)
|
|
2075
|
+
for (const file of Array.from(subagentHeaders.keys())) {
|
|
2076
|
+
if (live.has(file) !== true) subagentHeaders.delete(file)
|
|
2077
|
+
}
|
|
2078
|
+
for (let i = 0; i < files.length; i += 1) {
|
|
2079
|
+
const file = files[i]
|
|
2080
|
+
let stat = null
|
|
2081
|
+
try {
|
|
2082
|
+
stat = statSync(file)
|
|
2083
|
+
} catch (error) {
|
|
2084
|
+
stat = null
|
|
2085
|
+
}
|
|
2086
|
+
if (stat === null) {
|
|
2087
|
+
/* 文件在遍历中消失:连同缓存一起丢掉,下次它再出现就是新的一份 */
|
|
2088
|
+
subagentHeaders.delete(file)
|
|
2089
|
+
continue
|
|
2090
|
+
}
|
|
2091
|
+
const cached = subagentHeaders.get(file)
|
|
2092
|
+
if (cached !== undefined && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) continue
|
|
2093
|
+
/* 读失败(含"没有头部")也记下来:同一个 mtime/size 的下一问直接跳过,不再重读 */
|
|
2094
|
+
const header = sessionHeaderOfFile(file)
|
|
2095
|
+
subagentHeaders.delete(file)
|
|
2096
|
+
subagentHeaders.set(file, { mtimeMs: stat.mtimeMs, size: stat.size, header })
|
|
2097
|
+
while (subagentHeaders.size > SUBAGENT_HEADER_CACHE_MAX) {
|
|
2098
|
+
const oldest = subagentHeaders.keys().next()
|
|
2099
|
+
if (oldest.done === true) break
|
|
2100
|
+
subagentHeaders.delete(oldest.value)
|
|
2101
|
+
}
|
|
2102
|
+
/* 首次请求要把所有会话都读一遍头部:每 8 个让出一次事件循环,别把 host 卡住 */
|
|
2103
|
+
if (i % 8 === 7) await new Promise((resolve) => setImmediate(resolve))
|
|
2104
|
+
}
|
|
2105
|
+
/* 一个会话 id 可能出现在两个文件里:① 日志格式升级时同目录留下旧版本文件;
|
|
2106
|
+
② 手工复制会话目录;③ 异常产生的重复目录。只认**最新的那一份**参与聚合
|
|
2107
|
+
(mtime 相同则取更大的那份):既防同一批用量算两遍,也不让旧文件里的过期数字
|
|
2108
|
+
盖掉新文件。完全相同(mtime 与 size 都一样)时保留先读到的那份,保证结果稳定。 */
|
|
2109
|
+
const picked = new Map()
|
|
2110
|
+
subagentHeaders.forEach((entry, file) => {
|
|
2111
|
+
if (entry.header === null) return
|
|
2112
|
+
const one = {
|
|
2113
|
+
id: entry.header.id, file: file, parentSession: entry.header.parentSession,
|
|
2114
|
+
mtimeMs: entry.mtimeMs, size: entry.size,
|
|
2115
|
+
}
|
|
2116
|
+
const prev = picked.get(one.id)
|
|
2117
|
+
if (prev === undefined || headerEntryNewer(one, prev) === true) picked.set(one.id, one)
|
|
2118
|
+
})
|
|
2119
|
+
const children = new Map()
|
|
2120
|
+
picked.forEach((one) => {
|
|
2121
|
+
if (one.parentSession === null) return
|
|
2122
|
+
const list = children.get(one.parentSession) ?? []
|
|
2123
|
+
/* 把 stat 一起带上:后面算用量时就不必再对同一批文件 stat 一遍
|
|
2124
|
+
(队友多时这一遍是实打实的开销),mtime/size 也就是缓存键要用的那两个值 */
|
|
2125
|
+
list.push({ id: one.id, file: one.file, parentSession: one.parentSession, mtimeMs: one.mtimeMs, size: one.size })
|
|
2126
|
+
children.set(one.parentSession, list)
|
|
2127
|
+
})
|
|
2128
|
+
let withHeader = 0
|
|
2129
|
+
subagentHeaders.forEach((entry) => { if (entry.header !== null) withHeader += 1 })
|
|
2130
|
+
return { children, duplicates: withHeader - picked.size, byId: picked }
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
/**
|
|
2134
|
+
* 一段父会话日志里的"本轮起点":最后一条 `turn/start` 的时间 —— 但**和最后一条
|
|
2135
|
+
* `user/message` 取较大的那个**。都找不到就返回 null(调用方据此判 sinceSource='none')。
|
|
2136
|
+
*
|
|
2137
|
+
* 为什么不是"有 turn/start 就优先用它":用户消息比最后一条 turn/start 还新,说明
|
|
2138
|
+
* **新一轮已经开始、而它的 `turn/start` 还没落盘**;此时沿用旧的 turn/start 会把
|
|
2139
|
+
* **上一轮**的队友用量算进"本轮",方向正是用户不允许的"偏大"。取较新的那个时间
|
|
2140
|
+
* 只会把窗口往回收(宁少不多),符合"本轮只能小不能大"。
|
|
2141
|
+
* 取**最大值**而不是"文件里最后一行":正常日志是追加写的,两者一致;万一有乱序行,
|
|
2142
|
+
* 取最大值更稳。
|
|
2143
|
+
*/
|
|
2144
|
+
function lastTurnStartOfText(text) {
|
|
2145
|
+
if (typeof text !== 'string' || text.length === 0) return null
|
|
2146
|
+
let at = null
|
|
2147
|
+
const lines = text.split('\n')
|
|
2148
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
2149
|
+
const line = lines[i]
|
|
2150
|
+
if (line.indexOf('"turn/start"') < 0 && line.indexOf('"user/message"') < 0) continue
|
|
2151
|
+
let event
|
|
2152
|
+
try {
|
|
2153
|
+
event = JSON.parse(line)
|
|
2154
|
+
} catch (error) {
|
|
2155
|
+
continue
|
|
2156
|
+
}
|
|
2157
|
+
if (event === null || typeof event !== 'object' || typeof event.time !== 'number') continue
|
|
2158
|
+
if (event.type !== 'turn/start' && event.type !== 'user/message') continue
|
|
2159
|
+
if (at === null || event.time > at) at = event.time
|
|
2160
|
+
}
|
|
2161
|
+
return at
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
/**
|
|
2165
|
+
* 一段父会话日志里**最后一条用量事件**的时间(`assistant/message` 或 `compaction/summary`
|
|
2166
|
+
* 且带非空 usage)。只服务于一个问题:"这个窗口起点之后,父会话自己到底产出过没有?"
|
|
2167
|
+
*
|
|
2168
|
+
* 只留**最大值**(一个数字)而不是把整串事件时间攒下来:要回答的是存在性
|
|
2169
|
+
* ("有没有 time >= fromMs 的用量事件" ⟺ "最大用量时间 >= fromMs"),
|
|
2170
|
+
* 而父会话日志是整棵树里最大的那一份,不该为一道布尔判断在内存里留一串时间戳。
|
|
2171
|
+
* 全 0 的 usage(被中断的尝试)不算产出 —— 与 pickUsageEvents 的口径一致。
|
|
2172
|
+
*/
|
|
2173
|
+
function lastUsageEventTimeOfText(text) {
|
|
2174
|
+
if (typeof text !== 'string' || text.length === 0) return null
|
|
2175
|
+
let at = null
|
|
2176
|
+
const lines = text.split('\n')
|
|
2177
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
2178
|
+
const line = lines[i]
|
|
2179
|
+
/* 两道便宜的字符串筛(与 pickUsageEvents / foldSessionTiming 同一套省法):
|
|
2180
|
+
绝大多数行既没有 usage 也不是这两类事件,逐行 JSON.parse 太贵 */
|
|
2181
|
+
if (line.indexOf('"usage"') < 0) continue
|
|
2182
|
+
if (line.indexOf('"assistant/message"') < 0 && line.indexOf('"compaction/summary"') < 0) continue
|
|
2183
|
+
let event
|
|
2184
|
+
try {
|
|
2185
|
+
event = JSON.parse(line)
|
|
2186
|
+
} catch (error) {
|
|
2187
|
+
continue
|
|
2188
|
+
}
|
|
2189
|
+
if (event === null || typeof event !== 'object' || typeof event.time !== 'number') continue
|
|
2190
|
+
if (event.type !== 'assistant/message' && event.type !== 'compaction/summary') continue
|
|
2191
|
+
const usage = usageOfEvent(event)
|
|
2192
|
+
if (usage === null) continue
|
|
2193
|
+
const parts = usageParts(usage)
|
|
2194
|
+
if (parts.input === 0 && parts.cacheRead === 0 && parts.cacheWrite === 0 && parts.output === 0) continue
|
|
2195
|
+
if (at === null || event.time > at) at = event.time
|
|
2196
|
+
}
|
|
2197
|
+
return at
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
/* 父会话日志 → 它自己的"本轮事实":①本轮起点 ②窗口内有没有它自己的用量事件。
|
|
2201
|
+
这个文件每次对话都在写,但只是解压一份日志、折两个标量,代价很低;照样按
|
|
2202
|
+
`文件 + mtime + size` 记忆化(内容一变就重折)—— 两件事**共用同一次解压**,
|
|
2203
|
+
免得每秒的请求为了校验起点再把这棵树上最大的日志解第二遍。
|
|
2204
|
+
上限 SUBAGENT_SINCE_CACHE_MAX 条,超出丢最久没被重新写入的。 */
|
|
2205
|
+
const SUBAGENT_SINCE_CACHE_MAX = 32
|
|
2206
|
+
const subagentSinceCache = new Map()
|
|
2207
|
+
|
|
2208
|
+
function parentLogFactsOf(one) {
|
|
2209
|
+
const key = one.file + '|' + String(one.mtimeMs) + '|' + String(one.size)
|
|
2210
|
+
const hit = subagentSinceCache.get(key)
|
|
2211
|
+
if (hit !== undefined) return hit
|
|
2212
|
+
let text = null
|
|
2213
|
+
try {
|
|
2214
|
+
text = zstdText(readFileSync(one.file))
|
|
2215
|
+
} catch (error) {
|
|
2216
|
+
text = null
|
|
2217
|
+
}
|
|
2218
|
+
const value = text === null
|
|
2219
|
+
? { since: null, lastUsageAt: null }
|
|
2220
|
+
: { since: lastTurnStartOfText(text), lastUsageAt: lastUsageEventTimeOfText(text) }
|
|
2221
|
+
subagentSinceCache.set(key, value)
|
|
2222
|
+
while (subagentSinceCache.size > SUBAGENT_SINCE_CACHE_MAX) {
|
|
2223
|
+
const oldest = subagentSinceCache.keys().next()
|
|
2224
|
+
if (oldest.done === true) break
|
|
2225
|
+
subagentSinceCache.delete(oldest.value)
|
|
2226
|
+
}
|
|
2227
|
+
return value
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
/** 父会话日志 → 它自己的"本轮起点"(推不出来给 null)。薄壳,只为既有调用点读起来更直白。 */
|
|
2231
|
+
function parentTurnStartOf(one) {
|
|
2232
|
+
return parentLogFactsOf(one).since
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
/* 每个父会话一份结果:`sessionId → { key, value }`,key 见 collectSubagentUsage。
|
|
2236
|
+
底栏可能同时开着好几个会话,单槽缓存会被别的会话的请求挤掉 —— 那就等于没有缓存。
|
|
2237
|
+
最多留 8 份,超出丢最久没用的(Map 的插入顺序)。 */
|
|
2238
|
+
const SUBAGENT_CACHE_MAX = 8
|
|
2239
|
+
const subagentUsageCache = new Map()
|
|
2240
|
+
|
|
2241
|
+
/** 会话日志里的一笔用量折成钱(6 位小数,与台账的金额口径一致)。 */
|
|
2242
|
+
function roundCost(value) {
|
|
2243
|
+
return Math.round(value * 1e6) / 1e6
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
/** 四项 token 的空桶。 */
|
|
2247
|
+
function zeroTokens() {
|
|
2248
|
+
return { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 }
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
/**
|
|
2252
|
+
* 给返回值补上 `sinceClamped`:**本次请求**的起点是否被 host 收敛过。
|
|
2253
|
+
*
|
|
2254
|
+
* 为什么不在缓存里存:收敛后的窗口与"客户端本来就给对了"的窗口是同一个值,
|
|
2255
|
+
* 两条请求共用同一条缓存(这是有意的,避免同一窗口算两遍),所以"是否收敛"
|
|
2256
|
+
* 是**每次请求自己的事实**,不能烘进缓存对象里 —— 否则先来的那次收敛会把标记
|
|
2257
|
+
* 传染给后面给对了起点的请求。不收敛时原样返回(走缓存的那条零拷贝路径)。
|
|
2258
|
+
*/
|
|
2259
|
+
function withClampFlag(value, clamped) {
|
|
2260
|
+
if (clamped !== true) return value
|
|
2261
|
+
return Object.assign({}, value, { sinceClamped: true })
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
/**
|
|
2265
|
+
* 一条会话日志的「首字 / 解码」累计:给底栏那三项(首字平均、综合速度、缓存命中)里
|
|
2266
|
+
* 属于子智能体的那一份用。
|
|
2267
|
+
*
|
|
2268
|
+
* 子会话借不到官方 `sessionStats`(那是 **per-session** 的),只能照 `sessionTimingProjection`
|
|
2269
|
+
* 那套逐次折法自己再来一遍,规则必须与它**逐条一致**,否则底栏那个"会话级"数字
|
|
2270
|
+
* 与气泡里的极值会是两套口径:
|
|
2271
|
+
* · `step/start` 记下 startTime;
|
|
2272
|
+
* · 首个 token 时间来自 `assistant/attempt` / `assistant/message` 的 stream(工具调用分片也算);
|
|
2273
|
+
* · `ttft = 首个 token 时间 − startTime`,每个组装出 message 的 step 记一次;
|
|
2274
|
+
* · `decode = message 时间 − 首个 token 时间`,`output` 取 `data.usage.outputTokens`;
|
|
2275
|
+
* 只把「非纯工具调用、decode > 0、有 output」的 step 计入速度分子分母(纯工具调用的分片
|
|
2276
|
+
* 时间戳挤在同一刻,算出来是假值 —— 首字照旧统计)。
|
|
2277
|
+
* 与投影一样只在有对应 `step/start` 的前提下计数,重试的第二条 message 不会重复计入。
|
|
2278
|
+
* 返回 `{ ttftMs, ttftSteps, decodeMs, decodeTokens }`;坏行/坏 JSON 一律跳过。
|
|
2279
|
+
*/
|
|
2280
|
+
function foldSessionTiming(text) {
|
|
2281
|
+
const out = { ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0 }
|
|
2282
|
+
if (typeof text !== 'string' || text.length === 0) return out
|
|
2283
|
+
const lines = text.split('\n')
|
|
2284
|
+
let open = null
|
|
2285
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
2286
|
+
const line = lines[i]
|
|
2287
|
+
/* 先按类型子串筛一道:整份日志里绝大多数行既不是 step/start 也不是 assistant/*,
|
|
2288
|
+
逐行 JSON.parse 太贵 */
|
|
2289
|
+
if (line.indexOf('"step/start"') < 0 && line.indexOf('"assistant/') < 0) continue
|
|
2290
|
+
let event
|
|
2291
|
+
try {
|
|
2292
|
+
event = JSON.parse(line)
|
|
2293
|
+
} catch (error) {
|
|
2294
|
+
continue
|
|
2295
|
+
}
|
|
2296
|
+
if (event === null || typeof event !== 'object') continue
|
|
2297
|
+
const data = event.data !== null && event.data !== undefined && typeof event.data === 'object' ? event.data : null
|
|
2298
|
+
if (event.type === 'step/start') {
|
|
2299
|
+
open = data === null || typeof event.time !== 'number'
|
|
2300
|
+
? null
|
|
2301
|
+
: { turn: data.turn, step: data.step, startTime: event.time, firstTokenTime: null, firstTokenTool: false }
|
|
2302
|
+
continue
|
|
2303
|
+
}
|
|
2304
|
+
if (open === null || data === null) continue
|
|
2305
|
+
/* 与投影一样:只认当前这个 step 的事件(跨轮/跨步的事件不参与) */
|
|
2306
|
+
if (data.turn !== open.turn || data.step !== open.step) continue
|
|
2307
|
+
if (event.type === 'assistant/attempt') {
|
|
2308
|
+
if (open.firstTokenTime !== null) continue
|
|
2309
|
+
const first = firstStreamToken(data.stream)
|
|
2310
|
+
if (first === undefined || typeof first.time !== 'number') continue
|
|
2311
|
+
open.firstTokenTime = first.time
|
|
2312
|
+
open.firstTokenTool = first.tool === true
|
|
2313
|
+
continue
|
|
2314
|
+
}
|
|
2315
|
+
if (event.type !== 'assistant/message') continue
|
|
2316
|
+
const started = open.startTime
|
|
2317
|
+
const first = open.firstTokenTime !== null
|
|
2318
|
+
? { time: open.firstTokenTime, tool: open.firstTokenTool === true }
|
|
2319
|
+
: firstStreamToken(data.stream)
|
|
2320
|
+
open = null
|
|
2321
|
+
if (first === undefined || typeof first.time !== 'number' || typeof event.time !== 'number') continue
|
|
2322
|
+
out.ttftMs += Math.max(0, first.time - started)
|
|
2323
|
+
out.ttftSteps += 1
|
|
2324
|
+
const output = stepOutputTokens(data.usage)
|
|
2325
|
+
const decode = Math.max(0, event.time - first.time)
|
|
2326
|
+
if (output !== null && decode > 0 && first.tool !== true) {
|
|
2327
|
+
out.decodeMs += decode
|
|
2328
|
+
out.decodeTokens += output
|
|
2329
|
+
}
|
|
2330
|
+
}
|
|
2331
|
+
return out
|
|
2332
|
+
}
|
|
2333
|
+
|
|
2334
|
+
/* 单个后代文件在"某个 mtime/size + 某个窗口起点 + 某个价格指纹"下的汇总。
|
|
2335
|
+
队友一边写、底栏一边每秒一问:一次变化只该重解压**变了的那一份**,
|
|
2336
|
+
其余文件直接复用上次的结果 —— 否则每问一次就要把所有队友的日志重解一遍。
|
|
2337
|
+
键里带 mtime/size(文件一变就换键)与价格指纹(改价后必须按新价重算,
|
|
2338
|
+
否则「总计」里本会话用新价、队友用旧价,一个数字混两套价)。
|
|
2339
|
+
上限必须 ≥「后代数 × 窗口数」:客户端固定问两个窗口(整会话 + 本轮),
|
|
2340
|
+
所以 64 这种小上限在几十个队友时就会互相驱逐,**每次有文件变化都退化成全量解压**
|
|
2341
|
+
(实测 120 个队友改 1 个文件:上限 64 时读 121 份 / 75ms)。取 4096 = 后代上限 2000 × 2,
|
|
2342
|
+
正常永远不淘汰;每个条目只有几个数字,就算填满也就几十万个数字量级的内存。 */
|
|
2343
|
+
const SUBAGENT_FILE_CACHE_MAX = 4096
|
|
2344
|
+
const subagentFileCache = new Map()
|
|
2345
|
+
|
|
2346
|
+
/** 一个后代文件的用量汇总(带记忆化,键 = 文件 + mtime + size + 窗口起点 + 价格指纹)。
|
|
2347
|
+
返回里带 `stats`(首字/解码累计,与窗口和价格都无关,只是搭同一次读一起折出来)。 */
|
|
2348
|
+
function subagentFileUsage(one, fromMs, fingerprint) {
|
|
2349
|
+
const aggKey = one.file + '|' + String(one.mtimeMs) + '|' + String(one.size) + '|' + String(fromMs)
|
|
2350
|
+
+ '|' + fingerprint
|
|
2351
|
+
const hit = subagentFileCache.get(aggKey)
|
|
2352
|
+
if (hit !== undefined) return hit
|
|
2353
|
+
const agg = { cost: 0, byCurrency: {}, tokens: zeroTokens(), calls: 0, stats: { ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0 } }
|
|
2354
|
+
let text = null
|
|
2355
|
+
try {
|
|
2356
|
+
text = zstdText(readFileSync(one.file))
|
|
2357
|
+
} catch (error) {
|
|
2358
|
+
/* 解不动 / 文件没了:这一份当 0,其余后代照算 */
|
|
2359
|
+
text = null
|
|
2360
|
+
}
|
|
2361
|
+
if (text !== null) {
|
|
2362
|
+
/* fromMs = 客户端要的窗口起点(本轮起点);pickUsageEvents 就按 event.time 过滤 */
|
|
2363
|
+
const picked = pickUsageEvents(text, fromMs)
|
|
2364
|
+
picked.forEach((event) => {
|
|
2365
|
+
const parts = event.parts
|
|
2366
|
+
agg.tokens.input += parts.input
|
|
2367
|
+
agg.tokens.cacheRead += parts.cacheRead
|
|
2368
|
+
agg.tokens.cacheWrite += parts.cacheWrite
|
|
2369
|
+
agg.tokens.output += parts.output
|
|
2370
|
+
/* 一次 usage 事件 = 一次调用(pickUsageEvents 已按 turn/step 去过重、全 0 的不算) */
|
|
2371
|
+
agg.calls += 1
|
|
2372
|
+
const amount = tokenCost(event.model, event.time, parts)
|
|
2373
|
+
agg.cost += amount
|
|
2374
|
+
/* 同一笔同时按"模型条目的币种"分桶:客户端混币时要折成一个数字(见 tokenCostCurrency) */
|
|
2375
|
+
const code = tokenCostCurrency(event.model)
|
|
2376
|
+
agg.byCurrency[code] = (agg.byCurrency[code] ?? 0) + amount
|
|
2377
|
+
})
|
|
2378
|
+
agg.stats = foldSessionTiming(text)
|
|
2379
|
+
}
|
|
2380
|
+
subagentFileCache.set(aggKey, agg)
|
|
2381
|
+
while (subagentFileCache.size > SUBAGENT_FILE_CACHE_MAX) {
|
|
2382
|
+
const oldest = subagentFileCache.keys().next()
|
|
2383
|
+
if (oldest.done === true) break
|
|
2384
|
+
subagentFileCache.delete(oldest.value)
|
|
2385
|
+
}
|
|
2386
|
+
return agg
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
/**
|
|
2390
|
+
* 从 `sessionId` 出发,把它**全部后代**(多层:队友再开队友)的用量汇总起来。
|
|
2391
|
+
*
|
|
2392
|
+
* 只统计后代,不统计父会话自己 —— 本会话的费用客户端已经算在「总计」里了。
|
|
2393
|
+
* 计价走当前价格库(`tokenCost` → `priceEntryFor`),与底栏、台账同一套单价。
|
|
2394
|
+
*
|
|
2395
|
+
* 窗口起点(`sinceMs`,毫秒)的三种来源,见 `sinceSource`:
|
|
2396
|
+
* · `client`:客户端传了(它精确知道自己那一轮)→ 只看 `event.time >= sinceMs`;
|
|
2397
|
+
* **但客户端的起点可能过期**(新一轮已写 `turn/start`、客户端节点还没跟上,
|
|
2398
|
+
* 它会把上一轮的起点送上来),所以再用父会话日志推的起点**按 max 收敛一道**
|
|
2399
|
+
* (只收紧、不放大;`since=0` 是显式整会话,不收敛);真收敛了会带
|
|
2400
|
+
* `sinceClamped: true`(`sinceSource` 仍是 `client`);
|
|
2401
|
+
* · `host`:没传 → host 读**父会话自己**的日志推一个起点(最后一条 `turn/start`,
|
|
2402
|
+
* 但和最后一条 `user/message` 取较新的那个 —— 后者更新说明新一轮已开始、其
|
|
2403
|
+
* `turn/start` 还没落盘,沿用旧起点会把上一轮算进来);而且**推出来的起点之后
|
|
2404
|
+
* 必须真的有用量事件**,否则说明起点无从验证,同样当 `none`(宁少不多);
|
|
2405
|
+
* · `none`:父会话日志缺失/读不出,或推出来的起点事后没有事件 → 本轮**什么都不并**
|
|
2406
|
+
* (整条返回零值),绝不退回整会话 —— 用户规则:本轮只能小不能大;
|
|
2407
|
+
* 要整会话(「总计」那一项)就显式传 `since=0`。
|
|
2408
|
+
* 全程只读会话日志,不碰台账、不写任何文件。
|
|
2409
|
+
*
|
|
2410
|
+
* **排进台账队列**(不是怕写冲突,是怕混价):价格库的换入换出发生在 `queueLedgerJob` 里,
|
|
2411
|
+
* 排在同一条队列上就不会"扫到一半换了价"(否则同一个数字里前几笔按旧价、后几笔按新价)。
|
|
2412
|
+
* 顺带还解决了并发去重:客户端一次到期会并发问两个窗口,串行执行时后一个必然命中
|
|
2413
|
+
* 前一个刚写好的缓存,同一批日志不会被重复冷解压。
|
|
2414
|
+
*/
|
|
2415
|
+
function collectSubagentUsage(sessionId, sinceMs) {
|
|
2416
|
+
return queueLedgerJob(() => collectSubagentUsageNow(sessionId, sinceMs))
|
|
2417
|
+
}
|
|
2418
|
+
|
|
2419
|
+
async function collectSubagentUsageNow(sessionId, sinceMs) {
|
|
2420
|
+
/* 价格指纹:并进两个缓存键。改价后必须按新价重算 —— 否则队友那段会一直挂着旧价,
|
|
2421
|
+
同一个「总计」里本会话用新价、队友用旧价(实测缺陷)。 */
|
|
2422
|
+
const fingerprint = priceFingerprint()
|
|
2423
|
+
const clientSince = typeof sinceMs === 'number' && Number.isFinite(sinceMs) && sinceMs >= 0 ? sinceMs : null
|
|
2424
|
+
const files = collectSessionFiles(sessionsRoot(), [])
|
|
2425
|
+
const index = await subagentIndexSnapshot(files)
|
|
2426
|
+
/* BFS 收全部后代:环形日志(A→B→A)靠 visited 兜住,不会转不出来 */
|
|
2427
|
+
const found = []
|
|
2428
|
+
const visited = new Set([sessionId])
|
|
2429
|
+
const queue = [sessionId]
|
|
2430
|
+
let truncated = false
|
|
2431
|
+
while (queue.length > 0) {
|
|
2432
|
+
const parent = queue.shift()
|
|
2433
|
+
const kids = index.children.get(parent)
|
|
2434
|
+
if (kids === undefined) continue
|
|
2435
|
+
for (let i = 0; i < kids.length; i += 1) {
|
|
2436
|
+
const child = kids[i]
|
|
2437
|
+
if (visited.has(child.id) === true) continue
|
|
2438
|
+
visited.add(child.id)
|
|
2439
|
+
if (found.length >= SUBAGENT_MAX_DESCENDANTS) {
|
|
2440
|
+
truncated = true
|
|
2441
|
+
break
|
|
2442
|
+
}
|
|
2443
|
+
found.push(child)
|
|
2444
|
+
queue.push(child.id)
|
|
2445
|
+
}
|
|
2446
|
+
if (truncated === true) break
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2449
|
+
/* 窗口起点从哪来:① 客户端给了(它精确知道自己那一轮)→ `client`;
|
|
2450
|
+
② 没给就问 host 自己 —— 读**父会话自己**的日志取最后一条 turn/start(或更新的
|
|
2451
|
+
user/message,见 lastTurnStartOfText)→ `host`;③ 两者都没有(父会话文件缺失/读不出)
|
|
2452
|
+
→ `none`:**本轮什么都不并**(用户规则:本轮只能小不能大,绝不能拿整会话的量顶上)。
|
|
2453
|
+
另外 host 推的起点还要过一道"事后有事件"的验证(见下面聚合之后的那段)。 */
|
|
2454
|
+
const parentEntry = index.byId.get(sessionId)
|
|
2455
|
+
let sinceSource = 'none'
|
|
2456
|
+
let fromMs = null
|
|
2457
|
+
let clamped = false
|
|
2458
|
+
if (clientSince !== null) {
|
|
2459
|
+
sinceSource = 'client'
|
|
2460
|
+
fromMs = clientSince
|
|
2461
|
+
/* 客户端给的起点**可能已经过期**:它折叠的是对话节点,而新一轮从父会话写
|
|
2462
|
+
`turn/start` 到"本轮第一个 assistant 节点出现"之间有延迟(首字延迟量级,
|
|
2463
|
+
长思考/失败时更久),这期间客户端还会把**上一轮的起点**当 since 送上来,
|
|
2464
|
+
于是上一轮的队友用量被算进"本轮"(偏大)。这里用父会话日志推出来的起点
|
|
2465
|
+
兜一道:**只在收紧方向**生效(取较新的那个),绝不把窗口放大。
|
|
2466
|
+
`since=0` 是客户端显式要的整会话(「总计」那一问),不参与收敛。 */
|
|
2467
|
+
if (clientSince > 0 && parentEntry !== undefined) {
|
|
2468
|
+
const hostSince = parentTurnStartOf(parentEntry)
|
|
2469
|
+
if (hostSince !== null && hostSince > fromMs) {
|
|
2470
|
+
fromMs = hostSince
|
|
2471
|
+
clamped = true
|
|
2472
|
+
}
|
|
2473
|
+
}
|
|
2474
|
+
} else if (parentEntry !== undefined) {
|
|
2475
|
+
const hostSince = parentTurnStartOf(parentEntry)
|
|
2476
|
+
if (hostSince !== null) {
|
|
2477
|
+
sinceSource = 'host'
|
|
2478
|
+
fromMs = hostSince
|
|
2479
|
+
}
|
|
2480
|
+
}
|
|
2481
|
+
let hasWindow = fromMs !== null
|
|
2482
|
+
|
|
2483
|
+
/* 每个后代文件的 mtime+size 直接用索引里刚 stat 到的那一份:它既是"要不要重算"的缓存键,
|
|
2484
|
+
也顺手滤掉了已经消失的文件(索引阶段已经 stat 过一遍,这里不再重复 statSync ——
|
|
2485
|
+
队友上百个时那一遍是实打实的开销)。索引与这里只差几微秒,真在这中间被写入的话,
|
|
2486
|
+
下一次请求(每秒一回)就会重新 stat 到。 */
|
|
2487
|
+
const parts = []
|
|
2488
|
+
const reading = []
|
|
2489
|
+
for (let i = 0; i < found.length; i += 1) {
|
|
2490
|
+
reading.push({ id: found[i].id, file: found[i].file, mtimeMs: found[i].mtimeMs, size: found[i].size })
|
|
2491
|
+
parts.push(found[i].id + ':' + String(found[i].mtimeMs) + ':' + String(found[i].size))
|
|
2492
|
+
}
|
|
2493
|
+
/* 新出现的子会话会改变文件集合(索引重建后出现在 found 里),键自然就变了;
|
|
2494
|
+
价格指纹也在键里:改价后这一段必须按新价重算,否则队友那段会一直挂着旧价。
|
|
2495
|
+
例外:窗口是 **host 推的**(含推不出来)时,父会话日志也必须进键 ——
|
|
2496
|
+
否则"新一轮开始了"改变不了键,缓存会一直沿用旧窗口(或一直沿用推不出来的空结果)。 */
|
|
2497
|
+
const parentTag = parentEntry === undefined
|
|
2498
|
+
? 'parent:none'
|
|
2499
|
+
: 'parent:' + String(parentEntry.mtimeMs) + ':' + String(parentEntry.size)
|
|
2500
|
+
const cacheKey = sessionId + '@' + (hasWindow ? String(fromMs) : 'none') + '#' + fingerprint
|
|
2501
|
+
const keyParts = sinceSource === 'client' ? parts : parts.concat([parentTag])
|
|
2502
|
+
const key = cacheKey + '|' + keyParts.join('|')
|
|
2503
|
+
const cached = subagentUsageCache.get(cacheKey)
|
|
2504
|
+
if (cached !== undefined && cached.key === key) return withClampFlag(cached.value, clamped)
|
|
2505
|
+
|
|
2506
|
+
const total = { cost: 0, byCurrency: {}, tokens: zeroTokens(), calls: 0, stats: { ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0 } }
|
|
2507
|
+
const children = []
|
|
2508
|
+
for (let i = 0; i < reading.length; i += 1) {
|
|
2509
|
+
if (hasWindow !== true) {
|
|
2510
|
+
/* 没有窗口(sinceSource='none'):一个字节都不读 —— 本轮为空,但队友仍然列出来(都是 0),
|
|
2511
|
+
与"窗口里没有事件"保持同一种形状。total.stats 也随之为 0(它只服务于会话级那三项,
|
|
2512
|
+
客户端只从**整会话**那一份里读它)。 */
|
|
2513
|
+
children.push({ id: reading[i].id, cost: 0, tokens: zeroTokens(), calls: 0 })
|
|
2514
|
+
continue
|
|
2515
|
+
}
|
|
2516
|
+
const agg = subagentFileUsage(reading[i], fromMs, fingerprint)
|
|
2517
|
+
children.push({ id: reading[i].id, cost: roundCost(agg.cost), tokens: agg.tokens, calls: agg.calls })
|
|
2518
|
+
total.cost += agg.cost
|
|
2519
|
+
/* 分币种累计:客户端按参考价比例折成底栏显示币种后再相加(混币时一个数字) */
|
|
2520
|
+
for (const code of Object.keys(agg.byCurrency)) {
|
|
2521
|
+
total.byCurrency[code] = (total.byCurrency[code] ?? 0) + agg.byCurrency[code]
|
|
2522
|
+
}
|
|
2523
|
+
total.tokens.input += agg.tokens.input
|
|
2524
|
+
total.tokens.cacheRead += agg.tokens.cacheRead
|
|
2525
|
+
total.tokens.cacheWrite += agg.tokens.cacheWrite
|
|
2526
|
+
total.tokens.output += agg.tokens.output
|
|
2527
|
+
total.calls += agg.calls
|
|
2528
|
+
/* 首字 / 解码的累计:这三项客户端只用在"会话级"那三个数上,
|
|
2529
|
+
所以它是**整会话**口径、与 since 窗口无关(窗口那边客户端不看它)。 */
|
|
2530
|
+
total.stats.ttftMs += agg.stats.ttftMs
|
|
2531
|
+
total.stats.ttftSteps += agg.stats.ttftSteps
|
|
2532
|
+
total.stats.decodeMs += agg.stats.decodeMs
|
|
2533
|
+
total.stats.decodeTokens += agg.stats.decodeTokens
|
|
2534
|
+
if (i % 4 === 3) await new Promise((resolve) => setImmediate(resolve))
|
|
2535
|
+
}
|
|
2536
|
+
/* host 推出来的起点必须有"事后证据"才算有效。**证据有两条,任一条成立即可**:
|
|
2537
|
+
① 父会话**自己**在窗口内有用量事件(fromMs 之后有 assistant/message 或
|
|
2538
|
+
compaction/summary 且带非空 usage);
|
|
2539
|
+
② 队友在窗口内有用量事件(`total.calls`,就是上面聚合出来的事件数)。
|
|
2540
|
+
为什么必须补上 ①:`total` 只累加**队友**,不含父会话自己。于是"新一轮已起、
|
|
2541
|
+
父会话主模型已产出、队友还没产出"这几秒里 `total.calls === 0`,旧实现把起点
|
|
2542
|
+
判成无效(sinceSource 从 host 掉成 none)—— 实测复现的缺陷。父会话自己那条
|
|
2543
|
+
assistant/message 就是"这个 turn/start 是活的"的直接证据。
|
|
2544
|
+
为什么保留 ②:**不许因此丢掉真实存在的队友用量**。队友在窗口内已经花了钱,
|
|
2545
|
+
说明这个起点确实是本轮起点(否则那笔钱会被静默抹掉,方向正是"少算")。
|
|
2546
|
+
实测约束(2026-10-08,本机 41 份会话日志):父会话最后一条 turn/start 之后
|
|
2547
|
+
"队友有、父会话自己没有用量事件"的窗口 0 个,但用例夹具 p-1 就是这一形态,
|
|
2548
|
+
且它钉住的契约是"host 代推 = 客户端传同一起点"—— 保留 ② 才能两边都不破。
|
|
2549
|
+
两条都不成立时才按用户的"宁少不多"当没有窗口 —— 本轮不含队友。
|
|
2550
|
+
判断只用已经折好的标量(父会话日志的最大用量时间、同一个窗口的聚合结果),
|
|
2551
|
+
没有再解压一遍。 */
|
|
2552
|
+
const parentFacts = sinceSource === 'host' && parentEntry !== undefined ? parentLogFactsOf(parentEntry) : null
|
|
2553
|
+
const parentProduced = parentFacts !== null && parentFacts.lastUsageAt !== null
|
|
2554
|
+
&& fromMs !== null && parentFacts.lastUsageAt >= fromMs
|
|
2555
|
+
if (sinceSource === 'host' && parentProduced !== true && total.calls === 0) {
|
|
2556
|
+
sinceSource = 'none'
|
|
2557
|
+
hasWindow = false
|
|
2558
|
+
fromMs = null
|
|
2559
|
+
children.length = 0
|
|
2560
|
+
for (let i = 0; i < reading.length; i += 1) {
|
|
2561
|
+
children.push({ id: reading[i].id, cost: 0, tokens: zeroTokens(), calls: 0 })
|
|
2562
|
+
}
|
|
2563
|
+
total.cost = 0
|
|
2564
|
+
total.byCurrency = {}
|
|
2565
|
+
total.tokens = zeroTokens()
|
|
2566
|
+
total.calls = 0
|
|
2567
|
+
total.stats = { ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0 }
|
|
2568
|
+
}
|
|
2569
|
+
/* 分币种那几笔一起舍入(与 total.cost 同一口径):客户端据此折算 */
|
|
2570
|
+
const costByCurrency = {}
|
|
2571
|
+
for (const code of Object.keys(total.byCurrency)) costByCurrency[code] = roundCost(total.byCurrency[code])
|
|
2572
|
+
const value = {
|
|
2573
|
+
ok: true,
|
|
2574
|
+
sessionId,
|
|
2575
|
+
/* 窗口起点来自哪里:client(客户端传的)/ host(host 按父会话日志推的)/
|
|
2576
|
+
none(都没有,或 host 推出来的起点事后没有任何用量事件 → 本轮不并队友)。
|
|
2577
|
+
`sinceSource` 说不出"客户端给的起点被 host 收紧了",那个事实用 `sinceClamped` 表达
|
|
2578
|
+
(只在真收敛时才出现,风格与 truncated 一致)。既有字段形状不变。 */
|
|
2579
|
+
sinceSource,
|
|
2580
|
+
children,
|
|
2581
|
+
/* `costByCurrency` 是这次新增的可选字段:按每个队友模型**自己条目的币种**分桶。
|
|
2582
|
+
客户端混币时用它折成底栏显示币种;老客户端不看它,`total.cost` 语义不变。 */
|
|
2583
|
+
total: {
|
|
2584
|
+
cost: roundCost(total.cost),
|
|
2585
|
+
tokens: total.tokens,
|
|
2586
|
+
calls: total.calls,
|
|
2587
|
+
stats: total.stats,
|
|
2588
|
+
costByCurrency: costByCurrency,
|
|
2589
|
+
},
|
|
2590
|
+
}
|
|
2591
|
+
if (truncated === true) value.truncated = true
|
|
2592
|
+
/* 只在这条路径上打一行:命中缓存时不说话。队友在干活时它出现的频率就是"重算"的频率,
|
|
2593
|
+
排查"总计怎么没变"时看这一行就知道 host 是算了还是直接用缓存给了旧结果。
|
|
2594
|
+
只写会话 id 与数字,不碰日志正文。 */
|
|
2595
|
+
logHost(ctxRef, 'subagent-usage: 会话 ' + sessionId + ' 的队友 ' + String(children.length)
|
|
2596
|
+
+ ' 个,重算 ¥' + String(value.total.cost)
|
|
2597
|
+
+ '(窗口来源 ' + sinceSource + (hasWindow ? ' @' + String(fromMs) : '')
|
|
2598
|
+
+ (clamped === true ? ',sinceClamped(客户端起点被 host 收紧到父会话日志推的起点)' : '') + ')'
|
|
2599
|
+
+ (index.duplicates > 0 ? '(同 id 重复文件 ' + String(index.duplicates) + ' 份,已取最新)' : '')
|
|
2600
|
+
+ (truncated === true ? '(超过上限已截断)' : ''))
|
|
2601
|
+
/* delete + set:让这条键变成 Map 里最新的一个(Map 按插入顺序淘汰)。
|
|
2602
|
+
键含窗口起点:同一个会话的"整会话"与"本轮"各占一条,互不挤掉。 */
|
|
2603
|
+
subagentUsageCache.delete(cacheKey)
|
|
2604
|
+
subagentUsageCache.set(cacheKey, { key, value })
|
|
2605
|
+
if (subagentUsageCache.size > SUBAGENT_CACHE_MAX) {
|
|
2606
|
+
const oldest = subagentUsageCache.keys().next()
|
|
2607
|
+
if (oldest.done !== true) subagentUsageCache.delete(oldest.value)
|
|
2608
|
+
}
|
|
2609
|
+
return withClampFlag(value, clamped)
|
|
2610
|
+
}
|
|
2611
|
+
|
|
2612
|
+
/** `<DSH_HOME>`。 */
|
|
2613
|
+
function dshHome() {
|
|
2614
|
+
return process.env.DSH_HOME !== undefined && process.env.DSH_HOME.length > 0
|
|
2615
|
+
? process.env.DSH_HOME
|
|
2616
|
+
: join(homedir(), '.dsh')
|
|
2617
|
+
}
|
|
2618
|
+
|
|
2619
|
+
/** 台账文件:与状态栏其它数据同目录(`<DSH_HOME>/dsh-status-bar/`)。 */
|
|
2620
|
+
function ledgerPath() {
|
|
2621
|
+
return join(dshHome(), 'dsh-status-bar', 'usage-ledger.json')
|
|
2622
|
+
}
|
|
2623
|
+
|
|
2624
|
+
/**
|
|
2625
|
+
* 台账形状:
|
|
2626
|
+
* days 每个本地日期的累计用量(只增不减 —— 会话被删掉也不会缩水)
|
|
2627
|
+
* days[日期].cost 已结算的费用(人民币);一旦记下就不再改动,
|
|
2628
|
+
* 改价也不重算(重算历史曾经把用户的数据弄丢过)
|
|
2629
|
+
* days[日期].unpriced 当时没有单价、还没算钱的用量,按模型名与峰谷分档攒着;
|
|
2630
|
+
* 用户补上价后结算成 cost,条目随即移除
|
|
2631
|
+
* seen 每个会话已计入的 seq,用来防重复
|
|
2632
|
+
* sessions 每个会话最后一条用量事件的时间,用来淘汰老会话的去重记录
|
|
2633
|
+
* files 每个日志文件上次处理时的 mtime+size,没变就跳过解压
|
|
2634
|
+
* priceFingerprint 上一次结算用的价格库指纹:判断价格有没有变,也给回扫还原旧价目表
|
|
2635
|
+
* unpricedBackfill 'pending' = 还需要一次性回扫,把历史缺价明细补全;'done' = 已补完
|
|
2636
|
+
*/
|
|
2637
|
+
const LEDGER_VERSION = 3
|
|
2638
|
+
|
|
2639
|
+
function emptyLedger() {
|
|
2640
|
+
return {
|
|
2641
|
+
version: LEDGER_VERSION,
|
|
2642
|
+
updatedAt: 0,
|
|
2643
|
+
priceFingerprint: '',
|
|
2644
|
+
unpricedBackfill: '',
|
|
2645
|
+
days: {},
|
|
2646
|
+
seen: {},
|
|
2647
|
+
sessions: {},
|
|
2648
|
+
files: {},
|
|
2649
|
+
}
|
|
2650
|
+
}
|
|
2651
|
+
|
|
2652
|
+
/**
|
|
2653
|
+
* 读一个台账文件;文件不存在、读不出或不是合法 JSON 对象时返回 null。
|
|
2654
|
+
*/
|
|
2655
|
+
function readLedgerFile(file) {
|
|
2656
|
+
try {
|
|
2657
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'))
|
|
2658
|
+
return parsed !== null && typeof parsed === 'object' ? parsed : null
|
|
2659
|
+
} catch (error) {
|
|
2660
|
+
return null
|
|
2661
|
+
}
|
|
2662
|
+
}
|
|
2663
|
+
|
|
2664
|
+
/**
|
|
2665
|
+
* 老版本台账 → 当前版本:**只补字段、不动 days 里的任何数字**。
|
|
2666
|
+
* 版本比当前新(用户装过更新的版本又回退)时也照样收下能用的部分,宁可少字段也不丢数据。
|
|
2667
|
+
*/
|
|
2668
|
+
function migrateLedger(parsed) {
|
|
2669
|
+
const pick = (value) => (value !== null && value !== undefined && typeof value === 'object' ? value : {})
|
|
2670
|
+
const ledger = {
|
|
2671
|
+
version: LEDGER_VERSION,
|
|
2672
|
+
updatedAt: typeof parsed.updatedAt === 'number' ? parsed.updatedAt : 0,
|
|
2673
|
+
priceFingerprint: typeof parsed.priceFingerprint === 'string' ? parsed.priceFingerprint : '',
|
|
2674
|
+
unpricedBackfill: typeof parsed.unpricedBackfill === 'string' ? parsed.unpricedBackfill : '',
|
|
2675
|
+
days: pick(parsed.days),
|
|
2676
|
+
seen: pick(parsed.seen),
|
|
2677
|
+
sessions: pick(parsed.sessions),
|
|
2678
|
+
files: pick(parsed.files),
|
|
2679
|
+
}
|
|
2680
|
+
/* v1/v2 台账没记过"哪些 token 当时没有单价",那部分钱因此永远补不上。
|
|
2681
|
+
标记待办,下次扫描时回扫一遍会话日志把欠账明细补出来 —— 回扫只写 unpriced,
|
|
2682
|
+
不改历史 token 与 cost(见 backfillUnpriced)。 */
|
|
2683
|
+
if (typeof parsed.version !== 'number' || parsed.version < LEDGER_VERSION) {
|
|
2684
|
+
if (ledger.unpricedBackfill !== 'done') ledger.unpricedBackfill = 'pending'
|
|
2685
|
+
}
|
|
2686
|
+
return ledger
|
|
2687
|
+
}
|
|
2688
|
+
|
|
2689
|
+
/**
|
|
2690
|
+
* 读台账。**任何情况下都不清空 days** —— 台账一旦记账就是历史,
|
|
2691
|
+
* 改价格、删对话、删工作区都不许把它抹掉(重算历史曾经把用户两天的数据弄丢过一次)。
|
|
2692
|
+
* · 主文件读不出来(不存在 / JSON 坏了)→ 退回上一次自动留的 .bak
|
|
2693
|
+
* · 老版本 → migrateLedger 原样收下
|
|
2694
|
+
*/
|
|
2695
|
+
/** 读台账时手边没有 ctx,宿主日志函数用不了,就直接写一行 stderr(失败也不影响功能)。 */
|
|
2696
|
+
function logLedgerNote(message) {
|
|
2697
|
+
try {
|
|
2698
|
+
if (typeof process !== 'undefined' && process.stderr !== undefined && typeof process.stderr.write === 'function') {
|
|
2699
|
+
process.stderr.write('[dsh-desktop-statusbar] ' + message + '\n')
|
|
2700
|
+
}
|
|
2701
|
+
} catch (error) {
|
|
2702
|
+
/* 日志失败不影响记账 */
|
|
2703
|
+
}
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2706
|
+
function readLedger() {
|
|
2707
|
+
const parsed = readLedgerFile(ledgerPath())
|
|
2708
|
+
if (parsed !== null) return migrateLedger(parsed)
|
|
2709
|
+
const backup = readLedgerFile(ledgerPath() + '.bak')
|
|
2710
|
+
if (backup !== null) {
|
|
2711
|
+
logLedgerNote('usage-ledger: 主文件读不出来,已回退到自动备份')
|
|
2712
|
+
const recovered = migrateLedger(backup)
|
|
2713
|
+
/* 顺手把好数据写回主文件:否则它会一直保持损坏态,每次读都回退备份并打一行日志。
|
|
2714
|
+
writeLedger 只在"上一份能解析"时才覆盖 .bak,所以这份好备份不会被写坏。 */
|
|
2715
|
+
writeLedger(recovered)
|
|
2716
|
+
return recovered
|
|
2717
|
+
}
|
|
2718
|
+
return emptyLedger()
|
|
2719
|
+
}
|
|
2720
|
+
|
|
2721
|
+
/**
|
|
2722
|
+
* 先写临时文件再改名,避免读到写了一半的 JSON;改名之前把上一份留成 .bak。
|
|
2723
|
+
* 台账是会话日志被删之后**唯一**留得住历史的地方,所以这层备份不省。
|
|
2724
|
+
*/
|
|
2725
|
+
function writeLedger(ledger) {
|
|
2726
|
+
const target = ledgerPath()
|
|
2727
|
+
const temp = target + '.tmp'
|
|
2728
|
+
try {
|
|
2729
|
+
mkdirSync(join(dshHome(), 'dsh-status-bar'), { recursive: true })
|
|
2730
|
+
let kept = false
|
|
2731
|
+
/* 只有"上一份是能解析的好数据"才留成备份:主文件损坏时我们刚回退过 .bak,
|
|
2732
|
+
若这时把损坏内容复制成新 .bak,备份就只能救命一次(实测缺陷)。 */
|
|
2733
|
+
if (readLedgerFile(target) !== null) {
|
|
2734
|
+
try {
|
|
2735
|
+
copyFileSync(target, target + '.bak')
|
|
2736
|
+
kept = true
|
|
2737
|
+
} catch (error) {
|
|
2738
|
+
/* 复制失败不影响本次落盘 */
|
|
2739
|
+
}
|
|
2740
|
+
}
|
|
2741
|
+
writeFileSync(temp, JSON.stringify(ledger), 'utf8')
|
|
2742
|
+
renameSync(temp, target)
|
|
2743
|
+
/* 首次写入没有"上一份"可留,就补一份同内容的备份 —— 保护不该从第二次才开始生效 */
|
|
2744
|
+
if (kept !== true) {
|
|
2745
|
+
try {
|
|
2746
|
+
copyFileSync(target, target + '.bak')
|
|
2747
|
+
} catch (error) {
|
|
2748
|
+
/* 备份失败不影响本次落盘 */
|
|
2749
|
+
}
|
|
2750
|
+
}
|
|
2751
|
+
} catch (error) {
|
|
2752
|
+
/* 落盘失败不影响本次读数 */
|
|
2753
|
+
}
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2756
|
+
/* ------------------------------------------------ 缺价用量(欠账)与补算 */
|
|
2757
|
+
|
|
2758
|
+
/** 日桶里"欠账"那一格:{ 模型名: { peak: 四项, offPeak: 四项 } }。 */
|
|
2759
|
+
function addUnpriced(day, model, parts, peakTime) {
|
|
2760
|
+
const name = typeof model === 'string' && model.length > 0 ? model : '(未知模型)'
|
|
2761
|
+
const bucket = day.unpriced ?? {}
|
|
2762
|
+
const one = bucket[name] ?? {
|
|
2763
|
+
peak: { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 },
|
|
2764
|
+
offPeak: { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 },
|
|
2765
|
+
}
|
|
2766
|
+
const tier = peakTime ? one.peak : one.offPeak
|
|
2767
|
+
tier.input += parts.input
|
|
2768
|
+
tier.cacheRead += parts.cacheRead
|
|
2769
|
+
tier.cacheWrite += parts.cacheWrite
|
|
2770
|
+
tier.output += parts.output
|
|
2771
|
+
bucket[name] = one
|
|
2772
|
+
day.unpriced = bucket
|
|
2773
|
+
}
|
|
2774
|
+
|
|
2775
|
+
/** 一档用量按一档单价折成人民币(用量或单价缺失按 0 算)。 */
|
|
2776
|
+
function costOfTier(parts, price) {
|
|
2777
|
+
if (parts === null || parts === undefined) return 0
|
|
2778
|
+
if (price === null || price === undefined) return 0
|
|
2779
|
+
return (numOrZero(parts.input) * numOrZero(price.input) + numOrZero(parts.cacheRead) * numOrZero(price.cacheRead)
|
|
2780
|
+
+ numOrZero(parts.cacheWrite) * numOrZero(price.cacheWrite) + numOrZero(parts.output) * numOrZero(price.output)) / 1e6
|
|
2781
|
+
}
|
|
2782
|
+
|
|
2783
|
+
/**
|
|
2784
|
+
* 结算欠账:把 days[].unpriced 里**现在能算出价**的用量按当前价格库折成人民币,
|
|
2785
|
+
* 加到当天的 cost 上,条目随即从欠账里移除;仍缺价的条目原样留着等下次补价。
|
|
2786
|
+
*
|
|
2787
|
+
* 只碰欠账,绝不重算已经算过的费用 —— 这是与上一版"整本作废重扫"的关键区别:
|
|
2788
|
+
* 用户补一次价,不该把过去按旧价算好的钱全部改写。
|
|
2789
|
+
*/
|
|
2790
|
+
function settleUnpriced(ledger) {
|
|
2791
|
+
let entries = 0
|
|
2792
|
+
let amount = 0
|
|
2793
|
+
const dates = Object.keys(ledger.days)
|
|
2794
|
+
for (let i = 0; i < dates.length; i += 1) {
|
|
2795
|
+
const day = ledger.days[dates[i]]
|
|
2796
|
+
if (day === null || day === undefined || typeof day !== 'object') continue
|
|
2797
|
+
const bucket = day.unpriced
|
|
2798
|
+
if (bucket === null || bucket === undefined || typeof bucket !== 'object') continue
|
|
2799
|
+
const names = Object.keys(bucket)
|
|
2800
|
+
for (let k = 0; k < names.length; k += 1) {
|
|
2801
|
+
const entry = priceEntryFor(names[k])
|
|
2802
|
+
if (entry === null) continue
|
|
2803
|
+
const one = bucket[names[k]]
|
|
2804
|
+
/* 单档条目(没勾「峰谷计价」):两个桶都按 peak 价结算,否则空闲桶会被算成 0 */
|
|
2805
|
+
const peakPrice = entry.peak !== null && entry.peak !== undefined ? entry.peak : entry.offPeak
|
|
2806
|
+
const offPeakPrice = entry.tiered === false
|
|
2807
|
+
? peakPrice
|
|
2808
|
+
: (entry.offPeak !== null && entry.offPeak !== undefined ? entry.offPeak : entry.peak)
|
|
2809
|
+
const cost = costOfTier(one.peak, peakPrice) + costOfTier(one.offPeak, offPeakPrice)
|
|
2810
|
+
day.cost = (typeof day.cost === 'number' && Number.isFinite(day.cost) ? day.cost : 0) + cost
|
|
2811
|
+
amount += cost
|
|
2812
|
+
entries += 1
|
|
2813
|
+
delete bucket[names[k]]
|
|
2814
|
+
}
|
|
2815
|
+
if (Object.keys(bucket).length === 0) delete day.unpriced
|
|
2816
|
+
else day.unpriced = bucket
|
|
2817
|
+
}
|
|
2818
|
+
return { entries, amount: Math.round(amount * 1e6) / 1e6 }
|
|
2819
|
+
}
|
|
2820
|
+
|
|
2821
|
+
/**
|
|
2822
|
+
* 一次性回扫(升级后只做一次):v1/v2 台账没记过"哪些 token 当时没有单价",
|
|
2823
|
+
* 只有会话日志里找得到。回扫只补这一件事:
|
|
2824
|
+
* ① 用**台账指纹还原出来的旧价目表**判断每个事件当时有没有价;
|
|
2825
|
+
* ② 当时没价的,交给 settleUnpriced 用**现在的价格库**结算 —— 能算就把钱补进当天 cost,
|
|
2826
|
+
* 仍缺价的进 unpriced 等以后再补。
|
|
2827
|
+
*
|
|
2828
|
+
* 两条安全线:
|
|
2829
|
+
* · 只处理 `seen` 里记过账的事件(没入过账的归正常扫描路径),避免重复计价;
|
|
2830
|
+
* · 当时就有价的一律跳过,历史 cost 一分不动。
|
|
2831
|
+
* 每个文件补完打 unpricedAt 标记,中断了下次接着做,不会重复补算。
|
|
2832
|
+
*/
|
|
2833
|
+
async function backfillUnpriced(ledger) {
|
|
2834
|
+
const parsedBook = parseFingerprint(ledger.priceFingerprint)
|
|
2835
|
+
/* 老台账没有指纹字段(更早的版本用的就是那张内置参考价)→ 按内置表还原"当时的价格库" */
|
|
2836
|
+
const historyBook = parsedBook !== null ? parsedBook : builtinPriceBook()
|
|
2837
|
+
const files = collectSessionFiles(sessionsRoot(), [])
|
|
2838
|
+
const keepFrom = Date.now() - 400 * 24 * 3600 * 1000
|
|
2839
|
+
let scanned = 0
|
|
2840
|
+
for (let i = 0; i < files.length; i += 1) {
|
|
2841
|
+
const file = files[i]
|
|
2842
|
+
const mark = ledger.files[file]
|
|
2843
|
+
let stat = null
|
|
2844
|
+
try {
|
|
2845
|
+
stat = statSync(file)
|
|
2846
|
+
} catch (error) {
|
|
2847
|
+
continue
|
|
2848
|
+
}
|
|
2849
|
+
/* 很久没动过、也从没扫过:它的内容在保留期之外,正常扫描也不看它 */
|
|
2850
|
+
if (mark === undefined && stat.mtimeMs < keepFrom) continue
|
|
2851
|
+
if (mark !== undefined && mark.unpricedAt === LEDGER_VERSION) continue
|
|
2852
|
+
let text = null
|
|
2853
|
+
try {
|
|
2854
|
+
text = zstdText(readFileSync(file))
|
|
2855
|
+
} catch (error) {
|
|
2856
|
+
text = null
|
|
2857
|
+
}
|
|
2858
|
+
if (text === null) continue
|
|
2859
|
+
scanned += 1
|
|
2860
|
+
const sessionId = basename(dirname(file))
|
|
2861
|
+
const seen = new Set(Array.isArray(ledger.seen[sessionId]) ? ledger.seen[sessionId] : [])
|
|
2862
|
+
const picked = pickUsageEvents(text, keepFrom)
|
|
2863
|
+
const keys = []
|
|
2864
|
+
picked.forEach((one, key) => keys.push(key))
|
|
2865
|
+
for (let k = 0; k < keys.length; k += 1) {
|
|
2866
|
+
const one = picked.get(keys[k])
|
|
2867
|
+
if (seen.has(one.seq) !== true) continue
|
|
2868
|
+
/* 必须用**当年那套规则**判"当时有没有价":新规则(词元匹配)会把这些事件
|
|
2869
|
+
误判成"当时有价"直接跳过,历史欠账就永远补不出来了(实测缺陷) */
|
|
2870
|
+
if (legacyPriceEntryIn(historyBook, one.model) !== null) continue
|
|
2871
|
+
const day = ledger.days[localDateKey(one.time)]
|
|
2872
|
+
if (day === null || day === undefined || typeof day !== 'object') continue
|
|
2873
|
+
addUnpriced(day, one.model, one.parts, isPeakTime(one.time))
|
|
2874
|
+
}
|
|
2875
|
+
const next = mark !== undefined && mark !== null && typeof mark === 'object'
|
|
2876
|
+
? mark
|
|
2877
|
+
: { mtimeMs: 0, size: 0, fromMs: 0 }
|
|
2878
|
+
next.unpricedAt = LEDGER_VERSION
|
|
2879
|
+
ledger.files[file] = next
|
|
2880
|
+
if (i % 4 === 3) await new Promise((resolve) => setImmediate(resolve))
|
|
2881
|
+
}
|
|
2882
|
+
return { files: files.length, scanned }
|
|
2883
|
+
}
|
|
2884
|
+
|
|
2885
|
+
/**
|
|
2886
|
+
* 把会话日志里的用量并进台账。文件没变(mtime+size 相同)就直接跳过,空闲时几乎没有开销;
|
|
2887
|
+
* 每 4 个文件让出一次事件循环,别把 host 卡住。
|
|
2888
|
+
*/
|
|
2889
|
+
async function collectIntoLedger(ledger, fromMs) {
|
|
2890
|
+
const files = collectSessionFiles(sessionsRoot(), [])
|
|
2891
|
+
let scannedFiles = 0
|
|
2892
|
+
let skippedFiles = 0
|
|
2893
|
+
let added = 0
|
|
2894
|
+
for (let i = 0; i < files.length; i += 1) {
|
|
2895
|
+
const file = files[i]
|
|
2896
|
+
let stat
|
|
2897
|
+
try {
|
|
2898
|
+
stat = statSync(file)
|
|
2899
|
+
} catch (error) {
|
|
2900
|
+
delete ledger.files[file]
|
|
2901
|
+
continue
|
|
2902
|
+
}
|
|
2903
|
+
const mark = ledger.files[file]
|
|
2904
|
+
/* 跳过条件必须连"上次扫描用的窗口起点"一起比。
|
|
2905
|
+
窗口只覆盖最近几天时,文件里更早的事件会被 fromMs 过滤掉,但文件照样被标记成已扫;
|
|
2906
|
+
之后窗口放宽(柱改成近 7 天、活跃度要整月、12 个月要整年)如果不重扫,
|
|
2907
|
+
那些被过滤掉的数据就永远补不回来 —— 这正是 9/26 少掉 2.8 亿 token 的原因。
|
|
2908
|
+
mark.fromMs 缺失(老记录)时视为"不满足",于是会自动重扫一遍把历史补齐。 */
|
|
2909
|
+
if (mark !== undefined && mark.mtimeMs === stat.mtimeMs && mark.size === stat.size
|
|
2910
|
+
&& typeof mark.fromMs === 'number' && mark.fromMs <= fromMs) {
|
|
2911
|
+
skippedFiles += 1
|
|
2912
|
+
continue
|
|
2913
|
+
}
|
|
2914
|
+
/* 窗口起点之前就没再动过、也从没扫过:窗口外的内容与我们无关 */
|
|
2915
|
+
if (stat.mtimeMs < fromMs && mark === undefined) {
|
|
2916
|
+
skippedFiles += 1
|
|
2917
|
+
continue
|
|
2918
|
+
}
|
|
2919
|
+
let text = null
|
|
2920
|
+
try {
|
|
2921
|
+
text = zstdText(readFileSync(file))
|
|
2922
|
+
} catch (error) {
|
|
2923
|
+
text = null
|
|
2924
|
+
}
|
|
2925
|
+
/* 连同这次的窗口起点一起记:下次只有当窗口不晚于它时才允许整文件跳过。
|
|
2926
|
+
顺手保住 unpricedAt —— 那是"这个文件的欠账明细已经回扫过"的标记,
|
|
2927
|
+
丢了会让下次启动再回扫一遍,同一批用量被记两次欠账。 */
|
|
2928
|
+
const nextMark = { mtimeMs: stat.mtimeMs, size: stat.size, fromMs }
|
|
2929
|
+
if (mark !== undefined && mark !== null && typeof mark.unpricedAt === 'number') nextMark.unpricedAt = mark.unpricedAt
|
|
2930
|
+
ledger.files[file] = nextMark
|
|
2931
|
+
if (text === null) continue
|
|
2932
|
+
scannedFiles += 1
|
|
2933
|
+
const sessionId = basename(dirname(file))
|
|
2934
|
+
const picked = pickUsageEvents(text, fromMs)
|
|
2935
|
+
if (picked.size === 0) continue
|
|
2936
|
+
const seen = new Set(Array.isArray(ledger.seen[sessionId]) ? ledger.seen[sessionId] : [])
|
|
2937
|
+
const record = ledger.sessions[sessionId]
|
|
2938
|
+
let last = record !== undefined && typeof record.last === 'number' ? record.last : 0
|
|
2939
|
+
for (const one of picked.values()) {
|
|
2940
|
+
last = Math.max(last, one.time)
|
|
2941
|
+
if (seen.has(one.seq)) continue
|
|
2942
|
+
seen.add(one.seq)
|
|
2943
|
+
const key = localDateKey(one.time)
|
|
2944
|
+
const day = ledger.days[key] ?? { input: 0, cacheRead: 0, cacheWrite: 0, output: 0, calls: 0, cost: 0, peak: { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 } }
|
|
2945
|
+
day.input += one.parts.input
|
|
2946
|
+
day.cacheRead += one.parts.cacheRead
|
|
2947
|
+
day.cacheWrite += one.parts.cacheWrite
|
|
2948
|
+
day.output += one.parts.output
|
|
2949
|
+
day.calls += 1
|
|
2950
|
+
/* 高峰时段(北京时间工作日 09-12 / 14-18,排除法定节假日)那一份单独记,
|
|
2951
|
+
柱状图会把它截出来放在柱子顶部 */
|
|
2952
|
+
const peakTime = isPeakTime(one.time)
|
|
2953
|
+
if (peakTime) {
|
|
2954
|
+
const peak = day.peak ?? { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 }
|
|
2955
|
+
peak.input += one.parts.input
|
|
2956
|
+
peak.cacheRead += one.parts.cacheRead
|
|
2957
|
+
peak.cacheWrite += one.parts.cacheWrite
|
|
2958
|
+
peak.output += one.parts.output
|
|
2959
|
+
day.peak = peak
|
|
2960
|
+
}
|
|
2961
|
+
/* 有单价就当场折成人民币;**还没有单价的用量单独记成"欠账"**,
|
|
2962
|
+
等用户在设置页把价补上再结算(补价只结算欠账,不动已经算好的费用)。 */
|
|
2963
|
+
if (priceEntryFor(one.model) === null) addUnpriced(day, one.model, one.parts, peakTime)
|
|
2964
|
+
else day.cost = (day.cost || 0) + tokenCost(one.model, one.time, one.parts)
|
|
2965
|
+
ledger.days[key] = day
|
|
2966
|
+
added += 1
|
|
2967
|
+
}
|
|
2968
|
+
ledger.seen[sessionId] = Array.from(seen)
|
|
2969
|
+
ledger.sessions[sessionId] = { last }
|
|
2970
|
+
if (i % 4 === 3) await new Promise((resolve) => setImmediate(resolve))
|
|
2971
|
+
}
|
|
2972
|
+
return { files: files.length, scannedFiles, skippedFiles, added }
|
|
2973
|
+
}
|
|
2974
|
+
|
|
2975
|
+
/** 丢掉太久以前的日桶与去重记录。保留期是滚动的「今天往前 400 天」,与统计窗口无关。 */
|
|
2976
|
+
function pruneLedger(ledger) {
|
|
2977
|
+
const keepFrom = localDateKey(Date.now() - 400 * 24 * 3600 * 1000)
|
|
2978
|
+
for (const key of Object.keys(ledger.days)) {
|
|
2979
|
+
if (key < keepFrom) delete ledger.days[key]
|
|
2980
|
+
}
|
|
2981
|
+
for (const sessionId of Object.keys(ledger.sessions)) {
|
|
2982
|
+
const one = ledger.sessions[sessionId]
|
|
2983
|
+
if (one === undefined || typeof one.last !== 'number' || localDateKey(one.last) < keepFrom) {
|
|
2984
|
+
delete ledger.sessions[sessionId]
|
|
2985
|
+
delete ledger.seen[sessionId]
|
|
2986
|
+
}
|
|
2987
|
+
}
|
|
2988
|
+
}
|
|
2989
|
+
|
|
2990
|
+
/** 把台账里的日桶铺成「近 7 天」固定 7 格(最早一天在最左、今天是最后一格),并算合计;另附当月与全年。 */
|
|
2991
|
+
function weekUsagePayload(ledger, rangeStart, monthStart, meta) {
|
|
2992
|
+
const DAY = 24 * 3600 * 1000
|
|
2993
|
+
const days = []
|
|
2994
|
+
const totals = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0, total: 0, calls: 0, cost: 0 }
|
|
2995
|
+
for (let i = 0; i < 7; i += 1) {
|
|
2996
|
+
/* 按北京时间逐日推进,不受机器时区影响 */
|
|
2997
|
+
const key = localDateKey(rangeStart + i * DAY)
|
|
2998
|
+
const bucket = ledger.days[key] ?? { input: 0, cacheRead: 0, cacheWrite: 0, output: 0, calls: 0, cost: 0, peak: { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 } }
|
|
2999
|
+
const total = bucket.input + bucket.cacheRead + bucket.cacheWrite + bucket.output
|
|
3000
|
+
const cost = typeof bucket.cost === 'number' && Number.isFinite(bucket.cost) ? bucket.cost : 0
|
|
3001
|
+
/* 高峰那一份:老台账没有 peak 字段时按 0 处理 */
|
|
3002
|
+
const pk = bucket.peak ?? { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 }
|
|
3003
|
+
const peak = {
|
|
3004
|
+
input: Number(pk.input) || 0,
|
|
3005
|
+
cacheRead: Number(pk.cacheRead) || 0,
|
|
3006
|
+
cacheWrite: Number(pk.cacheWrite) || 0,
|
|
3007
|
+
output: Number(pk.output) || 0,
|
|
3008
|
+
}
|
|
3009
|
+
peak.total = peak.input + peak.cacheRead + peak.cacheWrite + peak.output
|
|
3010
|
+
days.push({
|
|
3011
|
+
date: key,
|
|
3012
|
+
/* 窗口内第几天(0 = 最早那天,6 = 今天);不再是星期几 */
|
|
3013
|
+
offset: i,
|
|
3014
|
+
input: bucket.input,
|
|
3015
|
+
cacheRead: bucket.cacheRead,
|
|
3016
|
+
cacheWrite: bucket.cacheWrite,
|
|
3017
|
+
output: bucket.output,
|
|
3018
|
+
total,
|
|
3019
|
+
calls: bucket.calls,
|
|
3020
|
+
/* 高峰时段那一份(四项 + 合计),客户端把它截出来画在柱子顶部 */
|
|
3021
|
+
peak,
|
|
3022
|
+
/* 人民币,保留 4 位小数,客户端自己截到分 */
|
|
3023
|
+
cost: Math.round(cost * 10000) / 10000,
|
|
3024
|
+
})
|
|
3025
|
+
totals.input += bucket.input
|
|
3026
|
+
totals.cacheRead += bucket.cacheRead
|
|
3027
|
+
totals.cacheWrite += bucket.cacheWrite
|
|
3028
|
+
totals.output += bucket.output
|
|
3029
|
+
totals.calls += bucket.calls
|
|
3030
|
+
totals.total += total
|
|
3031
|
+
totals.cost += cost
|
|
3032
|
+
}
|
|
3033
|
+
totals.cost = Math.round(totals.cost * 10000) / 10000
|
|
3034
|
+
return Object.assign({
|
|
3035
|
+
ok: true,
|
|
3036
|
+
at: Date.now(),
|
|
3037
|
+
rangeStart,
|
|
3038
|
+
days,
|
|
3039
|
+
totals,
|
|
3040
|
+
month: monthUsagePayload(ledger, monthStart),
|
|
3041
|
+
year: yearUsagePayload(ledger, Date.now()),
|
|
3042
|
+
}, meta)
|
|
3043
|
+
}
|
|
3044
|
+
|
|
3045
|
+
/**
|
|
3046
|
+
* 当月活跃度日历:从「1 号所在那一周的周一」排到「月末所在那一周的周日」。
|
|
3047
|
+
* 跨月的头尾几天照样带上(周底条要统计整周,不能只看当月),用 inMonth 标出归属;
|
|
3048
|
+
* 「月内最高的一天」(要标紫的那天)只在当月天里评,避免被上月的量抢走。
|
|
3049
|
+
*/
|
|
3050
|
+
function monthUsagePayload(ledger, monthStart) {
|
|
3051
|
+
const BEIJING = 8 * 3600 * 1000
|
|
3052
|
+
const DAY = 24 * 3600 * 1000
|
|
3053
|
+
const first = new Date(monthStart + BEIJING)
|
|
3054
|
+
const year = first.getUTCFullYear()
|
|
3055
|
+
const month = first.getUTCMonth() + 1
|
|
3056
|
+
const daysInMonth = new Date(Date.UTC(year, month, 0)).getUTCDate()
|
|
3057
|
+
const firstWeekday = (first.getUTCDay() + 6) % 7 /* 周一=0 */
|
|
3058
|
+
const gridStart = monthStart - firstWeekday * DAY
|
|
3059
|
+
const monthEnd = monthStart + (daysInMonth - 1) * DAY
|
|
3060
|
+
const lastWeekday = (new Date(monthEnd + BEIJING).getUTCDay() + 6) % 7
|
|
3061
|
+
const gridDays = daysInMonth + firstWeekday + (6 - lastWeekday)
|
|
3062
|
+
const days = []
|
|
3063
|
+
let max = 0
|
|
3064
|
+
let maxDate = null
|
|
3065
|
+
for (let i = 0; i < gridDays; i += 1) {
|
|
3066
|
+
const at = gridStart + i * DAY
|
|
3067
|
+
const key = localDateKey(at)
|
|
3068
|
+
const inMonth = at >= monthStart && at <= monthEnd
|
|
3069
|
+
const bucket = ledger.days[key]
|
|
3070
|
+
/* 三段明细一并带上:客户端悬停每天的方块时要显示命中 / 未命中 / 输出 */
|
|
3071
|
+
const input = bucket === undefined ? 0 : bucket.input || 0
|
|
3072
|
+
const cacheRead = bucket === undefined ? 0 : bucket.cacheRead || 0
|
|
3073
|
+
const cacheWrite = bucket === undefined ? 0 : bucket.cacheWrite || 0
|
|
3074
|
+
const output = bucket === undefined ? 0 : bucket.output || 0
|
|
3075
|
+
const total = input + cacheRead + cacheWrite + output
|
|
3076
|
+
const calls = bucket === undefined ? 0 : bucket.calls || 0
|
|
3077
|
+
const rawCost = bucket === undefined ? 0 : bucket.cost
|
|
3078
|
+
const cost = typeof rawCost === 'number' && Number.isFinite(rawCost) ? rawCost : 0
|
|
3079
|
+
days.push({
|
|
3080
|
+
date: key,
|
|
3081
|
+
inMonth,
|
|
3082
|
+
total,
|
|
3083
|
+
calls,
|
|
3084
|
+
cost: Math.round(cost * 10000) / 10000,
|
|
3085
|
+
input,
|
|
3086
|
+
cacheRead,
|
|
3087
|
+
cacheWrite,
|
|
3088
|
+
output,
|
|
3089
|
+
})
|
|
3090
|
+
if (inMonth && total > max) {
|
|
3091
|
+
max = total
|
|
3092
|
+
maxDate = key
|
|
3093
|
+
}
|
|
3094
|
+
}
|
|
3095
|
+
return { year, month, max, maxDate, days }
|
|
3096
|
+
}
|
|
3097
|
+
|
|
3098
|
+
/**
|
|
3099
|
+
* 当年的 12 个月聚合:每月的三段 token、调用次数与花费。
|
|
3100
|
+
* 客户端拿它画「活跃总览」标题下面那 12 个方块(配色规则与右侧月历一致)。
|
|
3101
|
+
*/
|
|
3102
|
+
function yearUsagePayload(ledger, now) {
|
|
3103
|
+
const BEIJING = 8 * 3600 * 1000
|
|
3104
|
+
const year = new Date(now + BEIJING).getUTCFullYear()
|
|
3105
|
+
const months = []
|
|
3106
|
+
let max = 0
|
|
3107
|
+
let maxMonth = null
|
|
3108
|
+
for (let m = 1; m <= 12; m += 1) {
|
|
3109
|
+
const daysInMonth = new Date(Date.UTC(year, m, 0)).getUTCDate()
|
|
3110
|
+
let input = 0
|
|
3111
|
+
let cacheRead = 0
|
|
3112
|
+
let cacheWrite = 0
|
|
3113
|
+
let output = 0
|
|
3114
|
+
let calls = 0
|
|
3115
|
+
let cost = 0
|
|
3116
|
+
for (let d = 1; d <= daysInMonth; d += 1) {
|
|
3117
|
+
const bucket = ledger.days[localDateKey(Date.UTC(year, m - 1, d) - BEIJING)]
|
|
3118
|
+
if (bucket === undefined) continue
|
|
3119
|
+
input += bucket.input || 0
|
|
3120
|
+
cacheRead += bucket.cacheRead || 0
|
|
3121
|
+
cacheWrite += bucket.cacheWrite || 0
|
|
3122
|
+
output += bucket.output || 0
|
|
3123
|
+
calls += bucket.calls || 0
|
|
3124
|
+
const raw = bucket.cost
|
|
3125
|
+
cost += typeof raw === 'number' && Number.isFinite(raw) ? raw : 0
|
|
3126
|
+
}
|
|
3127
|
+
const total = input + cacheRead + cacheWrite + output
|
|
3128
|
+
months.push({
|
|
3129
|
+
month: m,
|
|
3130
|
+
total,
|
|
3131
|
+
calls,
|
|
3132
|
+
cost: Math.round(cost * 10000) / 10000,
|
|
3133
|
+
input,
|
|
3134
|
+
cacheRead,
|
|
3135
|
+
cacheWrite,
|
|
3136
|
+
output,
|
|
3137
|
+
})
|
|
3138
|
+
if (total > max) {
|
|
3139
|
+
max = total
|
|
3140
|
+
maxMonth = m
|
|
3141
|
+
}
|
|
3142
|
+
}
|
|
3143
|
+
return { year, max, maxMonth, months }
|
|
3144
|
+
}
|
|
3145
|
+
|
|
3146
|
+
/**
|
|
3147
|
+
* 气泡定位诊断:客户端把「模式 / 基准 / 结果」回传到这里,落到
|
|
3148
|
+
* `<DSH_HOME>/dsh-status-bar/tip-diag.log`。用途是在没有控制台的环境下核对气泡为什么越界。
|
|
3149
|
+
* 只收几个数字与短字符串,不写任何会话内容;超过 64KB 就重开,避免无限增长。
|
|
3150
|
+
*/
|
|
3151
|
+
function appendTipDiag(body) {
|
|
3152
|
+
if (body === null || body === undefined || typeof body !== 'object') return
|
|
3153
|
+
const num = (value) => (typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : null)
|
|
3154
|
+
const text = (value) => (typeof value === 'string' ? value.slice(0, 40) : null)
|
|
3155
|
+
const line = JSON.stringify({
|
|
3156
|
+
at: Date.now(),
|
|
3157
|
+
tip: text(body.tip),
|
|
3158
|
+
mode: text(body.mode),
|
|
3159
|
+
left: num(body.left),
|
|
3160
|
+
ring: num(body.ring),
|
|
3161
|
+
ringFound: body.ringFound === true,
|
|
3162
|
+
anchor: num(body.anchor),
|
|
3163
|
+
bar: num(body.bar),
|
|
3164
|
+
width: num(body.width),
|
|
3165
|
+
viewport: num(body.viewport),
|
|
3166
|
+
}) + '\n'
|
|
3167
|
+
try {
|
|
3168
|
+
const dir = join(dshHome(), 'dsh-status-bar')
|
|
3169
|
+
const file = join(dir, 'tip-diag.log')
|
|
3170
|
+
let size = 0
|
|
3171
|
+
try {
|
|
3172
|
+
size = statSync(file).size
|
|
3173
|
+
} catch (error) {
|
|
3174
|
+
size = 0
|
|
3175
|
+
}
|
|
3176
|
+
mkdirSync(dir, { recursive: true })
|
|
3177
|
+
if (size > 65536) writeFileSync(file, line, 'utf8')
|
|
3178
|
+
else appendFileSync(file, line, 'utf8')
|
|
3179
|
+
} catch (error) {
|
|
3180
|
+
/* 落盘失败不影响功能 */
|
|
3181
|
+
}
|
|
3182
|
+
}
|
|
3183
|
+
|
|
3184
|
+
/* 台账只在「有新数据」时才重扫(文件 mtime+size 没变就跳过),因此这层只是省掉重复的 stat。
|
|
3185
|
+
不挂定时器:打开活跃总览面板时会带 force=1 现扫一次,数据只在被看的时候才需要最新。 */
|
|
3186
|
+
const WEEK_USAGE_CACHE_MS = 30000
|
|
3187
|
+
let weekUsageCache = { at: 0, value: null }
|
|
3188
|
+
/* 扫描可能被并发触发:复用同一次进行中的扫描。 */
|
|
3189
|
+
let weekUsageScan = null
|
|
3190
|
+
|
|
3191
|
+
/* 台账是"读进来 → 改 → 写回去"。扫描与补算(价格上报)可能同时发生,
|
|
3192
|
+
串成一条队列逐个跑,免得两边各读到同一份旧内容、后写的把先写的盖掉。 */
|
|
3193
|
+
let ledgerQueue = Promise.resolve()
|
|
3194
|
+
|
|
3195
|
+
function queueLedgerJob(job) {
|
|
3196
|
+
const run = ledgerQueue.then(job, job)
|
|
3197
|
+
ledgerQueue = run.then(() => undefined, () => undefined)
|
|
3198
|
+
return run
|
|
3199
|
+
}
|
|
3200
|
+
|
|
3201
|
+
async function refreshWeekUsage(force, ctx) {
|
|
3202
|
+
const now = Date.now()
|
|
3203
|
+
const rangeStart = recentStartMs(now)
|
|
3204
|
+
const monthStart = monthStartMs(now)
|
|
3205
|
+
const yearStart = yearStartMs(now)
|
|
3206
|
+
/* 扫描与保留窗口取「近 7 天起点」「本月 1 号」「当年 1 月 1 日」里最早的那个:
|
|
3207
|
+
柱要看近 7 天、活跃度要整月、12 个月统计要整年。
|
|
3208
|
+
台账一旦记过就留着(保留期已放宽),所以全量解压只在首次或很久没开时发生。 */
|
|
3209
|
+
const windowStart = Math.min(rangeStart, monthStart, yearStart)
|
|
3210
|
+
const note = (message) => { if (ctx !== undefined && ctx !== null) logHost(ctx, message) }
|
|
3211
|
+
if (force !== true && weekUsageCache.value !== null && now - weekUsageCache.at < WEEK_USAGE_CACHE_MS) {
|
|
3212
|
+
return weekUsageCache.value
|
|
3213
|
+
}
|
|
3214
|
+
if (weekUsageScan === null) {
|
|
3215
|
+
const run = queueLedgerJob(async () => {
|
|
3216
|
+
const ledger = readLedger()
|
|
3217
|
+
/* 升级后第一次扫描:回扫一遍日志,把"当时缺价、没算钱"的用量找出来。
|
|
3218
|
+
只补欠账清单,历史 token 与 cost 一个都不动。 */
|
|
3219
|
+
let backfilled = null
|
|
3220
|
+
if (ledger.unpricedBackfill === 'pending') {
|
|
3221
|
+
backfilled = await backfillUnpriced(ledger)
|
|
3222
|
+
ledger.unpricedBackfill = 'done'
|
|
3223
|
+
note('usage-ledger: 回扫补全缺价明细,处理了 ' + String(backfilled.scanned) + ' 个日志文件')
|
|
3224
|
+
}
|
|
3225
|
+
/* 价格补上来了就把欠账结算掉(只碰欠账,不重算已经算好的费用) */
|
|
3226
|
+
const settled = settleUnpriced(ledger)
|
|
3227
|
+
if (settled.entries > 0) {
|
|
3228
|
+
note('usage-ledger: 结算欠账 ' + String(settled.entries) + ' 项,补记 ¥' + String(settled.amount))
|
|
3229
|
+
}
|
|
3230
|
+
ledger.priceFingerprint = priceFingerprint()
|
|
3231
|
+
const stats = await collectIntoLedger(ledger, windowStart)
|
|
3232
|
+
pruneLedger(ledger)
|
|
3233
|
+
if (stats.added > 0 || stats.scannedFiles > 0 || ledger.updatedAt === 0
|
|
3234
|
+
|| settled.entries > 0 || backfilled !== null) {
|
|
3235
|
+
ledger.updatedAt = Date.now()
|
|
3236
|
+
writeLedger(ledger)
|
|
3237
|
+
}
|
|
3238
|
+
return { ledger, stats }
|
|
3239
|
+
})
|
|
3240
|
+
weekUsageScan = run
|
|
3241
|
+
const clear = () => { if (weekUsageScan === run) weekUsageScan = null }
|
|
3242
|
+
void run.then(clear, clear)
|
|
3243
|
+
}
|
|
3244
|
+
const result = await weekUsageScan
|
|
3245
|
+
const value = weekUsagePayload(result.ledger, rangeStart, monthStart, {
|
|
3246
|
+
files: result.stats.files,
|
|
3247
|
+
scannedFiles: result.stats.scannedFiles,
|
|
3248
|
+
skippedFiles: result.stats.skippedFiles,
|
|
3249
|
+
added: result.stats.added,
|
|
3250
|
+
zstd: typeof zlib.zstdDecompressSync === 'function',
|
|
3251
|
+
})
|
|
3252
|
+
weekUsageCache = { at: Date.now(), value }
|
|
3253
|
+
return value
|
|
3254
|
+
}
|
|
3255
|
+
|
|
3256
|
+
/* ------------------------------------------------------------ 节假日抓取 */
|
|
3257
|
+
|
|
3258
|
+
const GOV_SEARCH_URL = 'https://sousuo.www.gov.cn/search-gov/data'
|
|
3259
|
+
const GOV_TITLE_PREFIX = '国务院办公厅关于'
|
|
3260
|
+
const HOLIDAY_FETCH_TIMEOUT_MS = 15000
|
|
3261
|
+
const BROWSER_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
|
|
3262
|
+
|
|
3263
|
+
/**
|
|
3264
|
+
* 抓「国务院办公厅关于<year>年部分节假日安排的通知」并解析出放假日。
|
|
3265
|
+
* 两步:先走政府网政策文件库的搜索接口拿通知页 URL,再取正文解析(解析在 lib/holiday.js)。
|
|
3266
|
+
* 未发布、HTTP 失败、解析为空都返回 { ok: false, reason },交给客户端提示后重试。
|
|
3267
|
+
*/
|
|
3268
|
+
async function fetchHolidayDays(year) {
|
|
3269
|
+
const title = GOV_TITLE_PREFIX + String(year) + '年部分节假日安排的通知'
|
|
3270
|
+
const search = new URL(GOV_SEARCH_URL)
|
|
3271
|
+
search.searchParams.set('t', 'zhengcelibrary_gw')
|
|
3272
|
+
search.searchParams.set('q', title)
|
|
3273
|
+
search.searchParams.set('searchfield', 'title')
|
|
3274
|
+
search.searchParams.set('p', '1')
|
|
3275
|
+
search.searchParams.set('n', '5')
|
|
3276
|
+
search.searchParams.set('sort', 'score')
|
|
3277
|
+
search.searchParams.set('sortType', '1')
|
|
3278
|
+
const headers = { 'user-agent': BROWSER_UA }
|
|
3279
|
+
/* 每次请求各自设超时:AbortSignal 不能复用 */
|
|
3280
|
+
const timeout = () => AbortSignal.timeout(HOLIDAY_FETCH_TIMEOUT_MS)
|
|
3281
|
+
|
|
3282
|
+
const found = await fetch(search.toString(), { headers, signal: timeout() })
|
|
3283
|
+
if (found.ok !== true) return { ok: false, reason: 'search-http-' + String(found.status) }
|
|
3284
|
+
const payload = await found.json()
|
|
3285
|
+
const list = payload !== null && payload !== undefined && payload.searchVO !== null && payload.searchVO !== undefined
|
|
3286
|
+
&& Array.isArray(payload.searchVO.listVO)
|
|
3287
|
+
? payload.searchVO.listVO
|
|
3288
|
+
: []
|
|
3289
|
+
/* 搜索是按相关度排的,标题里必须正好有这一年的年份,否则宁可当没搜到。
|
|
3290
|
+
标题可能带 <em> 高亮标签,比较前先剥掉。 */
|
|
3291
|
+
const wanted = String(year) + '年部分节假日安排'
|
|
3292
|
+
const plainTitle = (item) => String(item.title).replace(/<[^>]+>/g, '')
|
|
3293
|
+
const hit = list.filter((item) => item !== null && typeof item === 'object'
|
|
3294
|
+
&& typeof item.title === 'string' && plainTitle(item).indexOf(wanted) !== -1
|
|
3295
|
+
&& typeof item.url === 'string')[0]
|
|
3296
|
+
if (hit === undefined) return { ok: false, reason: 'not-found' }
|
|
3297
|
+
|
|
3298
|
+
const page = await fetch(hit.url, { headers, signal: timeout() })
|
|
3299
|
+
if (page.ok !== true) return { ok: false, reason: 'page-http-' + String(page.status) }
|
|
3300
|
+
const days = parseHolidayNotice(holidayHtmlToText(await page.text()), year)
|
|
3301
|
+
if (days.length === 0) return { ok: false, reason: 'parse-empty' }
|
|
3302
|
+
return { ok: true, year, days, title: plainTitle(hit), source: hit.url }
|
|
3303
|
+
}
|
|
3304
|
+
|
|
3305
|
+
/* --------------------------------------------------------------- 版本自检 */
|
|
3306
|
+
|
|
3307
|
+
/** registry 的最新版条目地址:只读它的 version 字段,不下载包、不写任何文件。 */
|
|
3308
|
+
const REGISTRY_LATEST_URL = 'https://registry.npmjs.org/dsh-desktop-statusbar/latest'
|
|
3309
|
+
const VERSION_CHECK_TIMEOUT_MS = 15000
|
|
3310
|
+
|
|
3311
|
+
/** a 比 b 新返回正数。按数字段比(2.10.0 > 2.9.0);带预发布后缀的排在同号正式版之前。 */
|
|
3312
|
+
function compareVersions(a, b) {
|
|
3313
|
+
const parts = (value) => String(value).split('-')[0].split('.').map((one) => Number(one) || 0)
|
|
3314
|
+
const left = parts(a)
|
|
3315
|
+
const right = parts(b)
|
|
3316
|
+
for (let i = 0; i < 3; i += 1) {
|
|
3317
|
+
const one = left[i] === undefined ? 0 : left[i]
|
|
3318
|
+
const other = right[i] === undefined ? 0 : right[i]
|
|
3319
|
+
if (one !== other) return one - other
|
|
3320
|
+
}
|
|
3321
|
+
const leftPre = String(a).indexOf('-') >= 0
|
|
3322
|
+
const rightPre = String(b).indexOf('-') >= 0
|
|
3323
|
+
if (leftPre === rightPre) return 0
|
|
3324
|
+
return leftPre ? -1 : 1
|
|
3325
|
+
}
|
|
3326
|
+
|
|
3327
|
+
/* 启动自检一次的结果。查不到(离线、被墙、包没发过)时 latest 为 null,界面什么都不显示。 */
|
|
3328
|
+
let latestVersion = { checkedAt: 0, latest: null, error: null }
|
|
3329
|
+
let versionCheck = null
|
|
3330
|
+
|
|
3331
|
+
async function checkLatestVersion() {
|
|
3332
|
+
try {
|
|
3333
|
+
const response = await fetch(REGISTRY_LATEST_URL, {
|
|
3334
|
+
headers: { 'user-agent': BROWSER_UA },
|
|
3335
|
+
signal: AbortSignal.timeout(VERSION_CHECK_TIMEOUT_MS),
|
|
3336
|
+
})
|
|
3337
|
+
if (response.ok !== true) {
|
|
3338
|
+
return { checkedAt: Date.now(), latest: null, error: 'http-' + String(response.status) }
|
|
3339
|
+
}
|
|
3340
|
+
const payload = await response.json()
|
|
3341
|
+
const latest = payload !== null && typeof payload === 'object' && typeof payload.version === 'string'
|
|
3342
|
+
? payload.version
|
|
3343
|
+
: null
|
|
3344
|
+
return { checkedAt: Date.now(), latest, error: latest === null ? 'no-version' : null }
|
|
3345
|
+
} catch (error) {
|
|
3346
|
+
return { checkedAt: Date.now(), latest: null, error: 'fetch-failed' }
|
|
3347
|
+
}
|
|
3348
|
+
}
|
|
3349
|
+
|
|
3350
|
+
/* ------------------------------------------------------------------ apply */
|
|
3351
|
+
|
|
3352
|
+
export function apply(ctx) {
|
|
3353
|
+
ctxRef = ctx
|
|
3354
|
+
ctx.effect(
|
|
3355
|
+
() => ctx.sessionProjections.register(sessionModelProjection),
|
|
3356
|
+
'dsh-desktop-statusbar: sessionModel projection',
|
|
3357
|
+
)
|
|
3358
|
+
ctx.effect(
|
|
3359
|
+
() => ctx.sessionProjections.register(sessionCapacityProjection),
|
|
3360
|
+
'dsh-desktop-statusbar: sessionCapacity projection',
|
|
3361
|
+
)
|
|
3362
|
+
ctx.effect(
|
|
3363
|
+
() => ctx.sessionProjections.register(sessionProgressProjection),
|
|
3364
|
+
'dsh-desktop-statusbar: sessionProgress projection',
|
|
3365
|
+
)
|
|
3366
|
+
ctx.effect(
|
|
3367
|
+
() => ctx.sessionProjections.register(sessionUsageProjection),
|
|
3368
|
+
'dsh-desktop-statusbar: sessionUsage projection',
|
|
3369
|
+
)
|
|
3370
|
+
ctx.effect(
|
|
3371
|
+
() => ctx.sessionProjections.register(sessionTimeRangeProjection),
|
|
3372
|
+
'dsh-desktop-statusbar: sessionTimeRange projection',
|
|
3373
|
+
)
|
|
3374
|
+
ctx.effect(
|
|
3375
|
+
() => ctx.sessionProjections.register(sessionTimingProjection),
|
|
3376
|
+
'dsh-desktop-statusbar: sessionTiming projection',
|
|
3377
|
+
)
|
|
3378
|
+
|
|
3379
|
+
/* 会话日志一旦被删就没了,台账是唯一留得住的地方:启动时先并一次作为预热,
|
|
3380
|
+
之后只在用户打开活跃总览面板时(带 force=1)现扫。 */
|
|
3381
|
+
ctx.effect(() => {
|
|
3382
|
+
void refreshWeekUsage(false, ctx).catch(() => {})
|
|
3383
|
+
}, 'dsh-desktop-statusbar: usage ledger')
|
|
3384
|
+
|
|
3385
|
+
/* 启动自检一次:只问 registry 最新版号是多少(不下载包)。结果给 /api/version 用,
|
|
3386
|
+
决定导航项后面那颗「发现新版本」胶囊显不显示。查不到就安静地什么都不显示。 */
|
|
3387
|
+
ctx.effect(() => {
|
|
3388
|
+
versionCheck = checkLatestVersion().then((result) => {
|
|
3389
|
+
latestVersion = result
|
|
3390
|
+
logHost(ctxRef, result.latest === null
|
|
3391
|
+
? 'version: 自检没拿到结果(' + String(result.error) + ')'
|
|
3392
|
+
: 'version: registry 最新版 ' + String(result.latest))
|
|
3393
|
+
return result
|
|
3394
|
+
})
|
|
3395
|
+
}, 'dsh-desktop-statusbar: version self-check')
|
|
3396
|
+
|
|
3397
|
+
ctx.effect(() => ctx.webServer.register({
|
|
3398
|
+
kind: 'prefix',
|
|
3399
|
+
path: '/dsh-desktop-statusbar/api',
|
|
3400
|
+
handler: async (req, res) => {
|
|
3401
|
+
const url = new URL(req.url ?? '/', 'http://127.0.0.1')
|
|
3402
|
+
if (url.pathname === '/dsh-desktop-statusbar/api/prices') {
|
|
3403
|
+
/* POST = 客户端把它的价格库(localStorage 那份,用户可增删改)报上来。
|
|
3404
|
+
活跃总览的 cost 由 host 记账时算出,必须用同一份价格,否则用户在设置页
|
|
3405
|
+
加/改的单价只对底栏生效、活跃总览恒为旧值(实测 2026-10-07)。 */
|
|
3406
|
+
if (req.method === 'POST') {
|
|
3407
|
+
const body = await readJsonBody(req)
|
|
3408
|
+
const book = body === null ? null : sanitizePriceBook(body.models)
|
|
3409
|
+
if (book === null) {
|
|
3410
|
+
sendJson(res, 400, { ok: false, reason: 'bad-models' })
|
|
3411
|
+
return
|
|
3412
|
+
}
|
|
3413
|
+
/* 判断"价格是否变了"用的快照在队列里取(见下):并排上报不会双双报"已变更" */
|
|
3414
|
+
/* **在队列里换价格库**:换库必须与"读台账→结算→写盘"是同一段原子操作,
|
|
3415
|
+
否则在途扫描会一半用旧价、一半用新价,同一天的数字混两套价(实测缺陷)。 */
|
|
3416
|
+
let settled = { entries: 0, amount: 0 }
|
|
3417
|
+
let changed = false
|
|
3418
|
+
const swapped = await queueLedgerJob(async () => {
|
|
3419
|
+
const before = activePriceFingerprint
|
|
3420
|
+
clientPrices = book
|
|
3421
|
+
const nowFingerprint = priceFingerprint()
|
|
3422
|
+
activePriceFingerprint = nowFingerprint
|
|
3423
|
+
const nowChanged = before !== nowFingerprint
|
|
3424
|
+
/* 价格变了只做一件事:把"当时没有单价、还没算钱"的欠账按新价结算掉。
|
|
3425
|
+
已经算好的费用一律不动,台账也不重建 —— 所以会话日志被删也不影响已有数字。 */
|
|
3426
|
+
if (nowChanged !== true) return { changed: false, settled: { entries: 0, amount: 0 } }
|
|
3427
|
+
const ledger = readLedger()
|
|
3428
|
+
const done = settleUnpriced(ledger)
|
|
3429
|
+
/* 两种情况才落盘:真结算了欠账;或者盘上还是老版本,顺手把迁移结果固化一次
|
|
3430
|
+
(免得每次都重迁一遍)。没有台账时不在这里创建。
|
|
3431
|
+
**绝不改写 priceFingerprint** —— 那个字段记的是"上次记账用的那份价目表",
|
|
3432
|
+
是回扫判断"当时有没有价"的唯一依据;价格上报抢先改写它,历史缺价明细就永远补不出来。 */
|
|
3433
|
+
const onDisk = readLedgerFile(ledgerPath())
|
|
3434
|
+
const stale = onDisk !== null && onDisk.version !== LEDGER_VERSION
|
|
3435
|
+
if (done.entries > 0 || stale) {
|
|
3436
|
+
ledger.updatedAt = Date.now()
|
|
3437
|
+
writeLedger(ledger)
|
|
3438
|
+
}
|
|
3439
|
+
return { changed: true, settled: done }
|
|
3440
|
+
})
|
|
3441
|
+
changed = swapped.changed
|
|
3442
|
+
settled = swapped.settled
|
|
3443
|
+
if (changed) {
|
|
3444
|
+
/* 面板的数字要重新取一次(payload 缓存在上面) */
|
|
3445
|
+
weekUsageCache = { at: 0, value: null }
|
|
3446
|
+
weekUsageScan = null
|
|
3447
|
+
}
|
|
3448
|
+
logHost(ctx, 'prices: 收到客户端价格库 ' + String(Object.keys(book).length) + ' 条'
|
|
3449
|
+
+ (changed
|
|
3450
|
+
? '(已变更,结算欠账 ' + String(settled.entries) + ' 项 ¥' + String(settled.amount) + ')'
|
|
3451
|
+
: '(与当前一致)'))
|
|
3452
|
+
sendJson(res, 200, { ok: true, changed: changed, count: Object.keys(book).length, settled: settled })
|
|
3453
|
+
return
|
|
3454
|
+
}
|
|
3455
|
+
/* GET = 官方参考价(价格库为空时客户端拿它自愈),与用户价格库无关 */
|
|
3456
|
+
sendJson(res, 200, {
|
|
3457
|
+
models: {
|
|
3458
|
+
'deepseek-flash': TOKEN_PRICES.peak['deepseek-flash'] === undefined ? null : {
|
|
3459
|
+
peak: TOKEN_PRICES.peak['deepseek-flash'],
|
|
3460
|
+
offPeak: TOKEN_PRICES.offPeak['deepseek-flash'],
|
|
3461
|
+
},
|
|
3462
|
+
'deepseek-v4-pro': {
|
|
3463
|
+
peak: TOKEN_PRICES.peak['deepseek-v4-pro'],
|
|
3464
|
+
offPeak: TOKEN_PRICES.offPeak['deepseek-v4-pro'],
|
|
3465
|
+
},
|
|
3466
|
+
},
|
|
3467
|
+
note: 'CNY per 1M tokens, 高峰=北京时间周一至周五 9-12、14-18(法定节假日与周末全天按空闲价)',
|
|
3468
|
+
})
|
|
3469
|
+
return
|
|
977
3470
|
}
|
|
978
3471
|
if (url.pathname === '/dsh-desktop-statusbar/api/holidays') {
|
|
979
3472
|
const raw = url.searchParams.get('year')
|
|
@@ -984,7 +3477,12 @@ export function apply(ctx) {
|
|
|
984
3477
|
}
|
|
985
3478
|
try {
|
|
986
3479
|
const result = await fetchHolidayDays(year)
|
|
987
|
-
if (result.ok === true)
|
|
3480
|
+
if (result.ok === true) {
|
|
3481
|
+
/* 落盘:host 自己也要用这份假期表判峰谷,否则 2027 年起底栏与活跃总览口径不一致 */
|
|
3482
|
+
const merged = saveHolidayDays(result.days)
|
|
3483
|
+
logHost(ctxRef, 'holidays: ' + String(year) + ' 年 ' + String(result.days.length)
|
|
3484
|
+
+ ' 天(累计 ' + String(merged.length) + ' 天,已落盘)')
|
|
3485
|
+
}
|
|
988
3486
|
sendJson(res, 200, result)
|
|
989
3487
|
} catch (error) {
|
|
990
3488
|
const detail = error !== null && error !== undefined && typeof error.message === 'string' ? error.message : 'fetch-failed'
|
|
@@ -993,6 +3491,29 @@ export function apply(ctx) {
|
|
|
993
3491
|
}
|
|
994
3492
|
return
|
|
995
3493
|
}
|
|
3494
|
+
if (url.pathname === '/dsh-desktop-statusbar/api/version') {
|
|
3495
|
+
/* 启动自检可能还没回来:等这一次在途的查询,别让客户端误判成「没有新版」 */
|
|
3496
|
+
if (latestVersion.checkedAt === 0 && versionCheck !== null) {
|
|
3497
|
+
try {
|
|
3498
|
+
latestVersion = await versionCheck
|
|
3499
|
+
} catch (error) {
|
|
3500
|
+
/* 保持 latest 为 null,下面按「查不到」处理 */
|
|
3501
|
+
}
|
|
3502
|
+
}
|
|
3503
|
+
const raw = url.searchParams.get('current')
|
|
3504
|
+
const current = raw === null ? '' : raw.trim()
|
|
3505
|
+
sendJson(res, 200, {
|
|
3506
|
+
ok: latestVersion.latest !== null,
|
|
3507
|
+
current,
|
|
3508
|
+
latest: latestVersion.latest,
|
|
3509
|
+
/* 只有确实拿到版本号、且它比本地这个新,才算「有更新」 */
|
|
3510
|
+
hasUpdate: latestVersion.latest !== null && current.length > 0
|
|
3511
|
+
&& compareVersions(latestVersion.latest, current) > 0,
|
|
3512
|
+
checkedAt: latestVersion.checkedAt,
|
|
3513
|
+
error: latestVersion.error,
|
|
3514
|
+
})
|
|
3515
|
+
return
|
|
3516
|
+
}
|
|
996
3517
|
if (url.pathname === '/dsh-desktop-statusbar/api/active-model') {
|
|
997
3518
|
if (req.method === 'POST') {
|
|
998
3519
|
const body = await readJsonBody(req)
|
|
@@ -1017,6 +3538,55 @@ export function apply(ctx) {
|
|
|
1017
3538
|
})
|
|
1018
3539
|
return
|
|
1019
3540
|
}
|
|
3541
|
+
if (url.pathname === '/dsh-desktop-statusbar/api/subagent-usage') {
|
|
3542
|
+
/* 底栏「总计」和「本轮」的队友部分:子智能体是独立会话,它的用量只能这样按 parentSession
|
|
3543
|
+
找回来。`since` 是可选的窗口起点(毫秒):客户端拿"本轮起点"传进来,「本轮」就只算
|
|
3544
|
+
本轮里队友花了多少;不传就是整会话(「总计」用)。
|
|
3545
|
+
参数不对(客户端拿不到会话 id / 传了别的东西)给 400;会话没有队友、或队友日志
|
|
3546
|
+
全都解不出来时给零值而不是 404 —— 客户端按"加上 0"处理,界面不该因此报错。 */
|
|
3547
|
+
const raw = url.searchParams.get('sessionId')
|
|
3548
|
+
const sessionId = raw === null ? '' : raw.trim()
|
|
3549
|
+
if (sessionId.length === 0 || sessionId.length > 200) {
|
|
3550
|
+
sendJson(res, 400, { ok: false, reason: 'bad-session' })
|
|
3551
|
+
return
|
|
3552
|
+
}
|
|
3553
|
+
/* 非法/缺失的 since 一律当"没传":空串、非有限数、负数、以及**未来时刻**。
|
|
3554
|
+
本轮起点必然在过去,未来时刻只可能是客户端传了坏值;真按它过滤会把队友的
|
|
3555
|
+
数字静默清零(少算钱),所以宁可忽略。留 5 分钟余量容忍时钟抖动。
|
|
3556
|
+
"当没传"的新语义(task-18 起):由 host 读父会话日志代推起点;连父会话都没有
|
|
3557
|
+
就本轮为空(`sinceSource='none'`),**不**退回整会话 —— 要整会话就显式传 `since=0`。 */
|
|
3558
|
+
const rawSince = url.searchParams.get('since')
|
|
3559
|
+
const sinceText = rawSince === null ? '' : rawSince.trim()
|
|
3560
|
+
const parsedSince = sinceText.length === 0 ? NaN : Number(sinceText)
|
|
3561
|
+
const usableSince = Number.isFinite(parsedSince) === true && parsedSince >= 0
|
|
3562
|
+
&& parsedSince <= Date.now() + 5 * 60 * 1000
|
|
3563
|
+
/* 这个接口只读日志、不排队:它不碰台账,没有与扫描抢写盘的问题 */
|
|
3564
|
+
let value
|
|
3565
|
+
try {
|
|
3566
|
+
value = await collectSubagentUsage(sessionId, usableSince === true ? parsedSince : undefined)
|
|
3567
|
+
} catch (error) {
|
|
3568
|
+
logHost(ctx, 'subagent-usage: 汇总失败 ' + probeDetail(error))
|
|
3569
|
+
value = { ok: false, reason: 'scan-failed' }
|
|
3570
|
+
}
|
|
3571
|
+
sendJson(res, 200, value)
|
|
3572
|
+
return
|
|
3573
|
+
}
|
|
3574
|
+
if (url.pathname === '/dsh-desktop-statusbar/api/daily-usage') {
|
|
3575
|
+
let value
|
|
3576
|
+
try {
|
|
3577
|
+
value = await refreshWeekUsage(url.searchParams.get('force') === '1', ctx)
|
|
3578
|
+
} catch (error) {
|
|
3579
|
+
logHost(ctx, 'usage: 台账刷新失败 ' + probeDetail(error))
|
|
3580
|
+
value = { ok: false, reason: 'ledger-failed' }
|
|
3581
|
+
}
|
|
3582
|
+
sendJson(res, 200, value)
|
|
3583
|
+
return
|
|
3584
|
+
}
|
|
3585
|
+
if (url.pathname === '/dsh-desktop-statusbar/api/tip-diag') {
|
|
3586
|
+
if (req.method === 'POST') appendTipDiag(await readJsonBody(req))
|
|
3587
|
+
sendJson(res, 200, { ok: true })
|
|
3588
|
+
return
|
|
3589
|
+
}
|
|
1020
3590
|
if (url.pathname !== '/dsh-desktop-statusbar/api/balance') {
|
|
1021
3591
|
sendJson(res, 404, { ok: false, reason: 'not-found' })
|
|
1022
3592
|
return
|