feat(host): 浮窗聊天框打通(apiProxy.sessions.prompt)+ 会话控制路由 + 事件直方图自检

This commit is contained in:
2026-09-27 14:25:35 +08:00
parent b58bde162f
commit 5aa92d50da
3 changed files with 126 additions and 5 deletions
+18 -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 同一条路) |
| 计费/状态条 | 轮数、步数、tok/s、累计 MTOK、缓存命中率、上下文用量进度条 | | 计费/状态条 | 轮数、步数、tok/s、累计 MTOK、缓存命中率、上下文用量进度条 |
| 外观自定义 | 背景效果:不透明 / 毛玻璃(亚克力)/ 云母 / 模糊 / 半透明;不透明度、界面缩放 | | 外观自定义 | 背景效果:不透明 / 毛玻璃(亚克力)/ 云母 / 模糊 / 半透明;不透明度、界面缩放 |
| 按钮触发 | 运行 `shell\run-float.cmd`,或 DSH 里点插件入口 | | 按钮触发 | 运行 `shell\run-float.cmd`,或 DSH 里点插件入口 |
@@ -168,10 +168,26 @@ dsh-float-window/
## 已知限制 / 路线图 ## 已知限制 / 路线图
* 仅 Windows(macOS 的排除要换 `NSWindow.sharingType`,且 ScreenCaptureKit 是否遵守需要单独实测)。 * 仅 Windows(macOS 的排除要换 `NSWindow.sharingType`,且 ScreenCaptureKit 是否遵守需要单独实测)。
* 聊天框要真正把消息发进会话,还需要接宿主侧“发送到会话”的服务(当前只做只读镜像)。 * 从浮窗**新建会话**还没打通:`session.create` 在宿主里要工作区上下文,直接从插件调会被拒(`/session` 路由已留好,等接上 workspace)。
* 背景效果里的“半透明”依赖整窗 alpha,WebView2 子窗口在这种模式下的观感不如毛玻璃,建议优先用亚克力/云母。 * 背景效果里的“半透明”依赖整窗 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 ## License
MIT MIT
+87 -2
View File
@@ -14,6 +14,7 @@
import fs from 'node:fs' import fs from 'node:fs'
import os from 'node:os' import os from 'node:os'
import path from 'node:path' import path from 'node:path'
import { randomUUID } from 'node:crypto'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
export const name = 'dsh-float-window' export const name = 'dsh-float-window'
@@ -58,11 +59,15 @@ export function apply(ctx) {
// 运行状态与自检日志落盘:插件跑在 DSH 内部,控制台不一定看得到, // 运行状态与自检日志落盘:插件跑在 DSH 内部,控制台不一定看得到,
// 有了这个文件(和 /health)才能在用户机器上远程排障。 // 有了这个文件(和 /health)才能在用户机器上远程排障。
const notes = [] const notes = []
const seenTypes = new Map() // 事件类型直方图:写进状态文件,便于在用户机器上核对字段名
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 }
const writeStatus = () => { const writeStatus = () => {
try { try {
status.notes = notes.slice(-40) status.notes = notes.slice(-40)
status.eventTypes = Object.fromEntries([...seenTypes].slice(-40))
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))
} catch { } } catch { }
@@ -111,6 +116,7 @@ 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 || '')
seenTypes.set(type, (seenTypes.get(type) || 0) + 1)
if (type === 'turn/end') c.turns++ if (type === 'turn/end') c.turns++
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++
@@ -121,6 +127,7 @@ export function apply(ctx) {
if (out && ms > 0) { c.outTokens += out; c.decodeMs += ms; c.lastTps = out / (ms / 1000) } if (out && ms > 0) { c.outTokens += out; c.decodeMs += ms; c.lastTps = out / (ms / 1000) }
} }
const row = toRow(event) const row = toRow(event)
lastRow = row ? { type, row } : { type, row: null }
if (!row) return if (!row) return
const list = rows.get(id) || [] const list = rows.get(id) || []
const last = list[list.length - 1] const last = list[list.length - 1]
@@ -193,13 +200,14 @@ export function apply(ctx) {
} }
// ---------------------------------------------------------------- 路由 // ---------------------------------------------------------------- 路由
let tickCount = 0
const sseClients = new Set() const sseClients = new Set()
const push = () => { const push = () => {
if (!sseClients.size) return if (!sseClients.size) return
const payload = `data: ${JSON.stringify(snapshot())}\n\n` const payload = `data: ${JSON.stringify(snapshot())}\n\n`
for (const res of sseClients) { try { res.write(payload) } catch { } } 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)) ctx.effect(() => () => clearInterval(tick))
const handler = async (req, res) => { const handler = async (req, res) => {
@@ -225,7 +233,84 @@ export function apply(ctx) {
} }
if (url.pathname === `${ROUTE}/health`) { if (url.pathname === `${ROUTE}/health`) {
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, 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 return
} }
res.writeHead(404, { ...cors, 'Content-Type': 'text/plain; charset=utf-8' }) res.writeHead(404, { ...cors, 'Content-Type': 'text/plain; charset=utf-8' })
+21 -1
View File
@@ -610,8 +610,10 @@
} }
let es = null let es = null
let ENDPOINT = null
function setEndpoint(ep) { function setEndpoint(ep) {
const live = !!(ep && ep.baseUrl) const live = !!(ep && ep.baseUrl)
ENDPOINT = live ? ep.baseUrl : null
$('dot').className = 'dot ' + (live ? 'live' : 'wait') $('dot').className = 'dot ' + (live ? 'live' : 'wait')
$('sub').textContent = live ? ep.baseUrl.replace(/^https?:\/\//, '') : '未连接' $('sub').textContent = live ? ep.baseUrl.replace(/^https?:\/\//, '') : '未连接'
$('input').disabled = !live $('input').disabled = !live
@@ -702,8 +704,26 @@
const submit = () => { const submit = () => {
const v = $('input').value.trim() const v = $('input').value.trim()
if (!v) return if (!v) return
send({ cmd: 'chat', text: v })
$('input').value = ''; $('input').style.height = 'auto' $('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 $('send').onclick = submit
$('input').addEventListener('keydown', (e) => { $('input').addEventListener('keydown', (e) => {