Files
dsh-float-window/lib/host.js
T

473 lines
22 KiB
JavaScript

/**
* dsh-float-window — Host 半(标准 DSH / cordis 插件)
*
* 职责:
* 1. 把原生浮窗需要的接入信息写成 `%DSH_HOME%\float-window\endpoint.json`
* (WebView2 壳读它来连这里;因为壳是独立进程,拿不到 ctx)。
* 2. 提供 SSE 数据通道 `/dsh-float-window/events`:会话消息 + 计费指标快照。
* 3. 提供 `/dsh-float-window/cmd`:浮窗 → 宿主的控制命令。
* 4. 注册 `float_window` 工具,让 Agent 能自己把浮窗调出来 / 收起 / 置顶 / 全屏。
*
* 设计原则:宿主内部 API 一律"探测式"使用(ctx.get(...) + try/catch),
* 任何一个服务缺失都只是少几个指标,绝不能让插件加载失败拖垮 DSH。
*/
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'
export const inject = ['webServer']
const HERE = path.dirname(fileURLToPath(import.meta.url))
const REPO = path.resolve(HERE, '..')
const ROUTE = '/dsh-float-window'
function dshHome() {
return process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
}
/** 会话事件 → 浮窗消息行的宽松映射(不同版本字段不完全一致,宁可少显示也不要报错) */
function toRow(event) {
if (!event || typeof event !== 'object') return null
const type = String(event.type || '')
const text = (v) => (typeof v === 'string' ? v : '')
if (type === 'tool/call') {
return { role: 'tool', text: `${event.name || event.tool || 'tool'}(${short(event.args ?? event.input)})` }
}
if (type === 'tool/result') return null
const msg = event.message
const role = msg?.role || event.role || (type.includes('user') ? 'user' : type.includes('assistant') ? 'assistant' : '')
if (!role) return null
let body = ''
const content = msg?.content ?? event.content ?? event.text
if (typeof content === 'string') body = content
else if (Array.isArray(content)) body = content.map((c) => (typeof c === 'string' ? c : text(c?.text))).join('')
if (!body) return null
return { role, text: body.length > 4000 ? body.slice(0, 4000) + '…' : body, streaming: /delta|chunk|stream/.test(type) }
}
function short(v, n = 120) {
try {
const s = typeof v === 'string' ? v : JSON.stringify(v)
return s && s.length > n ? s.slice(0, n) + '…' : s || ''
} catch { return '' }
}
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 { }
}
const log = (...a) => {
const line = a.map((x) => (typeof x === 'string' ? x : short(x, 200))).join(' ')
notes.push(`${new Date().toISOString().slice(11, 19)} ${line}`)
if (notes.length > 80) notes.shift()
try { (ctx.logger?.info || console.log)('[float-window]', ...a) } catch { }
writeStatus()
}
// ---------------------------------------------------------------- endpoint
const endpointFile = path.join(dshHome(), 'float-window', 'endpoint.json')
const writeEndpoint = () => {
try {
fs.mkdirSync(path.dirname(endpointFile), { recursive: true })
const data = {
baseUrl: `http://127.0.0.1:${ctx.webServer.port}`,
host: ctx.webServer.host,
pid: process.pid,
updatedAt: new Date().toISOString(),
}
fs.writeFileSync(endpointFile, JSON.stringify(data, null, 2))
log('endpoint written', data.baseUrl, '->', endpointFile)
} catch (e) { log('endpoint write failed:', String(e?.message || e)) }
}
writeEndpoint()
ctx.effect(() => () => { try { fs.rmSync(endpointFile, { force: true }) } catch { } })
// ---------------------------------------------------------------- 会话镜像缓冲
const MAX_ROWS = 120
const rows = new Map() // sessionId -> row[]
const counts = new Map() // sessionId -> 事件计数兜底(没有 sessionProjection 也能出数字)
let lastSessionId = null
function counter(id) {
let c = counts.get(id)
if (!c) { c = { turns: 0, steps: 0, outTokens: 0, decodeMs: 0, stepStart: 0, lastTps: null }; counts.set(id, c) }
return c
}
ctx.on('session/event', (session, event) => {
try {
const id = session?.id || session?.sessionId || 'unknown'
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++
else if (type === 'assistant/message') {
const usage = event?.usage || event?.message?.usage || {}
const out = usage.outputTokens ?? usage.output_tokens ?? usage.completionTokens
const ms = c.stepStart ? Date.now() - c.stepStart : 0
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]
if (row.streaming && last && last.role === row.role && last.streaming) last.text += row.text
else list.push(row)
while (list.length > MAX_ROWS) list.shift()
rows.set(id, list)
} catch { }
})
// ---------------------------------------------------------------- 指标
let meterKeysLogged = false
function metricsFor(session) {
const id = session?.id || lastSessionId || 'unknown'
const c = counts.get(id) || { turns: 0, steps: 0, outTokens: 0, decodeMs: 0, lastTps: null }
const out = {
turns: c.turns || null,
steps: c.steps || null,
tps: c.lastTps,
tokens: null,
cacheHit: null,
contextUsed: null,
}
// 有 session-projection 就用它(更权威),否则用事件计数兜底
try {
const proj = ctx.get('sessionProjection')
const st = proj?.get?.(session, 'sessionStats') || proj?.peek?.(session, 'sessionStats')
if (st) {
if (st.turns != null) out.turns = st.turns
if (st.steps != null) out.steps = st.steps
if (st.decodeTokens && st.decodeMs) out.tps = st.decodeTokens / (st.decodeMs / 1000)
}
} catch { }
try {
const meter = ctx.get('tokenMeter')
const m = meter?.measure?.(session)
if (m) {
// 第一次把真实字段名记进状态文件,便于在用户机器上核对映射
if (!meterKeysLogged) { meterKeysLogged = true; log('tokenMeter keys: ' + Object.keys(m).join(',')) }
out.tokens = m.totalTokens ?? m.surfaceTokens ?? null
const u = m.usage || m.providerUsage || m.usageBuckets
if (u) {
const read = u.cacheRead ?? u.cache_read ?? u.cacheReadTokens ?? 0
const input = u.input ?? u.inputTokens ?? 0
const denom = input + read
if (denom > 0) out.cacheHit = read / denom
}
let capacity = null
try { capacity = ctx.get('llm')?.resolveModelInfo?.()?.context ?? null } catch { }
const used = m.surfaceTokens ?? m.totalTokens
if (capacity && used) out.contextUsed = Math.min(1, used / capacity)
}
} catch (e) { if (!meterKeysLogged) { meterKeysLogged = true; log('tokenMeter measure failed: ' + String(e?.message || e)) } }
return out
}
function snapshot() {
const sessions = (() => { try { return ctx.get('sessions')?.list?.() || [] } catch { return [] } })()
let session = sessions.find((s) => s?.id === lastSessionId) || sessions[sessions.length - 1] || null
const id = session?.id || lastSessionId
return {
ok: true,
at: Date.now(),
sessionId: id || null,
title: session?.meta?.title || session?.title || null,
messages: (id && rows.get(id)) || [],
metrics: session ? metricsFor(session) : { turns: null, steps: null, tps: null, tokens: null, cacheHit: null, contextUsed: null },
sessions: sessions.slice(-10).map((s) => ({ id: s?.id, title: s?.meta?.title || s?.title || null })),
}
}
// ---------------------------------------------------------------- 路由
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(); if (++tickCount % 10 === 0) writeStatus() }, 1000)
ctx.effect(() => () => clearInterval(tick))
const handler = async (req, res) => {
const url = new URL(req.url || '/', 'http://127.0.0.1')
const cors = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'content-type',
'Access-Control-Allow-Methods': 'GET,POST,OPTIONS',
}
if (req.method === 'OPTIONS') { res.writeHead(204, cors); res.end(); return }
if (url.pathname === `${ROUTE}/events`) {
res.writeHead(200, { ...cors, 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache', Connection: 'keep-alive' })
res.write(': connected\n\n')
res.write(`data: ${JSON.stringify(snapshot())}\n\n`)
sseClients.add(res)
req.on('close', () => sseClients.delete(res))
return
}
if (url.pathname === `${ROUTE}/state`) {
res.writeHead(200, { ...cors, 'Content-Type': 'application/json; charset=utf-8' })
res.end(JSON.stringify(snapshot()))
return
}
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(), 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' })
res.end('dsh-float-window: not found')
}
ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: ROUTE, handler }))
// ---------------------------------------------------------------- 壳进程
const stateDir = path.join(dshHome(), 'float-window')
const cmdFile = path.join(stateDir, 'command.json')
const aliveFile = path.join(stateDir, 'alive.json')
function shellPath() {
return path.join(REPO, 'shell', 'run-float.cmd')
}
/** 壳每 700ms 写一次心跳,据此判断浮窗是否还活着 */
function shellAlive() {
try { return Date.now() - fs.statSync(aliveFile).mtimeMs < 8000 } catch { return false }
}
async function spawnShell() {
const cmd = shellPath()
if (!fs.existsSync(cmd)) return { ok: false, error: `launcher not found: ${cmd}` }
try {
const shell = ctx.get('shell')
const sp = ctx.get('sandboxPolicy')
const policy = sp?.resolve?.({ mode: 'danger-full-access' })
if (shell?.resolve && shell?.run) {
const spec = shell.resolve({ command: `cmd /c start "" "${cmd}"`, ...(policy ? { sandboxPolicy: policy } : {}) })
await shell.run(spec)
return { ok: true, via: 'ctx.shell' }
}
} catch (e) { log('ctx.shell launch failed, fallback to spawn:', String(e?.message || e)) }
try {
const { spawn } = await import('node:child_process')
spawn('cmd', ['/c', 'start', '', cmd], { detached: true, stdio: 'ignore', windowsHide: true }).unref()
return { ok: true, via: 'spawn' }
} catch (e) { return { ok: false, error: String(e?.message || e) } }
}
/** 浮窗已经在跑就投递命令,否则按需拉起 */
async function controlFloat(action) {
if (action === 'open' && !shellAlive()) return spawnShell()
if (action === 'close' && !shellAlive()) return { ok: true, via: 'already closed' }
try {
fs.mkdirSync(stateDir, { recursive: true })
fs.writeFileSync(cmdFile, JSON.stringify({ cmd: action, at: new Date().toISOString() }))
return { ok: true, via: 'command file' }
} catch (e) { return { ok: false, error: String(e?.message || e) } }
}
// ---------------------------------------------------------------- Agent 工具
status.shell = shellPath()
writeStatus()
try {
const tools = ctx.get('tools')
if (tools?.register) {
tools.register({
name: 'float_window',
description: '控制 DSH 悬浮窗(始终置顶的会话镜像小窗):open 打开/聚焦,close 关闭,pin 切换固定位置,fullscreen 切换全屏,hide 临时隐藏(抓屏前用)。',
parameters: {
action: { type: 'string', required: true, description: 'open | close | pin | fullscreen | hide | status' },
},
output: { schema: { type: 'string' }, render: (_a, v) => [{ type: 'text', text: v }] },
async execute(args) {
const action = String(args?.action || 'open')
if (action === 'status') {
const s = snapshot()
return `float-window: alive=${shellAlive()} session=${s.sessionId || 'none'} messages=${s.messages.length} turns=${s.metrics.turns ?? '?'} steps=${s.metrics.steps ?? '?'}`
}
if (action === 'hide') { broadcast({ type: 'cmd', cmd: 'hide' }); const r = await controlFloat('hide'); return `float-window: hide (${r.via || r.error})` }
const r = await controlFloat(action)
return r.ok ? `float-window: ${action} ok (${r.via})` : `float-window: ${action} failed: ${r.error}`
},
})
status.tool = true
log('tool float_window registered')
} else {
log('tools service not available; agent trigger disabled')
}
} catch (e) { log('tool registration failed:', String(e?.message || e)) }
writeStatus()
const broadcast = (msg) => {
const payload = `data: ${JSON.stringify(msg)}\n\n`
for (const res of sseClients) { try { res.write(payload) } catch { } }
}
const pushHidden = () => broadcast({ type: 'cmd', cmd: 'hide' })
// 抓屏前隐藏的对外门面:交给宿主的共享对象。
// 注意 cordis 要求先 provide 再 set,否则会抛 "cannot set property ... without provide"
// (实测:漏了 provide 会让整个插件树加载失败、DSH 起不来)。这里做成可选的,
// 拿不到 provide 就退化成只广播 SSE 命令。
try {
ctx.provide?.('floatWindow')
ctx.set?.('floatWindow', { hide: pushHidden, show: () => broadcast({ type: 'cmd', cmd: 'show' }), snapshot, endpoint: () => endpointFile })
} catch (e) { log('floatWindow facade skipped:', String(e?.message || e)) }
// ---------------------------------------------------------------- DSH 设置入口
// 注册一个设置命名空间,DSH 的「设置 → 插件」会把它渲染成一张卡片。
// schemastery 是从 profile 的 node_modules 解析的;link: 安装的插件解析不到时
// 只跳过这一步(浮窗自己的设置面板照常可用)。
log('services: ' + ['settings', 'sessions', 'tools', 'tokenMeter', 'sessionProjection', 'llm', 'shell', 'sandboxPolicy', 'webServer']
.map((n) => `${n}=${(() => { try { return ctx.get(n) ? 'y' : 'n' } catch { return '?' } })()}`).join(' '))
; (async () => {
try {
const settings = ctx.get('settings')
if (!settings?.register) { log('settings service absent; skip settings entry'); return }
let z = null
// link: 安装时插件自己的目录里没有 node_modules,裸导入会失败;
// 依次尝试:常规解析 -> 以宿主入口为基准解析 -> profile 目录解析
try { z = (await import('@deepseek-ai/schemastery')).default } catch { }
if (!z) {
try {
const { createRequire } = await import('node:module')
const req = createRequire(process.argv[1] || path.join(dshHome(), 'index.js'))
const m = req('@deepseek-ai/schemastery')
z = m?.default || m
} catch { }
}
if (!z) {
try {
const { createRequire } = await import('node:module')
for (const prof of ['desktop', 'web']) {
const req = createRequire(path.join(dshHome(), 'profiles', prof, 'package.json'))
const m = req('@deepseek-ai/schemastery')
z = m?.default || m
if (z) break
}
} catch { }
}
if (!z) { log('schemastery not resolvable; skip settings entry'); return }
const schema = z.object({
topmost: z.boolean().default(true).description('始终置顶'),
posLock: z.boolean().default(false).description('固定位置'),
sizeLock: z.boolean().default(false).description('锁定尺寸'),
exclude: z.boolean().default(true).description('抓屏时隐藏本窗'),
opacity: z.number().min(0.3).max(1).default(0.92).description('不透明度'),
scale: z.number().min(0.8).max(1.5).default(1).description('界面缩放'),
backdrop: z.union(['opaque', 'translucent', 'acrylic', 'mica', 'blur']).default('opaque').description('背景效果'),
})
const scope = settings.register('dsh-float-window', schema)
const scope2 = scope
ctx.effect(() => {
const stop = scope2.watch?.((next) => {
try { broadcast({ type: 'settings', value: next?.value ?? next }) } catch { }
})
return () => { try { stop?.() } catch { } }
})
log('settings namespace registered')
status.settings = true
writeStatus()
} catch (e) { log('settings registration failed:', String(e?.message || e)) }
})()
log('ready; endpoints:', `${ROUTE}/events`, `${ROUTE}/state`, `${ROUTE}/health`)
}