/** * 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 { createRequire } from 'node:module' 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') } // ---------------------------------------------------------------- 配置 schema // 0.1.7 的设置模型是"编辑插件自身的 Config(profile 条目配置)",所以这里导出一个 // schemastery schema,DSH 的「设置 → 插件」就能渲染出悬浮窗的配置卡片。 // 难点:插件用 link: 安装时自身目录没有 node_modules,裸 import 会失败 —— // 用 createRequire 以"宿主入口 / profile 目录"为基准去解析。 /** 通用"宿主侧模块"解析:link: 安装的插件自身没有 node_modules,必须借宿主的 */ function loadHostModule(spec) { const tries = [ () => process.argv[1] && createRequire(process.argv[1]), () => createRequire(path.join(dshHome(), 'profiles', 'desktop', 'package.json')), () => createRequire(path.join(dshHome(), 'profiles', 'web', 'package.json')), ] for (const make of tries) { try { const req = make() if (!req) continue const m = req(spec) // 返回模块命名空间本身:default 是类(ToolRuntime)时具名导出 defineTool 会被吞掉 if (m && (m.defineTool || m.default || m.object)) return m } catch { } } return null } function loadSchemastery() { const m = loadHostModule('@deepseek-ai/schemastery') const z = m?.default ?? m return z?.object ? z : null } const z = loadSchemastery() const floatSchema = z ? 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('背景效果'), tool: z.boolean().default(true).description('给 Agent 注册 float_window 工具(关掉则模型看不到它)'), }) : undefined /** 浮窗设置项(DSH 设置页写进 profile 条目 config,这里读取并下发给浮窗) */ export const Config = floatSchema /** 从 content 数组/字符串里取纯文本 */ function textOf(content) { if (typeof content === 'string') return content if (Array.isArray(content)) { return content .map((c) => (typeof c === 'string' ? c : (c?.type === 'text' || c?.text ? String(c.text ?? '') : ''))) .join('') } return '' } /** * 会话事件 → 浮窗消息行的宽松映射。 * 实测(dsh 0.1.x)事件载荷在 `event.data` 下: * user/message { data: { content:[{type:'text',text}], role, source:{kind:'user'|'plugin'} , id } } * assistant/chunk { data: { turn, step, chunk:{ type, text?, reason? } } } * assistant/message{ data: { content:[...] } } * tool/call { data: { name, args } } * 插件注入的消息(source.kind === 'plugin',例如系统提示快照)不进镜像。 */ function toRow(event) { if (!event || typeof event !== 'object') return null const type = String(event.type || '') const d = event.data || event const src = d.source || event.source if (type === 'user/message') { if (src && src.kind && src.kind !== 'user') return null const text = textOf(d.content ?? d.message?.content ?? d.text) return text ? { role: 'user', text: clip(text) } : null } if (type === 'assistant/message') { const text = textOf(d.content ?? d.message?.content ?? d.text) return text ? { role: 'assistant', text: clip(text) } : null } if (type === 'assistant/chunk') { const c = d.chunk || {} const kind = String(c.type || '') const piece = c.text ?? c.delta ?? c.value ?? (kind.includes('text') ? c.content : '') if (piece) return { role: 'assistant', text: String(piece), streaming: true } if (kind === 'finish' && c.reason?.kind === 'error') { return { role: 'assistant', text: '⚠️ ' + short(c.reason.failure ?? c.reason, 240) } } return null } if (type === 'tool/call') { return { role: 'tool', text: `${d.name || d.tool || 'tool'}(${short(d.args ?? d.input ?? '', 160)})` } } return null } function clip(text, n = 4000) { const s = String(text) return s.length > n ? s.slice(0, n) + '…' : s } 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 '' } } /** 脱敏采样:保留结构与短字符串,避免把长内容/密钥写进状态文件 */ function sanitize(v, depth = 0) { if (v == null || typeof v === 'number' || typeof v === 'boolean') return v if (typeof v === 'string') return v.length > 200 ? v.slice(0, 200) + '…' : v if (depth >= 4) return Array.isArray(v) ? `[array ${v.length}]` : '[object]' if (Array.isArray(v)) return v.slice(0, 6).map((x) => sanitize(x, depth + 1)) const out = {} for (const [k, val] of Object.entries(v)) out[k] = sanitize(val, depth + 1) return out } const SAMPLE_TYPES = new Set(['user/message', 'assistant/chunk', 'assistant/message', 'tool/call', 'tool/result', 'session/title', 'request/header', 'request/context']) export function apply(ctx, config) { // 浮窗设置:DSH 设置页写进 profile 条目 config 的值 + 默认值 const floatCfg = Object.assign( { topmost: true, posLock: false, sizeLock: false, exclude: true, opacity: 0.92, scale: 1, backdrop: 'opaque', tool: true, button: true }, (config && typeof config === 'object') ? config : {}, ) // 运行状态与自检日志落盘:插件跑在 DSH 内部,控制台不一定看得到, // 有了这个文件(和 /health)才能在用户机器上远程排障。 const notes = [] const seenTypes = new Map() // 事件类型直方图:写进状态文件,便于在用户机器上核对字段名 const samples = {} // 每种关键事件的脱敏样本(最多 3 条),用于对齐不同 DSH 版本的字段名 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.samples = samples 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 { } }) // ---------------------------------------------------------------- 版本自适应的宿主调用 // dsh 0.1.x 早期:ctx.apiProxy.sessions.*(信封 { rpcId, payload }) // dsh 0.1.7+ :ctx.sessionController.*(Typert Remote 的宿主面,扁平信封) // 两个都试,谁在就用谁;都没有就返回可读错误,绝不让插件抛出去。 async function hostCall(method, payload) { const sc = ctx.get('sessionController') if (sc && typeof sc[method] === 'function') { const flat = { ...payload } delete flat.rpcId if (!flat.requestId) flat.requestId = randomUUID() // 0.1.7 的 Remote 签名是 (request, signal);signal 缺失时宿主内部会 // "Cannot read properties of undefined (reading 'throwIfAborted')" const r = await sc[method](flat, new AbortController().signal) return { value: r?.value ?? r, via: 'sessionController.' + method } } const api = ctx.get('apiProxy') if (api?.sessions && typeof api.sessions[method] === 'function') { const r = await api.sessions[method]({ rpcId: randomUUID(), payload }) return { value: r?.result?.value ?? r?.result ?? r, via: 'apiProxy.sessions.' + method } } throw new Error(`no host API for "${method}" (tried sessionController / apiProxy)`) } const MAX_ROWS = 120 const rows = new Map() // sessionId -> row[] const counts = new Map() // sessionId -> 事件计数兜底(没有 sessionProjection 也能出数字) const titles = new Map() // sessionId -> 会话标题(来自 session/title 事件) const contextWindows = new Map() // sessionId -> 上下文窗口(来自 request/context 事件) let lastSessionId = null let pinnedSessionId = 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' if (!pinnedSessionId) lastSessionId = id const c = counter(id) const type = String(event?.type || '') const d2 = () => event?.data || event || {} seenTypes.set(type, (seenTypes.get(type) || 0) + 1) if (SAMPLE_TYPES.has(type) && (samples[type]?.length ?? 0) < 3) { (samples[type] ||= []).push(sanitize(event)) } if (type === 'turn/end') c.turns++ else if (type === 'request/header') { // 记录模型实际会收到的工具 schema 摘要:这是"schema 是否合法"的最终证据 const h = (event?.data || event)?.header if (Array.isArray(h?.tools)) { status.toolSchemas = h.tools.slice(0, 12).map((t) => ({ name: t?.name, parametersType: t?.parameters?.type ?? null, required: Array.isArray(t?.parameters?.required) ? t.parameters.required : null, })) } } else if (type === 'request/context') { // 真机上 provider 会在这条事件里给出模型上下文窗口(实测 deepseek-flash = 1000000) const cw = (event?.data || event)?.contextWindow if (typeof cw === 'number' && cw > 0) contextWindows.set(id, cw) } else if (type === 'session/title') { const t = d2(event)?.title if (t) titles.set(id, String(t)) } 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 = contextWindows.get(id) || modelContext() || 1000000 // 兜底:DSH 常用 1M 上下文 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 } /** * 模型上下文容量。 * 踩坑(实测):`llm.resolveModelInfo()` 在没注册 provider 时**会抛错**,而且它返回 * Promise —— 只 try/catch 同步部分会留下 unhandled rejection,直接让 DSH 进程 * "fatal load failure" 退出(我在隔离实例里把 DSH 弄崩过一次)。 * 所以这里:同步异常、异步 reject、undefined 三种情况全部吞掉,并且只在拿到值时缓存。 */ let cachedCapacity = null function modelContext() { try { const llm = ctx.get('llm') const r = llm?.resolveModelInfo?.() if (r && typeof r.then === 'function') { r.then((v) => { cachedCapacity = v?.context ?? cachedCapacity }).catch(() => { }) return cachedCapacity } if (r?.context) cachedCapacity = r.context return cachedCapacity } catch { return cachedCapacity } } function snapshot() { const sessions = (() => { try { return ctx.get('sessions')?.list?.() || [] } catch { return [] } })() const activeId = pinnedSessionId || lastSessionId let session = sessions.find((s) => s?.id === activeId) || sessions[sessions.length - 1] || null const id = session?.id || activeId return { ok: true, at: Date.now(), sessionId: id || null, pinned: pinnedSessionId || null, title: (id && titles.get(id)) || 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 }, settings: floatCfg, // 会话列表:浮窗的切换器用它(多会话/多任务并行时挑一个跟随) sessions: sessions.slice(-20).map((s) => { const sid = s?.id const c = counts.get(sid) || {} return { id: sid, title: titles.get(sid) || s?.meta?.title || s?.title || null, turns: c.turns || 0, steps: c.steps || 0, messages: (rows.get(sid) || []).length, active: sid === id, } }), } } // ---------------------------------------------------------------- 路由 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 { } } } // ---------------------------------------------------------------- 文件桥 // 桌面壳(DSH Desktop)的 HTTP 网关会把未认证请求全部挡掉(实测 6 种请求形态都是 403), // 而浮窗是独立进程 + file:// 页面,拿不到壳的 auth cookie。所以除了 HTTP/SSE, // 再开一条纯文件通道:宿主每秒写 snapshot.json,浮窗每秒读;浮窗要发消息就写 outbox.json。 const snapshotFile = path.join(dshHome(), 'float-window', 'snapshot.json') const outboxFile = path.join(dshHome(), 'float-window', 'outbox.json') const writeSnapshotFile = () => { try { const snap = snapshot() // 镜像只留最近 40 条,避免文件太大 if (Array.isArray(snap.messages) && snap.messages.length > 40) snap.messages = snap.messages.slice(-40) fs.mkdirSync(path.dirname(snapshotFile), { recursive: true }) fs.writeFileSync(snapshotFile, JSON.stringify(snap)) } catch { } } const tick = setInterval(() => { push() writeSnapshotFile() if (++tickCount % 10 === 0) writeStatus() }, 1000) ctx.effect(() => () => clearInterval(tick)) // 浮窗 → 宿主:轮询 outbox.json let lastOutboxText = '' const outboxTimer = setInterval(async () => { try { if (!fs.existsSync(outboxFile)) return // 容错:外部写入可能带 UTF-8 BOM const text = fs.readFileSync(outboxFile, 'utf8').replace(/^\uFEFF/, '') if (!text || text === lastOutboxText) return lastOutboxText = text fs.rmSync(outboxFile, { force: true }) const msg = JSON.parse(text) log('outbox: ' + short(msg, 140)) if (msg.cmd === 'chat' && msg.text) { const target = msg.sessionId || lastSessionId if (!target) { log('outbox chat skipped: no session yet'); return } const { value, via } = await hostCall('prompt', { sessionId: target, mode: 'queue', content: [{ type: 'text', text: String(msg.text).slice(0, 20000) }], clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, }) log(`outbox chat -> ${target} via ${via}: ${short(value, 120)}`) } else if (msg.cmd === 'window' && msg.action) { await windowAction(msg.action) } else if (msg.cmd === 'watch') { // 浮窗切换跟随的会话:'' / null 表示回到"跟随最新" pinnedSessionId = msg.sessionId ? String(msg.sessionId) : null if (pinnedSessionId) lastSessionId = pinnedSessionId log('watch -> ' + (pinnedSessionId || 'latest')) } else if (msg.cmd === 'session' && msg.action === 'create') { const { value } = await hostCall('create', { sessionId: randomUUID(), ...(msg.cwd ? { cwd: String(msg.cwd) } : {}), ...(msg.agentPreset ? { agentPreset: String(msg.agentPreset) } : {}), }) if (value?.sessionId) lastSessionId = value.sessionId } writeSnapshotFile() } catch (e) { log('outbox failed: ' + short(e?.data || e?.message || e, 180)) } }, 700) ctx.effect(() => () => { clearInterval(outboxTimer) try { fs.rmSync(snapshotFile, { force: true }) } catch { } try { fs.rmSync(outboxFile, { force: true }) } catch { } }) 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 { if (!target) throw new Error('no session yet') const { value, via } = await hostCall('prompt', { sessionId: target, mode: 'queue', content: [{ type: 'text', text }], clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, }) log(`chat -> session ${target} via ${via}: ${short(value, 160)}`) res.writeHead(200, { ...cors, 'Content-Type': 'application/json; charset=utf-8' }) res.end(JSON.stringify({ ok: true, sessionId: target, result: value, via })) } 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 { if (body.action === 'create') { const { value, via } = await hostCall('create', { sessionId: body.sessionId ? String(body.sessionId) : randomUUID(), ...(body.cwd ? { cwd: String(body.cwd) } : {}), ...(body.workspaceId ? { workspaceId: body.workspaceId } : {}), ...(body.agentPreset ? { agentPreset: String(body.agentPreset) } : {}), }) const sessionId = value?.sessionId || null if (sessionId) lastSessionId = sessionId log(`session created via ${via}: ` + short(value, 160)) json({ ok: true, sessionId, via, result: value }) } else if (body.action === 'cancel') { const { value, via } = await hostCall('cancel', { sessionId: body.sessionId || lastSessionId }) json({ ok: true, via, result: value }) } else { json({ ok: true, sessions: snapshot().sessions, current: lastSessionId }) } } catch (e) { log('session route failed: ' + short(e?.data || e?.message || e, 200)) if (e?.stack) log('stack: ' + String(e.stack).split('\n').slice(0, 4).join(' | ')) json({ ok: false, error: short(e?.data || e?.message || e, 300) }) } return } // 浮窗动作(Agent 工具走同一段逻辑):open/close/pin/sizeLock/topmost/fullscreen/hide/show/status if (url.pathname === `${ROUTE}/window` && 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 r = await windowAction(body.action) res.writeHead(200, { ...cors, 'Content-Type': 'application/json; charset=utf-8' }) res.end(JSON.stringify({ ...r, alive: shellAlive() })) 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') } /** 浮窗动作的唯一入口:Agent 工具和 /window 路由都走这里 */ async function windowAction(action) { const a = String(action || 'open') if (a === 'status') { const s = snapshot() return { ok: true, text: `alive=${shellAlive()} session=${s.sessionId || 'none'} messages=${s.messages.length} turns=${s.metrics.turns ?? '?'} steps=${s.metrics.steps ?? '?'}` } } if (a === 'hide') broadcast({ type: 'cmd', cmd: 'hide' }) const r = await controlFloat(a) return { ok: !!r.ok, text: r.ok ? `${a} ok (${r.via})` : `${a} failed: ${r.error}`, via: r.via, error: r.error } } /** 壳每 700ms 写一次心跳,据此判断浮窗是否还活着 */ function aliveMtime() { try { return fs.statSync(aliveFile).mtimeMs } catch { return 0 } } function shellAlive() { const t = aliveMtime() return !!t && Date.now() - t < 8000 } async function spawnShell() { // 直接起 powershell(隐藏窗口)。**不要**走 `cmd /c start`:那会弹出一个黑色控制台窗口 // (用户已经看到过:标题 C:\WINDOWS\system32\cmd.exe,且批处理里的中文被 GBK 解析成乱码报错)。 const ps1 = path.join(REPO, 'shell', 'float-window.ps1') if (!fs.existsSync(ps1)) return { ok: false, error: `shell script not found: ${ps1}` } try { const { spawn } = await import('node:child_process') spawn('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-WindowStyle', 'Hidden', '-File', ps1], { detached: true, stdio: 'ignore', windowsHide: true }).unref() return { ok: true, via: 'spawn powershell (hidden)' } } catch (e) { return { ok: false, error: String(e?.message || e) } } } /** 浮窗已经在跑就投递命令,否则按需拉起;投递后要等心跳确认,不能"发给死人" */ async function controlFloat(action) { if (action === 'close' && !shellAlive()) return { ok: true, via: 'already closed' } if (shellAlive()) { const before = aliveMtime() const posted = await writeCommand(action) if (posted.ok) { // 等心跳更新(浮窗每 700ms 写一次 alive.json);没更新说明它其实已经死了 → 重新拉起 for (let i = 0; i < 8; i++) { await new Promise((r) => setTimeout(r, 200)) if (aliveMtime() > before) return { ok: true, via: 'command file' } } log(`float did not acknowledge "${action}" within 1.6s; respawning`) } } if (action === 'open' || action === 'fullscreen' || action === 'show') return spawnShell() // close/hide 之类:没进程就算完成 return { ok: true, via: 'no shell running' } } async function writeCommand(action) { try { fs.mkdirSync(stateDir, { recursive: true }) fs.writeFileSync(cmdFile, JSON.stringify({ cmd: action, at: new Date().toISOString() })) return { ok: true } } catch (e) { return { ok: false, error: String(e?.message || e) } } } // ---------------------------------------------------------------- Agent 工具 // 踩坑(真机事故 ×2): // a) 0.1.7 的 tools 服务要求模型侧看到的是 **JSON Schema**,参数必须经 `defineTool()` // (内部 parameterSchemaSpecToJsonSchema)转换。直接塞 DSL 对象会让 provider 收到 // `parameters.type = null`,于是**每一轮请求都被拒**: // Invalid schema for function 'float_window': schema must be a JSON Schema of 'type: "object"' // 所以:解析不到 defineTool 就**宁可不注册**;注册前自检 type === 'object'。 // b) 注册必须发生在 apply() 的**同步**阶段。放进 async 续体会拿到 // "cannot create effect on inactive context"(cordis 的 ctx 在 apply 返回后失效)。 status.shell = shellPath() writeStatus() try { const tools = ctx.get('tools') const defineTool = loadHostModule('@deepseek-ai/dsh-tools')?.defineTool if (floatCfg.tool === false) { log('tool disabled by config (tool: false)') } else if (!tools?.register) { log('tools service not available; agent trigger disabled') } else if (typeof defineTool !== 'function') { log('defineTool unresolvable; tool NOT registered (avoid emitting an invalid schema)') } else { const tool = defineTool({ 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 r = await windowAction(args?.action) return `float-window: ${r.text}` }, }) const schema = tool?.parameters if (!schema || schema.type !== 'object' || !schema.properties) { status.toolSchema = schema ?? null log('tool schema self-check FAILED; not registering: ' + short(schema, 200)) } else { tools.register(tool) status.tool = true status.toolSchema = schema log('tool float_window registered; schema: ' + short(schema, 240)) } } } 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' }) // ---------------------------------------------------------------- 页面按钮(index.html 注入) // 宿主侧往 index.html 里塞一个悬浮按钮:不依赖客户端插件 roster,web/桌面壳都生效。 // 客户端半(lib/client.js)如果注册成功,会把按钮移除,避免出现两个入口。 if (floatCfg.button !== false) { try { const pill = ` ` ctx.effect(() => ctx.webServer.tapIndex((html) => (typeof html === 'string' && html.includes('') ? html.replace('', pill + '') : html))) log('index pill injected') } catch (e) { log('index pill injection skipped:', String(e?.message || e)) } } // 抓屏前隐藏的对外门面:交给宿主的共享对象。 // 注意 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 设置入口(0.1.0 的 settings 服务路径) // 0.1.7 走的是"插件 Config schema"(见文件顶部导出的 Config),这条是旧版兼容路径。 // 同样必须**同步**注册:async 续体里的 ctx 已经失效(inactive context)。 log('services: ' + ['settings', 'sessions', 'tools', 'tokenMeter', 'sessionProjection', 'llm', 'shell', 'sandboxPolicy', 'webServer'] .map((n) => `${n}=${(() => { try { return ctx.get(n) ? 'y' : 'n' } catch { return '?' } })()}`).join(' ')) try { const settings = ctx.get('settings') if (!settings?.register) { log('settings service absent; skip settings entry') } else if (!floatSchema) { log('schemastery not resolvable; skip settings entry') } else { const scope = settings.register('dsh-float-window', floatSchema) ctx.effect(() => { const stop = scope?.watch?.((next) => { try { broadcast({ type: 'settings', value: next?.value ?? next }) } catch { } }) return () => { try { stop?.() } catch { } } }) status.settings = true log('settings namespace registered') } } catch (e) { log('settings registration failed:', String(e?.message || e)) } writeStatus() log('ready; endpoints:', `${ROUTE}/events`, `${ROUTE}/state`, `${ROUTE}/health`) }