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) {
// 浮窗设置:DSH 设置页写进 profile 条目 config 的值 + 默认值
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 : {},
)
@@ -237,7 +237,7 @@ export function apply(ctx, config) {
function counter(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
}
@@ -275,11 +275,25 @@ export function apply(ctx, config) {
}
else if (type === 'step/start') c.stepStart = Date.now()
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') {
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 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)
lastRow = row ? { type, row } : { type, row: null }
@@ -297,13 +311,16 @@ export function apply(ctx, config) {
let meterKeysLogged = false
function metricsFor(session) {
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 = {
turns: c.turns || null,
steps: c.steps || null,
tps: c.lastTps,
tokens: null,
cacheHit: null,
// 总量 = 输出 + (未缓存输入 + 命中缓存输入)
tokens: (c.outTokens || 0) + promptTokens || null,
// 命中率 = 命中缓存输入 / 全部输入(与 App 状态栏口径一致)
cacheHit: promptTokens > 0 ? (c.cacheRead || 0) / promptTokens : null,
contextUsed: null,
}
// 有 session-projection 就用它(更权威),否则用事件计数兜底
@@ -322,7 +339,8 @@ export function apply(ctx, config) {
if (m) {
// 第一次把真实字段名记进状态文件,便于在用户机器上核对映射
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
if (u) {
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
}
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)
}
} 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
}
@@ -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)) }
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`)
}