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) {
|
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
|
// ---------------------------------------------------------------- endpoint
|
||||||
const endpointFile = path.join(dshHome(), 'float-window', 'endpoint.json')
|
const endpointFile = path.join(dshHome(), 'float-window', 'endpoint.json')
|
||||||
@@ -78,12 +96,30 @@ export function apply(ctx) {
|
|||||||
// ---------------------------------------------------------------- 会话镜像缓冲
|
// ---------------------------------------------------------------- 会话镜像缓冲
|
||||||
const MAX_ROWS = 120
|
const MAX_ROWS = 120
|
||||||
const rows = new Map() // sessionId -> row[]
|
const rows = new Map() // sessionId -> row[]
|
||||||
|
const counts = new Map() // sessionId -> 事件计数兜底(没有 sessionProjection 也能出数字)
|
||||||
let lastSessionId = null
|
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) => {
|
ctx.on('session/event', (session, event) => {
|
||||||
try {
|
try {
|
||||||
const id = session?.id || session?.sessionId || 'unknown'
|
const id = session?.id || session?.sessionId || 'unknown'
|
||||||
lastSessionId = id
|
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)
|
const row = toRow(event)
|
||||||
if (!row) return
|
if (!row) return
|
||||||
const list = rows.get(id) || []
|
const list = rows.get(id) || []
|
||||||
@@ -96,33 +132,48 @@ export function apply(ctx) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// ---------------------------------------------------------------- 指标
|
// ---------------------------------------------------------------- 指标
|
||||||
|
let meterKeysLogged = false
|
||||||
function metricsFor(session) {
|
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 {
|
try {
|
||||||
const proj = ctx.get('sessionProjection')
|
const proj = ctx.get('sessionProjection')
|
||||||
const stats = proj?.get?.(session, 'sessionStats') || proj?.peek?.(session, 'sessionStats')
|
const st = proj?.get?.(session, 'sessionStats') || proj?.peek?.(session, 'sessionStats')
|
||||||
if (stats) {
|
if (st) {
|
||||||
out.turns = stats.turns ?? null
|
if (st.turns != null) out.turns = st.turns
|
||||||
out.steps = stats.steps ?? null
|
if (st.steps != null) out.steps = st.steps
|
||||||
if (stats.decodeTokens && stats.decodeMs) out.tps = (stats.decodeTokens / (stats.decodeMs / 1000))
|
if (st.decodeTokens && st.decodeMs) out.tps = st.decodeTokens / (st.decodeMs / 1000)
|
||||||
}
|
}
|
||||||
} catch { }
|
} catch { }
|
||||||
try {
|
try {
|
||||||
const meter = ctx.get('tokenMeter')
|
const meter = ctx.get('tokenMeter')
|
||||||
const m = meter?.measure?.(session)
|
const m = meter?.measure?.(session)
|
||||||
if (m) {
|
if (m) {
|
||||||
|
// 第一次把真实字段名记进状态文件,便于在用户机器上核对映射
|
||||||
|
if (!meterKeysLogged) { meterKeysLogged = true; log('tokenMeter keys: ' + Object.keys(m).join(',')) }
|
||||||
out.tokens = m.totalTokens ?? m.surfaceTokens ?? null
|
out.tokens = m.totalTokens ?? m.surfaceTokens ?? null
|
||||||
const u = m.usage || m.providerUsage
|
const u = m.usage || m.providerUsage || m.usageBuckets
|
||||||
if (u) {
|
if (u) {
|
||||||
const read = u.cacheRead ?? u.cache_read ?? 0
|
const read = u.cacheRead ?? u.cache_read ?? u.cacheReadTokens ?? 0
|
||||||
const input = (u.input ?? 0) + read
|
const input = u.input ?? u.inputTokens ?? 0
|
||||||
if (input > 0) out.cacheHit = read / input
|
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
|
const used = 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 { }
|
} catch (e) { if (!meterKeysLogged) { meterKeysLogged = true; log('tokenMeter measure failed: ' + String(e?.message || e)) } }
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,6 +279,9 @@ export function apply(ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- Agent 工具
|
// ---------------------------------------------------------------- Agent 工具
|
||||||
|
status.shell = shellPath()
|
||||||
|
writeStatus()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tools = ctx.get('tools')
|
const tools = ctx.get('tools')
|
||||||
if (tools?.register) {
|
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}`
|
return r.ok ? `float-window: ${action} ok (${r.via})` : `float-window: ${action} failed: ${r.error}`
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
status.tool = true
|
||||||
log('tool float_window registered')
|
log('tool float_window registered')
|
||||||
} else {
|
} else {
|
||||||
log('tools service not available; agent trigger disabled')
|
log('tools service not available; agent trigger disabled')
|
||||||
}
|
}
|
||||||
} catch (e) { log('tool registration failed:', String(e?.message || e)) }
|
} catch (e) { log('tool registration failed:', String(e?.message || e)) }
|
||||||
|
writeStatus()
|
||||||
|
|
||||||
const broadcast = (msg) => {
|
const broadcast = (msg) => {
|
||||||
const payload = `data: ${JSON.stringify(msg)}\n\n`
|
const payload = `data: ${JSON.stringify(msg)}\n\n`
|
||||||
@@ -274,12 +330,35 @@ export function apply(ctx) {
|
|||||||
// 注册一个设置命名空间,DSH 的「设置 → 插件」会把它渲染成一张卡片。
|
// 注册一个设置命名空间,DSH 的「设置 → 插件」会把它渲染成一张卡片。
|
||||||
// schemastery 是从 profile 的 node_modules 解析的;link: 安装的插件解析不到时
|
// 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 () => {
|
; (async () => {
|
||||||
try {
|
try {
|
||||||
const settings = ctx.get('settings')
|
const settings = ctx.get('settings')
|
||||||
if (!settings?.register) { log('settings service absent; skip settings entry'); return }
|
if (!settings?.register) { log('settings service absent; skip settings entry'); return }
|
||||||
let z = null
|
let z = null
|
||||||
|
// link: 安装时插件自己的目录里没有 node_modules,裸导入会失败;
|
||||||
|
// 依次尝试:常规解析 -> 以宿主入口为基准解析 -> profile 目录解析
|
||||||
try { z = (await import('@deepseek-ai/schemastery')).default } catch { }
|
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 }
|
if (!z) { log('schemastery not resolvable; skip settings entry'); return }
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
topmost: z.boolean().default(true).description('始终置顶'),
|
topmost: z.boolean().default(true).description('始终置顶'),
|
||||||
@@ -299,6 +378,8 @@ export function apply(ctx) {
|
|||||||
return () => { try { stop?.() } catch { } }
|
return () => { try { stop?.() } catch { } }
|
||||||
})
|
})
|
||||||
log('settings namespace registered')
|
log('settings namespace registered')
|
||||||
|
status.settings = true
|
||||||
|
writeStatus()
|
||||||
} catch (e) { log('settings registration failed:', String(e?.message || e)) }
|
} catch (e) { log('settings registration failed:', String(e?.message || e)) }
|
||||||
})()
|
})()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user