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:
+46
-9
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user