From 5aa92d50da66faf055fc0168c6f3fd843fd3d66f Mon Sep 17 00:00:00 2001 From: jiangchen <1703935164@qq.com> Date: Sun, 27 Sep 2026 14:25:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(host):=20=E6=B5=AE=E7=AA=97=E8=81=8A?= =?UTF-8?q?=E5=A4=A9=E6=A1=86=E6=89=93=E9=80=9A=EF=BC=88apiProxy.sessions.?= =?UTF-8?q?prompt=EF=BC=89+=20=E4=BC=9A=E8=AF=9D=E6=8E=A7=E5=88=B6?= =?UTF-8?q?=E8=B7=AF=E7=94=B1=20+=20=E4=BA=8B=E4=BB=B6=E7=9B=B4=E6=96=B9?= =?UTF-8?q?=E5=9B=BE=E8=87=AA=E6=A3=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 20 ++++++++++-- lib/host.js | 89 +++++++++++++++++++++++++++++++++++++++++++++++++-- shell/ui.html | 22 ++++++++++++- 3 files changed, 126 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index ef3e6b5..5ec05ef 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ | 自适应调整 | 布局随窗口尺寸重排;拖动**任意边缘和四角**都能缩放(系统级 WM_NCHITTEST) | | 全屏切换 | 标题栏 ⛶ 或 `F11`,返回时还原原来的位置与尺寸 | | 抓屏隐身 | `SetWindowDisplayAffinity(WDA_EXCLUDEFROMCAPTURE)`,Agent 截屏里彻底看不到这个窗 | -| 底部聊天框 | 与当前会话互通(随插件启用) | +| 底部聊天框 | 直接发消息进当前会话(浮窗 POST 给插件,插件调宿主网关 `ctx.apiProxy.sessions.prompt`,与浏览器 UI 同一条路) | | 计费/状态条 | 轮数、步数、tok/s、累计 MTOK、缓存命中率、上下文用量进度条 | | 外观自定义 | 背景效果:不透明 / 毛玻璃(亚克力)/ 云母 / 模糊 / 半透明;不透明度、界面缩放 | | 按钮触发 | 运行 `shell\run-float.cmd`,或 DSH 里点插件入口 | @@ -168,10 +168,26 @@ dsh-float-window/ ## 已知限制 / 路线图 * 仅 Windows(macOS 的排除要换 `NSWindow.sharingType`,且 ScreenCaptureKit 是否遵守需要单独实测)。 -* 聊天框要真正把消息发进会话,还需要接宿主侧“发送到会话”的服务(当前只做只读镜像)。 +* 从浮窗**新建会话**还没打通:`session.create` 在宿主里要工作区上下文,直接从插件调会被拒(`/session` 路由已留好,等接上 workspace)。 * 背景效果里的“半透明”依赖整窗 alpha,WebView2 子窗口在这种模式下的观感不如毛玻璃,建议优先用亚克力/云母。 * 还没做托盘图标与全局热键。 +## 排障:插件自检文件 + +插件每次启动会写 `%DSH_HOME%\float-window\plugin-status.json`: + +```json +{ "version": "0.1.0", "tool": true, "settings": false, + "notes": ["06:24:39 tool float_window registered", "..."], + "eventTypes": { "user/message": 3, "assistant/message": 2, "tool/call": 1 }, + "lastRow": { "type": "assistant/message", "row": { "role": "assistant", "text": "…" } } } +``` + +* `tool` —— Agent 工具是否注册成功(`false` 说明 tools 服务不在); +* `settings` —— 设置命名空间是否注册成功(`link:` 安装时可能拿不到 schemastery,属已知降级); +* `notes` —— 启动自检日志(服务探测、endpoint 路径、错误); +* `eventTypes` / `lastRow` —— 会话事件直方图与最近一次映射结果,用于核对不同 DSH 版本的事件字段名。 + ## License MIT diff --git a/lib/host.js b/lib/host.js index 77d792f..8c0e2f9 100644 --- a/lib/host.js +++ b/lib/host.js @@ -14,6 +14,7 @@ import fs from 'node:fs' import os from 'node:os' import path from 'node:path' +import { randomUUID } from 'node:crypto' import { fileURLToPath } from 'node:url' export const name = 'dsh-float-window' @@ -58,11 +59,15 @@ export function apply(ctx) { // 运行状态与自检日志落盘:插件跑在 DSH 内部,控制台不一定看得到, // 有了这个文件(和 /health)才能在用户机器上远程排障。 const notes = [] + const seenTypes = new Map() // 事件类型直方图:写进状态文件,便于在用户机器上核对字段名 + 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 } const writeStatus = () => { try { status.notes = notes.slice(-40) + status.eventTypes = Object.fromEntries([...seenTypes].slice(-40)) + status.lastRow = lastRow fs.mkdirSync(path.dirname(statusFile0), { recursive: true }) fs.writeFileSync(statusFile0, JSON.stringify(status, null, 2)) } catch { } @@ -111,6 +116,7 @@ export function apply(ctx) { lastSessionId = id const c = counter(id) const type = String(event?.type || '') + seenTypes.set(type, (seenTypes.get(type) || 0) + 1) if (type === 'turn/end') c.turns++ else if (type === 'step/start') c.stepStart = Date.now() else if (type === 'step/end') c.steps++ @@ -121,6 +127,7 @@ export function apply(ctx) { if (out && ms > 0) { c.outTokens += out; c.decodeMs += ms; c.lastTps = out / (ms / 1000) } } const row = toRow(event) + lastRow = row ? { type, row } : { type, row: null } if (!row) return const list = rows.get(id) || [] const last = list[list.length - 1] @@ -193,13 +200,14 @@ export function apply(ctx) { } // ---------------------------------------------------------------- 路由 + let tickCount = 0 const sseClients = new Set() const push = () => { if (!sseClients.size) return const payload = `data: ${JSON.stringify(snapshot())}\n\n` for (const res of sseClients) { try { res.write(payload) } catch { } } } - const tick = setInterval(push, 1000) + const tick = setInterval(() => { push(); if (++tickCount % 10 === 0) writeStatus() }, 1000) ctx.effect(() => () => clearInterval(tick)) const handler = async (req, res) => { @@ -225,7 +233,84 @@ export function apply(ctx) { } if (url.pathname === `${ROUTE}/health`) { res.writeHead(200, { ...cors, 'Content-Type': 'application/json; charset=utf-8' }) - res.end(JSON.stringify({ ok: true, version: '0.1.0', shell: shellPath() })) + res.end(JSON.stringify({ ok: true, version: '0.1.0', shell: shellPath(), tool: status.tool, settings: status.settings })) + return + } + // 悬浮窗聊天框 -> 会话。走宿主自己的网关服务 ctx.apiProxy(和浏览器 UI 同一条路), + // 不经过 /api 的浏览器信任围栏,所以 file:// 的浮窗页面也能发消息。 + if (url.pathname === `${ROUTE}/chat` && req.method === 'POST') { + let text = '' + let sessionId = null + try { + const chunks = [] + for await (const c of req) chunks.push(c) + const body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}') + text = String(body.text || '').slice(0, 20000) + sessionId = body.sessionId || null + } catch (e) { + res.writeHead(400, { ...cors, 'Content-Type': 'application/json; charset=utf-8' }) + res.end(JSON.stringify({ ok: false, error: 'bad-body: ' + String(e?.message || e) })) + return + } + if (!text) { + res.writeHead(400, { ...cors, 'Content-Type': 'application/json; charset=utf-8' }) + res.end(JSON.stringify({ ok: false, error: 'empty text' })) + return + } + const target = sessionId || lastSessionId + try { + const api = ctx.get('apiProxy') + if (!api?.sessions?.prompt) throw new Error('apiProxy unavailable') + 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, + }) + const result = r?.result ?? r + log(`chat -> session ${target}: ${JSON.stringify(result).slice(0, 160)}`) + res.writeHead(200, { ...cors, 'Content-Type': 'application/json; charset=utf-8' }) + res.end(JSON.stringify({ ok: true, sessionId: target, result })) + } catch (e) { + const detail = e?.data || e?.message || e + log('chat failed: ' + short(detail, 200)) + res.writeHead(200, { ...cors, 'Content-Type': 'application/json; charset=utf-8' }) + res.end(JSON.stringify({ ok: false, error: short(detail, 300) })) + } + return + } + // 会话控制:列表 / 新建 / 打断(浮窗顶部的"新会话"和"停止"将来用这里) + if (url.pathname === `${ROUTE}/session` && req.method === 'POST') { + let body = {} + try { + const chunks = [] + for await (const c of req) chunks.push(c) + body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}') + } catch { } + const json = (obj) => { res.writeHead(200, { ...cors, 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(obj)) } + try { + const api = ctx.get('apiProxy') + if (!api?.sessions) throw new Error('apiProxy unavailable') + 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 } : {}), + }) + 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 }) + 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) }) } return } res.writeHead(404, { ...cors, 'Content-Type': 'text/plain; charset=utf-8' }) diff --git a/shell/ui.html b/shell/ui.html index de5a3c4..7078ff8 100644 --- a/shell/ui.html +++ b/shell/ui.html @@ -610,8 +610,10 @@ } let es = null + let ENDPOINT = null function setEndpoint(ep) { const live = !!(ep && ep.baseUrl) + ENDPOINT = live ? ep.baseUrl : null $('dot').className = 'dot ' + (live ? 'live' : 'wait') $('sub').textContent = live ? ep.baseUrl.replace(/^https?:\/\//, '') : '未连接' $('input').disabled = !live @@ -702,8 +704,26 @@ const submit = () => { const v = $('input').value.trim() if (!v) return - send({ cmd: 'chat', text: v }) $('input').value = ''; $('input').style.height = 'auto' + if (ENDPOINT) { + // 真实发送:POST 给插件,由它走宿主网关送进会话 + fetch(ENDPOINT + '/dsh-float-window/chat', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ text: v }), + }).then((r) => r.json()).then((j) => { + if (!j.ok) toast('发送失败:' + j.error) + }).catch((e) => toast('发送失败:' + e.message)) + } else { + send({ cmd: 'chat', text: v }) + } + } + function toast(msg) { + const el = document.createElement('div') + el.textContent = msg + el.style.cssText = 'position:fixed;left:8px;right:8px;bottom:44px;background:#3a2028;border:1px solid #7a2b3a;color:#ffd7de;font-size:11.5px;padding:6px 8px;border-radius:8px;z-index:40' + document.body.appendChild(el) + setTimeout(() => el.remove(), 4000) } $('send').onclick = submit $('input').addEventListener('keydown', (e) => {