dsh-agent-board 1.7.2 → 1.7.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -13
- package/index.mjs +13 -5
- package/lib/client/dashboard.js +120 -39
- package/lib/client/kernel.js +44 -6
- package/lib/client/task-detail.js +7 -3
- package/lib/client.js +171 -48
- package/lib/core.mjs +61 -29
- package/lib/dispatch.mjs +920 -226
- package/lib/rpc.mjs +127 -18
- package/lib/store.mjs +26 -1
- package/lib/usage.mjs +323 -20
- package/package.json +1 -1
- package/test/core.test.mjs +2066 -107
- package/test/usage-mainwindow.test.mjs +230 -0
package/lib/usage.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// dsh-agent-board — Token 消耗统计(lib/usage.mjs)
|
|
2
|
-
// v4 会话日志定位 / zstd 分帧 / usage
|
|
2
|
+
// v4 会话日志定位 / zstd 分帧 / usage 聚合(含可续跑 Worker 的 seq 水位线增量结算):纯函数 + node 模块,
|
|
3
|
+
// 不碰 ctx 与共享状态。
|
|
3
4
|
// index.mjs 薄壳 re-export findRunLog/readRunUsage/aggregateUsageSummary(对外契约不变);
|
|
4
5
|
// readLogBytes/readLogFrames 新增 export 供 rpc.mjs 的 agent-activity 复用(原 index.mjs 模块内私有)。
|
|
5
6
|
import { zstdDecompressSync } from 'node:zlib'
|
|
@@ -76,25 +77,38 @@ export function readLogFrames(buf, limit) {
|
|
|
76
77
|
return out
|
|
77
78
|
}
|
|
78
79
|
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
|
|
80
|
+
// ===== 水位线(sinceSeq):可续跑 Worker 的「同一份日志多次结算」防重复计账 =====
|
|
81
|
+
// 背景(task-muw5hhsh 卡3):一次性 run 各自一份日志文件,全量累加即正确;但 continuable 的
|
|
82
|
+
// 硬超时 interrupt 留存 + 重派冷复活续跑**沿用同一个子会话 → 同一份 v4 日志**——一个会话被结算
|
|
83
|
+
// 多次,整份全量累加会把上几轮的 token 反复记进看板(本机实测同一 childId 结算两次=双倍账)。
|
|
84
|
+
// 水位线取事件自带的全序字段 seq(v4 日志每条事件都带 seq,会话内单调递增——用本机真实日志逐帧核对):
|
|
85
|
+
// 结算时只认 seq > sinceSeq 的 assistant/message 增量帧,并把本份日志见过的 maxSeq 回给调用方
|
|
86
|
+
// 落在 t.runs[].usageSeq 上,下一轮结算从该水位继续。本轮完全没产出(maxSeq 不前进)→ 返回 null
|
|
87
|
+
// (不是 0 值对象,调用方按「暂无数据」跳过),绝不再记一笔空账。
|
|
88
|
+
// sinceSeq 缺省(undefined/非正数)=全量累加 → one-shot 路径逐字不变(水位的「首次结算」形态)。
|
|
89
|
+
export function readRunUsage(runId, sessionsRoot, sinceSeq) {
|
|
83
90
|
try {
|
|
84
91
|
var log = findRunLog(runId, sessionsRoot)
|
|
85
92
|
if (!log) return null
|
|
86
|
-
// 整份读取:usage
|
|
93
|
+
// 整份读取:usage 需要全部帧(单次 run 日志量级 MB,结算时只读一次)。
|
|
87
94
|
// 极端超大日志封顶 64MB——只丢最老的历史帧,好过结算路径被一次同步 IO 拖住。
|
|
95
|
+
// 注意与水位线的关系:封顶只可能丢「最老」帧(seq 小的),水位线语义不受影响。
|
|
88
96
|
var buf = readLogBytes(log, 64 * 1024 * 1024)
|
|
89
97
|
if (!buf) return null
|
|
90
98
|
var frames = readLogFrames(buf, 0)
|
|
91
|
-
var out = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0, model: '' }
|
|
99
|
+
var out = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0, model: '', maxSeq: -1 }
|
|
92
100
|
var hasUsage = false
|
|
101
|
+
var from = numOr0(sinceSeq)
|
|
102
|
+
// 帧序是「从新到旧」,逐帧扫即可;maxSeq 取全份日志的最大 seq(含无 usage 的事件)——
|
|
103
|
+
// 这样「本轮只有工具调用、没有新助手消息」也不会让水位线倒退(下一轮的增量判定仍然正确)。
|
|
93
104
|
for (var fi = 0; fi < frames.length; fi++) {
|
|
94
105
|
var evs = frames[fi]
|
|
95
106
|
for (var i = 0; i < evs.length; i++) {
|
|
96
107
|
var e = evs[i]; var dta = (e && e.data) || {}
|
|
108
|
+
var sq = numOr0(e && e.seq)
|
|
109
|
+
if (sq > out.maxSeq) out.maxSeq = sq
|
|
97
110
|
if (e && e.type === 'request/context' && dta.model && !out.model) out.model = String(dta.model)
|
|
111
|
+
if (from > 0 && sq <= from) continue // 已结算过的历史帧(水位线之前)不再计入
|
|
98
112
|
if (!e || e.type !== 'assistant/message') continue
|
|
99
113
|
var u = dta.usage
|
|
100
114
|
if (!u) continue
|
|
@@ -116,7 +130,8 @@ function numOr0(v) { var n = Number(v); return isFinite(n) && n > 0 ? n : 0 }
|
|
|
116
130
|
|
|
117
131
|
// 本地日期 key(YYYY-MM-DD):与 dispatch.mjs 的日账记账同一口径。
|
|
118
132
|
// 用本地 getters 拼而不用 toISOString()——UTC 会把晚间消耗挪到次日,「今日消耗」直接错位。
|
|
119
|
-
|
|
133
|
+
// 导出供测试直接钉死口径(dispatch.mjs 仍持自己同名的一份——那边是记账热路径,不动)。
|
|
134
|
+
export function localDayKey(d) {
|
|
120
135
|
var x = d
|
|
121
136
|
function p2(n) { return (n < 10 ? '0' : '') + n }
|
|
122
137
|
return x.getFullYear() + '-' + p2(x.getMonth() + 1) + '-' + p2(x.getDate())
|
|
@@ -167,21 +182,183 @@ function dayCell(v) {
|
|
|
167
182
|
return { t: isFinite(n) && n > 0 ? n : 0, e: null }
|
|
168
183
|
}
|
|
169
184
|
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
|
|
173
|
-
|
|
185
|
+
// ===== 统计范围(range)过滤:range = { from, to },本地日 YYYY-MM-DD,两端都含、可各自缺省 =====
|
|
186
|
+
// 为什么在 host 侧过滤:run 级数据(runs[i].usage 五分量 + runs[i].at 时间戳)只在 host,
|
|
187
|
+
// 客户端轮询只拿聚合——范围筛选只能在这一层做,客户端没法自己裁。
|
|
188
|
+
// 口径:run 的本地日落点 localDayKey(run.at) 判 in/out;范围外 run 不计入 total/byModel/top,
|
|
189
|
+
// byDay 也同步裁到范围内(范围外日不入 byDay;run 留账任务按入选 run 逐日重建,to 端闭区间)。
|
|
190
|
+
// byDayFull 与范围无关恒为全量(task-muwsnyqv ②)——客户端「今日」「近 7 天」固定口径读它。
|
|
191
|
+
// 缺省(无 range / from 与 to 皆空)= 现状逐字不变(parity):不引入任何新的过滤分支语义,
|
|
192
|
+
// 老任务(无 runs 留账)与无 at 的老 run 照旧全量计入。
|
|
193
|
+
function normDayKey(v) {
|
|
194
|
+
if (v === undefined || v === null) return ''
|
|
195
|
+
var s = String(v).trim()
|
|
196
|
+
if (!s) return ''
|
|
197
|
+
return /^\d{4}-\d{2}-\d{2}$/.test(s) ? s : '' // 只认标准本地日 key,脏值当没填(宁可不过滤,不乱裁)
|
|
198
|
+
}
|
|
199
|
+
// 范围是否有效:两端都空视为「无范围」——调用方传 {from:'',to:''} 与不传等价(客户端空范围即此形态)
|
|
200
|
+
function rangeOn(range) {
|
|
201
|
+
if (!range || typeof range !== 'object') return false
|
|
202
|
+
return !!(normDayKey(range.from) || normDayKey(range.to))
|
|
203
|
+
}
|
|
204
|
+
// 日 key 是否落在范围内(字符串比较即可:YYYY-MM-DD 定长零填充,字典序 = 时间序);端点缺省 = 该侧不设限
|
|
205
|
+
function dayInRange(dayKey, range) {
|
|
206
|
+
var d = normDayKey(dayKey)
|
|
207
|
+
if (!d) return false
|
|
208
|
+
var from = normDayKey(range && range.from)
|
|
209
|
+
var to = normDayKey(range && range.to)
|
|
210
|
+
if (from && d < from) return false
|
|
211
|
+
if (to && d > to) return false
|
|
212
|
+
return true
|
|
213
|
+
}
|
|
214
|
+
// 一次 run 的 usage 分量提取:**任务级** t.runs[] 条目上的 usage 是 run 级留账
|
|
215
|
+
// (五分量俱全,dispatch.accumulateRunUsage 结算时按 runId 原样写回),条目同时自带 at/model。
|
|
216
|
+
// 字段位置务必分清(task-muwq9u04 的 bug 根因):t.runs 是 run 记录**数组**,
|
|
217
|
+
// 而 t.usage.runs 只是「结算次数」的**计数**(number)——把后者当数组读会恒得空数组。
|
|
218
|
+
function runUsageOf(r) {
|
|
219
|
+
var ru = r && r.usage
|
|
220
|
+
if (!ru || typeof ru !== 'object') return null
|
|
221
|
+
var tot = numOr0(ru.total)
|
|
222
|
+
if (!tot) return null // 无总量的 run 条目不算一次有效结算(与 readRunUsage 的 hasUsage 同哲学)
|
|
223
|
+
return { total: tot, input: numOr0(ru.input), output: numOr0(ru.output), cacheRead: numOr0(ru.cacheRead), cacheWrite: numOr0(ru.cacheWrite) }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// board 级聚合(get-tasks 现算,不落盘额外表):总量 + 输入/输出/缓存读拆分 + 有效合计 + 按模型小计
|
|
227
|
+
// (合计与有效各一份)+ 任务 Top8 + 日账。归档任务同样计入(它们确实消耗过 token);无 usage 的任务跳过。
|
|
228
|
+
// 第二参 range 可选({from,to} 本地日):给定时只计范围内 run(口径见上方 range 段);省略/空范围 = 全量。
|
|
229
|
+
// 【范围过滤的数据源 · task-muwq9u04 修正】run 级留账在**任务级 t.runs[]**(dispatch 写入,条目形如
|
|
230
|
+
// { role, id, at, model, outcome, usage:{五分量} });t.usage.runs 只是「结算次数」计数(number)。
|
|
231
|
+
// 上一版把 uRaw.runs(= t.usage.runs 计数)当数组读 → 真实数据上恒为 [],范围过滤整个退化成
|
|
232
|
+
// 「按任务 updatedAt 单日近似」,于是切范围后 topTasks 与无范围逐字相同(用户活体实证:选 2026-10-05
|
|
233
|
+
// 后 total/byDay 收窄、Top8 第一名仍是全量 48M 的那张卡)。现在改为逐 run 精确裁切。
|
|
234
|
+
export function aggregateUsageSummary(tasks, range) {
|
|
235
|
+
var s = { total: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, effective: 0, byModel: {}, byModelEff: {}, byDay: {}, byDayFull: {}, topTasks: [] }
|
|
174
236
|
var list = Array.isArray(tasks) ? tasks : []
|
|
237
|
+
var hasRange = rangeOn(range)
|
|
175
238
|
for (var i = 0; i < list.length; i++) {
|
|
176
|
-
var t = list[i]; var
|
|
177
|
-
if (!
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
239
|
+
var t = list[i]; var uRaw = t && t.usage
|
|
240
|
+
if (!uRaw) continue
|
|
241
|
+
// ===== byDayFull:未过滤全量日账(task-muwsnyqv ②)=====
|
|
242
|
+
// 「今日」「近 7 天」是固定口径(客户端 caption 承诺不随统计范围变),数据源必须是全量日账——
|
|
243
|
+
// 只读范围内裁剪的 byDay 时,「今天被范围裁掉」会让今日大数字凭空归零(活体实证:范围
|
|
244
|
+
// {10-05,10-06} 下今日条目整丢)。口径与无范围 byDay 逐字同源:任务级 uRaw.byDay 逐日合并
|
|
245
|
+
// (老 number → e=null 不猜),无 byDay 的存量任务整笔归 updatedAt 本地日;与下方范围判定
|
|
246
|
+
// 完全解耦(范围外任务照样计入 byDayFull),无范围时 byDayFull 与 byDay 内容一致(parity)。
|
|
247
|
+
var byF = (uRaw.byDay && typeof uRaw.byDay === 'object') ? uRaw.byDay : null
|
|
248
|
+
var hasByF = false
|
|
249
|
+
for (var bk0 in byF) { if (Object.prototype.hasOwnProperty.call(byF, bk0)) { hasByF = true; break } }
|
|
250
|
+
if (hasByF) {
|
|
251
|
+
for (var dk0 in byF) {
|
|
252
|
+
if (!Object.prototype.hasOwnProperty.call(byF, dk0)) continue
|
|
253
|
+
var c0 = dayCell(byF[dk0])
|
|
254
|
+
// 合并规则与 byDay 相同:任一来源 e 不可知 → 该日 e=null(不假装算得出);全部已知才累加。
|
|
255
|
+
var cur0 = s.byDayFull[dk0] || { t: 0, e: undefined }
|
|
256
|
+
cur0.t += c0.t
|
|
257
|
+
if (c0.e === null) cur0.e = null
|
|
258
|
+
else if (cur0.e !== null) cur0.e = (cur0.e || 0) + c0.e
|
|
259
|
+
if (cur0.e === undefined) cur0.e = null
|
|
260
|
+
s.byDayFull[dk0] = cur0
|
|
261
|
+
}
|
|
262
|
+
} else if (numOr0(uRaw.total)) {
|
|
263
|
+
var lkF = dayKeyOf(uRaw.updatedAt)
|
|
264
|
+
if (lkF) { var lcF = s.byDayFull[lkF] || { t: 0, e: undefined }; lcF.t += numOr0(uRaw.total); lcF.e = null; s.byDayFull[lkF] = lcF }
|
|
265
|
+
}
|
|
266
|
+
// 过滤层只决定「这次任务贡献哪些用量」,下游累加口径(模型/Top/byDay)逐字不动。
|
|
267
|
+
var u = uRaw
|
|
268
|
+
if (hasRange) {
|
|
269
|
+
var trs = Array.isArray(t.runs) ? t.runs : [] // 任务级 run 留档(真实数据源:条目自带 at + model + usage)
|
|
270
|
+
var uSum = { total: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
|
|
271
|
+
var models = null
|
|
272
|
+
var modelsE = null
|
|
273
|
+
var picked = 0 // 范围内入选的 run 数(Top8 的「N 次 run」在范围下报它)
|
|
274
|
+
var covered = 0 // 落了 usage 的 run 数(判「有没有 run 级留账可裁」——与 at 无关)
|
|
275
|
+
var modeledTot = 0 // 入选 run 里**记了模型名**的那部分总量(剩下的按任务级占比摊,见下)
|
|
276
|
+
var modeledEff = 0
|
|
277
|
+
var dayB = null // 入选 run 的逐日归桶(重建范围内 byDay,见下)
|
|
278
|
+
for (var ri = 0; ri < trs.length; ri++) {
|
|
279
|
+
var r = trs[ri]
|
|
280
|
+
var ru = runUsageOf(r)
|
|
281
|
+
if (!ru) continue // run 条目还没落 usage(老 run / 未结算):不参与裁切,只影响 covered 计数
|
|
282
|
+
covered++
|
|
283
|
+
if (!dayInRange(dayKeyOf(r && r.at), range)) continue // 范围外 run:不计入任何分量
|
|
284
|
+
picked++
|
|
285
|
+
uSum.total += ru.total; uSum.input += ru.input; uSum.output += ru.output
|
|
286
|
+
uSum.cacheRead += ru.cacheRead; uSum.cacheWrite += ru.cacheWrite
|
|
287
|
+
// byDay 同步重建(task-muwsnyqv ①):旧版合成的 u 丢 byDay/updatedAt → 范围内日账整块消失
|
|
288
|
+
// (「今天被吞」实证:range 含今天时 total/effective 含今天、byDay 却无今天)。按入选 run 的
|
|
289
|
+
// at 本地日逐日归桶——与范围过滤同一落点口径(dayInRange 两端闭区间:d > to 才丢),
|
|
290
|
+
// from=to=今天 时今天必在 byDay。run 级五分量俱全 → e 恒为已知有效值(不是 null)。
|
|
291
|
+
var rd = dayKeyOf(r && r.at)
|
|
292
|
+
if (rd) {
|
|
293
|
+
if (!dayB) dayB = {}
|
|
294
|
+
var dc1 = dayB[rd] || { t: 0, e: 0 }
|
|
295
|
+
dc1.t += ru.total; dc1.e += effectiveTokens(ru)
|
|
296
|
+
dayB[rd] = dc1
|
|
297
|
+
}
|
|
298
|
+
// 按模型分布同步按范围收窄:模型 key 与结算同源(派发覆盖 > 日志记录),run 留账里没记模型名
|
|
299
|
+
// → 退化为任务级模型小计,但只在本次有 run 入选时摊(否则等于把范围外消耗算进来)。
|
|
300
|
+
// 合计与有效(byModel / byModelEff)同一轮同源累加,两条口径不各算一遍。
|
|
301
|
+
var rm = (r && r.model) || ''
|
|
302
|
+
if (rm) {
|
|
303
|
+
if (!models) { models = {}; modelsE = {} }
|
|
304
|
+
models[rm] = (models[rm] || 0) + ru.total
|
|
305
|
+
modelsE[rm] = (modelsE[rm] || 0) + effectiveTokens(ru)
|
|
306
|
+
modeledTot += ru.total; modeledEff += effectiveTokens(ru)
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
if (picked) {
|
|
310
|
+
// 有 run 入选:模型分布优先用 run 级重算(能精确按范围切)。
|
|
311
|
+
var taskModels = (uRaw.models && typeof uRaw.models === 'object') ? uRaw.models : {}
|
|
312
|
+
var tmKeys = []
|
|
313
|
+
for (var tk in taskModels) { if (Object.prototype.hasOwnProperty.call(taskModels, tk)) tmKeys.push(tk) }
|
|
314
|
+
var tTot = numOr0(uRaw.total)
|
|
315
|
+
// 未被 run 级模型名归属的那部分用量(含「一条都没记模型名」的极端)按任务级模型小计占比摊派:
|
|
316
|
+
// 模型键不凭空消失(真实数据里 r.model 常是空串——派发未覆盖模型时模型名只在日志里,
|
|
317
|
+
// 而 u.models 用的是日志记录的名字),ΣbyModel 也仍与本次入选 total 对齐,不出现
|
|
318
|
+
// 「按模型分布加起来 ≠ 累计」。全部入选 run 都记了模型名(restTot=0)→ 一个字节都不摊,保持精确。
|
|
319
|
+
var restTot = uSum.total - modeledTot
|
|
320
|
+
var restEff = effectiveTokens(uSum) - modeledEff
|
|
321
|
+
if (tmKeys.length && (restTot > 0 || !models)) {
|
|
322
|
+
if (!models) { models = {}; modelsE = {} }
|
|
323
|
+
var ratio = tTot > 0 ? (restTot / tTot) : 0
|
|
324
|
+
var ratioE = tTot > 0 ? (restEff / tTot) : 0
|
|
325
|
+
for (var ti = 0; ti < tmKeys.length; ti++) {
|
|
326
|
+
models[tmKeys[ti]] = (models[tmKeys[ti]] || 0) + Math.round(numOr0(taskModels[tmKeys[ti]]) * ratio)
|
|
327
|
+
modelsE[tmKeys[ti]] = (modelsE[tmKeys[ti]] || 0) + Math.round(numOr0(taskModels[tmKeys[ti]]) * ratioE)
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
u = { total: uSum.total, input: uSum.input, output: uSum.output, cacheRead: uSum.cacheRead, cacheWrite: uSum.cacheWrite, models: models || {}, modelEff: modelsE || {}, runs: picked, byDay: dayB || {} }
|
|
331
|
+
} else if (!covered) {
|
|
332
|
+
// 无 run 级留账可裁(本功能上线前的存量卡;或 runs 条目全都没落 usage):没有 run 级时间戳,
|
|
333
|
+
// 整笔退化为「updatedAt 的本地日」这一近似口径(与 byDay 存量兜底同源);无 updatedAt / 解析失败 →
|
|
334
|
+
// 无日可判 → 范围外(宁可漏不错,绝不把不知何时花的钱算进用户选的范围)。
|
|
335
|
+
var lk0 = dayKeyOf(uRaw.updatedAt)
|
|
336
|
+
if (!lk0 || !dayInRange(lk0, range)) continue
|
|
337
|
+
} else {
|
|
338
|
+
continue // 有 run 留账但范围内一条都没入选(含 run 无 at 的老数据):本任务对本次范围零贡献
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
var uTot = numOr0(u.total)
|
|
342
|
+
if (!uTot) continue // 范围内无用量 → 不计入(也不进 Top,零值条目无信息量)
|
|
343
|
+
var uEff = effectiveTokens(u) // 有效口径单点定义(effectiveTokens),与 ROI 行/近 7 天同源
|
|
344
|
+
s.total += uTot; s.input += numOr0(u.input); s.output += numOr0(u.output)
|
|
345
|
+
s.cacheRead += numOr0(u.cacheRead); s.cacheWrite += numOr0(u.cacheWrite)
|
|
346
|
+
s.effective += uEff
|
|
181
347
|
var ms = u.models || {}
|
|
182
|
-
for (var mk in ms) { if (Object.prototype.hasOwnProperty.call(ms, mk)) s.byModel[mk] = (s.byModel[mk] || 0) + (ms[mk]
|
|
183
|
-
//
|
|
184
|
-
|
|
348
|
+
for (var mk in ms) { if (Object.prototype.hasOwnProperty.call(ms, mk)) s.byModel[mk] = (s.byModel[mk] || 0) + numOr0(ms[mk]) }
|
|
349
|
+
// 按模型有效消耗(byModelEff):口径统一后模型分布行的主数字也要是有效消耗,否则同一块里
|
|
350
|
+
// 「今日有效 3.7M / 单模型 21M」并排自相矛盾。范围路径已按入选 run 精确算出;无范围路径
|
|
351
|
+
// (以及 run 未记模型名的兜底)按该任务「有效/总量」比例摊派——Σ(byModelEff) 与 s.effective
|
|
352
|
+
// 至多差各模型四舍五入的个位数,不制造新的口径矛盾。
|
|
353
|
+
var meEff = (u.modelEff && typeof u.modelEff === 'object') ? u.modelEff : null
|
|
354
|
+
if (meEff) {
|
|
355
|
+
for (var me1 in meEff) { if (Object.prototype.hasOwnProperty.call(meEff, me1)) s.byModelEff[me1] = (s.byModelEff[me1] || 0) + numOr0(meEff[me1]) }
|
|
356
|
+
} else {
|
|
357
|
+
var ratioE2 = uTot > 0 ? (uEff / uTot) : 0
|
|
358
|
+
for (var me2 in ms) { if (Object.prototype.hasOwnProperty.call(ms, me2)) s.byModelEff[me2] = (s.byModelEff[me2] || 0) + Math.round(numOr0(ms[me2]) * ratioE2) }
|
|
359
|
+
}
|
|
360
|
+
// Top8 带有效/缓存读拆分:仪表盘 Top 行主数字取 effective(有效消耗),合计/缓存读进 title 悬浮
|
|
361
|
+
s.topTasks.push({ id: t.id, title: t.title, total: uTot, effective: uEff, cacheRead: numOr0(u.cacheRead), runs: u.runs || 0 })
|
|
185
362
|
// 日账合并:任务自带 byDay(逐日精确)→ 逐 key 累加,并把日单元归一化成 {t,e}
|
|
186
363
|
// (老 number 形态 → {t:n, e:null},e 不可知就不猜;e 为 null 的日聚合后仍是 null)。
|
|
187
364
|
// 存量兜底:本功能上线前结算的老任务没有 byDay,把整笔 total 归到 updatedAt 的本地日——
|
|
@@ -193,6 +370,7 @@ export function aggregateUsageSummary(tasks) {
|
|
|
193
370
|
if (hasBy) {
|
|
194
371
|
for (var dk in by) {
|
|
195
372
|
if (!Object.prototype.hasOwnProperty.call(by, dk)) continue
|
|
373
|
+
if (hasRange && !dayInRange(dk, range)) continue // 范围外日:不进 byDay(日账随范围一起裁,聚合体内不自相矛盾)
|
|
196
374
|
var c = dayCell(by[dk])
|
|
197
375
|
// 累加器初值 e 用 undefined(=还没合并过任何来源),与「已知不可知」(null)区分开——
|
|
198
376
|
// 若初值直接写 null,第一轮 cur.e===null 判定就把已知的有效值自我清空成 null(本实现踩过的坑)。
|
|
@@ -213,3 +391,128 @@ export function aggregateUsageSummary(tasks) {
|
|
|
213
391
|
s.topTasks = s.topTasks.slice(0, 8)
|
|
214
392
|
return s
|
|
215
393
|
}
|
|
394
|
+
|
|
395
|
+
// ===== 主窗口(本会话对话)Token 消耗:增量尾读主会话自己的 v4 日志(task-muwsol23)=====
|
|
396
|
+
// 与 readRunUsage 的差异:主会话日志持续增长(可达数万事件),3s 轮询下每轮全量读会把
|
|
397
|
+
// 同步 IO 摊进轮询热路径——这里按「文件偏移水位」增量尾读,缓存条目由调用方持有(键=会话 id):
|
|
398
|
+
// { size, mtimeMs, total, input, output, cacheRead, cacheWrite, effective, byDay, hasUsage }
|
|
399
|
+
// - size = 已结算到的文件偏移;只推进到「最后一个完整解压帧」的末尾——尾部半帧(写入中)
|
|
400
|
+
// 不结算,下轮从该偏移重读(绝不丢事件、绝不重复计账);
|
|
401
|
+
// - 文件变大 → 只读 [size, 新size) 的新增字节段(3s 轮询稳态零全量重读);
|
|
402
|
+
// - 文件变小(日志轮换/截断)→ 缓存作废,全量重读一次(封顶 64MB,与 readRunUsage 同一护栏);
|
|
403
|
+
// - 文件不变 → 零读直接用缓存聚合值。
|
|
404
|
+
// 聚合口径与 Worker 完全同源:五分量(input/output/cacheRead/cacheWrite/total)+ e=input+output+cacheWrite,
|
|
405
|
+
// 日落点取 assistant/message 事件的 time 字段(epoch ms)的本地日(与 run 口径同一 localDayKey)。
|
|
406
|
+
// 范围裁剪:缓存里存的是**未裁剪**全量聚合;range({from,to} 本地日)在出参时按 byDay 逐日裁
|
|
407
|
+
// (与 run 口径同一 dayInRange,两端闭区间),范围内 total/effective/cacheRead 由入选日重算——
|
|
408
|
+
// 无日可判(无 time/坏 time)的事件只进平账不进任何日,范围下自然被裁(宁可漏不错)。
|
|
409
|
+
// 隔离红线:返回值只挂 usageSummary.mainWindow——不进 Top8 / 「本看板累计」/ 架构健康与学习飞轮基数。
|
|
410
|
+
// 失败静默降级:日志不存在/不可读/解不出任何 usage → null(客户端不渲染该行),绝不抛进轮询。
|
|
411
|
+
var MAINWIN_LOG_CAP = 64 * 1024 * 1024
|
|
412
|
+
// 聚合器初始形态(也是缓存条目形态)
|
|
413
|
+
function mainWinAcc() {
|
|
414
|
+
return { size: 0, mtimeMs: 0, total: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, effective: 0, byDay: {}, hasUsage: false }
|
|
415
|
+
}
|
|
416
|
+
// 事件时间戳(v4 事件的 time 字段,epoch ms)→ 本地日 key;缺失/非法返回 ''(不进任何日账)
|
|
417
|
+
function dayKeyOfMs(ms) {
|
|
418
|
+
var t = Number(ms)
|
|
419
|
+
if (!isFinite(t) || t <= 0) return ''
|
|
420
|
+
var d = new Date(t)
|
|
421
|
+
if (isNaN(d.getTime())) return ''
|
|
422
|
+
return localDayKey(d)
|
|
423
|
+
}
|
|
424
|
+
// 把 buf 内全部完整帧的 assistant/message usage 累加进 acc;返回「结算到」的相对偏移。
|
|
425
|
+
// 帧序旧→新扫(追加写):中间坏帧跳过但照样结算掉(坏帧永远读不回来,别让水位卡住);
|
|
426
|
+
// 最后一段解压失败 = 尾部半帧(写入进行中)→ 停在它的起点,下轮写完整后重读。
|
|
427
|
+
function mainWinAggregateFrames(acc, buf) {
|
|
428
|
+
var offs = []
|
|
429
|
+
var at = buf.indexOf(ZSTD_MAGIC)
|
|
430
|
+
while (at >= 0) { offs.push(at); at = buf.indexOf(ZSTD_MAGIC, at + 1) }
|
|
431
|
+
var consumed = 0
|
|
432
|
+
for (var k = 0; k < offs.length; k++) {
|
|
433
|
+
var end = k + 1 < offs.length ? offs[k + 1] : buf.length
|
|
434
|
+
var lines = null
|
|
435
|
+
try {
|
|
436
|
+
lines = zstdDecompressSync(buf.subarray(offs[k], end)).toString('utf8').split('\n')
|
|
437
|
+
} catch (_) {
|
|
438
|
+
if (k === offs.length - 1) break // 尾部半帧:不结算,下轮从它的起点重读
|
|
439
|
+
consumed = end; continue
|
|
440
|
+
}
|
|
441
|
+
for (var i = 0; i < lines.length; i++) {
|
|
442
|
+
if (!lines[i]) continue
|
|
443
|
+
var e
|
|
444
|
+
try { e = JSON.parse(lines[i]) } catch (_) { continue }
|
|
445
|
+
if (!e || e.type !== 'assistant/message') continue
|
|
446
|
+
var u = e.data && e.data.usage
|
|
447
|
+
if (!u) continue
|
|
448
|
+
var inp = numOr0(u.inputTokens), outp = numOr0(u.outputTokens)
|
|
449
|
+
var cr = numOr0(u.cacheReadTokens), cw = numOr0(u.cacheWriteTokens)
|
|
450
|
+
var tot = numOr0(u.totalTokens) || (inp + outp + cr + cw)
|
|
451
|
+
var eff = inp + outp + cw
|
|
452
|
+
acc.total += tot; acc.input += inp; acc.output += outp
|
|
453
|
+
acc.cacheRead += cr; acc.cacheWrite += cw; acc.effective += eff
|
|
454
|
+
acc.hasUsage = true
|
|
455
|
+
var dk = dayKeyOfMs(e.time)
|
|
456
|
+
if (dk) {
|
|
457
|
+
var cell = acc.byDay[dk] || { t: 0, e: 0, cr: 0 }
|
|
458
|
+
cell.t += tot; cell.e += eff; cell.cr += cr
|
|
459
|
+
acc.byDay[dk] = cell
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
consumed = end
|
|
463
|
+
}
|
|
464
|
+
return consumed
|
|
465
|
+
}
|
|
466
|
+
// 主窗口消耗读取入口。cacheStore 缺省(undefined/非对象)→ 不缓存每次全量读(测试/老宿主兜底)。
|
|
467
|
+
// 出参 byDay 单元为 { t, e, cr }(t=总量含缓存读,e=有效,cr=缓存读)——范围裁剪要按日重算
|
|
468
|
+
// 三个口径,故比 run 日账 {t,e} 多带一个缓存读分量;出参是副本,改出参不污染缓存。
|
|
469
|
+
export function readMainWindowUsage(sessionId, sessionsRoot, range, cacheStore) {
|
|
470
|
+
try {
|
|
471
|
+
if (!sessionId) return null
|
|
472
|
+
var log = findRunLog(sessionId, sessionsRoot)
|
|
473
|
+
if (!log) return null
|
|
474
|
+
var st = null
|
|
475
|
+
try { st = fsNode.statSync(log) } catch (_) { return null }
|
|
476
|
+
var store = (cacheStore && typeof cacheStore === 'object') ? cacheStore : null
|
|
477
|
+
var key = String(sessionId)
|
|
478
|
+
var acc = store ? store[key] : null
|
|
479
|
+
if (acc && st.size < acc.size) acc = null // 日志轮换/截断(文件变小)→ 缓存作废全量重读
|
|
480
|
+
if (!acc) {
|
|
481
|
+
var buf0 = readLogBytes(log, MAINWIN_LOG_CAP)
|
|
482
|
+
if (!buf0) return null
|
|
483
|
+
var base0 = st.size - buf0.length // 封顶截断时只丢最老帧(buf 起点对应绝对偏移 base0)
|
|
484
|
+
acc = mainWinAcc()
|
|
485
|
+
acc.size = base0 + mainWinAggregateFrames(acc, buf0)
|
|
486
|
+
acc.mtimeMs = st.mtimeMs
|
|
487
|
+
if (store) store[key] = acc
|
|
488
|
+
} else if (st.size > acc.size) {
|
|
489
|
+
// 增量尾读:只读新增字节段 [acc.size, st.size);读取失败保留旧聚合值出数(降级不闪断,下轮再试)
|
|
490
|
+
var buf1 = readLogBytes(log, st.size - acc.size)
|
|
491
|
+
if (buf1) { acc.size += mainWinAggregateFrames(acc, buf1); acc.mtimeMs = st.mtimeMs }
|
|
492
|
+
}
|
|
493
|
+
// st.size === acc.size → 零读直接用缓存
|
|
494
|
+
if (!acc.hasUsage) return null // 还没有任何 usage 事件 → 静默(客户端不渲染该行)
|
|
495
|
+
var out = { total: 0, effective: 0, cacheRead: 0, byDay: {} }
|
|
496
|
+
if (rangeOn(range)) {
|
|
497
|
+
for (var dk in acc.byDay) {
|
|
498
|
+
if (!Object.prototype.hasOwnProperty.call(acc.byDay, dk)) continue
|
|
499
|
+
if (!dayInRange(dk, range)) continue
|
|
500
|
+
var c = acc.byDay[dk]
|
|
501
|
+
out.byDay[dk] = { t: c.t, e: c.e, cr: c.cr }
|
|
502
|
+
out.total += c.t; out.effective += c.e; out.cacheRead += c.cr
|
|
503
|
+
}
|
|
504
|
+
} else {
|
|
505
|
+
out.total = acc.total; out.effective = acc.effective; out.cacheRead = acc.cacheRead
|
|
506
|
+
for (var dk2 in acc.byDay) {
|
|
507
|
+
if (!Object.prototype.hasOwnProperty.call(acc.byDay, dk2)) continue
|
|
508
|
+
var c2 = acc.byDay[dk2]
|
|
509
|
+
out.byDay[dk2] = { t: c2.t, e: c2.e, cr: c2.cr }
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
return out
|
|
513
|
+
} catch (e) {
|
|
514
|
+
// 展示统计绝不影响轮询:任何意外一律记日志降级为「无该行」
|
|
515
|
+
console.error('[task-board] readMainWindowUsage failed (' + sessionId + '):', String(e))
|
|
516
|
+
return null
|
|
517
|
+
}
|
|
518
|
+
}
|
package/package.json
CHANGED