From 7d1417fa1c9e28a06702e9a6d1cad2190c11e3b0 Mon Sep 17 00:00:00 2001 From: jiangchen <1703935164@qq.com> Date: Sun, 27 Sep 2026 17:02:07 +0800 Subject: [PATCH] =?UTF-8?q?fix(shell):=20=E5=8E=BB=E6=8E=89=E6=89=80?= =?UTF-8?q?=E6=9C=89=E7=AA=97=E5=8F=A3=E7=BA=A7=E7=8E=BB=E7=92=83=EF=BC=88?= =?UTF-8?q?=E7=99=BD=E6=A1=86/=E9=BB=91=E5=9D=97=E6=A0=B9=E5=9B=A0?= =?UTF-8?q?=EF=BC=89=EF=BC=9Bfeat:=20=E5=A4=9A=E4=BC=9A=E8=AF=9D=E5=88=87?= =?UTF-8?q?=E6=8D=A2=20+=20=E6=96=87=E4=BB=B6=E6=A1=A5=EF=BC=88=E6=A1=8C?= =?UTF-8?q?=E9=9D=A2=E5=A3=B3=20HTTP=20=E7=BD=91=E5=85=B3=20403=20?= =?UTF-8?q?=E7=9A=84=E8=A7=A3=E6=B3=95=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 白框根因:SetWindowCompositionAttribute(Win10 亚克力) / DwmExtendFrameIntoClientArea(客户区变玻璃) / WebView2 Transparent 背景 三者在真机上都会让 8px 边环露白、WebView2 变黑。现在窗口永远由 应用自绘深色,背景效果只作为页面侧半透明(不再碰 DWM 与窗口 alpha)。 新增: - 会话切换:标题栏 ⇄ 按钮 + 列表(会话标题/轮/步/消息数,可'跟随最新');宿主侧 pinnedSessionId, 被选中的会话不再被其它会话的事件抢走;snapshot 里带 sessions[] - 文件桥:桌面壳对未认证请求一律 403(实测 6 种请求形态),浮窗改走文件——宿主每秒写 snapshot.json, 浮窗渲染;浮窗的 chat/watch/newSession 写 outbox.json,宿主轮询执行。端到端实测通过 - 编码修复:快照按显式 UTF-8 读(PS 5.1 Get-Content -Raw 会按 GBK 读坏中文 JSON); outbox 写 UTF-8 无 BOM --- lib/host.js | 92 +++++++++++++++++++++++++++++++-- shell/float-window.ps1 | 64 +++++++++++++++++------ shell/ui.html | 114 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 248 insertions(+), 22 deletions(-) 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 @@