feat(host): 指标改为事件计数兜底 + 自检状态文件;实测真实实例中工具注册成功

- 隔离实例(独立 DSH_HOME + 最小 profile)实测:plugin-status.json tool=true,无加载错误
- 服务探测:sessions/tools/tokenMeter/llm/shell/sandboxPolicy/webServer 均可用;本 profile 无 settings 服务
- 轮/步/tok-s 改为从 session/event 自行累计(不依赖 session-projection),tokenMeter 字段首次调用记入状态文件便于核对
This commit is contained in:
2026-09-27 14:19:38 +08:00
parent baa4e86058
commit b58bde162f
+94 -13
View File
@@ -55,7 +55,25 @@ function short(v, n = 120) {
}
export function apply(ctx) {
const log = (...a) => { try { (ctx.logger?.info || console.log)('[float-window]', ...a) } catch { } }
// 运行状态与自检日志落盘:插件跑在 DSH 内部,控制台不一定看得到,
// 有了这个文件(和 /health)才能在用户机器上远程排障。
const notes = []
const statusFile0 = path.join(dshHome(), 'float-window', 'plugin-status.json')
const status = { version: '0.1.0', pid: process.pid, startedAt: new Date().toISOString(), tool: false, settings: false, notes }
const writeStatus = () => {
try {
status.notes = notes.slice(-40)
fs.mkdirSync(path.dirname(statusFile0), { recursive: true })
fs.writeFileSync(statusFile0, JSON.stringify(status, null, 2))
} catch { }
}
const log = (...a) => {
const line = a.map((x) => (typeof x === 'string' ? x : short(x, 200))).join(' ')
notes.push(`${new Date().toISOString().slice(11, 19)} ${line}`)
if (notes.length > 80) notes.shift()
try { (ctx.logger?.info || console.log)('[float-window]', ...a) } catch { }
writeStatus()
}
// ---------------------------------------------------------------- endpoint
const endpointFile = path.join(dshHome(), 'float-window', 'endpoint.json')
@@ -78,12 +96,30 @@ export function apply(ctx) {
// ---------------------------------------------------------------- 会话镜像缓冲
const MAX_ROWS = 120
const rows = new Map() // sessionId -> row[]
const counts = new Map() // sessionId -> 事件计数兜底(没有 sessionProjection 也能出数字)
let lastSessionId = null
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) }
return c
}
ctx.on('session/event', (session, event) => {
try {
const id = session?.id || session?.sessionId || 'unknown'
lastSessionId = id
const c = counter(id)
const type = String(event?.type || '')
if (type === 'turn/end') c.turns++
else if (type === 'step/start') c.stepStart = Date.now()
else if (type === 'step/end') c.steps++
else if (type === 'assistant/message') {
const usage = event?.usage || 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) }
}
const row = toRow(event)
if (!row) return
const list = rows.get(id) || []
@@ -96,33 +132,48 @@ export function apply(ctx) {
})
// ---------------------------------------------------------------- 指标
let meterKeysLogged = false
function metricsFor(session) {
const out = { turns: null, steps: null, tps: null, tokens: null, cacheHit: null, contextUsed: null }
const id = session?.id || lastSessionId || 'unknown'
const c = counts.get(id) || { turns: 0, steps: 0, outTokens: 0, decodeMs: 0, lastTps: null }
const out = {
turns: c.turns || null,
steps: c.steps || null,
tps: c.lastTps,
tokens: null,
cacheHit: null,
contextUsed: null,
}
// 有 session-projection 就用它(更权威),否则用事件计数兜底
try {
const proj = ctx.get('sessionProjection')
const stats = proj?.get?.(session, 'sessionStats') || proj?.peek?.(session, 'sessionStats')
if (stats) {
out.turns = stats.turns ?? null
out.steps = stats.steps ?? null
if (stats.decodeTokens && stats.decodeMs) out.tps = (stats.decodeTokens / (stats.decodeMs / 1000))
const st = proj?.get?.(session, 'sessionStats') || proj?.peek?.(session, 'sessionStats')
if (st) {
if (st.turns != null) out.turns = st.turns
if (st.steps != null) out.steps = st.steps
if (st.decodeTokens && st.decodeMs) out.tps = st.decodeTokens / (st.decodeMs / 1000)
}
} catch { }
try {
const meter = ctx.get('tokenMeter')
const m = meter?.measure?.(session)
if (m) {
// 第一次把真实字段名记进状态文件,便于在用户机器上核对映射
if (!meterKeysLogged) { meterKeysLogged = true; log('tokenMeter keys: ' + Object.keys(m).join(',')) }
out.tokens = m.totalTokens ?? m.surfaceTokens ?? null
const u = m.usage || m.providerUsage
const u = m.usage || m.providerUsage || m.usageBuckets
if (u) {
const read = u.cacheRead ?? u.cache_read ?? 0
const input = (u.input ?? 0) + read
if (input > 0) out.cacheHit = read / input
const read = u.cacheRead ?? u.cache_read ?? u.cacheReadTokens ?? 0
const input = u.input ?? u.inputTokens ?? 0
const denom = input + read
if (denom > 0) out.cacheHit = read / denom
}
const capacity = ctx.get('llm')?.resolveModelInfo?.()?.context
let capacity = null
try { capacity = ctx.get('llm')?.resolveModelInfo?.()?.context ?? null } catch { }
const used = m.surfaceTokens ?? m.totalTokens
if (capacity && used) out.contextUsed = Math.min(1, used / capacity)
}
} catch { }
} catch (e) { if (!meterKeysLogged) { meterKeysLogged = true; log('tokenMeter measure failed: ' + String(e?.message || e)) } }
return out
}
@@ -228,6 +279,9 @@ export function apply(ctx) {
}
// ---------------------------------------------------------------- Agent 工具
status.shell = shellPath()
writeStatus()
try {
const tools = ctx.get('tools')
if (tools?.register) {
@@ -249,11 +303,13 @@ export function apply(ctx) {
return r.ok ? `float-window: ${action} ok (${r.via})` : `float-window: ${action} failed: ${r.error}`
},
})
status.tool = true
log('tool float_window registered')
} else {
log('tools service not available; agent trigger disabled')
}
} catch (e) { log('tool registration failed:', String(e?.message || e)) }
writeStatus()
const broadcast = (msg) => {
const payload = `data: ${JSON.stringify(msg)}\n\n`
@@ -274,12 +330,35 @@ export function apply(ctx) {
// 注册一个设置命名空间,DSH 的「设置 → 插件」会把它渲染成一张卡片。
// schemastery 是从 profile 的 node_modules 解析的;link: 安装的插件解析不到时
// 只跳过这一步(浮窗自己的设置面板照常可用)。
log('services: ' + ['settings', 'sessions', 'tools', 'tokenMeter', 'sessionProjection', 'llm', 'shell', 'sandboxPolicy', 'webServer']
.map((n) => `${n}=${(() => { try { return ctx.get(n) ? 'y' : 'n' } catch { return '?' } })()}`).join(' '))
; (async () => {
try {
const settings = ctx.get('settings')
if (!settings?.register) { log('settings service absent; skip settings entry'); return }
let z = null
// link: 安装时插件自己的目录里没有 node_modules,裸导入会失败;
// 依次尝试:常规解析 -> 以宿主入口为基准解析 -> profile 目录解析
try { z = (await import('@deepseek-ai/schemastery')).default } catch { }
if (!z) {
try {
const { createRequire } = await import('node:module')
const req = createRequire(process.argv[1] || path.join(dshHome(), 'index.js'))
const m = req('@deepseek-ai/schemastery')
z = m?.default || m
} catch { }
}
if (!z) {
try {
const { createRequire } = await import('node:module')
for (const prof of ['desktop', 'web']) {
const req = createRequire(path.join(dshHome(), 'profiles', prof, 'package.json'))
const m = req('@deepseek-ai/schemastery')
z = m?.default || m
if (z) break
}
} catch { }
}
if (!z) { log('schemastery not resolvable; skip settings entry'); return }
const schema = z.object({
topmost: z.boolean().default(true).description('始终置顶'),
@@ -299,6 +378,8 @@ export function apply(ctx) {
return () => { try { stop?.() } catch { } }
})
log('settings namespace registered')
status.settings = true
writeStatus()
} catch (e) { log('settings registration failed:', String(e?.message || e)) }
})()