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
+10
View File
@@ -109,6 +109,9 @@ shell\run-float.cmd -Action pin # 给正在运行的实例
8. **cordis:`ctx.set` 之前必须 `ctx.provide`**。漏了会抛 `cannot set property "floatWindow" without provide`,后果不是"少个功能"而是**整个插件树加载失败、DSH 进程直接退出**。本轮就是在隔离实例里实测复现后修掉的。 8. **cordis:`ctx.set` 之前必须 `ctx.provide`**。漏了会抛 `cannot set property "floatWindow" without provide`,后果不是"少个功能"而是**整个插件树加载失败、DSH 进程直接退出**。本轮就是在隔离实例里实测复现后修掉的。
9. **PowerShell 事件处理器里参数不能叫 `$args`**:它是自动变量,会把整个事件参数数组占住,于是 `$e.TryGetWebMessageAsString()` 变成"数组没有这个方法"——表现为**页面所有按钮(含右下角缩放、置顶、锁定、全屏、设置)全部失效**。正确写法是 `param($s, $e)`。 9. **PowerShell 事件处理器里参数不能叫 `$args`**:它是自动变量,会把整个事件参数数组占住,于是 `$e.TryGetWebMessageAsString()` 变成"数组没有这个方法"——表现为**页面所有按钮(含右下角缩放、置顶、锁定、全屏、设置)全部失效**。正确写法是 `param($s, $e)`。
10. **profile 的 `package.json` 不能带 BOM**:Node 读 JSON 会报 `SyntaxError: Unexpected token '锘?`。用 `[IO.File]::WriteAllText(..., UTF8Encoding($false))` 写。 10. **profile 的 `package.json` 不能带 BOM**:Node 读 JSON 会报 `SyntaxError: Unexpected token '锘?`。用 `[IO.File]::WriteAllText(..., UTF8Encoding($false))` 写。
11. **宿主里可能返回 Promise 的 API,必须 `.catch()`**:`ctx.get('llm').resolveModelInfo()` 在没注册 provider 时会抛错,而且它返回 Promise——只 `try/catch` 同步部分会留下 **unhandled rejection,直接把 DSH 进程 `fatal load failure` 干掉**(我在隔离实例里真把 DSH 搞崩过一次,1 秒一次的 SSE tick 是触发器)。
12. **会话事件载荷在 `event.data` 下**,不在顶层:`user/message` → `data.content[{type:'text',text}]`、`assistant/chunk` → `data.chunk{type,text,reason}`、`session/title` → `data.title`、`tool/call` → `data.name/args`。另外要过滤 `data.source.kind === 'plugin'` 的消息(系统提示快照),否则镜像里会混入一大段内部注入文本。
13. **宿主 RPC 的信封是 `{ rpcId, payload }`**:`ctx.apiProxy.sessions.prompt({ rpcId, payload: { sessionId, mode, content } })`。把字段平铺在顶层会得到 `Cannot read properties of undefined (reading 'sessionId')`。
## 怎么验证(可复现) ## 怎么验证(可复现)
@@ -134,6 +137,13 @@ curl -N http://127.0.0.1:8099/dsh-float-window/events # SSE,每秒一帧
# 4) 让浮窗连到这个测试实例(看视觉效果时再加 -NoExclude) # 4) 让浮窗连到这个测试实例(看视觉效果时再加 -NoExclude)
shell\run-float.cmd -DshHome $t -NoExclude shell\run-float.cmd -DshHome $t -NoExclude
# 标题栏应变成绿点 + 127.0.0.1:8099 # 标题栏应变成绿点 + 127.0.0.1:8099
# 5) 造一个真实会话并灌一条消息,验证镜像与指标(不需要浏览器)
curl -X POST http://127.0.0.1:8099/dsh-float-window/session -H "content-type: application/json" -d "{\"action\":\"create\",\"cwd\":\"D:/project\"}"
curl -X POST http://127.0.0.1:8099/dsh-float-window/chat -H "content-type: application/json" -d "{\"text\":\"你好\"}"
curl http://127.0.0.1:8099/dsh-float-window/state
# -> messages 里出现 user 行;metrics 里 turns/steps/tokens 有数(没有 API key 时
# assistant 行会是一条 ⚠️ 报错,这正是 LLM 分支被正确映射的证据)
``` ```
`tools/smoke-host.mjs` 则是更轻的一层:假 ctx 直接调 `apply()`,验证路由/SSE/工具注册不抛异常。 `tools/smoke-host.mjs` 则是更轻的一层:假 ctx 直接调 `apply()`,验证路由/SSE/工具注册不抛异常。
+113 -26
View File
@@ -28,24 +28,59 @@ function dshHome() {
return process.env.DSH_HOME || path.join(os.homedir(), '.dsh') 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) { function toRow(event) {
if (!event || typeof event !== 'object') return null if (!event || typeof event !== 'object') return null
const type = String(event.type || '') const type = String(event.type || '')
const text = (v) => (typeof v === 'string' ? v : '') const d = event.data || event
if (type === 'tool/call') { const src = d.source || event.source
return { role: 'tool', text: `${event.name || event.tool || 'tool'}(${short(event.args ?? event.input)})` } 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 if (type === 'assistant/message') {
const msg = event.message const text = textOf(d.content ?? d.message?.content ?? d.text)
const role = msg?.role || event.role || (type.includes('user') ? 'user' : type.includes('assistant') ? 'assistant' : '') return text ? { role: 'assistant', text: clip(text) } : null
if (!role) return null }
let body = '' if (type === 'assistant/chunk') {
const content = msg?.content ?? event.content ?? event.text const c = d.chunk || {}
if (typeof content === 'string') body = content const kind = String(c.type || '')
else if (Array.isArray(content)) body = content.map((c) => (typeof c === 'string' ? c : text(c?.text))).join('') const piece = c.text ?? c.delta ?? c.value ?? (kind.includes('text') ? c.content : '')
if (!body) return null if (piece) return { role: 'assistant', text: String(piece), streaming: true }
return { role, text: body.length > 4000 ? body.slice(0, 4000) + '…' : body, streaming: /delta|chunk|stream/.test(type) } 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) { function short(v, n = 120) {
@@ -55,11 +90,25 @@ function short(v, n = 120) {
} catch { return '' } } 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) { export function apply(ctx) {
// 运行状态与自检日志落盘:插件跑在 DSH 内部,控制台不一定看得到, // 运行状态与自检日志落盘:插件跑在 DSH 内部,控制台不一定看得到,
// 有了这个文件(和 /health)才能在用户机器上远程排障。 // 有了这个文件(和 /health)才能在用户机器上远程排障。
const notes = [] const notes = []
const seenTypes = new Map() // 事件类型直方图:写进状态文件,便于在用户机器上核对字段名 const seenTypes = new Map() // 事件类型直方图:写进状态文件,便于在用户机器上核对字段名
const samples = {} // 每种关键事件的脱敏样本(最多 3 条),用于对齐不同 DSH 版本的字段名
let lastRow = null let lastRow = null
const statusFile0 = path.join(dshHome(), 'float-window', 'plugin-status.json') 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 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 { try {
status.notes = notes.slice(-40) status.notes = notes.slice(-40)
status.eventTypes = Object.fromEntries([...seenTypes].slice(-40)) status.eventTypes = Object.fromEntries([...seenTypes].slice(-40))
status.samples = samples
status.lastRow = lastRow status.lastRow = lastRow
fs.mkdirSync(path.dirname(statusFile0), { recursive: true }) fs.mkdirSync(path.dirname(statusFile0), { recursive: true })
fs.writeFileSync(statusFile0, JSON.stringify(status, null, 2)) fs.writeFileSync(statusFile0, JSON.stringify(status, null, 2))
@@ -102,6 +152,7 @@ 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 也能出数字) const counts = new Map() // sessionId -> 事件计数兜底(没有 sessionProjection 也能出数字)
const titles = new Map() // sessionId -> 会话标题(来自 session/title 事件)
let lastSessionId = null let lastSessionId = null
function counter(id) { function counter(id) {
@@ -116,8 +167,16 @@ export function apply(ctx) {
lastSessionId = id lastSessionId = id
const c = counter(id) const c = counter(id)
const type = String(event?.type || '') const type = String(event?.type || '')
const d2 = () => event?.data || event || {}
seenTypes.set(type, (seenTypes.get(type) || 0) + 1) 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++ 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/start') c.stepStart = Date.now()
else if (type === 'step/end') c.steps++ else if (type === 'step/end') c.steps++
else if (type === 'assistant/message') { else if (type === 'assistant/message') {
@@ -175,8 +234,7 @@ export function apply(ctx) {
const denom = input + read const denom = input + read
if (denom > 0) out.cacheHit = read / denom if (denom > 0) out.cacheHit = read / denom
} }
let capacity = null let capacity = modelContext()
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)
} }
@@ -184,6 +242,27 @@ export function apply(ctx) {
return out 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() { function snapshot() {
const sessions = (() => { try { return ctx.get('sessions')?.list?.() || [] } catch { return [] } })() const sessions = (() => { try { return ctx.get('sessions')?.list?.() || [] } catch { return [] } })()
let session = sessions.find((s) => s?.id === lastSessionId) || sessions[sessions.length - 1] || null let session = sessions.find((s) => s?.id === lastSessionId) || sessions[sessions.length - 1] || null
@@ -192,7 +271,7 @@ export function apply(ctx) {
ok: true, ok: true,
at: Date.now(), at: Date.now(),
sessionId: id || null, 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)) || [], messages: (id && rows.get(id)) || [],
metrics: session ? metricsFor(session) : { turns: null, steps: null, tps: null, tokens: null, cacheHit: null, contextUsed: null }, 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 })), 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') if (!target) throw new Error('no session yet')
const r = await api.sessions.prompt({ const r = await api.sessions.prompt({
rpcId: randomUUID(), rpcId: randomUUID(),
sessionId: target, payload: {
mode: 'queue', sessionId: target,
content: [{ type: 'text', text }], mode: 'queue',
clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, content: [{ type: 'text', text }],
clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
},
}) })
const result = r?.result ?? r const result = r?.result ?? r
log(`chat -> session ${target}: ${JSON.stringify(result).slice(0, 160)}`) log(`chat -> session ${target}: ${JSON.stringify(result).slice(0, 160)}`)
@@ -296,21 +377,27 @@ export function apply(ctx) {
if (body.action === 'create') { if (body.action === 'create') {
const r = await api.sessions.create({ const r = await api.sessions.create({
rpcId: randomUUID(), rpcId: randomUUID(),
sessionId: body.sessionId ? String(body.sessionId) : randomUUID(), payload: {
...(body.cwd ? { cwd: String(body.cwd) } : {}), sessionId: body.sessionId ? String(body.sessionId) : randomUUID(),
...(body.workspaceId ? { workspaceId: body.workspaceId } : {}), ...(body.cwd ? { cwd: String(body.cwd) } : {}),
...(body.workspaceId ? { workspaceId: body.workspaceId } : {}),
},
}) })
const result = r?.result ?? r const result = r?.result ?? r
if (result?.sessionId) lastSessionId = result.sessionId if (result?.sessionId) lastSessionId = result.sessionId
log('session created: ' + short(result, 160)) log('session created: ' + short(result, 160))
json({ ok: true, ...result }) json({ ok: true, ...result })
} else if (body.action === 'cancel') { } 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 }) json({ ok: true, result: r?.result ?? r })
} else { } else {
json({ ok: true, sessions: snapshot().sessions, current: lastSessionId }) 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 return
} }
res.writeHead(404, { ...cors, 'Content-Type': 'text/plain; charset=utf-8' }) res.writeHead(404, { ...cors, 'Content-Type': 'text/plain; charset=utf-8' })
+28 -11
View File
@@ -82,18 +82,27 @@
#title { #title {
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0;
display: flex;
align-items: center;
gap: 6px;
font-size: var(--title-font); font-size: var(--title-font);
font-weight: 600; font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
letter-spacing: .2px; letter-spacing: .2px;
} }
#titleText {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1 1 auto;
min-width: 0;
}
#title small { #title small {
flex: 0 0 auto;
color: var(--fg-dim); color: var(--fg-dim);
font-weight: 400; font-weight: 400;
margin-left: 6px;
} }
.tbtn { .tbtn {
@@ -472,7 +481,7 @@
<div id="app"> <div id="app">
<div id="bar"> <div id="bar">
<div class="dot wait" id="dot"></div> <div class="dot wait" id="dot"></div>
<div id="title">DSH 悬浮窗<small id="sub">未连接</small></div> <div id="title"><span id="titleText">DSH 悬浮窗</span><small id="sub">未连接</small></div>
<button class="tbtn" id="bPin" title="固定位置"> <button class="tbtn" id="bPin" title="固定位置">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
<path d="M9 4h6l-1 6 3 3v1H7v-1l3-3-1-6z" /> <path d="M9 4h6l-1 6 3 3v1H7v-1l3-3-1-6z" />
@@ -650,18 +659,26 @@
if (msg.type === 'cmd') return handleCmd(msg.cmd) if (msg.type === 'cmd') return handleCmd(msg.cmd)
if (msg.metrics) metrics(msg.metrics) if (msg.metrics) metrics(msg.metrics)
if (msg.messages) messages(msg.messages) if (msg.messages) messages(msg.messages)
if (msg.title) $('title').firstChild.textContent = msg.title if (msg.title) {
const t = String(msg.title).slice(0, 42)
$('titleText').textContent = 'DSH · ' + t
}
} }
function metrics(m) { function metrics(m) {
$('m-turn').textContent = m.turns ?? '—' $('m-turn').textContent = m.turns ?? '—'
$('m-step').textContent = m.steps ?? '—' $('m-step').textContent = m.steps ?? '—'
$('m-tps').textContent = (m.tps ?? 0).toFixed ? (m.tps ?? 0).toFixed(1) : '—' $('m-tps').textContent = m.tps == null ? '—' : Number(m.tps).toFixed(1)
$('m-mtok').textContent = ((m.tokens ?? 0) / 1e6).toFixed(3) $('m-mtok').textContent = m.tokens == null ? '—' : (m.tokens / 1e6).toFixed(3)
$('m-cache').textContent = m.cacheHit == null ? '—' : Math.round(m.cacheHit * 100) + '%' $('m-cache').textContent = m.cacheHit == null ? '—' : Math.round(m.cacheHit * 100) + '%'
const p = Math.max(0, Math.min(1, m.contextUsed ?? 0)) if (m.contextUsed == null) {
$('m-ctxbar').style.right = (100 - p * 100) + '%' $('m-ctxbar').style.right = '100%'
$('m-ctx').textContent = Math.round(p * 100) + '%' $('m-ctx').textContent = '—'
} else {
const p = Math.max(0, Math.min(1, m.contextUsed))
$('m-ctxbar').style.right = (100 - p * 100) + '%'
$('m-ctx').textContent = Math.round(p * 100) + '%'
}
} }
function messages(list) { function messages(list) {