- ctx.settings.register('dsh-float-window', schema) best-effort:schemastery 解析不到时只跳过
- 设置变更通过 SSE 广播给浮窗
- 说明:link: 安装的插件解析不到 profile 依赖时降级,浮窗自带设置面板仍然可用
301 lines
13 KiB
JavaScript
301 lines
13 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 { fileURLToPath } from 'node:url'
|
|
|
|
export const name = 'dsh-float-window'
|
|
export const inject = ['webServer']
|
|
|
|
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
|
const REPO = path.resolve(HERE, '..')
|
|
const ROUTE = '/dsh-float-window'
|
|
|
|
function dshHome() {
|
|
return process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
|
|
}
|
|
|
|
/** 会话事件 → 浮窗消息行的宽松映射(不同版本字段不完全一致,宁可少显示也不要报错) */
|
|
function toRow(event) {
|
|
if (!event || typeof event !== 'object') return null
|
|
const type = String(event.type || '')
|
|
const text = (v) => (typeof v === 'string' ? v : '')
|
|
if (type === 'tool/call') {
|
|
return { role: 'tool', text: `${event.name || event.tool || 'tool'}(${short(event.args ?? event.input)})` }
|
|
}
|
|
if (type === 'tool/result') return null
|
|
const msg = event.message
|
|
const role = msg?.role || event.role || (type.includes('user') ? 'user' : type.includes('assistant') ? 'assistant' : '')
|
|
if (!role) return null
|
|
let body = ''
|
|
const content = msg?.content ?? event.content ?? event.text
|
|
if (typeof content === 'string') body = content
|
|
else if (Array.isArray(content)) body = content.map((c) => (typeof c === 'string' ? c : text(c?.text))).join('')
|
|
if (!body) return null
|
|
return { role, text: body.length > 4000 ? body.slice(0, 4000) + '…' : body, streaming: /delta|chunk|stream/.test(type) }
|
|
}
|
|
|
|
function short(v, n = 120) {
|
|
try {
|
|
const s = typeof v === 'string' ? v : JSON.stringify(v)
|
|
return s && s.length > n ? s.slice(0, n) + '…' : s || ''
|
|
} catch { return '' }
|
|
}
|
|
|
|
export function apply(ctx) {
|
|
const log = (...a) => { try { (ctx.logger?.info || console.log)('[float-window]', ...a) } catch { } }
|
|
|
|
// ---------------------------------------------------------------- endpoint
|
|
const endpointFile = path.join(dshHome(), 'float-window', 'endpoint.json')
|
|
const writeEndpoint = () => {
|
|
try {
|
|
fs.mkdirSync(path.dirname(endpointFile), { recursive: true })
|
|
const data = {
|
|
baseUrl: `http://127.0.0.1:${ctx.webServer.port}`,
|
|
host: ctx.webServer.host,
|
|
pid: process.pid,
|
|
updatedAt: new Date().toISOString(),
|
|
}
|
|
fs.writeFileSync(endpointFile, JSON.stringify(data, null, 2))
|
|
log('endpoint written', data.baseUrl, '->', endpointFile)
|
|
} catch (e) { log('endpoint write failed:', String(e?.message || e)) }
|
|
}
|
|
writeEndpoint()
|
|
ctx.effect(() => () => { try { fs.rmSync(endpointFile, { force: true }) } catch { } })
|
|
|
|
// ---------------------------------------------------------------- 会话镜像缓冲
|
|
const MAX_ROWS = 120
|
|
const rows = new Map() // sessionId -> row[]
|
|
let lastSessionId = null
|
|
|
|
ctx.on('session/event', (session, event) => {
|
|
try {
|
|
const id = session?.id || session?.sessionId || 'unknown'
|
|
lastSessionId = id
|
|
const row = toRow(event)
|
|
if (!row) return
|
|
const list = rows.get(id) || []
|
|
const last = list[list.length - 1]
|
|
if (row.streaming && last && last.role === row.role && last.streaming) last.text += row.text
|
|
else list.push(row)
|
|
while (list.length > MAX_ROWS) list.shift()
|
|
rows.set(id, list)
|
|
} catch { }
|
|
})
|
|
|
|
// ---------------------------------------------------------------- 指标
|
|
function metricsFor(session) {
|
|
const out = { turns: null, steps: null, tps: null, tokens: null, cacheHit: null, contextUsed: null }
|
|
try {
|
|
const proj = ctx.get('sessionProjection')
|
|
const stats = proj?.get?.(session, 'sessionStats') || proj?.peek?.(session, 'sessionStats')
|
|
if (stats) {
|
|
out.turns = stats.turns ?? null
|
|
out.steps = stats.steps ?? null
|
|
if (stats.decodeTokens && stats.decodeMs) out.tps = (stats.decodeTokens / (stats.decodeMs / 1000))
|
|
}
|
|
} catch { }
|
|
try {
|
|
const meter = ctx.get('tokenMeter')
|
|
const m = meter?.measure?.(session)
|
|
if (m) {
|
|
out.tokens = m.totalTokens ?? m.surfaceTokens ?? null
|
|
const u = m.usage || m.providerUsage
|
|
if (u) {
|
|
const read = u.cacheRead ?? u.cache_read ?? 0
|
|
const input = (u.input ?? 0) + read
|
|
if (input > 0) out.cacheHit = read / input
|
|
}
|
|
const capacity = ctx.get('llm')?.resolveModelInfo?.()?.context
|
|
const used = m.surfaceTokens ?? m.totalTokens
|
|
if (capacity && used) out.contextUsed = Math.min(1, used / capacity)
|
|
}
|
|
} catch { }
|
|
return out
|
|
}
|
|
|
|
function snapshot() {
|
|
const sessions = (() => { try { return ctx.get('sessions')?.list?.() || [] } catch { return [] } })()
|
|
let session = sessions.find((s) => s?.id === lastSessionId) || sessions[sessions.length - 1] || null
|
|
const id = session?.id || lastSessionId
|
|
return {
|
|
ok: true,
|
|
at: Date.now(),
|
|
sessionId: id || null,
|
|
title: session?.meta?.title || session?.title || null,
|
|
messages: (id && rows.get(id)) || [],
|
|
metrics: session ? metricsFor(session) : { turns: null, steps: null, tps: null, tokens: null, cacheHit: null, contextUsed: null },
|
|
sessions: sessions.slice(-10).map((s) => ({ id: s?.id, title: s?.meta?.title || s?.title || null })),
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- 路由
|
|
const sseClients = new Set()
|
|
const push = () => {
|
|
if (!sseClients.size) return
|
|
const payload = `data: ${JSON.stringify(snapshot())}\n\n`
|
|
for (const res of sseClients) { try { res.write(payload) } catch { } }
|
|
}
|
|
const tick = setInterval(push, 1000)
|
|
ctx.effect(() => () => clearInterval(tick))
|
|
|
|
const handler = async (req, res) => {
|
|
const url = new URL(req.url || '/', 'http://127.0.0.1')
|
|
const cors = {
|
|
'Access-Control-Allow-Origin': '*',
|
|
'Access-Control-Allow-Headers': 'content-type',
|
|
'Access-Control-Allow-Methods': 'GET,POST,OPTIONS',
|
|
}
|
|
if (req.method === 'OPTIONS') { res.writeHead(204, cors); res.end(); return }
|
|
if (url.pathname === `${ROUTE}/events`) {
|
|
res.writeHead(200, { ...cors, 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache', Connection: 'keep-alive' })
|
|
res.write(': connected\n\n')
|
|
res.write(`data: ${JSON.stringify(snapshot())}\n\n`)
|
|
sseClients.add(res)
|
|
req.on('close', () => sseClients.delete(res))
|
|
return
|
|
}
|
|
if (url.pathname === `${ROUTE}/state`) {
|
|
res.writeHead(200, { ...cors, 'Content-Type': 'application/json; charset=utf-8' })
|
|
res.end(JSON.stringify(snapshot()))
|
|
return
|
|
}
|
|
if (url.pathname === `${ROUTE}/health`) {
|
|
res.writeHead(200, { ...cors, 'Content-Type': 'application/json; charset=utf-8' })
|
|
res.end(JSON.stringify({ ok: true, version: '0.1.0', shell: shellPath() }))
|
|
return
|
|
}
|
|
res.writeHead(404, { ...cors, 'Content-Type': 'text/plain; charset=utf-8' })
|
|
res.end('dsh-float-window: not found')
|
|
}
|
|
ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: ROUTE, handler }))
|
|
|
|
// ---------------------------------------------------------------- 壳进程
|
|
const stateDir = path.join(dshHome(), 'float-window')
|
|
const cmdFile = path.join(stateDir, 'command.json')
|
|
const aliveFile = path.join(stateDir, 'alive.json')
|
|
|
|
function shellPath() {
|
|
return path.join(REPO, 'shell', 'run-float.cmd')
|
|
}
|
|
|
|
/** 壳每 700ms 写一次心跳,据此判断浮窗是否还活着 */
|
|
function shellAlive() {
|
|
try { return Date.now() - fs.statSync(aliveFile).mtimeMs < 8000 } catch { return false }
|
|
}
|
|
|
|
async function spawnShell() {
|
|
const cmd = shellPath()
|
|
if (!fs.existsSync(cmd)) return { ok: false, error: `launcher not found: ${cmd}` }
|
|
try {
|
|
const shell = ctx.get('shell')
|
|
const sp = ctx.get('sandboxPolicy')
|
|
const policy = sp?.resolve?.({ mode: 'danger-full-access' })
|
|
if (shell?.resolve && shell?.run) {
|
|
const spec = shell.resolve({ command: `cmd /c start "" "${cmd}"`, ...(policy ? { sandboxPolicy: policy } : {}) })
|
|
await shell.run(spec)
|
|
return { ok: true, via: 'ctx.shell' }
|
|
}
|
|
} catch (e) { log('ctx.shell launch failed, fallback to spawn:', String(e?.message || e)) }
|
|
try {
|
|
const { spawn } = await import('node:child_process')
|
|
spawn('cmd', ['/c', 'start', '', cmd], { detached: true, stdio: 'ignore', windowsHide: true }).unref()
|
|
return { ok: true, via: 'spawn' }
|
|
} catch (e) { return { ok: false, error: String(e?.message || e) } }
|
|
}
|
|
|
|
/** 浮窗已经在跑就投递命令,否则按需拉起 */
|
|
async function controlFloat(action) {
|
|
if (action === 'open' && !shellAlive()) return spawnShell()
|
|
if (action === 'close' && !shellAlive()) return { ok: true, via: 'already closed' }
|
|
try {
|
|
fs.mkdirSync(stateDir, { recursive: true })
|
|
fs.writeFileSync(cmdFile, JSON.stringify({ cmd: action, at: new Date().toISOString() }))
|
|
return { ok: true, via: 'command file' }
|
|
} catch (e) { return { ok: false, error: String(e?.message || e) } }
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Agent 工具
|
|
try {
|
|
const tools = ctx.get('tools')
|
|
if (tools?.register) {
|
|
tools.register({
|
|
name: 'float_window',
|
|
description: '控制 DSH 悬浮窗(始终置顶的会话镜像小窗):open 打开/聚焦,close 关闭,pin 切换固定位置,fullscreen 切换全屏,hide 临时隐藏(抓屏前用)。',
|
|
parameters: {
|
|
action: { type: 'string', required: true, description: 'open | close | pin | fullscreen | hide | status' },
|
|
},
|
|
output: { schema: { type: 'string' }, render: (_a, v) => [{ type: 'text', text: v }] },
|
|
async execute(args) {
|
|
const action = String(args?.action || 'open')
|
|
if (action === 'status') {
|
|
const s = snapshot()
|
|
return `float-window: alive=${shellAlive()} session=${s.sessionId || 'none'} messages=${s.messages.length} turns=${s.metrics.turns ?? '?'} steps=${s.metrics.steps ?? '?'}`
|
|
}
|
|
if (action === 'hide') { broadcast({ type: 'cmd', cmd: 'hide' }); const r = await controlFloat('hide'); return `float-window: hide (${r.via || r.error})` }
|
|
const r = await controlFloat(action)
|
|
return r.ok ? `float-window: ${action} ok (${r.via})` : `float-window: ${action} failed: ${r.error}`
|
|
},
|
|
})
|
|
log('tool float_window registered')
|
|
} else {
|
|
log('tools service not available; agent trigger disabled')
|
|
}
|
|
} catch (e) { log('tool registration failed:', String(e?.message || e)) }
|
|
|
|
const broadcast = (msg) => {
|
|
const payload = `data: ${JSON.stringify(msg)}\n\n`
|
|
for (const res of sseClients) { try { res.write(payload) } catch { } }
|
|
}
|
|
const pushHidden = () => broadcast({ type: 'cmd', cmd: 'hide' })
|
|
|
|
// 抓屏前隐藏:给其它插件/工具用的门面(没有消费者时静默)
|
|
ctx.set?.('floatWindow', { hide: pushHidden, show: () => broadcast({ type: 'cmd', cmd: 'show' }), snapshot, endpoint: () => endpointFile })
|
|
|
|
// ---------------------------------------------------------------- DSH 设置入口
|
|
// 注册一个设置命名空间,DSH 的「设置 → 插件」会把它渲染成一张卡片。
|
|
// schemastery 是从 profile 的 node_modules 解析的;link: 安装的插件解析不到时
|
|
// 只跳过这一步(浮窗自己的设置面板照常可用)。
|
|
; (async () => {
|
|
try {
|
|
const settings = ctx.get('settings')
|
|
if (!settings?.register) { log('settings service absent; skip settings entry'); return }
|
|
let z = null
|
|
try { z = (await import('@deepseek-ai/schemastery')).default } catch { }
|
|
if (!z) { log('schemastery not resolvable; skip settings entry'); return }
|
|
const schema = z.object({
|
|
topmost: z.boolean().default(true).description('始终置顶'),
|
|
posLock: z.boolean().default(false).description('固定位置'),
|
|
sizeLock: z.boolean().default(false).description('锁定尺寸'),
|
|
exclude: z.boolean().default(true).description('抓屏时隐藏本窗'),
|
|
opacity: z.number().min(0.3).max(1).default(0.92).description('不透明度'),
|
|
scale: z.number().min(0.8).max(1.5).default(1).description('界面缩放'),
|
|
backdrop: z.union(['opaque', 'translucent', 'acrylic', 'mica', 'blur']).default('opaque').description('背景效果'),
|
|
})
|
|
const scope = settings.register('dsh-float-window', schema)
|
|
const scope2 = scope
|
|
ctx.effect(() => {
|
|
const stop = scope2.watch?.((next) => {
|
|
try { broadcast({ type: 'settings', value: next?.value ?? next }) } catch { }
|
|
})
|
|
return () => { try { stop?.() } catch { } }
|
|
})
|
|
log('settings namespace registered')
|
|
} catch (e) { log('settings registration failed:', String(e?.message || e)) }
|
|
})()
|
|
|
|
log('ready; endpoints:', `${ROUTE}/events`, `${ROUTE}/state`, `${ROUTE}/health`)
|
|
}
|