fix(shell): 去掉所有窗口级玻璃(白框/黑块根因);feat: 多会话切换 + 文件桥(桌面壳 HTTP 网关 403 的解法)
白框根因: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
This commit is contained in:
+87
-5
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user