Files
dsh-float-window/lib/host.js
T
jiangchen e00d73be82 fix(host): 根因——spawn 的 detached:true 让 PowerShell 起来即退;feat: 三种跟随方式(默认跟随前台)
根因(对照实验):spawn('powershell', [...], {detached:true}) 在 Windows 上 → 子进程 exit 0、
脚本一行没跑(日志里连 start 都没有),表现就是『点打开没反应』;detached:false 完全正常。
现在 spawnShell:绝对路径优先 + 不 detached + 等心跳确认,失败把原因写进 status notes。

跟随方式(用户需求:切到哪个会话就跟哪个,且可配置):
- front(默认)跟随前台:客户端半订阅 uiSession.current,会话一换就上报 /window{action:front}
- latest 跟随最新事件;pinned 固定在 ⇄ 里选中的会话
设置入口:设置→悬浮窗(模式三选一);浮窗 ⇄ 里点会话=切到 pinned
快照新增 follow/front 字段;/window 支持 action=follow|front
2026-09-27 17:39:15 +08:00

893 lines
43 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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, autoOpen: 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 // 「固定」模式:用户在浮窗里选中的会话,不再被任何事件抢走
let frontSessionId = null // 「跟随前台」模式:客户端半上报的当前显示会话(uiSession.current)
// 跟随方式:front=跟随前台当前对话(默认);latest=跟随最新事件;pinned=固定某个会话
let followMode = ['front', 'latest', 'pinned'].includes(floatCfg.follow) ? floatCfg.follow : 'front'
const activeSessionId = () => (followMode === 'pinned' ? pinnedSessionId : (followMode === 'front' ? (frontSessionId || pinnedSessionId || lastSessionId) : lastSessionId))
function counter(id) {
let c = counts.get(id)
if (!c) { c = { turns: 0, steps: 0, outTokens: 0, inTokens: 0, cacheRead: 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 || followMode === 'latest') 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/chunk') {
// 真实用量在这里(实测):data.chunk = {type:'usage', usage:{inputTokens,outputTokens,cacheReadTokens,reasoningTokens}}
const ch = d2(event)?.chunk
if (ch?.type === 'usage' && ch.usage) {
const u = ch.usage
c.inTokens += u.inputTokens || 0
c.cacheRead += u.cacheReadTokens || 0
c.outTokens += u.outputTokens || 0
c.lastPrompt = (u.inputTokens || 0) + (u.cacheReadTokens || 0) // 当前上下文占用(最后一步输入)
const ms = c.stepStart ? Date.now() - c.stepStart : 0
if (u.outputTokens && ms > 0) { c.decodeMs += ms; c.lastTps = u.outputTokens / (ms / 1000) }
}
}
else if (type === 'assistant/message') {
// 兜底:有些版本把 usage 挂在 message 上
const usage = d2(event)?.usage || d2(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) { 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, inTokens: 0, cacheRead: 0, decodeMs: 0, lastTps: null }
const promptTokens = (c.inTokens || 0) + (c.cacheRead || 0)
const out = {
turns: c.turns || null,
steps: c.steps || null,
tps: c.lastTps,
// 总量 = 输出 + (未缓存输入 + 命中缓存输入)
tokens: (c.outTokens || 0) + promptTokens || null,
// 命中率 = 命中缓存输入 / 全部输入(与 App 状态栏口径一致)
cacheHit: promptTokens > 0 ? (c.cacheRead || 0) / promptTokens : 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(',')) }
// 事件流里已经拿到真实用量时不要被 tokenMeter 的估算覆盖
if (!out.tokens) 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 上下文
// 上下文占比:优先用"最后一步输入"(事件流里拿得到,就是当前 surface),其次 tokenMeter
const used = c.lastPrompt || 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)) } }
// tokenMeter 拿不到时,用事件流里的"最后一步输入"兜底算上下文占比
if (out.contextUsed == null && c.lastPrompt) {
const cap = contextWindows.get(id) || modelContext() || 1000000
if (cap) out.contextUsed = Math.min(1, c.lastPrompt / cap)
}
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 = activeSessionId()
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,
front: frontSessionId || null,
follow: followMode,
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 表示回到"跟随最新"
if (msg.mode && ['front', 'latest', 'pinned'].includes(msg.mode)) followMode = msg.mode
pinnedSessionId = msg.sessionId ? String(msg.sessionId) : null
if (pinnedSessionId) { followMode = 'pinned'; lastSessionId = pinnedSessionId }
else if (!msg.mode) followMode = 'latest'
log(`watch -> mode=${followMode} session=${pinnedSessionId || 'auto'}`)
} else if (msg.cmd === 'follow') {
if (['front', 'latest', 'pinned'].includes(msg.mode)) {
followMode = msg.mode
if (msg.mode !== 'pinned') pinnedSessionId = null
}
log(`outbox follow -> ${followMode}`)
} 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 { }
// 客户端半的两条上报:前台会话 / 跟随方式
if (body.action === 'front') {
const sid = body.sessionId ? String(body.sessionId) : null
if (sid !== frontSessionId) log(`front -> ${sid || 'none'}`)
frontSessionId = sid
res.writeHead(200, { ...cors, 'Content-Type': 'application/json; charset=utf-8' })
res.end(JSON.stringify({ ok: true, follow: followMode, front: frontSessionId, alive: shellAlive() }))
return
}
if (body.action === 'follow') {
if (['front', 'latest', 'pinned'].includes(body.mode)) followMode = body.mode
log(`follow mode -> ${followMode}`)
res.writeHead(200, { ...cors, 'Content-Type': 'application/json; charset=utf-8' })
res.end(JSON.stringify({ ok: true, follow: followMode, alive: shellAlive() }))
return
}
const r = await windowAction(body.action)
res.writeHead(200, { ...cors, 'Content-Type': 'application/json; charset=utf-8' })
res.end(JSON.stringify({ ...r, alive: shellAlive(), follow: followMode }))
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)) {
const msg = `shell script not found: ${ps1}`
log('spawnShell: ' + msg)
status.notes.push(`${new Date().toISOString()} spawnShell FAILED ${msg}`)
return { ok: false, error: msg }
}
// 绝对路径优先:DSH 应用进程的 PATH 未必和终端一致,裸名字可能找不到 powershell
const sysRoot = process.env.SystemRoot || 'C:\\Windows'
const candidates = [
path.join(sysRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'),
'powershell.exe',
'pwsh.exe',
]
const { spawn } = await import('node:child_process')
const before = aliveMtime()
let lastErr = null
for (const exe of candidates) {
try {
// 注意:**不要**加 detached:true —— 实测在 Windows 上 detached(CREATE_NO_WINDOW) 会让
// PowerShell 宿主起来立刻退出(exit code 0、脚本一行都没跑),表现就是"点了打开没反应"。
const child = spawn(exe, ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-WindowStyle', 'Hidden', '-File', ps1],
{ stdio: 'ignore', windowsHide: true })
child.on('error', (e) => { lastErr = String(e?.message || e) })
child.unref()
// 等心跳确认:真的起来了才算成功(否则就是"点了没反应")
for (let i = 0; i < 15; i++) {
await new Promise((r) => setTimeout(r, 400))
if (aliveMtime() > before) {
log(`spawnShell ok via ${exe}`)
status.notes.push(`${new Date().toISOString()} spawnShell ok via ${exe}`)
writeStatus()
return { ok: true, via: exe }
}
if (lastErr) break
}
log(`spawnShell: ${exe} produced no heartbeat${lastErr ? ' (' + lastErr + ')' : ''}`)
} catch (e) { lastErr = String(e?.message || e) }
}
const msg = `could not start float window (script=${ps1}, lastError=${lastErr || 'no heartbeat'})`
log('spawnShell FAILED: ' + msg)
status.notes.push(`${new Date().toISOString()} spawnShell FAILED ${msg}`)
writeStatus()
return { ok: false, error: msg }
}
/** 浮窗已经在跑就投递命令,否则按需拉起;投递后要等心跳确认,不能"发给死人" */
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 = `<style>
#dsh-float-pill{position:fixed;left:14px;bottom:58px;z-index:2147000000;display:flex;align-items:center;gap:7px;
padding:7px 12px;border-radius:999px;font:13px/1.2 "Segoe UI","Microsoft YaHei UI",system-ui,sans-serif;cursor:pointer;
border:1px solid rgba(120,140,180,.35);background:rgba(22,26,34,.92);color:#e6eaf2;box-shadow:0 6px 20px rgba(0,0,0,.45);backdrop-filter:blur(6px)}
#dsh-float-pill:hover{border-color:#4d7cfe}
#dsh-float-pill i{width:7px;height:7px;border-radius:50%;background:#f0b429;display:inline-block}
#dsh-float-pill.on i{background:#3ecf8e;box-shadow:0 0 6px #3ecf8e}
#dsh-float-pill.hide{display:none}
</style>
<button id="dsh-float-pill" title="DSH 悬浮窗:始终置顶的会话镜像小窗(抓屏时自动隐身)"><i></i>悬浮窗</button>
<script>(function(){
var R='/dsh-float-window';
function call(a){ return fetch(R+'/window',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({action:a})}).then(function(r){return r.json()}).catch(function(){return null}) }
function mount(){
if(window.__DSH_FLOAT_UI__) return; // 客户端半接管了就不重复
var b=document.getElementById('dsh-float-pill');
if(!b){ // SPA 重建 body 时自动补回
if(!document.body) return;
var wrap=document.createElement('div'); wrap.innerHTML=${JSON.stringify('<button id="dsh-float-pill" title="DSH 悬浮窗:始终置顶的会话镜像小窗(抓屏时自动隐身)"><i></i>悬浮窗</button>')};
b=wrap.firstChild; document.body.appendChild(b);
}
if(b.__bound) return; b.__bound=true;
b.addEventListener('click', function(){ b.classList.add('on'); call('open'); });
function ping(){ call('status').then(function(j){ if(!j){b.classList.add('hide');return} b.classList.remove('hide'); if(j.alive) b.classList.add('on'); }) }
ping(); setInterval(ping, 5000);
}
mount(); setInterval(mount, 3000);
})();</script>`
ctx.effect(() => ctx.webServer.tapIndex((html) => (typeof html === 'string' && html.includes('</body>')
? html.replace('</body>', pill + '</body>')
: 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()
// ---------------------------------------------------------------- DSH 启动时自动拉起浮窗
// 用户选的就是这条:DSH 每次重启都会把浮窗(进程树后代)带走,所以启动后自动补一个。
// 用 setTimeout 延后,让宿主先起完;回调里不碰 ctx(那时候它可能已经失效)。
if (floatCfg.autoOpen !== false) {
setTimeout(() => {
try {
if (shellAlive()) { log('autoOpen: float already running'); return }
log('autoOpen: spawning float window')
spawnShell().then((r) => log('autoOpen -> ' + short(r, 120))).catch((e) => log('autoOpen failed: ' + String(e?.message || e)))
} catch (e) { log('autoOpen error: ' + String(e?.message || e)) }
}, 1500)
}
log('ready; endpoints:', `${ROUTE}/events`, `${ROUTE}/state`, `${ROUTE}/health`)
}