diff --git a/lib/client.js b/lib/client.js index 6a6f706..ff0abe3 100644 --- a/lib/client.js +++ b/lib/client.js @@ -49,17 +49,17 @@ window.__ModuleLoader__.load({ }) 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 [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 }) + 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 }) + setStatus({ ok: false, alive: false, text: String(e && e.message || e), session: null, follow: 'front' }) } }, []) React.useEffect(() => { @@ -72,7 +72,18 @@ window.__ModuleLoader__.load({ try { await callHost(action) } catch (e) { /* 宿主离线:状态轮询会显示 */ } setTimeout(() => { setBusy(false); refresh() }, 800) }, [refresh]) - return { status, busy, act, 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 } } /** 侧边栏底部按钮(设置按钮上方那一排) */ @@ -98,9 +109,15 @@ window.__ModuleLoader__.load({ /** 设置 →「悬浮窗」整页 */ function FloatSettingsSection() { - const { status, busy, act } = useStatus() + 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 干活。'), @@ -119,6 +136,15 @@ window.__ModuleLoader__.load({ 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 || '还没有会话')), @@ -128,7 +154,31 @@ window.__ModuleLoader__.load({ ]) } - const inject = ['slots'] + 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() { @@ -141,6 +191,7 @@ window.__ModuleLoader__.load({ function apply(ctx) { let registered = 0 + reportFront(ctx) try { ctx.slots.inject('sidebar.footer.action', () => { try { diff --git a/lib/host.js b/lib/host.js index 280bfcb..c60c928 100644 --- a/lib/host.js +++ b/lib/host.js @@ -233,7 +233,11 @@ export function apply(ctx, config) { const titles = new Map() // sessionId -> 会话标题(来自 session/title 事件) const contextWindows = new Map() // sessionId -> 上下文窗口(来自 request/context 事件) let lastSessionId = null - let pinnedSessionId = null // 用户在浮窗里选中的会话:多会话并行时不再被最新事件抢走 + let pinnedSessionId = null // 「固定」模式:用户在浮窗里选中的会话,不再被任何事件抢走 + let frontSessionId = null // 「跟随前台」模式:客户端半上报的当前显示会话(uiSession.current) + // 跟随方式:front=跟随前台当前对话(默认);latest=跟随最新事件;pinned=固定某个会话 + let followMode = ['front', 'latest', 'pinned'].includes(floatCfg.follow) ? floatCfg.follow : 'front' + const activeSessionId = () => (followMode === 'pinned' ? pinnedSessionId : (followMode === 'front' ? (frontSessionId || pinnedSessionId || lastSessionId) : lastSessionId)) function counter(id) { let c = counts.get(id) @@ -244,7 +248,8 @@ export function apply(ctx, config) { ctx.on('session/event', (session, event) => { try { const id = session?.id || session?.sessionId || 'unknown' - if (!pinnedSessionId) lastSessionId = id + // 「跟随前台」模式下,事件不再改变当前会话(前台由客户端半上报),但仍记录"最新事件"兜底 + if (!pinnedSessionId || followMode === 'latest') lastSessionId = id const c = counter(id) const type = String(event?.type || '') const d2 = () => event?.data || event || {} @@ -385,7 +390,7 @@ export function apply(ctx, config) { function snapshot() { const sessions = (() => { try { return ctx.get('sessions')?.list?.() || [] } catch { return [] } })() - const activeId = pinnedSessionId || lastSessionId + const activeId = activeSessionId() let session = sessions.find((s) => s?.id === activeId) || sessions[sessions.length - 1] || null const id = session?.id || activeId return { @@ -393,6 +398,8 @@ export function apply(ctx, config) { at: Date.now(), sessionId: id || null, pinned: pinnedSessionId || null, + front: frontSessionId || null, + follow: followMode, 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 }, @@ -471,9 +478,17 @@ export function apply(ctx, config) { await windowAction(msg.action) } else if (msg.cmd === 'watch') { // 浮窗切换跟随的会话:'' / null 表示回到"跟随最新" + if (msg.mode && ['front', 'latest', 'pinned'].includes(msg.mode)) followMode = msg.mode pinnedSessionId = msg.sessionId ? String(msg.sessionId) : null - if (pinnedSessionId) lastSessionId = pinnedSessionId - log('watch -> ' + (pinnedSessionId || 'latest')) + if (pinnedSessionId) { followMode = 'pinned'; lastSessionId = pinnedSessionId } + else if (!msg.mode) followMode = 'latest' + log(`watch -> mode=${followMode} session=${pinnedSessionId || 'auto'}`) + } else if (msg.cmd === 'follow') { + if (['front', 'latest', 'pinned'].includes(msg.mode)) { + followMode = msg.mode + if (msg.mode !== 'pinned') pinnedSessionId = null + } + log(`outbox follow -> ${followMode}`) } else if (msg.cmd === 'session' && msg.action === 'create') { const { value } = await hostCall('create', { sessionId: randomUUID(), @@ -600,9 +615,25 @@ export function apply(ctx, config) { for await (const c of req) chunks.push(c) body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}') } catch { } + // 客户端半的两条上报:前台会话 / 跟随方式 + if (body.action === 'front') { + const sid = body.sessionId ? String(body.sessionId) : null + if (sid !== frontSessionId) log(`front -> ${sid || 'none'}`) + frontSessionId = sid + res.writeHead(200, { ...cors, 'Content-Type': 'application/json; charset=utf-8' }) + res.end(JSON.stringify({ ok: true, follow: followMode, front: frontSessionId, alive: shellAlive() })) + return + } + if (body.action === 'follow') { + if (['front', 'latest', 'pinned'].includes(body.mode)) followMode = body.mode + log(`follow mode -> ${followMode}`) + res.writeHead(200, { ...cors, 'Content-Type': 'application/json; charset=utf-8' }) + res.end(JSON.stringify({ ok: true, follow: followMode, alive: shellAlive() })) + return + } const r = await windowAction(body.action) res.writeHead(200, { ...cors, 'Content-Type': 'application/json; charset=utf-8' }) - res.end(JSON.stringify({ ...r, alive: shellAlive() })) + res.end(JSON.stringify({ ...r, alive: shellAlive(), follow: followMode })) return } res.writeHead(404, { ...cors, 'Content-Type': 'text/plain; charset=utf-8' }) @@ -644,14 +675,49 @@ export function apply(ctx, config) { // 直接起 powershell(隐藏窗口)。**不要**走 `cmd /c start`:那会弹出一个黑色控制台窗口 // (用户已经看到过:标题 C:\WINDOWS\system32\cmd.exe,且批处理里的中文被 GBK 解析成乱码报错)。 const ps1 = path.join(REPO, 'shell', 'float-window.ps1') - if (!fs.existsSync(ps1)) return { ok: false, error: `shell script not found: ${ps1}` } - try { - const { spawn } = await import('node:child_process') - spawn('powershell.exe', - ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-WindowStyle', 'Hidden', '-File', ps1], - { detached: true, stdio: 'ignore', windowsHide: true }).unref() - return { ok: true, via: 'spawn powershell (hidden)' } - } catch (e) { return { ok: false, error: String(e?.message || e) } } + if (!fs.existsSync(ps1)) { + const msg = `shell script not found: ${ps1}` + log('spawnShell: ' + msg) + status.notes.push(`${new Date().toISOString()} spawnShell FAILED ${msg}`) + return { ok: false, error: msg } + } + // 绝对路径优先:DSH 应用进程的 PATH 未必和终端一致,裸名字可能找不到 powershell + const sysRoot = process.env.SystemRoot || 'C:\\Windows' + const candidates = [ + path.join(sysRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'), + 'powershell.exe', + 'pwsh.exe', + ] + const { spawn } = await import('node:child_process') + const before = aliveMtime() + let lastErr = null + for (const exe of candidates) { + try { + // 注意:**不要**加 detached:true —— 实测在 Windows 上 detached(CREATE_NO_WINDOW) 会让 + // PowerShell 宿主起来立刻退出(exit code 0、脚本一行都没跑),表现就是"点了打开没反应"。 + const child = spawn(exe, ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-WindowStyle', 'Hidden', '-File', ps1], + { stdio: 'ignore', windowsHide: true }) + child.on('error', (e) => { lastErr = String(e?.message || e) }) + child.unref() + // 等心跳确认:真的起来了才算成功(否则就是"点了没反应") + for (let i = 0; i < 15; i++) { + await new Promise((r) => setTimeout(r, 400)) + if (aliveMtime() > before) { + log(`spawnShell ok via ${exe}`) + status.notes.push(`${new Date().toISOString()} spawnShell ok via ${exe}`) + writeStatus() + return { ok: true, via: exe } + } + if (lastErr) break + } + log(`spawnShell: ${exe} produced no heartbeat${lastErr ? ' (' + lastErr + ')' : ''}`) + } catch (e) { lastErr = String(e?.message || e) } + } + const msg = `could not start float window (script=${ps1}, lastError=${lastErr || 'no heartbeat'})` + log('spawnShell FAILED: ' + msg) + status.notes.push(`${new Date().toISOString()} spawnShell FAILED ${msg}`) + writeStatus() + return { ok: false, error: msg } } /** 浮窗已经在跑就投递命令,否则按需拉起;投递后要等心跳确认,不能"发给死人" */