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
+87 -2
View File
@@ -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' })