fix(host): 修掉会杀死 DSH 的 resolveModelInfo unhandled rejection;按真实事件结构(event.data)重写镜像映射;标题栏与指标显示打磨

This commit is contained in:
2026-09-27 14:37:50 +08:00
parent f708c52e3e
commit a6d2c828a8
3 changed files with 151 additions and 37 deletions
+113 -26
View File
@@ -28,24 +28,59 @@ function dshHome() {
return process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
}
/** 会话事件 → 浮窗消息行的宽松映射(不同版本字段不完全一致,宁可少显示也不要报错) */
/** 从 content 数组/字符串里取纯文本 */
function textOf(content) {
if (typeof content === 'string') return content
if (Array.isArray(content)) {
return content
.map((c) => (typeof c === 'string' ? c : (c?.type === 'text' || c?.text ? String(c.text ?? '') : '')))
.join('')
}
return ''
}
/**
* 会话事件 → 浮窗消息行的宽松映射。
* 实测(dsh 0.1.x)事件载荷在 `event.data` 下:
* user/message { data: { content:[{type:'text',text}], role, source:{kind:'user'|'plugin'} , id } }
* assistant/chunk { data: { turn, step, chunk:{ type, text?, reason? } } }
* assistant/message{ data: { content:[...] } }
* tool/call { data: { name, args } }
* 插件注入的消息(source.kind === 'plugin',例如系统提示快照)不进镜像。
*/
function toRow(event) {
if (!event || typeof event !== 'object') return null
const type = String(event.type || '')
const text = (v) => (typeof v === 'string' ? v : '')
if (type === 'tool/call') {
return { role: 'tool', text: `${event.name || event.tool || 'tool'}(${short(event.args ?? event.input)})` }
const d = event.data || event
const src = d.source || event.source
if (type === 'user/message') {
if (src && src.kind && src.kind !== 'user') return null
const text = textOf(d.content ?? d.message?.content ?? d.text)
return text ? { role: 'user', text: clip(text) } : null
}
if (type === 'tool/result') return null
const msg = event.message
const role = msg?.role || event.role || (type.includes('user') ? 'user' : type.includes('assistant') ? 'assistant' : '')
if (!role) return null
let body = ''
const content = msg?.content ?? event.content ?? event.text
if (typeof content === 'string') body = content
else if (Array.isArray(content)) body = content.map((c) => (typeof c === 'string' ? c : text(c?.text))).join('')
if (!body) return null
return { role, text: body.length > 4000 ? body.slice(0, 4000) + '…' : body, streaming: /delta|chunk|stream/.test(type) }
if (type === 'assistant/message') {
const text = textOf(d.content ?? d.message?.content ?? d.text)
return text ? { role: 'assistant', text: clip(text) } : null
}
if (type === 'assistant/chunk') {
const c = d.chunk || {}
const kind = String(c.type || '')
const piece = c.text ?? c.delta ?? c.value ?? (kind.includes('text') ? c.content : '')
if (piece) return { role: 'assistant', text: String(piece), streaming: true }
if (kind === 'finish' && c.reason?.kind === 'error') {
return { role: 'assistant', text: '⚠️ ' + short(c.reason.failure ?? c.reason, 240) }
}
return null
}
if (type === 'tool/call') {
return { role: 'tool', text: `${d.name || d.tool || 'tool'}(${short(d.args ?? d.input ?? '', 160)})` }
}
return null
}
function clip(text, n = 4000) {
const s = String(text)
return s.length > n ? s.slice(0, n) + '…' : s
}
function short(v, n = 120) {
@@ -55,11 +90,25 @@ function short(v, n = 120) {
} catch { return '' }
}
/** 脱敏采样:保留结构与短字符串,避免把长内容/密钥写进状态文件 */
function sanitize(v, depth = 0) {
if (v == null || typeof v === 'number' || typeof v === 'boolean') return v
if (typeof v === 'string') return v.length > 200 ? v.slice(0, 200) + '…' : v
if (depth >= 4) return Array.isArray(v) ? `[array ${v.length}]` : '[object]'
if (Array.isArray(v)) return v.slice(0, 6).map((x) => sanitize(x, depth + 1))
const out = {}
for (const [k, val] of Object.entries(v)) out[k] = sanitize(val, depth + 1)
return out
}
const SAMPLE_TYPES = new Set(['user/message', 'assistant/chunk', 'assistant/message', 'tool/call', 'tool/result', 'session/title', 'request/header', 'request/context'])
export function apply(ctx) {
// 运行状态与自检日志落盘:插件跑在 DSH 内部,控制台不一定看得到,
// 有了这个文件(和 /health)才能在用户机器上远程排障。
const notes = []
const seenTypes = new Map() // 事件类型直方图:写进状态文件,便于在用户机器上核对字段名
const samples = {} // 每种关键事件的脱敏样本(最多 3 条),用于对齐不同 DSH 版本的字段名
let lastRow = null
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 }
@@ -67,6 +116,7 @@ export function apply(ctx) {
try {
status.notes = notes.slice(-40)
status.eventTypes = Object.fromEntries([...seenTypes].slice(-40))
status.samples = samples
status.lastRow = lastRow
fs.mkdirSync(path.dirname(statusFile0), { recursive: true })
fs.writeFileSync(statusFile0, JSON.stringify(status, null, 2))
@@ -102,6 +152,7 @@ export function apply(ctx) {
const MAX_ROWS = 120
const rows = new Map() // sessionId -> row[]
const counts = new Map() // sessionId -> 事件计数兜底(没有 sessionProjection 也能出数字)
const titles = new Map() // sessionId -> 会话标题(来自 session/title 事件)
let lastSessionId = null
function counter(id) {
@@ -116,8 +167,16 @@ export function apply(ctx) {
lastSessionId = id
const c = counter(id)
const type = String(event?.type || '')
const d2 = () => event?.data || event || {}
seenTypes.set(type, (seenTypes.get(type) || 0) + 1)
if (SAMPLE_TYPES.has(type) && (samples[type]?.length ?? 0) < 3) {
(samples[type] ||= []).push(sanitize(event))
}
if (type === 'turn/end') c.turns++
else if (type === 'session/title') {
const t = d2(event)?.title
if (t) titles.set(id, String(t))
}
else if (type === 'step/start') c.stepStart = Date.now()
else if (type === 'step/end') c.steps++
else if (type === 'assistant/message') {
@@ -175,8 +234,7 @@ export function apply(ctx) {
const denom = input + read
if (denom > 0) out.cacheHit = read / denom
}
let capacity = null
try { capacity = ctx.get('llm')?.resolveModelInfo?.()?.context ?? null } catch { }
let capacity = modelContext()
const used = m.surfaceTokens ?? m.totalTokens
if (capacity && used) out.contextUsed = Math.min(1, used / capacity)
}
@@ -184,6 +242,27 @@ export function apply(ctx) {
return out
}
/**
* 模型上下文容量。
* 踩坑(实测):`llm.resolveModelInfo()` 在没注册 provider 时**会抛错**,而且它返回
* Promise —— 只 try/catch 同步部分会留下 unhandled rejection,直接让 DSH 进程
* "fatal load failure" 退出(我在隔离实例里把 DSH 弄崩过一次)。
* 所以这里:同步异常、异步 reject、undefined 三种情况全部吞掉,并且只在拿到值时缓存。
*/
let cachedCapacity = null
function modelContext() {
try {
const llm = ctx.get('llm')
const r = llm?.resolveModelInfo?.()
if (r && typeof r.then === 'function') {
r.then((v) => { cachedCapacity = v?.context ?? cachedCapacity }).catch(() => { })
return cachedCapacity
}
if (r?.context) cachedCapacity = r.context
return cachedCapacity
} catch { return cachedCapacity }
}
function snapshot() {
const sessions = (() => { try { return ctx.get('sessions')?.list?.() || [] } catch { return [] } })()
let session = sessions.find((s) => s?.id === lastSessionId) || sessions[sessions.length - 1] || null
@@ -192,7 +271,7 @@ export function apply(ctx) {
ok: true,
at: Date.now(),
sessionId: id || null,
title: session?.meta?.title || session?.title || null,
title: (id && titles.get(id)) || session?.meta?.title || session?.title || null,
messages: (id && rows.get(id)) || [],
metrics: session ? metricsFor(session) : { turns: null, steps: null, tps: null, tokens: null, cacheHit: null, contextUsed: null },
sessions: sessions.slice(-10).map((s) => ({ id: s?.id, title: s?.meta?.title || s?.title || null })),
@@ -264,10 +343,12 @@ export function apply(ctx) {
if (!target) throw new Error('no session yet')
const r = await api.sessions.prompt({
rpcId: randomUUID(),
sessionId: target,
mode: 'queue',
content: [{ type: 'text', text }],
clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
payload: {
sessionId: target,
mode: 'queue',
content: [{ type: 'text', text }],
clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
},
})
const result = r?.result ?? r
log(`chat -> session ${target}: ${JSON.stringify(result).slice(0, 160)}`)
@@ -296,21 +377,27 @@ export function apply(ctx) {
if (body.action === 'create') {
const r = await api.sessions.create({
rpcId: randomUUID(),
sessionId: body.sessionId ? String(body.sessionId) : randomUUID(),
...(body.cwd ? { cwd: String(body.cwd) } : {}),
...(body.workspaceId ? { workspaceId: body.workspaceId } : {}),
payload: {
sessionId: body.sessionId ? String(body.sessionId) : randomUUID(),
...(body.cwd ? { cwd: String(body.cwd) } : {}),
...(body.workspaceId ? { workspaceId: body.workspaceId } : {}),
},
})
const result = r?.result ?? r
if (result?.sessionId) lastSessionId = result.sessionId
log('session created: ' + short(result, 160))
json({ ok: true, ...result })
} else if (body.action === 'cancel') {
const r = await api.sessions.cancel({ rpcId: randomUUID(), sessionId: body.sessionId || lastSessionId })
const r = await api.sessions.cancel({ rpcId: randomUUID(), payload: { sessionId: body.sessionId || lastSessionId } })
json({ ok: true, result: r?.result ?? r })
} else {
json({ ok: true, sessions: snapshot().sessions, current: lastSessionId })
}
} catch (e) { json({ ok: false, error: short(e?.data || e?.message || e, 300) }) }
} catch (e) {
log('session route failed: ' + short(e?.data || e?.message || e, 200))
if (e?.stack) log('stack: ' + String(e.stack).split('\n').slice(0, 4).join(' | '))
json({ ok: false, error: short(e?.data || e?.message || e, 300) })
}
return
}
res.writeHead(404, { ...cors, 'Content-Type': 'text/plain; charset=utf-8' })