From 4e666033d2283fea7d28c689fd62f9274d75d2e2 Mon Sep 17 00:00:00 2001 From: jiangchen <1703935164@qq.com> Date: Sun, 27 Sep 2026 18:22:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(host):=20=E9=95=9C=E5=83=8F=E8=90=BD?= =?UTF-8?q?=E7=9B=98=E7=BC=93=E5=AD=98=20=E2=80=94=E2=80=94=20=E9=87=8D?= =?UTF-8?q?=E5=90=AF=E5=90=8E=E9=95=9C=E5=83=8F=E5=92=8C=E7=B4=AF=E8=AE=A1?= =?UTF-8?q?=E6=8C=87=E6=A0=87=E9=83=BD=E8=BF=98=E5=9C=A8=EF=BC=88'?= =?UTF-8?q?=E6=B2=A1=E6=9C=89=E5=86=85=E5=AE=B9=E6=98=A0=E5=B0=84'?= =?UTF-8?q?=E7=9A=84=E6=A0=B9=E6=B2=BB=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:DSH 重启后内存里的镜像/计数清空,而活动会话的 .jsonl.zstd 还没落盘(宿主是批量写), 所以镜像一直空着。 做法:插件每 3 秒把 最近 40 行消息 + 标题 + 累计计数(turns/steps/out/in/cacheRead/decodeMs/lastTps) 写进 %DSH_HOME%/float-window/mirror-cache.json,apply 时恢复;dispose 时再存一次。 实测:建会话发两条消息 → 重启实例 → rows=2、turns=2、steps=2,消息原文都在。 另修:快照在'宿主活动会话列表里还没有该会话'时也照算指标(否则恢复出来的数字不显示); 清理 tick 计数器的双自增 bug(导致缓存保存节奏错乱)。 --- lib/host.js | 57 +++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 51 insertions(+), 6 deletions(-) diff --git a/lib/host.js b/lib/host.js index c60c928..b3ccfdf 100644 --- a/lib/host.js +++ b/lib/host.js @@ -232,6 +232,33 @@ export function apply(ctx, config) { const counts = new Map() // sessionId -> 事件计数兜底(没有 sessionProjection 也能出数字) const titles = new Map() // sessionId -> 会话标题(来自 session/title 事件) const contextWindows = new Map() // sessionId -> 上下文窗口(来自 request/context 事件) + // 镜像落盘缓存:DSH 重启会把内存里的镜像/计数清空(会话日志又是批量落盘的、读不到活动会话), + // 所以自己持久化一份,重启后恢复,用户就不会看到"重启后镜像空空的"。 + const mirrorCacheFile = path.join(dshHome(), 'float-window', 'mirror-cache.json') + const loadMirrorCache = () => { + try { + const j = JSON.parse(fs.readFileSync(mirrorCacheFile, 'utf8')) + for (const [id, v] of Object.entries(j?.sessions || {})) { + if (Array.isArray(v?.rows) && v.rows.length) rows.set(id, v.rows.slice(-MAX_ROWS)) + if (v?.title) titles.set(id, String(v.title)) + if (v?.counts) counts.set(id, { turns: 0, steps: 0, outTokens: 0, inTokens: 0, cacheRead: 0, decodeMs: 0, stepStart: 0, lastTps: null, ...v.counts }) + } + if (j?.lastSessionId) lastSessionId = j.lastSessionId + log(`mirror cache restored: ${Object.keys(j?.sessions || {}).length} session(s)`) + } catch { } + } + const saveMirrorCache = () => { + try { + const out = { at: Date.now(), lastSessionId, sessions: {} } + const ids = [...rows.keys()].slice(-8) + for (const id of ids) { + out.sessions[id] = { title: titles.get(id) || null, rows: (rows.get(id) || []).slice(-40), counts: counts.get(id) || null } + } + fs.mkdirSync(path.dirname(mirrorCacheFile), { recursive: true }) + fs.writeFileSync(mirrorCacheFile, JSON.stringify(out)) + return ids.length + } catch (e) { log('mirror cache save failed: ' + String(e?.message || e)); return -1 } + } let lastSessionId = null let pinnedSessionId = null // 「固定」模式:用户在浮窗里选中的会话,不再被任何事件抢走 let frontSessionId = null // 「跟随前台」模式:客户端半上报的当前显示会话(uiSession.current) @@ -248,8 +275,8 @@ export function apply(ctx, config) { ctx.on('session/event', (session, event) => { try { const id = session?.id || session?.sessionId || 'unknown' - // 「跟随前台」模式下,事件不再改变当前会话(前台由客户端半上报),但仍记录"最新事件"兜底 - if (!pinnedSessionId || followMode === 'latest') lastSessionId = id + // 「跟随前台」时前台由客户端半上报,但"最新事件"仍要记着(latest 模式 / front 的兜底) + if (followMode !== 'pinned') lastSessionId = id const c = counter(id) const type = String(event?.type || '') const d2 = () => event?.data || event || {} @@ -402,7 +429,8 @@ export function apply(ctx, config) { 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 }, + // 即使当前会话还没出现在宿主的活动列表里(刚重启/镜像缓存恢复),也用恢复的累计数算指标 + metrics: id ? metricsFor(session || { id }) : { turns: null, steps: null, tps: null, tokens: null, cacheHit: null, contextUsed: null }, settings: floatCfg, // 会话列表:浮窗的切换器用它(多会话/多任务并行时挑一个跟随) sessions: sessions.slice(-20).map((s) => { @@ -421,7 +449,7 @@ export function apply(ctx, config) { } // ---------------------------------------------------------------- 路由 - let tickCount = 0 + let tickCount = 0 // 兼容旧引用(写状态用 tick 计数) const sseClients = new Set() const push = () => { if (!sseClients.size) return @@ -445,12 +473,16 @@ export function apply(ctx, config) { } catch { } } - const tick = setInterval(() => { + let tick = 0 + const tickTimer = setInterval(() => { push() writeSnapshotFile() - if (++tickCount % 10 === 0) writeStatus() + tick++ + if (tick % 3 === 0) { const n = saveMirrorCache(); if (tick % 30 === 0) log(`mirror cache saved (${n} session)`) } + if (tick % 10 === 0) writeStatus() }, 1000) ctx.effect(() => () => clearInterval(tick)) + loadMirrorCache() // 恢复上次的镜像与累计指标(必须在 tick 起来之前/之后都行,只要在服务前) // 浮窗 → 宿主:轮询 outbox.json let lastOutboxText = '' @@ -502,6 +534,7 @@ export function apply(ctx, config) { }, 700) ctx.effect(() => () => { clearInterval(outboxTimer) + saveMirrorCache() try { fs.rmSync(snapshotFile, { force: true }) } catch { } try { fs.rmSync(outboxFile, { force: true }) } catch { } }) @@ -855,6 +888,18 @@ border:1px solid rgba(120,140,180,.35);background:rgba(22,26,34,.92);color:#e6ea // 同样必须**同步**注册:async 续体里的 ctx 已经失效(inactive context)。 log('services: ' + ['settings', 'sessions', 'tools', 'tokenMeter', 'sessionProjection', 'llm', 'shell', 'sandboxPolicy', 'webServer'] .map((n) => `${n}=${(() => { try { return ctx.get(n) ? 'y' : 'n' } catch { return '?' } })()}`).join(' ')) + // 诊断:把 sessionController / apiProxy 暴露的方法名列出来(回填历史要用其中一个) + try { + const faces = {} + for (const n of ['sessionController', 'apiProxy', 'sessions', 'sessionProjection']) { + try { + const f = ctx.get(n) + if (f) faces[n] = Object.keys(f).slice(0, 80) + } catch { } + } + status.apiMethods = faces + log('api methods: ' + JSON.stringify(faces)) + } catch { } try { const settings = ctx.get('settings') if (!settings?.register) {