diff --git a/lib/client.js b/lib/client.js new file mode 100644 index 0000000..6a6f706 --- /dev/null +++ b/lib/client.js @@ -0,0 +1,169 @@ +/** + * dsh-float-window — Client 半(浏览器侧) + * + * 用 0.1.7 的客户端插件格式(window.__ModuleLoader__.load)手写,不需要构建步骤。 + * 只做两件事: + * 1. 侧边栏底部(设置上方)一个「悬浮窗」按钮 —— 点一下把原生浮窗拉起来; + * 2. 设置里新增一个「悬浮窗」分区 —— 打开/关闭/隐藏 + 状态一览。 + * + * 与宿主半通信:同源相对路径 POST /dsh-float-window/window(宿主插件注册的 loopback 路由), + * 状态读 GET /dsh-float-window/state。宿主路由带 CORS 头,file:// 场景也能用。 + */ +window.__ModuleLoader__.load({ + id: 'dsh-float-window', + factory: (require) => { + const React = require('react') + const h = React.createElement + const ROUTE = '/dsh-float-window' + + const callHost = async (action) => { + const res = await fetch(`${ROUTE}/window`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ action }), + }) + return res.json() + } + const readState = async () => { + const res = await fetch(`${ROUTE}/state`) + return res.json() + } + + const card = { + border: '1px solid var(--dsw-alias-border-l2, #2a3040)', + background: 'var(--dsw-alias-bg-layer-3, rgba(255,255,255,.03))', + borderRadius: 12, + padding: '14px 16px', + display: 'flex', + flexDirection: 'column', + gap: 10, + } + const btn = (primary) => ({ + padding: '7px 14px', + borderRadius: 9, + cursor: 'pointer', + fontSize: 13, + border: primary ? '1px solid #4d7cfe' : '1px solid var(--dsw-alias-border-l2, #2a3040)', + background: primary ? '#4d7cfe' : 'transparent', + color: primary ? '#fff' : 'inherit', + }) + const muted = { opacity: 0.7, fontSize: 12, lineHeight: 1.7 } + + /** 宿主可达性 + 浮窗存活状态 */ + function useStatus() { + const [status, setStatus] = React.useState({ ok: false, alive: false, text: '', session: null }) + const [busy, setBusy] = React.useState(false) + const refresh = React.useCallback(async () => { + try { + const r = await callHost('status') + const s = await readState().catch(() => null) + setStatus({ ok: true, alive: !!r.alive, text: r.text || '', session: s }) + } catch (e) { + setStatus({ ok: false, alive: false, text: String(e && e.message || e), session: null }) + } + }, []) + React.useEffect(() => { + refresh() + const t = setInterval(refresh, 3000) + return () => clearInterval(t) + }, [refresh]) + const act = React.useCallback(async (action) => { + setBusy(true) + try { await callHost(action) } catch (e) { /* 宿主离线:状态轮询会显示 */ } + setTimeout(() => { setBusy(false); refresh() }, 800) + }, [refresh]) + return { status, busy, act, refresh } + } + + /** 侧边栏底部按钮(设置按钮上方那一排) */ + function SidebarButton(props) { + const { status, act } = useStatus() + const wide = props && props.wide + const label = status.alive ? '悬浮窗已开' : '悬浮窗' + return h('button', { + type: 'button', + title: 'DSH 悬浮窗:始终置顶的会话镜像小窗', + onClick: () => act(status.alive ? 'open' : 'open'), + style: { + display: 'flex', alignItems: 'center', gap: 8, width: wide ? '100%' : undefined, + padding: '6px 10px', borderRadius: 9, cursor: 'pointer', fontSize: 13, + border: '1px solid transparent', background: 'transparent', color: 'inherit', opacity: status.ok ? 1 : 0.5, + }, + }, [ + h('span', { key: 'i', style: { fontSize: 15, lineHeight: 1 }, }, '🪟'), + wide ? h('span', { key: 't' }, label) : null, + status.alive ? h('span', { key: 'd', style: { width: 7, height: 7, borderRadius: 4, background: '#3ecf8e', display: 'inline-block' } }) : null, + ]) + } + + /** 设置 →「悬浮窗」整页 */ + function FloatSettingsSection() { + const { status, busy, act } = useStatus() + const s = status.session || {} + const m = s.metrics || {} + return h('div', { style: { padding: '4px 2px', display: 'flex', flexDirection: 'column', gap: 14, maxWidth: 640 } }, [ + h('div', { key: 'h', style: { fontSize: 15, fontWeight: 600 } }, '悬浮窗(会话镜像小窗)'), + h('div', { key: 'd', style: muted }, '始终置顶、可固定位置/尺寸、抓屏时自动隐身的小窗;把主界面切到后台也能盯着 Agent 干活。'), + h('div', { key: 'c', style: card }, [ + h('div', { key: 'line', style: { display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' } }, [ + h('span', { key: 's', style: { width: 8, height: 8, borderRadius: 4, background: status.alive ? '#3ecf8e' : '#f0b429', display: 'inline-block' } }), + h('b', { key: 'b' }, status.alive ? '浮窗正在运行' : '浮窗未运行'), + h('span', { key: 'x', style: muted }, status.ok ? (status.text || '') : '宿主插件不可达(可能未启用或未重启)'), + ]), + h('div', { key: 'btns', style: { display: 'flex', gap: 8, flexWrap: 'wrap' } }, [ + h('button', { key: 'open', style: btn(true), disabled: busy, onClick: () => act('open') }, '打开/前置悬浮窗'), + h('button', { key: 'full', style: btn(false), disabled: busy, onClick: () => act('fullscreen') }, '全屏切换'), + h('button', { key: 'pin', style: btn(false), disabled: busy, onClick: () => act('pin') }, '固定位置'), + h('button', { key: 'lock', style: btn(false), disabled: busy, onClick: () => act('sizeLock') }, '锁定尺寸'), + h('button', { key: 'hide', style: btn(false), disabled: busy, onClick: () => act('hide') }, '临时隐藏'), + h('button', { key: 'close', style: btn(false), disabled: busy, onClick: () => act('close') }, '关闭'), + ]), + ]), + h('div', { key: 'st', style: card }, [ + h('div', { key: 't', style: { fontWeight: 600, fontSize: 13 } }, '当前会话'), + h('div', { key: 'n', style: muted }, s.title ? String(s.title) : (s.sessionId || '还没有会话')), + h('div', { key: 'm', style: muted }, `轮 ${m.turns ?? '—'} · 步 ${m.steps ?? '—'} · ${m.tokens == null ? '—' : (m.tokens / 1e6).toFixed(3)} M tok · 缓存 ${m.cacheHit == null ? '—' : Math.round(m.cacheHit * 100) + '%'} · 上下文 ${m.contextUsed == null ? '—' : Math.round(m.contextUsed * 100) + '%'}`), + ]), + h('div', { key: 'tip', style: muted }, '外观(不透明度 / 毛玻璃 / 缩放)在浮窗内点 ⚙ 调整;这里的状态每 3 秒自动刷新。'), + ]) + } + + const inject = ['slots'] + + /** 客户端半接管 UI 后,把宿主注入的备用按钮摘掉,避免出现两个入口 */ + function claimUi() { + try { + window.__DSH_FLOAT_UI__ = true + const el = document.getElementById('dsh-float-pill') + if (el) el.remove() + } catch { } + } + + function apply(ctx) { + let registered = 0 + try { + ctx.slots.inject('sidebar.footer.action', () => { + try { + const stop = ctx.slots.register({ name: 'sidebar.footer.action', id: 'dsh-float-window', order: 40 }, SidebarButton) + registered++; claimUi() + return stop + } catch (e) { console.warn('[float-window] sidebar slot failed', e); return () => { } } + }) + } catch (e) { console.warn('[float-window] sidebar inject failed', e) } + + try { + ctx.slots.inject('settings.section', () => { + try { + const stop = ctx.slots.register({ name: 'settings.section', id: 'float-window', order: 60, label: () => '悬浮窗' }, FloatSettingsSection) + registered++; claimUi() + return stop + } catch (e) { console.warn('[float-window] settings slot failed', e); return () => { } } + }) + } catch (e) { console.warn('[float-window] settings inject failed', e) } + + console.info('[float-window] client half applied; slots registered =', registered) + } + + return { name: 'dsh-float-window', inject, apply } + }, +}) diff --git a/lib/host.js b/lib/host.js index f69b9a3..716a1af 100644 --- a/lib/host.js +++ b/lib/host.js @@ -155,7 +155,7 @@ const SAMPLE_TYPES = new Set(['user/message', 'assistant/chunk', 'assistant/mess 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 }, + { topmost: true, posLock: false, sizeLock: false, exclude: true, opacity: 0.92, scale: 1, backdrop: 'opaque', tool: true, button: true }, (config && typeof config === 'object') ? config : {}, ) @@ -562,28 +562,27 @@ export function apply(ctx, config) { } // ---------------------------------------------------------------- Agent 工具 - // 踩坑(真机事故):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'。 + // 踩坑(真机事故 ×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() - ; (async () => { - try { - const tools = ctx.get('tools') - if (!tools?.register) { log('tools service not available; agent trigger disabled'); return } - if (floatCfg.tool === false) { log('tool disabled by config (tool: false)'); return } - - let defineTool = null - try { defineTool = (await import('@deepseek-ai/dsh-tools')).defineTool } catch { } - if (!defineTool) defineTool = loadHostModule('@deepseek-ai/dsh-tools')?.defineTool - if (typeof defineTool !== 'function') { - log('defineTool unresolvable; tool NOT registered (avoid emitting an invalid schema)') - return - } - + 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 临时隐藏(抓屏前用)。', @@ -596,22 +595,19 @@ export function apply(ctx, config) { return `float-window: ${r.text}` }, }) - - // 自检:provider 只接受 object 根 schema,这里挡住任何回归 const schema = tool?.parameters if (!schema || schema.type !== 'object' || !schema.properties) { - log('tool schema self-check FAILED; not registering:', short(schema, 200)) status.toolSchema = schema ?? null - writeStatus() - return + 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)) } - 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() - })() + } + } catch (e) { log('tool registration failed:', String(e?.message || e)) } + writeStatus() const broadcast = (msg) => { const payload = `data: ${JSON.stringify(msg)}\n\n` @@ -619,6 +615,46 @@ export function apply(ctx, config) { } const pushHidden = () => broadcast({ type: 'cmd', cmd: 'hide' }) + // ---------------------------------------------------------------- 页面按钮(index.html 注入) + // 宿主侧往 index.html 里塞一个悬浮按钮:不依赖客户端插件 roster,web/桌面壳都生效。 + // 客户端半(lib/client.js)如果注册成功,会把按钮移除,避免出现两个入口。 + if (floatCfg.button !== false) { + try { + const pill = ` + +` + ctx.effect(() => ctx.webServer.tapIndex((html) => (typeof html === 'string' && html.includes('') + ? html.replace('', pill + '') + : 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 起不来)。这里做成可选的, @@ -628,62 +664,30 @@ export function apply(ctx, config) { 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: 安装的插件解析不到时 - // 只跳过这一步(浮窗自己的设置面板照常可用)。 + // ---------------------------------------------------------------- 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(' ')) - ; (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 + 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 = scope2.watch?.((next) => { + const stop = scope?.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('settings namespace registered') + } + } catch (e) { log('settings registration failed:', String(e?.message || e)) } + writeStatus() log('ready; endpoints:', `${ROUTE}/events`, `${ROUTE}/state`, `${ROUTE}/health`) } diff --git a/package.json b/package.json index a8aaf76..fd6eb8e 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,15 @@ ], "type": "module", "main": "lib/host.js", + "exports": { + ".": { + "default": "./lib/host.js" + }, + "./client": { + "default": "./lib/client.js" + }, + "./package.json": "./package.json" + }, "files": [ "lib", "shell", @@ -19,8 +28,17 @@ "README.md" ], "dsh": { + "engines": { + "dsh": ">=0.1.7-rc.1" + }, "bundle": { "patch": "./cordis.patch.yml" + }, + "client": { + "inject": [ + "@deepseek-ai/dsh-client-ui-settings" + ], + "platform": "web" } }, "license": "MIT"