Files
dsh-float-window/lib/host.js
T

592 lines
26 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 { 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')
}
/** 从 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) {
// 运行状态与自检日志落盘:插件跑在 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 事件)
let lastSessionId = 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'
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 === '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 = 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 [] } })()
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: (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 },
sessions: sessions.slice(-10).map((s) => ({ id: s?.id, title: s?.meta?.title || s?.title || null })),
}
}
// ---------------------------------------------------------------- 路由
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 { } }
}
const tick = setInterval(() => { push(); if (++tickCount % 10 === 0) writeStatus() }, 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(), 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 工具
status.shell = shellPath()
writeStatus()
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 r = await windowAction(args?.action)
return `float-window: ${r.text}`
},
})
status.tool = true
log('tool float_window registered')
} else {
log('tools service not available; agent trigger disabled')
}
} 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' })
// 抓屏前隐藏的对外门面:交给宿主的共享对象。
// 注意 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 设置入口
// 注册一个设置命名空间,DSH 的「设置 → 插件」会把它渲染成一张卡片。
// schemastery 是从 profile 的 node_modules 解析的;link: 安装的插件解析不到时
// 只跳过这一步(浮窗自己的设置面板照常可用)。
log('services: ' + ['settings', 'sessions', 'tools', 'tokenMeter', 'sessionProjection', 'llm', 'shell', 'sandboxPolicy', 'webServer']
.map((n) => `${n}=${(() => { try { return ctx.get(n) ? 'y' : 'n' } catch { return '?' } })()}`).join(' '))
; (async () => {
try {
const settings = ctx.get('settings')
if (!settings?.register) { log('settings service absent; skip settings entry'); return }
let z = null
// link: 安装时插件自己的目录里没有 node_modules,裸导入会失败;
// 依次尝试:常规解析 -> 以宿主入口为基准解析 -> profile 目录解析
try { z = (await import('@deepseek-ai/schemastery')).default } catch { }
if (!z) {
try {
const { createRequire } = await import('node:module')
const req = createRequire(process.argv[1] || path.join(dshHome(), 'index.js'))
const m = req('@deepseek-ai/schemastery')
z = m?.default || m
} catch { }
}
if (!z) {
try {
const { createRequire } = await import('node:module')
for (const prof of ['desktop', 'web']) {
const req = createRequire(path.join(dshHome(), 'profiles', prof, 'package.json'))
const m = req('@deepseek-ai/schemastery')
z = m?.default || m
if (z) break
}
} 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')
status.settings = true
writeStatus()
} catch (e) { log('settings registration failed:', String(e?.message || e)) }
})()
log('ready; endpoints:', `${ROUTE}/events`, `${ROUTE}/state`, `${ROUTE}/health`)
}