diff --git a/README.md b/README.md index 5ec05ef..edf04e0 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,9 @@ shell\run-float.cmd -Action pin # 给正在运行的实例 8. **cordis:`ctx.set` 之前必须 `ctx.provide`**。漏了会抛 `cannot set property "floatWindow" without provide`,后果不是"少个功能"而是**整个插件树加载失败、DSH 进程直接退出**。本轮就是在隔离实例里实测复现后修掉的。 9. **PowerShell 事件处理器里参数不能叫 `$args`**:它是自动变量,会把整个事件参数数组占住,于是 `$e.TryGetWebMessageAsString()` 变成"数组没有这个方法"——表现为**页面所有按钮(含右下角缩放、置顶、锁定、全屏、设置)全部失效**。正确写法是 `param($s, $e)`。 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) shell\run-float.cmd -DshHome $t -NoExclude # 标题栏应变成绿点 + 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/工具注册不抛异常。 diff --git a/lib/host.js b/lib/host.js index 8c0e2f9..720092b 100644 --- a/lib/host.js +++ b/lib/host.js @@ -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' }) diff --git a/shell/ui.html b/shell/ui.html index 7078ff8..2f1f962 100644 --- a/shell/ui.html +++ b/shell/ui.html @@ -82,18 +82,27 @@ #title { flex: 1 1 auto; + min-width: 0; + display: flex; + align-items: center; + gap: 6px; font-size: var(--title-font); font-weight: 600; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; letter-spacing: .2px; } + #titleText { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1 1 auto; + min-width: 0; + } + #title small { + flex: 0 0 auto; color: var(--fg-dim); font-weight: 400; - margin-left: 6px; } .tbtn { @@ -472,7 +481,7 @@