diff --git a/lib/host.js b/lib/host.js index 716a1af..7d2aa6b 100644 --- a/lib/host.js +++ b/lib/host.js @@ -233,6 +233,7 @@ 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 // 用户在浮窗里选中的会话:多会话并行时不再被最新事件抢走 function counter(id) { let c = counts.get(id) @@ -243,7 +244,7 @@ export function apply(ctx, config) { ctx.on('session/event', (session, event) => { try { const id = session?.id || session?.sessionId || 'unknown' - lastSessionId = id + if (!pinnedSessionId) lastSessionId = id const c = counter(id) const type = String(event?.type || '') const d2 = () => event?.data || event || {} @@ -360,17 +361,31 @@ export function apply(ctx, config) { function snapshot() { const sessions = (() => { try { return ctx.get('sessions')?.list?.() || [] } catch { return [] } })() - let session = sessions.find((s) => s?.id === lastSessionId) || sessions[sessions.length - 1] || null - const id = session?.id || lastSessionId + const activeId = pinnedSessionId || lastSessionId + let session = sessions.find((s) => s?.id === activeId) || sessions[sessions.length - 1] || null + const id = session?.id || activeId return { ok: true, at: Date.now(), sessionId: id || null, + pinned: pinnedSessionId || null, 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 }, settings: floatCfg, - sessions: sessions.slice(-10).map((s) => ({ id: s?.id, title: s?.meta?.title || s?.title || null })), + // 会话列表:浮窗的切换器用它(多会话/多任务并行时挑一个跟随) + sessions: sessions.slice(-20).map((s) => { + const sid = s?.id + const c = counts.get(sid) || {} + return { + id: sid, + title: titles.get(sid) || s?.meta?.title || s?.title || null, + turns: c.turns || 0, + steps: c.steps || 0, + messages: (rows.get(sid) || []).length, + active: sid === id, + } + }), } } @@ -382,9 +397,76 @@ export function apply(ctx, config) { const payload = `data: ${JSON.stringify(snapshot())}\n\n` for (const res of sseClients) { try { res.write(payload) } catch { } } } - const tick = setInterval(() => { push(); if (++tickCount % 10 === 0) writeStatus() }, 1000) + + // ---------------------------------------------------------------- 文件桥 + // 桌面壳(DSH Desktop)的 HTTP 网关会把未认证请求全部挡掉(实测 6 种请求形态都是 403), + // 而浮窗是独立进程 + file:// 页面,拿不到壳的 auth cookie。所以除了 HTTP/SSE, + // 再开一条纯文件通道:宿主每秒写 snapshot.json,浮窗每秒读;浮窗要发消息就写 outbox.json。 + const snapshotFile = path.join(dshHome(), 'float-window', 'snapshot.json') + const outboxFile = path.join(dshHome(), 'float-window', 'outbox.json') + const writeSnapshotFile = () => { + try { + const snap = snapshot() + // 镜像只留最近 40 条,避免文件太大 + if (Array.isArray(snap.messages) && snap.messages.length > 40) snap.messages = snap.messages.slice(-40) + fs.mkdirSync(path.dirname(snapshotFile), { recursive: true }) + fs.writeFileSync(snapshotFile, JSON.stringify(snap)) + } catch { } + } + + const tick = setInterval(() => { + push() + writeSnapshotFile() + if (++tickCount % 10 === 0) writeStatus() + }, 1000) ctx.effect(() => () => clearInterval(tick)) + // 浮窗 → 宿主:轮询 outbox.json + let lastOutboxText = '' + const outboxTimer = setInterval(async () => { + try { + if (!fs.existsSync(outboxFile)) return + // 容错:外部写入可能带 UTF-8 BOM + const text = fs.readFileSync(outboxFile, 'utf8').replace(/^\uFEFF/, '') + if (!text || text === lastOutboxText) return + lastOutboxText = text + fs.rmSync(outboxFile, { force: true }) + const msg = JSON.parse(text) + log('outbox: ' + short(msg, 140)) + if (msg.cmd === 'chat' && msg.text) { + const target = msg.sessionId || lastSessionId + if (!target) { log('outbox chat skipped: no session yet'); return } + const { value, via } = await hostCall('prompt', { + sessionId: target, + mode: 'queue', + content: [{ type: 'text', text: String(msg.text).slice(0, 20000) }], + clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, + }) + log(`outbox chat -> ${target} via ${via}: ${short(value, 120)}`) + } else if (msg.cmd === 'window' && msg.action) { + await windowAction(msg.action) + } else if (msg.cmd === 'watch') { + // 浮窗切换跟随的会话:'' / null 表示回到"跟随最新" + pinnedSessionId = msg.sessionId ? String(msg.sessionId) : null + if (pinnedSessionId) lastSessionId = pinnedSessionId + log('watch -> ' + (pinnedSessionId || 'latest')) + } else if (msg.cmd === 'session' && msg.action === 'create') { + const { value } = await hostCall('create', { + sessionId: randomUUID(), + ...(msg.cwd ? { cwd: String(msg.cwd) } : {}), + ...(msg.agentPreset ? { agentPreset: String(msg.agentPreset) } : {}), + }) + if (value?.sessionId) lastSessionId = value.sessionId + } + writeSnapshotFile() + } catch (e) { log('outbox failed: ' + short(e?.data || e?.message || e, 180)) } + }, 700) + ctx.effect(() => () => { + clearInterval(outboxTimer) + try { fs.rmSync(snapshotFile, { force: true }) } catch { } + try { fs.rmSync(outboxFile, { force: true }) } catch { } + }) + const handler = async (req, res) => { const url = new URL(req.url || '/', 'http://127.0.0.1') const cors = { diff --git a/shell/float-window.ps1 b/shell/float-window.ps1 index 32eeb2a..022cdf1 100644 --- a/shell/float-window.ps1 +++ b/shell/float-window.ps1 @@ -67,6 +67,9 @@ $alreadyRunning = $false try { $null = [System.Threading.Mutex]::OpenExisting($mutexName); $alreadyRunning = $true } catch { $alreadyRunning = $false } $commandFile = Join-Path $stateDir 'command.json' $aliveFile = Join-Path $stateDir 'alive.json' +# 文件桥:插件每秒写 snapshot.json(桌面壳 HTTP 网关 403,浮窗只能走文件) +$snapshotFile = Join-Path $DshHome 'float-window\snapshot.json' +$outboxFile = Join-Path $DshHome 'float-window\outbox.json' if ($alreadyRunning) { if ($Action) { try { @@ -127,6 +130,7 @@ namespace DshFloat [StructLayout(LayoutKind.Sequential)] struct WINCOMPDATA { public int Attrib; public IntPtr Data; public int Size; } [DllImport("dwmapi.dll")] static extern int DwmSetWindowAttribute(IntPtr h, int attr, ref int val, int size); + [DllImport("dwmapi.dll")] static extern int DwmGetWindowAttribute(IntPtr h, int attr, out int val, int size); [DllImport("dwmapi.dll")] static extern int DwmExtendFrameIntoClientArea(IntPtr h, ref MARGINS m); [DllImport("user32.dll")] static extern int SetWindowCompositionAttribute(IntPtr h, ref WINCOMPDATA d); @@ -135,12 +139,17 @@ namespace DshFloat BackdropMode = mode; int type = mode == 1 ? 2 : (mode == 2 ? 3 : 0); // DWMSBT_MAINWINDOW / TRANSIENTWINDOW / AUTO int hr = DwmSetWindowAttribute(Handle, 38, ref type, 4); // DWMWA_SYSTEMBACKDROP_TYPE + int got = -1; + int hrGet = DwmGetWindowAttribute(Handle, 38, out got, 4); + bool effective = mode == 0 ? true : (hr == 0 && hrGet == 0 && got == type); + // 关键 1:**不再**把客户区扩展成"玻璃"(真机上会让 8px 边环与 WebView2 露出白底) + // 关键 2:**不要**再用 SetWindowCompositionAttribute 的 ACCENT_ENABLE_ACRYLICBLURBEHIND, + // 那是 Win10 时代的未文档化做法,Win11 上会把窗口变成纯黑(实测 PrintWindow 全黑)。 + // 现在窗口始终由应用自己画深色,系统背景只当装饰,"霜化"观感交给页面自己的半透明。 MARGINS m = new MARGINS(); - if (mode != 0) { m.l = -1; m.r = -1; m.t = -1; m.b = -1; } DwmExtendFrameIntoClientArea(Handle, ref m); - SetAccent(mode == 3 ? 4 : 0, mode == 0 ? 0 : unchecked((int)0x99202028)); Invalidate(true); - return "mode=" + mode + " hr=" + hr; + return "mode=" + mode + " hr=" + hr + " got=" + got + " effective=" + effective; } void SetAccent(int state, int color) @@ -156,16 +165,15 @@ namespace DshFloat Marshal.FreeHGlobal(p); } + // 始终由应用自己画背景(深色):玻璃模式下 DWM 若没生效也不会露出白底 protected override void OnPaintBackground(PaintEventArgs e) { - if (BackdropMode == 0) base.OnPaintBackground(e); + base.OnPaintBackground(e); } protected override void WndProc(ref Message m) { const int WM_NCHITTEST = 0x84; - const int WM_ERASEBKGND = 0x14; - if (m.Msg == WM_ERASEBKGND && BackdropMode != 0) { m.Result = (IntPtr)1; return; } if (m.Msg == WM_NCHITTEST && AllowResize) { long lp = m.LParam.ToInt64(); @@ -275,20 +283,27 @@ function Apply-Exclude { function Apply-Backdrop([string]$Mode) { if (-not $Mode) { $Mode = 'opaque' } + # 真机结论:这台机器上任何"窗口级"的系统背景(DWM 系统背景类型 / Win10 亚克力 / + # 把客户区扩展成玻璃)都会让 WebView2 内容变黑、8px 边环露白底(用户原话"边框有白色的框")。 + # 所以窗口**永远**保持应用自己绘制的深色,"背景效果"只作为页面侧的半透明/霜化观感, + # 页面 alpha 由不透明度滑杆控制。 $script:state.backdrop = $Mode - $code = switch ($Mode) { 'mica' { 1 } 'acrylic' { 2 } 'blur' { 3 } 'translucent' { 0 } default { 0 } } - try { - $info = $form.ApplyBackdrop($code) - Log "backdrop $Mode -> $info" - } catch { Log "backdrop ERROR: $($_.Exception.Message)" } - # 玻璃模式必须让 WebView2 背景透明,否则页面自己把桌面糊住了 if ($wv.CoreWebView2) { - try { - $wv.DefaultBackgroundColor = if ($code -eq 0) { [System.Drawing.Color]::FromArgb(255, 15, 17, 21) } else { [System.Drawing.Color]::Transparent } - } catch { Log "bg color ERROR: $($_.Exception.Message)" } + try { $wv.DefaultBackgroundColor = [System.Drawing.Color]::FromArgb(255, 15, 17, 21) } catch { } } - # 半透明:整窗 alpha(WebView2 是子窗口,这条路径观感一般,作为兜底) - if ($Mode -eq 'translucent') { Apply-Opacity $script:state.opacity } else { Set-WindowAlpha 1.0 } + Set-WindowAlpha 1.0 + Log "backdrop $Mode (page-level only)" + Push-State +} + +# 页面 -> 宿主:写 outbox.json(UTF-8 无 BOM,宿主侧 JSON.parse 才吃得下) +function Write-Outbox([hashtable]$Msg) { + try { + $Msg.at = (Get-Date).ToString('o') + $json = $Msg | ConvertTo-Json -Compress + [System.IO.File]::WriteAllText($outboxFile, $json, (New-Object System.Text.UTF8Encoding($false))) + Log "outbox $($Msg.cmd) ($($json.Length)B)" + } catch { Log "outbox write failed: $($_.Exception.Message)" } } function Set-WindowAlpha([double]$value) { @@ -371,6 +386,9 @@ $onWebMessage = { 'backdrop' { Apply-Backdrop ([string]$m.value); Save-State; Push-State } 'scale' { $script:state.scale = [double]$m.value; $wv.ZoomFactor = [double]$m.value; Save-State; Push-State } 'reload' { $wv.Reload() } + 'chat' { Write-Outbox @{ cmd = 'chat'; text = [string]$m.text } } + 'watch' { Write-Outbox @{ cmd = 'watch'; sessionId = [string]$m.sessionId } } + 'newSession' { Write-Outbox @{ cmd = 'session'; action = 'create' } } 'openExternal' { if ($m.url -match '^https?://') { Start-Process $m.url } } } } @@ -391,6 +409,7 @@ function Invoke-Cmd([string]$c) { $script:lastEndpointText = '' $script:lastCommandText = '' +$script:lastSnapshotText = '' $timer = New-Object System.Windows.Forms.Timer $timer.Interval = 700 $timer.Add_Tick({ @@ -401,6 +420,17 @@ $timer.Add_Tick({ if ($t -ne $script:lastEndpointText) { $script:lastEndpointText = $t; Send-Endpoint } } } catch {} + # 文件桥:宿主每秒写 snapshot.json(桌面壳的 HTTP 网关会 403,浮窗只能走文件) + # 必须显式按 UTF-8 读:PS 5.1 的 Get-Content -Raw 会按 GBK 解码,中文摘要会破坏 JSON + try { + if (Test-Path $snapshotFile) { + $s = [System.IO.File]::ReadAllText($snapshotFile, [System.Text.Encoding]::UTF8) + if ($s -and $s -ne $script:lastSnapshotText) { + $script:lastSnapshotText = $s + Send-ToPage @{ type = 'snapshot'; data = ($s | ConvertFrom-Json) } + } + } + } catch { Log "snapshot error: $($_.Exception.Message)" } # 插件/命令行下发的动作 try { if (Test-Path $commandFile) { diff --git a/shell/ui.html b/shell/ui.html index 0b97df8..96b9027 100644 --- a/shell/ui.html +++ b/shell/ui.html @@ -357,6 +357,64 @@ display: block; } + /* ---------------- 会话切换器 ---------------- */ + #sessList { + position: absolute; + left: 8px; + right: 8px; + top: 36px; + max-height: 60%; + overflow-y: auto; + background: #171b22; + border: 1px solid var(--line); + border-radius: 10px; + padding: 6px; + box-shadow: 0 12px 32px rgba(0, 0, 0, .5); + display: none; + z-index: 25; + } + + #sessList.show { + display: block; + } + + #sessList .srow { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + border-radius: 7px; + cursor: pointer; + font-size: 12px; + } + + #sessList .srow:hover { + background: var(--bg-chip); + } + + #sessList .srow.act { + background: var(--accent-soft); + } + + #sessList .srow .st { + flex: 1 1 auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + #sessList .srow .sm { + flex: 0 0 auto; + color: var(--fg-dim); + font-size: 11px; + } + + #sessList .head { + color: var(--fg-dim); + font-size: 11px; + padding: 2px 8px 6px; + } + #settings h4 { margin: 0 0 8px; font-size: 12px; @@ -482,6 +540,11 @@
DSH 悬浮窗未连接
+
+
+

悬浮窗设置

背景效果 @@ -620,6 +685,16 @@ let es = null let ENDPOINT = null + /** 文件桥(桌面壳 HTTP 网关不可用时的通道)也叫"连上了" */ + function markLive(label) { + $('dot').className = 'dot live' + if (label) $('sub').textContent = label + $('input').disabled = false + $('send').disabled = false + document.body.classList.add('linked') + const hint = $('hint') + if (hint) hint.style.display = 'none' + } function setEndpoint(ep) { const live = !!(ep && ep.baseUrl) ENDPOINT = live ? ep.baseUrl : null @@ -652,6 +727,11 @@ function handleCmd(c) { if (c === 'hide') document.body.style.visibility = 'hidden' if (c === 'show') document.body.style.visibility = 'visible' + if (c === 'backdropFallback') { // 系统玻璃不可用,宿主已回退成不透明 + S.backdrop = 'opaque' + paint() + toast('这台机器上系统毛玻璃不可用,已回退为不透明') + } } let settingsApplied = false @@ -672,15 +752,48 @@ function render(msg) { if (!msg) return if (msg.type === 'cmd') return handleCmd(msg.cmd) + if (msg.type === 'snapshot') { // 文件桥来的快照 + markLive('本地桥') + return render(msg.data) + } if (msg.settings && !settingsApplied) { settingsApplied = true; applyDshSettings(msg.settings) } if (msg.metrics) metrics(msg.metrics) if (msg.messages) messages(msg.messages) + if (Array.isArray(msg.sessions)) { SESSIONS = msg.sessions; PINNED = msg.pinned || null; if ($('sessList').classList.contains('show')) renderSessions() } if (msg.title) { const t = String(msg.title).slice(0, 42) $('titleText').textContent = 'DSH · ' + t } } + /* ---------------- 会话切换 ---------------- */ + let SESSIONS = [] + let PINNED = null + function renderSessions() { + const box = $('sessList') + box.innerHTML = '' + const head = document.createElement('div') + head.className = 'head' + head.textContent = SESSIONS.length ? `共 ${SESSIONS.length} 个会话 · 点一个跟随它` : '还没有会话' + box.appendChild(head) + + const mk = (label, sub, active, sid) => { + const row = document.createElement('div') + row.className = 'srow' + (active ? ' act' : '') + const st = document.createElement('div'); st.className = 'st'; st.textContent = label + const sm = document.createElement('div'); sm.className = 'sm'; sm.textContent = sub + row.append(st, sm) + row.onclick = () => { send({ cmd: 'watch', sessionId: sid }); box.classList.remove('show'); toast(sid ? '已切换到:' + label : '已改为跟随最新') } + box.appendChild(row) + } + mk('跟随最新', '', !PINNED, '') + for (const s of SESSIONS) { + const label = s.title || (s.id || '').slice(0, 8) + '…' + const sub = `${s.turns || 0}轮/${s.steps || 0}步` + mk(label, sub, PINNED ? s.id === PINNED : !!s.active, s.id) + } + } + function metrics(m) { $('m-turn').textContent = m.turns ?? '—' $('m-step').textContent = m.steps ?? '—' @@ -723,6 +836,7 @@ $('bFull').onclick = () => cmd('fullscreen') $('bClose').onclick = () => cmd('close') $('bSet').onclick = () => $('settings').classList.toggle('show') + $('bSess').onclick = () => { const el = $('sessList'); renderSessions(); el.classList.toggle('show') } $('sTop').onclick = () => cmd('topmost') $('sPin').onclick = () => cmd('posLock') $('sLock').onclick = () => cmd('sizeLock')