feat(host): 版本自适应宿主调用(0.1.7 sessionController / 0.1.0 apiProxy),在 DSH Desktop 内置 0.1.7-rc.2 上实测镜像与发消息全通

This commit is contained in:
2026-09-27 14:46:07 +08:00
parent a6d2c828a8
commit 5ddca41b8a
2 changed files with 54 additions and 31 deletions
+12 -2
View File
@@ -34,7 +34,7 @@
| 自适应调整 | 布局随窗口尺寸重排;拖动**任意边缘和四角**都能缩放(系统级 WM_NCHITTEST) | | 自适应调整 | 布局随窗口尺寸重排;拖动**任意边缘和四角**都能缩放(系统级 WM_NCHITTEST) |
| 全屏切换 | 标题栏 ⛶ 或 `F11`,返回时还原原来的位置与尺寸 | | 全屏切换 | 标题栏 ⛶ 或 `F11`,返回时还原原来的位置与尺寸 |
| 抓屏隐身 | `SetWindowDisplayAffinity(WDA_EXCLUDEFROMCAPTURE)`,Agent 截屏里彻底看不到这个窗 | | 抓屏隐身 | `SetWindowDisplayAffinity(WDA_EXCLUDEFROMCAPTURE)`,Agent 截屏里彻底看不到这个窗 |
| 底部聊天框 | 直接发消息进当前会话(浮窗 POST 给插件,插件调宿主网关 `ctx.apiProxy.sessions.prompt`,与浏览器 UI 同一条路) | | 底部聊天框 | 直接发消息进当前会话(浮窗 POST 给插件,插件调宿主网关:0.1.7 走 `ctx.sessionController.prompt`,0.1.0 走 `ctx.apiProxy.sessions.prompt`,与浏览器 UI 同一条路) |
| 计费/状态条 | 轮数、步数、tok/s、累计 MTOK、缓存命中率、上下文用量进度条 | | 计费/状态条 | 轮数、步数、tok/s、累计 MTOK、缓存命中率、上下文用量进度条 |
| 外观自定义 | 背景效果:不透明 / 毛玻璃(亚克力)/ 云母 / 模糊 / 半透明;不透明度、界面缩放 | | 外观自定义 | 背景效果:不透明 / 毛玻璃(亚克力)/ 云母 / 模糊 / 半透明;不透明度、界面缩放 |
| 按钮触发 | 运行 `shell\run-float.cmd`,或 DSH 里点插件入口 | | 按钮触发 | 运行 `shell\run-float.cmd`,或 DSH 里点插件入口 |
@@ -111,7 +111,17 @@ shell\run-float.cmd -Action pin # 给正在运行的实例
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 是触发器)。 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'` 的消息(系统提示快照),否则镜像里会混入一大段内部注入文本。 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')`。 13. **宿主 RPC 的信封是 `{ rpcId, payload }`**(0.1.0 的 `ctx.apiProxy.sessions.*`):把字段平铺在顶层会得到 `Cannot read properties of undefined (reading 'sessionId')`。
14. **0.1.7 起宿主面换成了 `ctx.sessionController`**(`ctx.apiProxy` 不再存在),并且 Remote 签名是 `(request, signal)`——**漏了 signal 会报 `Cannot read properties of undefined (reading 'throwIfAborted')`**;`create` 还必须带一个合法的 `agentPreset`(否则 `Unknown agent preset: xxx`)。插件对两套 API 都自动适配(见 `hostCall`)。
## 版本支持
本插件在两种真实宿主上都实测跑通过(同一份代码,运行时自适应):
| 宿主 | 来源 | 会话 API | 结果 |
|---|---|---|---|
| dsh **0.1.0-rc.6** | npx 缓存(`dsh web`) | `ctx.apiProxy.sessions.*`(`{rpcId,payload}` 信封) | 插件加载、工具注册、路由、镜像、发消息全部通过 |
| dsh **0.1.7-rc.2** | `DSH Desktop.exe` 内置 | `ctx.sessionController.*`(`(request, signal)`) | 同上全部通过(`settings` 服务在 0.1.7 存在,设置入口走 profile 配置模型) |
## 怎么验证(可复现) ## 怎么验证(可复现)
+42 -29
View File
@@ -148,7 +148,30 @@ export function apply(ctx) {
writeEndpoint() writeEndpoint()
ctx.effect(() => () => { try { fs.rmSync(endpointFile, { force: true }) } catch { } }) ctx.effect(() => () => { try { fs.rmSync(endpointFile, { force: true }) } catch { } })
// ---------------------------------------------------------------- 会话镜像缓冲 // ---------------------------------------------------------------- 版本自适应的宿主调用
// dsh 0.1.x 早期:ctx.apiProxy.sessions.*(信封 { rpcId, payload })
// dsh 0.1.7+ :ctx.sessionController.*(Typert Remote 的宿主面,扁平信封)
// 两个都试,谁在就用谁;都没有就返回可读错误,绝不让插件抛出去。
async function hostCall(method, payload) {
const sc = ctx.get('sessionController')
if (sc && typeof sc[method] === 'function') {
const flat = { ...payload }
delete flat.rpcId
if (!flat.requestId) flat.requestId = randomUUID()
// 0.1.7 的 Remote 签名是 (request, signal);signal 缺失时宿主内部会
// "Cannot read properties of undefined (reading 'throwIfAborted')"
const r = await sc[method](flat, new AbortController().signal)
return { value: r?.value ?? r, via: 'sessionController.' + method }
}
const api = ctx.get('apiProxy')
if (api?.sessions && typeof api.sessions[method] === 'function') {
const r = await api.sessions[method]({ rpcId: randomUUID(), payload })
return { value: r?.result?.value ?? r?.result ?? r, via: 'apiProxy.sessions.' + method }
}
throw new Error(`no host API for "${method}" (tried sessionController / apiProxy)`)
}
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 也能出数字)
@@ -338,22 +361,16 @@ export function apply(ctx) {
} }
const target = sessionId || lastSessionId const target = sessionId || lastSessionId
try { try {
const api = ctx.get('apiProxy')
if (!api?.sessions?.prompt) throw new Error('apiProxy unavailable')
if (!target) throw new Error('no session yet') if (!target) throw new Error('no session yet')
const r = await api.sessions.prompt({ const { value, via } = await hostCall('prompt', {
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 log(`chat -> session ${target} via ${via}: ${short(value, 160)}`)
log(`chat -> session ${target}: ${JSON.stringify(result).slice(0, 160)}`)
res.writeHead(200, { ...cors, 'Content-Type': 'application/json; charset=utf-8' }) res.writeHead(200, { ...cors, 'Content-Type': 'application/json; charset=utf-8' })
res.end(JSON.stringify({ ok: true, sessionId: target, result })) res.end(JSON.stringify({ ok: true, sessionId: target, result: value, via }))
} catch (e) { } catch (e) {
const detail = e?.data || e?.message || e const detail = e?.data || e?.message || e
log('chat failed: ' + short(detail, 200)) log('chat failed: ' + short(detail, 200))
@@ -372,24 +389,20 @@ export function apply(ctx) {
} catch { } } catch { }
const json = (obj) => { res.writeHead(200, { ...cors, 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(obj)) } const json = (obj) => { res.writeHead(200, { ...cors, 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(obj)) }
try { try {
const api = ctx.get('apiProxy')
if (!api?.sessions) throw new Error('apiProxy unavailable')
if (body.action === 'create') { if (body.action === 'create') {
const r = await api.sessions.create({ const { value, via } = await hostCall('create', {
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.agentPreset ? { agentPreset: String(body.agentPreset) } : {}),
...(body.workspaceId ? { workspaceId: body.workspaceId } : {}),
},
}) })
const result = r?.result ?? r const sessionId = value?.sessionId || null
if (result?.sessionId) lastSessionId = result.sessionId if (sessionId) lastSessionId = sessionId
log('session created: ' + short(result, 160)) log(`session created via ${via}: ` + short(value, 160))
json({ ok: true, ...result }) json({ ok: true, sessionId, via, result: value })
} else if (body.action === 'cancel') { } else if (body.action === 'cancel') {
const r = await api.sessions.cancel({ rpcId: randomUUID(), payload: { sessionId: body.sessionId || lastSessionId } }) const { value, via } = await hostCall('cancel', { sessionId: body.sessionId || lastSessionId })
json({ ok: true, result: r?.result ?? r }) json({ ok: true, via, result: value })
} else { } else {
json({ ok: true, sessions: snapshot().sessions, current: lastSessionId }) json({ ok: true, sessions: snapshot().sessions, current: lastSessionId })
} }