From 387f9a00121ef95c531bc4494cc3ae08da7d4470 Mon Sep 17 00:00:00 2001
From: jiangchen <1703935164@qq.com>
Date: Sun, 27 Sep 2026 16:26:40 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E9=A1=B5=E9=9D=A2=E6=8C=89=E9=92=AE=20?=
=?UTF-8?q?+=20=E8=AE=BE=E7=BD=AE=E5=88=86=E5=8C=BA=20+=20=E4=BF=AE?=
=?UTF-8?q?=E6=8E=89=E5=90=8C=E6=AD=A5=E6=B3=A8=E5=86=8C=E4=BA=8B=E6=95=85?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- fix(host): 工具/设置注册从 async 续体挪回 apply 同步段 —— cordis 的 ctx 在 apply 返回后失效,
之前真机报 'cannot create effect on inactive context',导致 float_window 没装上
- feat(host): tapIndex 往 index.html 注入『悬浮窗』按钮(自带状态点/看门狗/被 SPA 重建后自动补回),
实测带 auth cookie 拉首页 HTML 已含该按钮;config.button=false 可关
- feat(client): 客户端半 lib/client.js(ModuleLoader 格式,无需构建)注册 sidebar.footer.action
与 settings.section(设置→悬浮窗整页:打开/全屏/固定/锁定/隐藏/关闭 + 会话状态);接管后摘掉注入的备用按钮
- package.json: exports['./client'] + dsh.client{inject,platform}
---
lib/client.js | 169 ++++++++++++++++++++++++++++++++++++++++++++++++++
lib/host.js | 164 ++++++++++++++++++++++++------------------------
package.json | 18 ++++++
3 files changed, 271 insertions(+), 80 deletions(-)
create mode 100644 lib/client.js
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('