From e69f6b3c1f57a6b94f6381c9f1a8788c91b424cd Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Mon, 10 Aug 2026 16:55:56 +0800 Subject: [PATCH] =?UTF-8?q?emrg:=20GUI=20renderer=20per-session=20chat=20s?= =?UTF-8?q?tate=20isolation=20=E2=80=94=20P3=20slice=200=20(GUI=20multi-se?= =?UTF-8?q?ssion=20rant=20P3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Agent.md | 4 +- README.cn.md | 2 +- README.md | 2 +- emrg/gui/renderer/js/app.js | 29 ++++--- emrg/gui/renderer/js/chat.js | 120 +++++++++++++++++---------- emrg/gui/test/renderer.smoke.test.js | 71 ++++++++++++++++ 6 files changed, 167 insertions(+), 61 deletions(-) diff --git a/Agent.md b/Agent.md index 59a2a91e..173cf59d 100644 --- a/Agent.md +++ b/Agent.md @@ -66,7 +66,7 @@ EMRG is a self-evolving AI agent architecture experiment. Python implementation, - 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` (127: 37 daemon_client + 8 conn-manager + 22 app-commands + 32 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` + - Unit tests `npm test` (131: 37 daemon_client + 8 conn-manager + 22 app-commands + 36 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` - **Auto project tracking** — Automatically detects and records working directories; project-scoped sessions - **Rant-driven evolution** — User feedback via `/rant` drives automatic self-improvement cycles - **Headless GitHub auth** — Non-interactive evolution auto-extracts `GH_TOKEN` from git credential store (osxkeychain / credential helper); PR comment/LGTM queries fall back to REST API (GraphQL needs `read:org` scope) @@ -94,7 +94,7 @@ pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg ``` Python: `uv run pytest tests/ -v` (680) — import check: `uv run python -c "from emrg.client.app import run_client"` -GUI: `cd emrg/gui && npm test` (127: 37 daemon_client + 8 conn-manager + 22 app-commands + 32 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` +GUI: `cd emrg/gui && npm test` (131: 37 daemon_client + 8 conn-manager + 22 app-commands + 36 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` CI: `uv run pytest` + 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/README.cn.md b/README.cn.md index 758a60b9..aa44bb9f 100644 --- a/README.cn.md +++ b/README.cn.md @@ -282,7 +282,7 @@ uv run python -m emrg # 启动 TUI cd emrg/gui npm ci # 安装依赖(生产模式可 --omit=dev) npm start # 启动 GUI(自动拉起 daemon) -npm test # 运行 Node 测试(127 项:37 daemon_client + 8 conn-manager + 22 app-commands + 32 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config;集成测试在 CI 跑,本地可 npm run test:integration) +npm test # 运行 Node 测试(131 项:37 daemon_client + 8 conn-manager + 22 app-commands + 36 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config;集成测试在 CI 跑,本地可 npm run test:integration) ``` CI 通过 GitHub Actions 自动运行测试并检查冲突标记(`.github/workflows/test.yml`)。 diff --git a/README.md b/README.md index 60215105..b2d7b7a3 100644 --- a/README.md +++ b/README.md @@ -281,7 +281,7 @@ uv run python -m emrg # launch TUI cd emrg/gui npm ci # install deps (production: --omit=dev) npm start # launch GUI (auto-starts daemon) -npm test # run Node tests (127: 37 daemon_client + 8 conn-manager + 22 app-commands + 32 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config; integration runs in CI, local: npm run test:integration) +npm test # run Node tests (131: 37 daemon_client + 8 conn-manager + 22 app-commands + 36 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config; integration runs in CI, local: npm run test:integration) ``` CI runs tests and checks for conflict markers automatically via GitHub Actions (`.github/workflows/test.yml`). diff --git a/emrg/gui/renderer/js/app.js b/emrg/gui/renderer/js/app.js index 9f8d4e51..e1657dca 100644 --- a/emrg/gui/renderer/js/app.js +++ b/emrg/gui/renderer/js/app.js @@ -961,12 +961,15 @@ const App = (() => { // ── 事件处理(main 已分类) ───────────── async function handleEvent(evt) { const { type, data } = evt; + // P3(rant 15:07:19):main 事件桥附带 sid(#629 起)——Chat 按会话隔离状态; + // 无 sid(单会话过渡期)→ 默认桶,行为与改造前一致。 + const sid = evt.sid; switch (type) { case "message_delta": - Chat.handleDelta(data.chunks || [data]); + Chat.handleDelta(data.chunks || [data], sid); break; case "done": - Chat.handleDone(data); + Chat.handleDone(data, sid); if (data.request_id && (state.ownStreamRequestId === data.request_id || data.timeout)) { state.busy = false; state.ownStreamRequestId = null; @@ -974,20 +977,20 @@ const App = (() => { } break; case "tool_started": - Chat.handleToolStart(data); + Chat.handleToolStart(data, sid); break; case "tool_finished": - Chat.handleToolEnd(data); + Chat.handleToolEnd(data, sid); ResultPanel.addToolResult(data); break; case "cancelled": - Chat.clearTyping(); // rant 14:11:取消时移除在途节点 typing 光标(无 request_id,全清) + Chat.clearTyping(sid); // rant 14:11:取消时移除在途节点 typing 光标(无 request_id,全清) state.busy = false; state.ownStreamRequestId = null; setComposerDisabled(false); break; case "error": - handleError(data); + handleError(data, sid); break; case "pong": state.serverId = data.identity?.instance_id || state.serverId; @@ -1011,10 +1014,10 @@ const App = (() => { state.busy = false; state.ownStreamRequestId = null; setComposerDisabled(false); - // G97:广播分组缓存清理(DOM 保留;仅清 Map 引用) - Chat.groupNodes.clear(); + // P3:广播分组缓存清理按会话隔离(DOM 保留;仅清该会话 Map 引用;无 sid → 默认桶) + Chat.groupNodesFor(sid).clear(); // 进行中的工具行 → 结果未知(工具副作用不可重放) - for (const row of Chat.toolRows.values()) { + for (const row of Chat.toolRowsFor(sid).values()) { if (row.classList.contains("running")) { row.classList.remove("running"); row.classList.add("failed"); @@ -1043,15 +1046,15 @@ const App = (() => { } } - function handleError(data) { + function handleError(data, sid) { if (data.error && String(data.error).includes("session busy")) { - Chat.addSystemMessage(EMRG_Copy.COPY.sessionBusy); + Chat.addSystemMessage(EMRG_Copy.COPY.sessionBusy, sid); state.busy = false; state.ownStreamRequestId = null; setComposerDisabled(false); } else { - Chat.clearTyping(); // rant 14:11:流式错误时移除在途节点 typing 光标 - Chat.addSystemMessage(_t("app.error", { msg: data.error || _t("app.unknownError") })); + Chat.clearTyping(sid); // rant 14:11:流式错误时移除在途节点 typing 光标 + Chat.addSystemMessage(_t("app.error", { msg: data.error || _t("app.unknownError") }), sid); } } diff --git a/emrg/gui/renderer/js/chat.js b/emrg/gui/renderer/js/chat.js index 3127e70d..4ca831e5 100644 --- a/emrg/gui/renderer/js/chat.js +++ b/emrg/gui/renderer/js/chat.js @@ -5,12 +5,34 @@ */ const Chat = (() => { - // requestId → 消息 DOM 节点(广播分组) - const groupNodes = new Map(); - // tool_call_id → 工具行 DOM 节点 - const toolRows = new Map(); - // rant 14:11:已 done 的 request_id(UUID 不复用)——残留 delta 直接丢弃,防孤儿节点 - const doneRids = new Set(); + // P3(rant 15:07:19):会话级状态隔离——每会话一份 groupNodes/toolRows/doneRids。 + // sid=null 为旧版单会话桶(无 sid 事件/旧调用方 → 行为与改造前完全一致)。 + // 容器路由:registerContainer(sid, el) 后该 sid 渲染进独立容器;未注册 → 默认 $("chat-view") + // (P4 openSessions 前实际只存在一个激活会话,容器切换随 P4 落地)。 + const sessionState = new Map(); // sid|null → { groupNodes, toolRows, doneRids } + const containers = new Map(); // sid → 容器元素(P4 起每会话一个 chat-view) + + function st(sid) { + const key = sid || null; + if (!sessionState.has(key)) { + sessionState.set(key, { groupNodes: new Map(), toolRows: new Map(), doneRids: new Set() }); + } + return sessionState.get(key); + } + + /** 该会话的渲染容器:已注册的独立容器优先,否则默认聊天区(P3 过渡期行为) */ + function chatContainer(sid) { + return containers.get(sid) || $("chat-view"); + } + + /** P4 起:为新打开的会话注册独立容器;关闭时 unregister 清引用 */ + function registerContainer(sid, el) { + containers.set(sid, el); + } + function unregisterContainer(sid) { + containers.delete(sid); + sessionState.delete(sid); // 会话关闭 → 释放其分组/工具行/已 done 状态 + } /** 复制代码按钮(设计 §3.3):事件委托在聊天区,CSP 无内联 handler */ function initCodeCopy() { @@ -51,36 +73,37 @@ const Chat = (() => { } initCodeCopy(); // 模块级绑定一次(boot 可重复调用,防 listener 泄漏) - /** 追加节点到聊天区并滚动 */ - function append(node) { - $("chat-view").appendChild(node); - scrollToBottom(); + /** 追加节点到该会话聊天区并滚动 */ + function append(node, sid) { + chatContainer(sid).appendChild(node); + scrollToBottom(sid); // rant 14:11:任何消息增删都重新评估欢迎屏显隐(此前只在切会话时评估 → 首条消息后欢迎屏不隐藏) App.updateEmptyState?.(); } - function scrollToBottom() { - const cv = $("chat-view"); + function scrollToBottom(sid) { + const cv = chatContainer(sid); cv.scrollTop = cv.scrollHeight; } - function clear() { - $("chat-view").innerHTML = ""; - groupNodes.clear(); - toolRows.clear(); - doneRids.clear(); + function clear(sid) { + const key = sid || null; + chatContainer(key).innerHTML = ""; + st(key).groupNodes.clear(); + st(key).toolRows.clear(); + st(key).doneRids.clear(); App.updateEmptyState?.(); // rant 14:11:清空(切会话/新会话)也同步欢迎屏显隐 } /** 用户消息:右对齐柔和气泡 */ - function addUserMessage(text) { + function addUserMessage(text, sid) { const node = el("div", { class: "msg user" }, text); - append(node); + append(node, sid); return node; } /** EMRG 消息:全宽 + ✦ 标识(流式节点,body 先 textContent 后整体 marked) */ - function createAssistantNode(isOwn) { + function createAssistantNode(isOwn, sid) { const node = el("div", { class: "msg assistant" }); if (!isOwn) { node.appendChild(el("div", { class: "remote-label" }, EMRG_Copy._t("chat.fromOtherClient"))); @@ -89,30 +112,31 @@ const Chat = (() => { const body = el("div", { class: "msg-body typing" }); body.appendChild(mark); node.appendChild(body); - append(node); + append(node, sid); return node; } /** 系统消息(温和置中) */ - function addSystemMessage(text) { - append(el("div", { class: "msg system" }, text)); + function addSystemMessage(text, sid) { + append(el("div", { class: "msg system" }, text), sid); } - /** 流式 delta(G122 main 已按 chunks 批量) */ - function handleDelta(chunks) { + /** 流式 delta(G122 main 已按 chunks 批量)——按会话隔离分组/已 done 集合 */ + function handleDelta(chunks, sid) { + const { groupNodes, doneRids } = st(sid); for (const chunk of chunks) { const rid = chunk.request_id; if (!rid || doneRids.has(rid)) continue; // rant 14:11:已 done 的流丢弃残留 delta,不建孤儿节点 let group = groupNodes.get(rid); if (!group) { const isOwn = App.state.ownStreamRequestId === rid; - const node = createAssistantNode(isOwn); + const node = createAssistantNode(isOwn, sid); group = { node, nodes: [node], hasText: false, sealed: false }; groupNodes.set(rid, group); } else if (group.sealed) { // rant 21:57:10:上一文本段被工具行"封存"→ 新文本段开新节点(旧节点保留在 DOM 原位) const isOwn = App.state.ownStreamRequestId === rid; - const node = createAssistantNode(isOwn); + const node = createAssistantNode(isOwn, sid); group.node = node; group.nodes.push(node); group.sealed = false; @@ -128,13 +152,13 @@ const Chat = (() => { if (!window.emrgMarkdown.streamProject(body, raw, stream)) { body.textContent += content; } - scrollToBottom(); + scrollToBottom(sid); } } - /** 取消/错误收尾:移除所有在途节点的 typing 光标(cancelled 事件无 request_id,只能全清) */ - function clearTyping() { - for (const group of groupNodes.values()) { + /** 取消/错误收尾:移除该会话所有在途节点的 typing 光标(cancelled 事件无 request_id,只能全清) */ + function clearTyping(sid) { + for (const group of st(sid).groupNodes.values()) { for (const node of group.nodes) { const body = node.querySelector(".msg-body") || node; body.classList.remove("typing"); @@ -143,7 +167,8 @@ const Chat = (() => { } /** done:整体 Markdown 渲染(requestIdleCallback 调度,G127)——该 rid 的全部文本段逐个渲染 */ - function handleDone(data) { + function handleDone(data, sid) { + const { groupNodes, doneRids } = st(sid); const rid = data.request_id; if (rid) { doneRids.add(rid); @@ -157,7 +182,7 @@ const Chat = (() => { const stream = node.__stream; if (stream && stream.container) { const render = () => { - window.emrgMarkdown.streamFinalize(body, stream.rawText).then(() => scrollToBottom()); + window.emrgMarkdown.streamFinalize(body, stream.rawText).then(() => scrollToBottom(sid)); }; if (window.requestIdleCallback) { window.requestIdleCallback(render, { timeout: 2000 }); @@ -174,7 +199,7 @@ const Chat = (() => { window.emrgMarkdown.renderMarkdown(text).then((html) => { body.innerHTML = html; body.insertBefore(el("span", { class: "msg-assistant-mark" }, "✦ "), body.firstChild); - scrollToBottom(); + scrollToBottom(sid); }); }; if (window.requestIdleCallback) { @@ -187,24 +212,25 @@ const Chat = (() => { } } if (data.timeout) { - addSystemMessage(EMRG_Copy._t("chat.timeoutWarn")); + addSystemMessage(EMRG_Copy._t("chat.timeoutWarn"), sid); } // 工具调用次数上限中断(跨项目教训:截断的工作不提示 = 用户拿半成品) // 对齐 TUI:明确提示结果可能不完整 + 可继续(TUI 已有 "Try '继续' to resume") if (data.content && /exceeded/i.test(data.content) && /max|limit|round/i.test(data.content)) { - addSystemMessage(EMRG_Copy._t("chat.maxRoundsHint")); + addSystemMessage(EMRG_Copy._t("chat.maxRoundsHint"), sid); } } /** 工具友好状态行(进行中 → 完成/失败,默认折叠,点开展示原始输出) */ - function handleToolStart(data) { + function handleToolStart(data, sid) { + const { groupNodes, toolRows } = st(sid); const rid = data.request_id; if (rid) { let group = groupNodes.get(rid); if (!group) { // G104:tool_start 也建组(LLM 先出 tool_calls 后出文本) const isOwn = App.state.ownStreamRequestId === rid; - const node = createAssistantNode(isOwn); + const node = createAssistantNode(isOwn, sid); group = { node, nodes: [node], hasText: false, sealed: false }; groupNodes.set(rid, group); } else if (group.hasText) { @@ -230,12 +256,12 @@ const Chat = (() => { row.classList.toggle("expanded", !out.classList.contains("hidden")); } }); - append(row); + append(row, sid); toolRows.set(data.tool_call_id, row); } - function handleToolEnd(data) { - const row = toolRows.get(data.tool_call_id); + function handleToolEnd(data, sid) { + const row = st(sid).toolRows.get(data.tool_call_id); if (!row) return; // rant 21:08:工具执行完成后 spinner 必须停止——移除转圈元素(CSS 亦有 // .tool-row:not(.running) 隐藏兜底),只保留 ✓ 完成标记,防止"对号前面一直转圈"。 @@ -273,7 +299,7 @@ const Chat = (() => { row.appendChild(btn); } } - scrollToBottom(); + scrollToBottom(sid); } return { @@ -287,11 +313,17 @@ const Chat = (() => { handleToolStart, handleToolEnd, clearTyping, + registerContainer, + unregisterContainer, + chatContainer, + // P3:会话级状态访问器(sid=null → 旧版单会话桶,兼容既有调用方) + groupNodesFor: (sid) => st(sid).groupNodes, + toolRowsFor: (sid) => st(sid).toolRows, get groupNodes() { - return groupNodes; + return st(null).groupNodes; }, get toolRows() { - return toolRows; + return st(null).toolRows; }, }; })(); diff --git a/emrg/gui/test/renderer.smoke.test.js b/emrg/gui/test/renderer.smoke.test.js index 10113e25..bf6d6f43 100644 --- a/emrg/gui/test/renderer.smoke.test.js +++ b/emrg/gui/test/renderer.smoke.test.js @@ -913,3 +913,74 @@ test("正常 done 帧不触发上限提示(无假阳性)", async () => { const texts = (els["chat-view"].children || []).map((c) => c.textContent).join("|"); assert.ok(!/继续/.test(texts), "正常完成不应出现继续提示"); }); + +// ── P3 slice 0(rant 15:07:19):会话级状态隔离 + 容器路由 ────────────── + +test("P3: 按 sid 隔离 delta 分组——两会话同 request_id 互不串扰", async () => { + const { ctx } = makeSandbox({}); + await tick(); + await vm.runInContext( + 'EMRG_Chat.handleDelta([{ request_id: "rid-x", content: "A", done: false, delta: true }], "sess-a");' + + 'EMRG_Chat.handleDelta([{ request_id: "rid-x", content: "B", done: false, delta: true }], "sess-b");', + ctx + ); + // 两会话各自建组:A 组在 sess-a 桶,B 组在 sess-b 桶 + assert.strictEqual(ctx.EMRG_Chat.groupNodesFor("sess-a").size, 1, "sess-a has its group"); + assert.strictEqual(ctx.EMRG_Chat.groupNodesFor("sess-b").size, 1, "sess-b has its group"); + assert.strictEqual(ctx.EMRG_Chat.groupNodesFor("sess-a").get("rid-x").node.querySelector(".msg-body").textContent.includes("A"), true); + assert.strictEqual(ctx.EMRG_Chat.groupNodesFor("sess-b").get("rid-x").node.querySelector(".msg-body").textContent.includes("B"), true); +}); + +test("P3: done 只清理该会话分组;另一会话同 rid 组保留", async () => { + const { ctx } = makeSandbox({}); + await tick(); + await vm.runInContext( + 'EMRG_Chat.handleDelta([{ request_id: "rid-y", content: "a", done: false, delta: true }], "sess-a");' + + 'EMRG_Chat.handleDelta([{ request_id: "rid-y", content: "b", done: false, delta: true }], "sess-b");' + + 'EMRG_Chat.handleDone({ request_id: "rid-y", done: true }, "sess-a");', + ctx + ); + assert.strictEqual(ctx.EMRG_Chat.groupNodesFor("sess-a").size, 0, "sess-a done clears its group"); + assert.strictEqual(ctx.EMRG_Chat.groupNodesFor("sess-b").size, 1, "sess-b group untouched"); + // 残留 delta 只丢已 done 会话(sess-a 的 rid 已 done),sess-b 同 rid 仍渲染 + await vm.runInContext( + 'EMRG_Chat.handleDelta([{ request_id: "rid-y", content: "more", done: false, delta: true }], "sess-b");', + ctx + ); + assert.strictEqual(ctx.EMRG_Chat.groupNodesFor("sess-b").get("rid-y").node.querySelector(".msg-body").textContent.includes("more"), true); +}); + +test("P3: clearTyping(sid) 只清该会话在途 typing;另一会话保留", async () => { + const { ctx } = makeSandbox({}); + await tick(); + await vm.runInContext( + 'EMRG_Chat.handleDelta([{ request_id: "rid-1", content: "x", done: false, delta: true }], "sess-a");' + + 'EMRG_Chat.handleDelta([{ request_id: "rid-2", content: "y", done: false, delta: true }], "sess-b");' + + 'EMRG_Chat.clearTyping("sess-a");', + ctx + ); + const bodyA = ctx.EMRG_Chat.groupNodesFor("sess-a").get("rid-1").node.querySelector(".msg-body"); + const bodyB = ctx.EMRG_Chat.groupNodesFor("sess-b").get("rid-2").node.querySelector(".msg-body"); + assert.ok(!bodyA.classList.contains("typing"), "sess-a typing cleared"); + assert.ok(bodyB.classList.contains("typing"), "sess-b typing retained"); +}); + +test("P3: registerContainer 后该会话渲染进独立容器;无 sid 回退默认聊天区", async () => { + const { ctx, els, document } = makeSandbox({}); + await tick(); + // 注册独立容器(P4 openSessions 语义)——直接经 ctx 导出的 API 挂 Node 侧元素 + const extra = document.createElement("div"); + extra.id = "chat-view-sess-c"; + els["main"].appendChild(extra); + ctx.EMRG_Chat.registerContainer("sess-c", extra); + ctx.EMRG_Chat.addSystemMessage("hello-c", "sess-c"); + ctx.EMRG_Chat.addSystemMessage("hello-default"); + assert.strictEqual(extra.children.length, 1, "registered container receives its session's nodes"); + assert.strictEqual(extra.children[0].textContent, "hello-c"); + assert.strictEqual(els["chat-view"].children.length, 1, "default container receives un-sid'd nodes"); + // unregister → 状态释放,再发同 sid 消息回落默认容器 + ctx.EMRG_Chat.unregisterContainer("sess-c"); + ctx.EMRG_Chat.addSystemMessage("after-unreg", "sess-c"); + assert.strictEqual(extra.children.length, 1, "unregistered container no longer receives"); + assert.strictEqual(els["chat-view"].children.length, 2, "falls back to default container"); +});