feat: 缓存命中率有来源了(assistant/chunk 的 usage 事件)+ DSH 启动自动拉起浮窗

挖到真源:事件流里 assistant/chunk 带 chunk.type='usage'
  {inputTokens,outputTokens,cacheReadTokens,reasoningTokens}
从此算出真实指标(不再依赖 tokenMeter 的估算):
  缓存命中率 = cacheRead/(input+cacheRead)     实测 0.9976(与 App 状态栏 99% 一致)
  总量       = output + input + cacheRead      实测 88.99M
  tok/s      = outputTokens/该步耗时            实测 37.3
  上下文占用 = 最后一步的 input+cacheRead / 窗口(不是历史累加)  实测 56.3%
插件与镜像桥两侧都接上了;镜像桥改为跟随插件认定的当前会话。

另:autoOpen(默认开)——DSH 启动后 1.5s 若浮窗没在跑就自动拉起(用户选定)。
This commit is contained in:
2026-09-27 17:29:51 +08:00
parent f234213297
commit 3205aaea76
2 changed files with 81 additions and 15 deletions
+35 -6
View File
@@ -87,6 +87,13 @@ function rowsFromLog(text) {
let streamStart = 0
let streamChars = 0
let lastChunkAt = 0
let outTokens = 0
let inTokens = 0
let cacheRead = 0
let stepStart = 0
let decodeMs = 0
let lastTps = null
let lastPromptTokens = 0 // 最后一步的输入(=未缓存+命中缓存):这才是"当前上下文占用"
const est = (chars) => Math.round(chars / 3.2) // 中英混排的粗略 token 估算
for (const line of text.split('\n')) {
@@ -97,7 +104,7 @@ function rowsFromLog(text) {
const d = e?.data || e || {}
const t = e?.time || 0
if (type === 'turn/start') turns++
else if (type === 'step/start') steps++
else if (type === 'step/start') { steps++; stepStart = t || Date.now() }
else if (type === 'request/context' && d.contextWindow) contextWindow = d.contextWindow
else if (type === 'session/title' && d.title) titles.set('title', String(d.title))
else if (type === 'user/message') {
@@ -110,6 +117,17 @@ function rowsFromLog(text) {
} else if (type === 'assistant/chunk') {
const c = d.chunk || {}
const kind = String(c.type || '')
if (kind === 'usage' && c.usage) {
// 真实用量:{inputTokens,outputTokens,cacheReadTokens,reasoningTokens}
const u = c.usage
inTokens += u.inputTokens || 0
cacheRead += u.cacheReadTokens || 0
outTokens += u.outputTokens || 0
lastPromptTokens = (u.inputTokens || 0) + (u.cacheReadTokens || 0)
const ms = stepStart ? (t || Date.now()) - stepStart : 0
if (u.outputTokens && ms > 0) { decodeMs += ms; lastTps = u.outputTokens / (ms / 1000) }
continue
}
const piece = c.text ?? c.delta ?? (kind.includes('text') ? c.content : '')
if (piece) {
const now = t || Date.now()
@@ -129,16 +147,20 @@ function rowsFromLog(text) {
}
for (const r of rows) delete r.streaming
const streaming = lastChunkAt && Date.now() - lastChunkAt < 8000
const tps = streaming && streamStart ? est(streamChars) / Math.max(0.5, (lastChunkAt - streamStart) / 1000) : null
// tok/s:优先用真实 outputTokens/步耗时;否则退化成"流式字符估算"
const tps = lastTps ?? (streaming && streamStart ? est(streamChars) / Math.max(0.5, (lastChunkAt - streamStart) / 1000) : null)
const promptTokens = inTokens + cacheRead
const realUsage = promptTokens + outTokens > 0
return {
rows, titles, turns, steps, streaming,
metrics: {
turns,
steps,
tps,
tokens: outChars ? est(outChars + userChars) : null,
cacheHit: null,
contextUsed: contextWindow ? Math.min(1, est(userChars + outChars) / contextWindow) : null,
// 有真实用量就用真实值;否则退化成字符估算(界面会带 ≈)
tokens: realUsage ? outTokens + promptTokens : (outChars ? est(outChars + userChars) : null),
cacheHit: promptTokens > 0 ? cacheRead / promptTokens : null,
contextUsed: contextWindow ? Math.min(1, (realUsage ? lastPromptTokens : est(userChars + outChars)) / contextWindow) : null,
},
}
}
@@ -164,9 +186,16 @@ function buildSnapshot() {
})
}
// 当前会话:watch.json 指定优先,否则"日志最新"(真正在跑的那个)
// 当前会话:watch.json 指定 > 插件自己认定的当前会话 > 日志最新
let active = null
if (watch?.sessionId) active = entries.find((e) => e.id === watch.sessionId || e.id.replace(/^session-/, '') === watch.sessionId) || null
if (!active) {
// 插件写的 snapshot.json 带 sessionId:跟它走,指标缺口才能对上同一个会话
const plug = readJson(SNAPSHOT)
if (plug?.sessionId && !plug.source) {
active = entries.find((e) => e.id === plug.sessionId || e.id.replace(/^session-/, '') === plug.sessionId) || null
}
}
if (!active) active = entries.sort((a, b) => b.mtime - a.mtime)[0] || null
if (!active) return null