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