diff --git a/lib/host.js b/lib/host.js index a5d3526..280bfcb 100644 --- a/lib/host.js +++ b/lib/host.js @@ -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`) } diff --git a/tools/mirror-bridge.mjs b/tools/mirror-bridge.mjs index 442b8ca..608a9ea 100644 --- a/tools/mirror-bridge.mjs +++ b/tools/mirror-bridge.mjs @@ -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