feat(host): 浮窗聊天框打通(apiProxy.sessions.prompt)+ 会话控制路由 + 事件直方图自检
This commit is contained in:
@@ -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
@@ -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
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user