Files
dsh-float-window/lib/host.js
T
jiangchen 7d1417fa1c fix(shell): 去掉所有窗口级玻璃(白框/黑块根因);feat: 多会话切换 + 文件桥(桌面壳 HTTP 网关 403 的解法)
白框根因:SetWindowCompositionAttribute(Win10 亚克力) / DwmExtendFrameIntoClientArea(客户区变玻璃)
/ WebView2 Transparent 背景 三者在真机上都会让 8px 边环露白、WebView2 变黑。现在窗口永远由
应用自绘深色,背景效果只作为页面侧半透明(不再碰 DWM 与窗口 alpha)。

新增:
- 会话切换:标题栏 ⇄ 按钮 + 列表(会话标题/轮/步/消息数,可'跟随最新');宿主侧 pinnedSessionId,
  被选中的会话不再被其它会话的事件抢走;snapshot 里带 sessions[]
- 文件桥:桌面壳对未认证请求一律 403(实测 6 种请求形态),浮窗改走文件——宿主每秒写 snapshot.json,
  浮窗渲染;浮窗的 chat/watch/newSession 写 outbox.json,宿主轮询执行。端到端实测通过
- 编码修复:快照按显式 UTF-8 读(PS 5.1 Get-Content -Raw 会按 GBK 读坏中文 JSON);
  outbox 写 UTF-8 无 BOM
2026-09-27 17:02:07 +08:00

776 lines
36 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 },
(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()
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 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 工具
// 踩坑(真机事故 ×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()
log('ready; endpoints:', `${ROUTE}/events`, `${ROUTE}/state`, `${ROUTE}/health`)
}