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
+46 -9
View File
@@ -155,7 +155,7 @@ const SAMPLE_TYPES = new Set(['user/message', 'assistant/chunk', 'assistant/mess
export function apply(ctx, config) { export function apply(ctx, config) {
// 浮窗设置:DSH 设置页写进 profile 条目 config 的值 + 默认值 // 浮窗设置:DSH 设置页写进 profile 条目 config 的值 + 默认值
const floatCfg = Object.assign( const floatCfg = Object.assign(
{ topmost: true, posLock: false, sizeLock: false, exclude: true, opacity: 0.92, scale: 1, backdrop: 'opaque', tool: true, button: true }, { topmost: true, posLock: false, sizeLock: false, exclude: true, opacity: 0.92, scale: 1, backdrop: 'opaque', tool: true, button: true, autoOpen: true },
(config && typeof config === 'object') ? config : {}, (config && typeof config === 'object') ? config : {},
) )
@@ -237,7 +237,7 @@ export function apply(ctx, config) {
function counter(id) { function counter(id) {
let c = counts.get(id) let c = counts.get(id)
if (!c) { c = { turns: 0, steps: 0, outTokens: 0, decodeMs: 0, stepStart: 0, lastTps: null }; counts.set(id, c) } if (!c) { c = { turns: 0, steps: 0, outTokens: 0, inTokens: 0, cacheRead: 0, decodeMs: 0, stepStart: 0, lastTps: null }; counts.set(id, c) }
return c return c
} }
@@ -275,11 +275,25 @@ export function apply(ctx, config) {
} }
else if (type === 'step/start') c.stepStart = Date.now() else if (type === 'step/start') c.stepStart = Date.now()
else if (type === 'step/end') c.steps++ else if (type === 'step/end') c.steps++
else if (type === 'assistant/chunk') {
// 真实用量在这里(实测):data.chunk = {type:'usage', usage:{inputTokens,outputTokens,cacheReadTokens,reasoningTokens}}
const ch = d2(event)?.chunk
if (ch?.type === 'usage' && ch.usage) {
const u = ch.usage
c.inTokens += u.inputTokens || 0
c.cacheRead += u.cacheReadTokens || 0
c.outTokens += u.outputTokens || 0
c.lastPrompt = (u.inputTokens || 0) + (u.cacheReadTokens || 0) // 当前上下文占用(最后一步输入)
const ms = c.stepStart ? Date.now() - c.stepStart : 0
if (u.outputTokens && ms > 0) { c.decodeMs += ms; c.lastTps = u.outputTokens / (ms / 1000) }
}
}
else if (type === 'assistant/message') { else if (type === 'assistant/message') {
const usage = event?.usage || event?.message?.usage || {} // 兜底:有些版本把 usage 挂在 message 上
const usage = d2(event)?.usage || d2(event)?.message?.usage || {}
const out = usage.outputTokens ?? usage.output_tokens ?? usage.completionTokens const out = usage.outputTokens ?? usage.output_tokens ?? usage.completionTokens
const ms = c.stepStart ? Date.now() - c.stepStart : 0 const ms = c.stepStart ? Date.now() - c.stepStart : 0
if (out && ms > 0) { c.outTokens += out; c.decodeMs += ms; c.lastTps = out / (ms / 1000) } if (out && ms > 0 && !c.outTokens) { c.outTokens += out; c.decodeMs += ms; c.lastTps = out / (ms / 1000) }
} }
const row = toRow(event) const row = toRow(event)
lastRow = row ? { type, row } : { type, row: null } lastRow = row ? { type, row } : { type, row: null }
@@ -297,13 +311,16 @@ export function apply(ctx, config) {
let meterKeysLogged = false let meterKeysLogged = false
function metricsFor(session) { function metricsFor(session) {
const id = session?.id || lastSessionId || 'unknown' const id = session?.id || lastSessionId || 'unknown'
const c = counts.get(id) || { turns: 0, steps: 0, outTokens: 0, decodeMs: 0, lastTps: null } const c = counts.get(id) || { turns: 0, steps: 0, outTokens: 0, inTokens: 0, cacheRead: 0, decodeMs: 0, lastTps: null }
const promptTokens = (c.inTokens || 0) + (c.cacheRead || 0)
const out = { const out = {
turns: c.turns || null, turns: c.turns || null,
steps: c.steps || null, steps: c.steps || null,
tps: c.lastTps, tps: c.lastTps,
tokens: null, // 总量 = 输出 + (未缓存输入 + 命中缓存输入)
cacheHit: null, tokens: (c.outTokens || 0) + promptTokens || null,
// 命中率 = 命中缓存输入 / 全部输入(与 App 状态栏口径一致)
cacheHit: promptTokens > 0 ? (c.cacheRead || 0) / promptTokens : null,
contextUsed: null, contextUsed: null,
} }
// 有 session-projection 就用它(更权威),否则用事件计数兜底 // 有 session-projection 就用它(更权威),否则用事件计数兜底
@@ -322,7 +339,8 @@ export function apply(ctx, config) {
if (m) { if (m) {
// 第一次把真实字段名记进状态文件,便于在用户机器上核对映射 // 第一次把真实字段名记进状态文件,便于在用户机器上核对映射
if (!meterKeysLogged) { meterKeysLogged = true; log('tokenMeter keys: ' + Object.keys(m).join(',')) } if (!meterKeysLogged) { meterKeysLogged = true; log('tokenMeter keys: ' + Object.keys(m).join(',')) }
out.tokens = m.totalTokens ?? m.surfaceTokens ?? null // 事件流里已经拿到真实用量时不要被 tokenMeter 的估算覆盖
if (!out.tokens) out.tokens = m.totalTokens ?? m.surfaceTokens ?? null
const u = m.usage || m.providerUsage || m.usageBuckets const u = m.usage || m.providerUsage || m.usageBuckets
if (u) { if (u) {
const read = u.cacheRead ?? u.cache_read ?? u.cacheReadTokens ?? 0 const read = u.cacheRead ?? u.cache_read ?? u.cacheReadTokens ?? 0
@@ -331,10 +349,16 @@ export function apply(ctx, config) {
if (denom > 0) out.cacheHit = read / denom if (denom > 0) out.cacheHit = read / denom
} }
let capacity = contextWindows.get(id) || modelContext() || 1000000 // 兜底:DSH 常用 1M 上下文 let capacity = contextWindows.get(id) || modelContext() || 1000000 // 兜底:DSH 常用 1M 上下文
const used = m.surfaceTokens ?? m.totalTokens // 上下文占比:优先用"最后一步输入"(事件流里拿得到,就是当前 surface),其次 tokenMeter
const used = c.lastPrompt || m.surfaceTokens || m.totalTokens
if (capacity && used) out.contextUsed = Math.min(1, used / capacity) if (capacity && used) out.contextUsed = Math.min(1, used / capacity)
} }
} catch (e) { if (!meterKeysLogged) { meterKeysLogged = true; log('tokenMeter measure failed: ' + String(e?.message || e)) } } } catch (e) { if (!meterKeysLogged) { meterKeysLogged = true; log('tokenMeter measure failed: ' + String(e?.message || e)) } }
// tokenMeter 拿不到时,用事件流里的"最后一步输入"兜底算上下文占比
if (out.contextUsed == null && c.lastPrompt) {
const cap = contextWindows.get(id) || modelContext() || 1000000
if (cap) out.contextUsed = Math.min(1, c.lastPrompt / cap)
}
return out return out
} }
@@ -785,5 +809,18 @@ border:1px solid rgba(120,140,180,.35);background:rgba(22,26,34,.92);color:#e6ea
} catch (e) { log('settings registration failed:', String(e?.message || e)) } } catch (e) { log('settings registration failed:', String(e?.message || e)) }
writeStatus() writeStatus()
// ---------------------------------------------------------------- DSH 启动时自动拉起浮窗
// 用户选的就是这条:DSH 每次重启都会把浮窗(进程树后代)带走,所以启动后自动补一个。
// 用 setTimeout 延后,让宿主先起完;回调里不碰 ctx(那时候它可能已经失效)。
if (floatCfg.autoOpen !== false) {
setTimeout(() => {
try {
if (shellAlive()) { log('autoOpen: float already running'); return }
log('autoOpen: spawning float window')
spawnShell().then((r) => log('autoOpen -> ' + short(r, 120))).catch((e) => log('autoOpen failed: ' + String(e?.message || e)))
} catch (e) { log('autoOpen error: ' + String(e?.message || e)) }
}, 1500)
}
log('ready; endpoints:', `${ROUTE}/events`, `${ROUTE}/state`, `${ROUTE}/health`) log('ready; endpoints:', `${ROUTE}/events`, `${ROUTE}/state`, `${ROUTE}/health`)
} }
+35 -6
View File
@@ -87,6 +87,13 @@ function rowsFromLog(text) {
let streamStart = 0 let streamStart = 0
let streamChars = 0 let streamChars = 0
let lastChunkAt = 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 估算 const est = (chars) => Math.round(chars / 3.2) // 中英混排的粗略 token 估算
for (const line of text.split('\n')) { for (const line of text.split('\n')) {
@@ -97,7 +104,7 @@ function rowsFromLog(text) {
const d = e?.data || e || {} const d = e?.data || e || {}
const t = e?.time || 0 const t = e?.time || 0
if (type === 'turn/start') turns++ 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 === 'request/context' && d.contextWindow) contextWindow = d.contextWindow
else if (type === 'session/title' && d.title) titles.set('title', String(d.title)) else if (type === 'session/title' && d.title) titles.set('title', String(d.title))
else if (type === 'user/message') { else if (type === 'user/message') {
@@ -110,6 +117,17 @@ function rowsFromLog(text) {
} else if (type === 'assistant/chunk') { } else if (type === 'assistant/chunk') {
const c = d.chunk || {} const c = d.chunk || {}
const kind = String(c.type || '') 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 : '') const piece = c.text ?? c.delta ?? (kind.includes('text') ? c.content : '')
if (piece) { if (piece) {
const now = t || Date.now() const now = t || Date.now()
@@ -129,16 +147,20 @@ function rowsFromLog(text) {
} }
for (const r of rows) delete r.streaming for (const r of rows) delete r.streaming
const streaming = lastChunkAt && Date.now() - lastChunkAt < 8000 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 { return {
rows, titles, turns, steps, streaming, rows, titles, turns, steps, streaming,
metrics: { metrics: {
turns, turns,
steps, steps,
tps, tps,
tokens: outChars ? est(outChars + userChars) : null, // 有真实用量就用真实值;否则退化成字符估算(界面会带 ≈)
cacheHit: null, tokens: realUsage ? outTokens + promptTokens : (outChars ? est(outChars + userChars) : null),
contextUsed: contextWindow ? Math.min(1, est(userChars + outChars) / contextWindow) : 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 let active = null
if (watch?.sessionId) active = entries.find((e) => e.id === watch.sessionId || e.id.replace(/^session-/, '') === watch.sessionId) || 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) active = entries.sort((a, b) => b.mtime - a.mtime)[0] || null
if (!active) return null if (!active) return null