Files
jiangchen e00d73be82 fix(host): 根因——spawn 的 detached:true 让 PowerShell 起来即退;feat: 三种跟随方式(默认跟随前台)
根因(对照实验):spawn('powershell', [...], {detached:true}) 在 Windows 上 → 子进程 exit 0、
脚本一行没跑(日志里连 start 都没有),表现就是『点打开没反应』;detached:false 完全正常。
现在 spawnShell:绝对路径优先 + 不 detached + 等心跳确认,失败把原因写进 status notes。

跟随方式(用户需求:切到哪个会话就跟哪个,且可配置):
- front(默认)跟随前台:客户端半订阅 uiSession.current,会话一换就上报 /window{action:front}
- latest 跟随最新事件;pinned 固定在 ⇄ 里选中的会话
设置入口:设置→悬浮窗(模式三选一);浮窗 ⇄ 里点会话=切到 pinned
快照新增 follow/front 字段;/window 支持 action=follow|front
2026-09-27 17:39:15 +08:00

221 lines
11 KiB
JavaScript

/**
* 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, follow: 'front' })
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, follow: r.follow || (s && s.follow) || 'front' })
} catch (e) {
setStatus({ ok: false, alive: false, text: String(e && e.message || e), session: null, follow: 'front' })
}
}, [])
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])
const setFollow = React.useCallback(async (mode) => {
setBusy(true)
try {
await fetch(`${ROUTE}/window`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ action: 'follow', mode }),
})
} catch (e) { }
setTimeout(() => { setBusy(false); refresh() }, 500)
}, [refresh])
return { status, busy, act, setFollow, 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, setFollow } = useStatus()
const s = status.session || {}
const m = s.metrics || {}
const mode = status.follow || 'front'
const modeBtn = (key, label, hint) => h('button', {
key, style: btn(mode === key), disabled: busy,
title: hint,
onClick: () => setFollow(key),
}, label)
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: 'follow', style: card }, [
h('div', { key: 't', style: { fontWeight: 600, fontSize: 13 } }, '镜像跟随方式'),
h('div', { key: 'row', style: { display: 'flex', gap: 8, flexWrap: 'wrap' } }, [
modeBtn('front', '跟随前台(推荐)', '主界面显示哪个会话,浮窗就镜像哪个;切换会话自动跟上'),
modeBtn('latest', '跟随最新事件', '哪个会话最新有动静就镜像哪个(多任务并行时会来回跳)'),
modeBtn('pinned', '固定某个会话', '固定在浮窗 ⇄ 里选中的那个会话'),
]),
h('div', { key: 'hint', style: muted }, '当前:' + (mode === 'front' ? '跟随前台当前对话' : mode === 'latest' ? '跟随最新事件' : '固定在选中的会话')),
]),
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', 'uiSession']
/** 把"当前显示的会话"上报给宿主 —— 「跟随前台」就是靠这个做到"切哪个跟哪个" */
function reportFront(ctx) {
try {
const ui = ctx.get('uiSession')
const read = () => {
try {
const cur = ui?.current
const id = cur?.value?.key ?? cur?.value?.sessionId ?? cur?.key ?? null
if (!id || id === lastFront) return
lastFront = id
fetch(`${ROUTE}/window`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ action: 'front', sessionId: id }),
}).catch(() => { })
} catch { }
}
read()
// uiSession.current 是 binding source,订阅它就能在切换会话时立刻跟上
try { return ui?.current?.subscribe?.(read) || ui?.subscribe?.(read) || null } catch { return null }
} catch (e) { console.warn('[float-window] uiSession unavailable', e); return null }
}
let lastFront = null
/** 客户端半接管 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
reportFront(ctx)
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 }
},
})