/** * 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 { 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) { const log = (...a) => { try { (ctx.logger?.info || console.log)('[float-window]', ...a) } catch { } } // ---------------------------------------------------------------- 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[] let lastSessionId = null ctx.on('session/event', (session, event) => { try { const id = session?.id || session?.sessionId || 'unknown' lastSessionId = id const row = toRow(event) 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 { } }) // ---------------------------------------------------------------- 指标 function metricsFor(session) { const out = { turns: null, steps: null, tps: null, tokens: null, cacheHit: null, contextUsed: null } try { const proj = ctx.get('sessionProjection') const stats = proj?.get?.(session, 'sessionStats') || proj?.peek?.(session, 'sessionStats') if (stats) { out.turns = stats.turns ?? null out.steps = stats.steps ?? null if (stats.decodeTokens && stats.decodeMs) out.tps = (stats.decodeTokens / (stats.decodeMs / 1000)) } } catch { } try { const meter = ctx.get('tokenMeter') const m = meter?.measure?.(session) if (m) { out.tokens = m.totalTokens ?? m.surfaceTokens ?? null const u = m.usage || m.providerUsage if (u) { const read = u.cacheRead ?? u.cache_read ?? 0 const input = (u.input ?? 0) + read if (input > 0) out.cacheHit = read / input } const capacity = ctx.get('llm')?.resolveModelInfo?.()?.context const used = m.surfaceTokens ?? m.totalTokens if (capacity && used) out.contextUsed = Math.min(1, used / capacity) } } catch { } 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 })), } } // ---------------------------------------------------------------- 路由 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) 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() })) 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 工具 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}` }, }) log('tool float_window registered') } else { log('tools service not available; agent trigger disabled') } } catch (e) { log('tool registration failed:', String(e?.message || e)) } 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: 安装的插件解析不到时 // 只跳过这一步(浮窗自己的设置面板照常可用)。 ; (async () => { try { const settings = ctx.get('settings') if (!settings?.register) { log('settings service absent; skip settings entry'); return } let z = null try { z = (await import('@deepseek-ai/schemastery')).default } 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') } catch (e) { log('settings registration failed:', String(e?.message || e)) } })() log('ready; endpoints:', `${ROUTE}/events`, `${ROUTE}/state`, `${ROUTE}/health`) }