- tools/mirror-bridge.mjs:直接读 %DSH_HOME%/sessions/**/session.jsonl.zstd(多帧 zstd 按魔数切帧解) 与 storages/session_projcache.json,算出 消息行/轮/步/tok·s⁻¹/token/上下文占比 并写 snapshot-bridge.json(插件缺字段时由浮窗补齐);插件在写 snapshot.json 时它不抢 - 浮窗标题栏不再显示 endpoint IP,只在未连接时显示状态词;数据来自桥时指标带 ≈ - host: contextUsed 兜底按 1M 上下文算(真机 sessionProjection=n,拿不到上下文窗口) - 实测:snapshot -> page rows=40 turns=1 steps=67 tok=214639;outbox watch 切会话生效
770 lines
36 KiB
JavaScript
770 lines
36 KiB
JavaScript
/**
|
||
* dsh-float-window — Host 半(标准 DSH / cordis 插件)
|
||
*
|
||
* 职责:
|
||
* 1. 把原生浮窗需要的接入信息写成 `%DSH_HOME%\float-window\endpoint.json`
|
||
* (WebView2 壳读它来连这里;因为壳是独立进程,拿不到 ctx)。
|
||
* 2. 提供 SSE 数据通道 `/dsh-float-window/events`:会话消息 + 计费指标快照。
|
||
* 3. 提供 `/dsh-float-window/cmd`:浮窗 → 宿主的控制命令。
|
||
* 4. 注册 `float_window` 工具,让 Agent 能自己把浮窗调出来 / 收起 / 置顶 / 全屏。
|
||
*
|
||
* 设计原则:宿主内部 API 一律"探测式"使用(ctx.get(...) + try/catch),
|
||
* 任何一个服务缺失都只是少几个指标,绝不能让插件加载失败拖垮 DSH。
|
||
*/
|
||
import fs from 'node:fs'
|
||
import os from 'node:os'
|
||
import path from 'node:path'
|
||
import { 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 shellAlive() {
|
||
try { return Date.now() - fs.statSync(aliveFile).mtimeMs < 8000 } catch { return false }
|
||
}
|
||
|
||
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 === '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`)
|
||
}
|