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:
+94
-13
@@ -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)) }
|
||||
})()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user