From e06ede190dc808077adb9c421705895857a03d6a Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Fri, 7 Aug 2026 22:03:02 +0800 Subject: [PATCH] emrg: GUI interleaved text/tool message order (rant 21:57:10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix: handleDelta reused one assistant node per request_id while tool rows append independently to chat-view → all text segments stacked at top, tool rows below. Now handleToolStart seals the current text segment and subsequent deltas open a new assistant node, preserving TUI-style text → tool → text → tool ordering; handleDone renders all segments. - chat.js: groupNodes value → {node, nodes, hasText, sealed} - handleToolStart seals when the group has text; handleDelta opens a new segment when sealed; handleDone/clearTyping iterate all segments - +1 renderer smoke test: alternating text/tool produces 5 nodes in order text,tool,text,tool,text with each segment independent; doc counts 91→93 --- Agent.md | 4 +- README.md | 2 +- emrg/gui/renderer/js/chat.js | 84 ++++++++++++++++++---------- emrg/gui/test/renderer.smoke.test.js | 50 +++++++++++++++++ 4 files changed, 106 insertions(+), 34 deletions(-) diff --git a/Agent.md b/Agent.md index 61ec7e7a..6c8c0ce6 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` (91: 22 daemon_client + 22 app-commands + 22 renderer smoke + 15 i18n + 7 integration + 3 commands); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` + - Unit tests `npm test` (93: 22 daemon_client + 22 app-commands + 24 renderer smoke + 15 i18n + 7 integration + 3 commands); 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` (572) — import check: `uv run python -c "from emrg.client.app import run_client"` -GUI: `cd emrg/gui && npm test` (91: 22 daemon_client + 22 app-commands + 22 renderer smoke + 15 i18n + 7 integration + 3 commands) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` +GUI: `cd emrg/gui && npm test` (93: 22 daemon_client + 22 app-commands + 24 renderer smoke + 15 i18n + 7 integration + 3 commands) — 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.md b/README.md index d00faf69..7c40dc44 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 (91: 22 daemon_client + 22 app-commands + 22 renderer smoke + 15 i18n + 7 integration + 3 commands; integration runs in CI, local: npm run test:integration) +npm test # run Node tests (93: 22 daemon_client + 22 app-commands + 24 renderer smoke + 15 i18n + 7 integration + 3 commands; 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/chat.js b/emrg/gui/renderer/js/chat.js index 4699000c..20f469a1 100644 --- a/emrg/gui/renderer/js/chat.js +++ b/emrg/gui/renderer/js/chat.js @@ -103,51 +103,65 @@ const Chat = (() => { for (const chunk of chunks) { const rid = chunk.request_id; if (!rid || doneRids.has(rid)) continue; // rant 14:11:已 done 的流丢弃残留 delta,不建孤儿节点 - let node = groupNodes.get(rid); - if (!node) { + let group = groupNodes.get(rid); + if (!group) { const isOwn = App.state.ownStreamRequestId === rid; - node = createAssistantNode(isOwn); - groupNodes.set(rid, node); + const node = createAssistantNode(isOwn); + 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); + group.node = node; + group.nodes.push(node); + group.sealed = false; } - const body = node.querySelector(".msg-body") || node; + const content = chunk.content || ""; + if (content) group.hasText = true; + const body = group.node.querySelector(".msg-body") || group.node; // 流式中只动 textContent(不解析 Markdown)——性能约束 - body.textContent += chunk.content || ""; + body.textContent += content; scrollToBottom(); } } /** 取消/错误收尾:移除所有在途节点的 typing 光标(cancelled 事件无 request_id,只能全清) */ function clearTyping() { - for (const node of groupNodes.values()) { - const body = node.querySelector(".msg-body") || node; - body.classList.remove("typing"); + for (const group of groupNodes.values()) { + for (const node of group.nodes) { + const body = node.querySelector(".msg-body") || node; + body.classList.remove("typing"); + } } } - /** done:整体 Markdown 渲染(requestIdleCallback 调度,G127) */ + /** done:整体 Markdown 渲染(requestIdleCallback 调度,G127)——该 rid 的全部文本段逐个渲染 */ function handleDone(data) { const rid = data.request_id; if (rid) { doneRids.add(rid); if (doneRids.size > 500) doneRids.clear(); // UUID 不复用,超限即清防长期运行增长 - } - const node = groupNodes.get(rid); - if (node) { - const body = node.querySelector(".msg-body") || node; - body.classList.remove("typing"); - const text = body.textContent; - const render = () => { - window.emrgMarkdown.renderMarkdown(text).then((html) => { - body.innerHTML = html; - scrollToBottom(); - }); - }; - if (window.requestIdleCallback) { - window.requestIdleCallback(render, { timeout: 2000 }); - } else { - render(); + const group = groupNodes.get(rid); + if (group) { + for (const node of group.nodes) { + const body = node.querySelector(".msg-body") || node; + body.classList.remove("typing"); + const text = body.textContent; + const render = () => { + window.emrgMarkdown.renderMarkdown(text).then((html) => { + body.innerHTML = html; + scrollToBottom(); + }); + }; + if (window.requestIdleCallback) { + window.requestIdleCallback(render, { timeout: 2000 }); + } else { + render(); + } + } + groupNodes.delete(rid); } - groupNodes.delete(rid); } if (data.timeout) { addSystemMessage(EMRG_Copy._t("chat.timeoutWarn")); @@ -162,10 +176,18 @@ const Chat = (() => { /** 工具友好状态行(进行中 → 完成/失败,默认折叠,点开展示原始输出) */ function handleToolStart(data) { const rid = data.request_id; - if (rid && !groupNodes.has(rid)) { - // G104:tool_start 也建组(LLM 先出 tool_calls 后出文本) - const isOwn = App.state.ownStreamRequestId === rid; - groupNodes.set(rid, createAssistantNode(isOwn)); + 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); + group = { node, nodes: [node], hasText: false, sealed: false }; + groupNodes.set(rid, group); + } else if (group.hasText) { + // rant 21:57:10:已有文本段之后来了工具 → 封存当前段,后续 delta 新建段(保持 TUI 交错顺序) + group.sealed = true; + } } const phrases = EMRG_Copy.toolPhrases(data.tool_name); const row = el("div", { class: "tool-row running" }); diff --git a/emrg/gui/test/renderer.smoke.test.js b/emrg/gui/test/renderer.smoke.test.js index 9f37229d..80e14e7a 100644 --- a/emrg/gui/test/renderer.smoke.test.js +++ b/emrg/gui/test/renderer.smoke.test.js @@ -211,6 +211,56 @@ test("流式 delta 追加 + 工具行 running→done 状态流转", async () => assert.ok(r.toolRowClass.includes("done"), `工具行应 done,实际 ${r.toolRowClass}`); }); +test("rant 21:57:10:交替文本/工具按顺序交错展示(每段文本独立成块)", async () => { + const { ctx } = makeSandbox(); + await tick(); + const r = vm.runInContext(`(function() { + App.state.sessionId = "s1"; + App.state.ownStreamRequestId = "rid-1"; + // LLM 常见输出序列:文本段1 → 工具1 → 文本段2 → 工具2 → 文本段3 + EMRG_Chat.handleDelta([{ request_id: "rid-1", content: "文本段1" }]); + EMRG_Chat.handleToolStart({ request_id: "rid-1", tool_call_id: "t1", tool_name: "read" }); + EMRG_Chat.handleToolEnd({ tool_call_id: "t1", tool_name: "read", content: "out1", elapsed: 0.1 }); + EMRG_Chat.handleDelta([{ request_id: "rid-1", content: "文本段2" }]); + EMRG_Chat.handleToolStart({ request_id: "rid-1", tool_call_id: "t2", tool_name: "bash" }); + EMRG_Chat.handleToolEnd({ tool_call_id: "t2", tool_name: "bash", content: "out2", elapsed: 0.2 }); + EMRG_Chat.handleDelta([{ request_id: "rid-1", content: "文本段3" }]); + const children = $("chat-view").children; + const kinds = []; + const texts = []; + const isToolRow = (c) => + c.children.length > 0 && (c.children[0].className || "").includes("tool-spinner"); + for (let i = 0; i < children.length; i++) { + const c = children[i]; + if (isToolRow(c)) { + kinds.push("tool"); + } else { + kinds.push("text"); + texts.push(c.textContent); + } + } + const beforeDone = children.length; + // done 后该 rid 的所有文本段都应渲染(typing 移除) + EMRG_Chat.handleDone({ request_id: "rid-1" }); + let typingAfter = 0; + for (let i = 0; i < $("chat-view").children.length; i++) { + const c = $("chat-view").children[i]; + if (c.className.includes("typing")) typingAfter++; + } + return { + count: children.length, + kinds: kinds.join(","), + texts: texts.join("|"), + beforeDone, + typingAfter, + }; + })()`, ctx); + assert.strictEqual(r.beforeDone, 5, "3 文本段 + 2 工具行 = 5 节点"); + assert.strictEqual(r.kinds, "text,tool,text,tool,text", "应按 文本→工具→文本→工具→文本 交错(TUI 一致)"); + assert.strictEqual(r.texts, "文本段1|文本段2|文本段3", "每段文本独立成块,不拼接在顶部(沙箱 textContent 不含 ✦ 标记 span)"); + assert.strictEqual(r.typingAfter, 0, "done 后所有文本段 typing 光标应移除"); +}); + test("rant 14:11:首条消息后欢迎屏立即隐藏(append 同步 updateEmptyState)", async () => { const { ctx } = makeSandbox(); await tick();