From f02982de91b2645e3caffa4d837181bddbe16453 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Thu, 13 Aug 2026 14:41:04 +0800 Subject: [PATCH] emrg: GUI session history on-demand loading (rant 2026-08-13T14:15:12 front-end) --- Agent.md | 4 +- emrg/gui/main.js | 11 ++-- emrg/gui/renderer/css/components.css | 13 +++++ emrg/gui/renderer/js/app.js | 86 +++++++++++++++++++++++++++- emrg/gui/renderer/js/chat.js | 34 +++++++++++ emrg/gui/renderer/js/i18n.js | 6 ++ emrg/gui/test/renderer.smoke.test.js | 37 ++++++++++++ 7 files changed, 184 insertions(+), 7 deletions(-) diff --git a/Agent.md b/Agent.md index b617955..63fbdb0 100644 --- a/Agent.md +++ b/Agent.md @@ -85,7 +85,7 @@ Usage: say "tool loop" for the whole process, "round N" for a single LLM request - Streaming chat with delta rendering (16ms batching), markdown on done (marked + DOMPurify + local highlight.js subset), tool call status cards (2000-char truncation + expand) - Session list/switch/new/delete + right-click rename (context menu, #423) synced with daemon; own-stream busy lock (G65); broadcast streams from other clients tagged "来自其他客户端" - Disconnect/reconnect: red status dot, auto daemon respawn (stale-port detection), session resume, input bar restored on disconnect (no 30s fake-timeout) - - Unit tests `npm test` (231: 44 daemon_client + 19 conn-manager + 22 app-commands + 109 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` + - Unit tests `npm test` (232: 44 daemon_client + 19 conn-manager + 22 app-commands + 110 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` - **Scheduled tasks** — Task generalization + CRUD (rant 2026-08-12T18:23:15, #709/#710/#711) - Task handler generalized: `TaskHandler` (renamed from `EvolutionHandler`), repo-configured self-heal for any project, template lookup builtin → `~/.emrg/task-templates/.md` → fallback - Daemon commands: `task_create/update/delete` + `task_template_create/list/update/delete` (tasks stored in `~/.emrg/tasks.yml`, custom type templates in `~/.emrg/task-templates/`) @@ -119,7 +119,7 @@ pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg ``` Python: `uv run pytest tests/ -v` (773) — import check: `uv run python -c "from emrg.client.app import run_client"` -GUI: `cd emrg/gui && npm test` (231: 44 daemon_client + 19 conn-manager + 22 app-commands + 109 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` +GUI: `cd emrg/gui && npm test` (232: 44 daemon_client + 19 conn-manager + 22 app-commands + 110 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响) diff --git a/emrg/gui/main.js b/emrg/gui/main.js index 1ae89c6..7749c0c 100644 --- a/emrg/gui/main.js +++ b/emrg/gui/main.js @@ -424,11 +424,14 @@ vision = false return { ok: true }; }); - ipcMain.handle("emrg:listHistory", async (_e, { sessionId }) => { - // GUI / 指令 P2:/rewind — 获取会话历史消息点(daemon 协议 list_history 已存在) + ipcMain.handle("emrg:listHistory", async (_e, { sessionId, limit, offset } = {}) => { + // GUI / 指令 P2:/rewind + rant 14:15:12 历史按需加载(limit/offset 可选) if (!validateSessionId(sessionId)) throw new Error("invalid session_id"); - const frame = await requireConn().sendCommandAndWait("list_history", { session_id: sessionId, cwd: projectDir }, 5000); - return { messages: frame.messages || [] }; + const payload = { session_id: sessionId, cwd: projectDir }; + if (limit != null) payload.limit = limit; + if (offset != null) payload.offset = offset; + const frame = await requireConn().sendCommandAndWait("list_history", payload, 5000); + return { messages: frame.messages || [], hasMore: !!frame.has_more }; }); ipcMain.handle("emrg:rewindSession", async (_e, { sessionId, recordIndex }) => { diff --git a/emrg/gui/renderer/css/components.css b/emrg/gui/renderer/css/components.css index f597aab..acb684a 100644 --- a/emrg/gui/renderer/css/components.css +++ b/emrg/gui/renderer/css/components.css @@ -90,6 +90,19 @@ font-size: var(--fs-aux); color: var(--text-3); } +/* 历史消息(rant 14:15:12:只读展示,视觉弱化区分于实时消息) */ +.msg.user.history { + opacity: 0.72; + animation: none; +} +.history-load-bar { + text-align: center; + font-size: var(--fs-secondary); + color: var(--accent); + padding: var(--sp-2) 0; + cursor: pointer; + user-select: none; +} /* Markdown 内容排版 */ .msg-body p { diff --git a/emrg/gui/renderer/js/app.js b/emrg/gui/renderer/js/app.js index 38c6c65..8496bfd 100644 --- a/emrg/gui/renderer/js/app.js +++ b/emrg/gui/renderer/js/app.js @@ -661,9 +661,10 @@ const App = (() => { } return; } - // G13:v1 不加载历史(G12) + // G13:v1 不加载历史(G12)→ rant 14:15:12:非 silent 切换加载最近 50 条 + 滚动分页 if (!opts.silent) { Chat.addSystemMessage(_t("app.switched"), sid); + await loadHistory(sid); } // P3 finalize:切入断线会话 → 提示自动重连中(状态保留,不打断输入——G89) if (sidState(sid).disconnected) { @@ -678,6 +679,78 @@ const App = (() => { } } + // ── 历史按需加载(rant 14:15:12:切会话恢复最近 N 条 + 滚动到顶加载更早)── + const historyPages = new Map(); // sid → { offset, hasMore, loading } + const HISTORY_PAGE = 50; + + function historyPageState(sid) { + if (!historyPages.has(sid)) { + historyPages.set(sid, { offset: 0, hasMore: false, loading: false }); + } + return historyPages.get(sid); + } + + /** 渲染历史消息到该会话容器(只读 user 气泡,不触发工具/交互)。 */ + function renderHistoryMessages(sid, messages) { + for (const m of messages || []) { + Chat.addHistoryMessage(m.preview || m.content || "", sid); + } + } + + /** 加载最近一页历史(offset 从最新往回数)——切会话时调用。 */ + async function loadHistory(sid) { + const st2 = historyPageState(sid); + st2.loading = true; + try { + const res = await window.emrg.listHistory({ sessionId: sid, limit: HISTORY_PAGE, offset: st2.offset }); + renderHistoryMessages(sid, res.messages || []); + st2.offset += (res.messages || []).length; + st2.hasMore = !!res.hasMore; + if (st2.hasMore) { + Chat.setLoadBar(sid, _t("app.historyLoadMore")); + } + } catch (e) { + Chat.addSystemMessage(_t("app.historyFailed", { msg: e.message }), sid); + } finally { + st2.loading = false; + } + } + + /** 滚动到顶 → 加载更早一页(prepend,保持滚动位置)。 */ + async function loadOlderHistory(sid) { + const st2 = historyPageState(sid); + if (!st2.hasMore || st2.loading) return; + st2.loading = true; + const view = Chat.chatContainer(sid); + const prevScrollTop = view.scrollTop; + const prevHeight = view.scrollHeight; + try { + const res = await window.emrg.listHistory({ sessionId: sid, limit: HISTORY_PAGE, offset: st2.offset }); + const msgs = res.messages || []; + // prepend:先清加载条,再逐条插到顶部(addHistoryMessage prepend 会插在加载条之后) + for (const m of msgs) { + Chat.addHistoryMessage(m.preview || m.content || "", sid, { prepend: true }); + } + st2.offset += msgs.length; + st2.hasMore = !!res.hasMore; + if (msgs.length === 0) st2.hasMore = false; + if (st2.hasMore) { + Chat.setLoadBar(sid, _t("app.historyLoadMore")); + } else { + Chat.setLoadBar(sid, _t("app.historyNoMore")); + } + // 保持视觉位置:新内容插到顶部后滚差补偿 + view.scrollTop = prevScrollTop + (view.scrollHeight - prevHeight); + } catch (e) { + Chat.addSystemMessage(_t("app.historyFailed", { msg: e.message }), sid); + } finally { + st2.loading = false; + } + } + + /** 会话视图滚动:到顶且有更早 → 加载(防抖 150ms,绑定在 bindUi 内)。 */ + let historyScrollTimer = null; + async function newSession(opts = {}) { if (state.busy) { Chat.addSystemMessage(EMRG_Copy.COPY.sessionBusy); @@ -1510,6 +1583,17 @@ const App = (() => { state.autoScroll = true; $("back-to-bottom").classList.add("hidden"); }); + // rant 14:15:12:会话视图滚动到顶 → 加载更早历史(防抖 150ms) + chatView.addEventListener("scroll", (e) => { + const t = e.target; + const isView = t && t.classList && t.classList.contains("session-view"); + if (!isView || !state.sessionId) return; + const st2 = historyPageState(state.sessionId); + if (t.scrollTop <= 2 && st2.hasMore && !st2.loading) { + clearTimeout(historyScrollTimer); + historyScrollTimer = setTimeout(() => loadOlderHistory(state.sessionId), 150); + } + }, { passive: true }); // 空状态示例问题卡片 → 填入输入框 $("empty-state").addEventListener("click", (e) => { diff --git a/emrg/gui/renderer/js/chat.js b/emrg/gui/renderer/js/chat.js index 3bb0b11..c28f117 100644 --- a/emrg/gui/renderer/js/chat.js +++ b/emrg/gui/renderer/js/chat.js @@ -128,6 +128,38 @@ const Chat = (() => { append(el("div", { class: "msg system" }, text), sid); } + /** Read-only history user message (rant 14:15:12: restore recent history on + * session switch; not interactive). Reuses the user-bubble style plus a + * history class; prepend inserts after the load bar keeping scroll pos. */ + function addHistoryMessage(text, sid, { prepend = false } = {}) { + const node = el("div", { class: "msg user history" }, text); + const cv = chatContainer(sid); + if (prepend) { + const bar = cv.querySelector(".history-load-bar"); + cv.insertBefore(node, bar ? bar.nextSibling : cv.firstChild); + } else { + cv.appendChild(node); + } + App.updateEmptyState?.(); + return node; + } + + /** Top history load bar (rant 14:15:12): text=null removes it; otherwise + * it becomes the first child of the session container. */ + function setLoadBar(sid, text) { + const cv = chatContainer(sid); + let bar = cv.querySelector(".history-load-bar"); + if (text == null) { + if (bar) bar.remove(); + return; + } + if (!bar) { + bar = el("div", { class: "history-load-bar" }); + cv.insertBefore(bar, cv.firstChild); + } + bar.textContent = text; + } + /** 流式 delta(G122 main 已按 chunks 批量)——按会话隔离分组/已 done 集合 */ function handleDelta(chunks, sid) { const { groupNodes, doneRids } = st(sid); @@ -313,6 +345,8 @@ const Chat = (() => { addUserMessage, addSystemMessage, createAssistantNode, + addHistoryMessage, + setLoadBar, clear, scrollToBottom, handleDelta, diff --git a/emrg/gui/renderer/js/i18n.js b/emrg/gui/renderer/js/i18n.js index a789dcc..2d3e6ec 100644 --- a/emrg/gui/renderer/js/i18n.js +++ b/emrg/gui/renderer/js/i18n.js @@ -353,6 +353,9 @@ const I18N = (() => { "app.current": "当前", "app.noHistory": "没有可回退的历史消息。", "app.historyFailed": "加载历史失败:{msg}", + "app.historyLoadMore": "↑ 加载更早消息", + "app.historyLoading": "加载中…", + "app.historyNoMore": "没有更多历史", "app.rewound": "已回退到消息点 #{index},移除了 {n} 条记录。", "app.rewindFailed": "回退失败:{msg}", "app.noMemories": "还没有{scope}记忆。", @@ -739,6 +742,9 @@ const I18N = (() => { "app.current": "current", "app.noHistory": "No history points to rewind to.", "app.historyFailed": "Failed to load history: {msg}", + "app.historyLoadMore": "↑ Load earlier messages", + "app.historyLoading": "Loading…", + "app.historyNoMore": "No more history", "app.rewound": "Rewound to message point #{index}, removed {n} records.", "app.rewindFailed": "Rewind failed: {msg}", "app.noMemories": "No {scope} memories yet.", diff --git a/emrg/gui/test/renderer.smoke.test.js b/emrg/gui/test/renderer.smoke.test.js index 358f1cd..80122a4 100644 --- a/emrg/gui/test/renderer.smoke.test.js +++ b/emrg/gui/test/renderer.smoke.test.js @@ -193,6 +193,7 @@ function makeSandbox(overrides = {}) { pickProjectDir: async () => null, listSessions: async () => [], switchSession: async () => ({}), + listHistory: async () => ({ messages: [], hasMore: false }), newSession: async () => ({ session_id: "s2" }), deleteSession: async () => ({}), setModel: async () => ({}), @@ -2628,3 +2629,39 @@ test("P3:自定义类型管理 —— 内置只读;自定义增删改走模 assert.strictEqual(createdTpl.name, "nightly"); assert.ok(createdTpl.prompt.includes("nightly"), "prompt 原样提交"); }); + +test("rant 14:15:12:切会话加载最近历史(只读气泡 + 加载条),滚动到顶加载更早", async () => { + const calls = []; + const { ctx, els } = makeSandbox({ + switchSession: async () => ({}), + listHistory: async ({ limit, offset } = {}) => { + calls.push({ limit, offset }); + if (offset === 0) return { messages: [{ content: "msg-49", preview: "msg-49" }, { content: "msg-50", preview: "msg-50" }], hasMore: true }; + return { messages: [{ content: "msg-00", preview: "msg-00" }], hasMore: false }; + }, + }); + await vm.runInContext('App.boot()', ctx); + await tick(); + // 切到 s1 → 应调 listHistory(limit=50, offset=0) 并渲染 2 条历史气泡 + 加载条 + await vm.runInContext('App.switchSession("s1")', ctx); + await tick(); + assert.strictEqual(calls.length, 1, "切会话应加载最近一页历史"); + assert.strictEqual(calls[0].limit, 50); + assert.strictEqual(calls[0].offset, 0); + const historyNodes = vm.runInContext('document.getElementById("chat-view").querySelectorAll(".history").length', ctx); + assert.strictEqual(historyNodes, 2, "应渲染 2 条只读历史气泡"); + const loadBar = vm.runInContext('document.getElementById("chat-view").querySelector(".history-load-bar")', ctx); + assert.ok(loadBar, "hasMore 时应显示加载条"); + // 模拟滚动到顶 → 触发加载更早(防抖 150ms;mock 的 scroll 事件需带 .session-view target) + const view = vm.runInContext('document.getElementById("chat-view").querySelector(".session-view")', ctx); + view.scrollTop = 0; + els["chat-view"].dispatch("scroll", { target: view }); + await new Promise((r) => setTimeout(r, 200)); + await tick(); + assert.strictEqual(calls.length, 2, "滚动到顶应加载更早一页"); + assert.strictEqual(calls[1].offset, 2, "第二次加载 offset=已加载数"); + const historyNodes2 = vm.runInContext('document.getElementById("chat-view").querySelectorAll(".history").length', ctx); + assert.strictEqual(historyNodes2, 3, "prepend 后共 3 条历史气泡"); + const noMore = vm.runInContext('document.getElementById("chat-view").querySelector(".history-load-bar")', ctx); + assert.ok(noMore, "无更多历史时加载条仍在(显示没有更多)"); +});