/** * 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 } }, })