From 693b8f8a598707eecfe6faa90c828a9c2d0d24c3 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Sun, 9 Aug 2026 21:40:06 +0800 Subject: [PATCH] emrg: GUI streaming markdown block projection (rant 21:00:28) --- Agent.md | 4 +- README.cn.md | 4 +- README.md | 2 +- emrg/gui/renderer/js/chat.js | 25 ++++- emrg/gui/renderer/js/markdown.js | 131 +++++++++++++++++++++- emrg/gui/test/renderer.smoke.test.js | 158 +++++++++++++++++++++++++++ 6 files changed, 312 insertions(+), 12 deletions(-) diff --git a/Agent.md b/Agent.md index 69cbb7b..9b5e01c 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` (103: 29 daemon_client + 22 app-commands + 27 renderer smoke + 15 i18n + 7 integration + 3 commands); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` + - Unit tests `npm test` (107: 29 daemon_client + 22 app-commands + 31 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` (652) — import check: `uv run python -c "from emrg.client.app import run_client"` -GUI: `cd emrg/gui && npm test` (103: 29 daemon_client + 22 app-commands + 27 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` (107: 29 daemon_client + 22 app-commands + 31 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.cn.md b/README.cn.md index 8bf7808..a22b5d7 100644 --- a/README.cn.md +++ b/README.cn.md @@ -282,8 +282,8 @@ uv run python -m emrg # 启动 TUI cd emrg/gui npm ci # 安装依赖(生产模式可 --omit=dev) npm start # 启动 GUI(自动拉起 daemon) -npm test # 运行 Node 测试(103 项:29 daemon_client + 22 app-commands + 27 renderer smoke + 15 i18n + 7 integration + 3 commands;集成测试在 CI 跑,本地可 npm run test:integration) -npm test # 运行 Node 测试(103 项:29 daemon_client + 22 app-commands + 27 renderer smoke + 15 i18n + 7 integration + 3 commands;集成测试在 CI 跑,本地可 npm run test:integration) +npm test # 运行 Node 测试(107 项:29 daemon_client + 22 app-commands + 31 renderer smoke + 15 i18n + 7 integration + 3 commands;集成测试在 CI 跑,本地可 npm run test:integration) +npm test # 运行 Node 测试(107 项:29 daemon_client + 22 app-commands + 31 renderer smoke + 15 i18n + 7 integration + 3 commands;集成测试在 CI 跑,本地可 npm run test:integration) ``` CI 通过 GitHub Actions 自动运行测试并检查冲突标记(`.github/workflows/test.yml`)。 diff --git a/README.md b/README.md index 8806b64..e376e78 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 (103: 29 daemon_client + 22 app-commands + 27 renderer smoke + 15 i18n + 7 integration + 3 commands; integration runs in CI, local: npm run test:integration) +npm test # run Node tests (107: 29 daemon_client + 22 app-commands + 31 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 2fc6831..3127e70 100644 --- a/emrg/gui/renderer/js/chat.js +++ b/emrg/gui/renderer/js/chat.js @@ -120,8 +120,14 @@ const Chat = (() => { const content = chunk.content || ""; if (content) group.hasText = true; const body = group.node.querySelector(".msg-body") || group.node; - // 流式中只动 textContent(不解析 Markdown)——性能约束 - body.textContent += content; + // rant 21:00:28:流式 markdown(块投影)——稳定块完整渲染并缓存 DOM(不闪烁/不打断选中), + // 尾部 live 块只渲染已稳定部分;代码围栏未闭合 → 纯文本不高亮(TUI fence_count%2 启发式一致)。 + // 无 marked.lexer 或投影异常 → 回退既有纯文本追加(done 时整体渲染)。 + const stream = group.node.__stream || (group.node.__stream = { stableCount: 0, container: null, live: null, rawText: "" }); + const raw = stream.rawText + content; + if (!window.emrgMarkdown.streamProject(body, raw, stream)) { + body.textContent += content; + } scrollToBottom(); } } @@ -147,7 +153,20 @@ const Chat = (() => { for (const node of group.nodes) { const body = node.querySelector(".msg-body") || node; body.classList.remove("typing"); - // rant 21:10:✦ 标记是元素而非文本——流式时 body.textContent 含 "✦ " 前缀, + // rant 21:00:28:流式投影结束 → live 块转 full 一次性校正(与旧 done 渲染同源 renderMarkdown) + const stream = node.__stream; + if (stream && stream.container) { + const render = () => { + window.emrgMarkdown.streamFinalize(body, stream.rawText).then(() => scrollToBottom()); + }; + if (window.requestIdleCallback) { + window.requestIdleCallback(render, { timeout: 2000 }); + } else { + render(); + } + continue; + } + // 非流式路径(既有):✦ 标记是元素而非文本——流式时 body.textContent 含 "✦ " 前缀, // 直接整体 render 会让 "✦ # Title" 等块语法(标题/列表/代码围栏)解析失败 // (前缀不在行首 → marked 不识别)。渲染前剥离前缀,渲染后重新插入标记保持视觉一致。 const text = body.textContent.replace(/^✦\s*/, ""); diff --git a/emrg/gui/renderer/js/markdown.js b/emrg/gui/renderer/js/markdown.js index e1faf02..d51521f 100644 --- a/emrg/gui/renderer/js/markdown.js +++ b/emrg/gui/renderer/js/markdown.js @@ -5,6 +5,10 @@ * - highlight.js 只注册 ~20 常用语言(vendor/highlight.custom.js,G132)——未注册降级纯文本 * - DOMPurify 消毒(marked v8 起无 sanitize 选项,G44) * - done 后整体渲染(流式中纯文本追加,G8) + * - rant 21:00:28:块投影流式渲染(Block Projection,参考 OpenCode markdown-stream.ts)—— + * marked.lexer 分词:除最后一个外的稳定块完整渲染并缓存 DOM(不打断选中/不闪烁), + * 尾部 live 块只渲染已稳定部分;代码块围栏未闭合 → 纯文本不高亮(与 TUI fence_count%2 启发式一致), + * 闭合后转完整渲染;done 时 live 块转 full 一次性校正(streamFinalize 走 renderMarkdown 同源)。 */ const renderer = {}; @@ -34,13 +38,17 @@ function codeRenderer(code, infostring, escaped) { return `
${codeHtml}
`; } +/** marked.use 自定义 renderer 只配置一次(renderMarkdown / streamProject 共用) */ +function ensureConfigured() { + if (!window.marked || window.__emrgMarkedConfigured) return; + window.marked.use({ renderer: { code: codeRenderer } }); + window.__emrgMarkedConfigured = true; +} + renderer.renderMarkdown = async function (mdText) { if (!window.marked) return escapeHtml(mdText || ""); try { - if (!window.__emrgMarkedConfigured) { - window.marked.use({ renderer: { code: codeRenderer } }); - window.__emrgMarkedConfigured = true; - } + ensureConfigured(); const html = window.marked.parse(mdText || "", { async: true, gfm: true, breaks: false }); const raw = await html; return window.DOMPurify.sanitize(raw); @@ -50,6 +58,121 @@ renderer.renderMarkdown = async function (mdText) { } }; +// ── 块投影流式渲染(rant 21:00:28) ────────────────────────────── + +/** 围栏结束标记(``` / ~~~,行尾)——判断 fenced code token 是否已闭合 */ +const FENCE_END_RE = /(```|~~~)[ \t]*$/; + +/** fenced code token 是否已闭合(未闭合 → 纯文本不高亮,与 TUI fence_count%2 启发式一致) */ +function isFenceClosed(token) { + return FENCE_END_RE.test(String(token.raw || "").trimEnd()); +} + +/** 渲染稳定块(完整,同步 parser;失败降级转义原文) */ +function renderStableToken(token) { + try { + const html = window.marked.parser([token]); + return window.DOMPurify ? window.DOMPurify.sanitize(html) : html; + } catch (e) { + console.warn("stable token render failed:", e); + return escapeHtml(token.raw || ""); + } +} + +/** 渲染尾部 live 块:未闭合代码块 → 纯文本;其余 → parser 局部渲染 */ +function renderLiveToken(token) { + if (!token) return ""; + if (token.type === "code" && !isFenceClosed(token)) { + // 围栏未闭合:只追加纯文本,不高亮(闭合后由稳定块完整渲染接管) + return `
${escapeHtml(token.text || token.raw || "")}
`; + } + try { + const html = window.marked.parser([token]); + return window.DOMPurify ? window.DOMPurify.sanitize(html) : html; + } catch (e) { + console.warn("live token render failed:", e); + return escapeHtml(token.raw || ""); + } +} + +/** + * 块投影增量更新:body 结构 = [span.msg-assistant-mark, div.md-stream] + * div.md-stream = [div.md-block(稳定块,缓存不动)]* + div.md-block.live(每 delta 重渲染) + * state(挂在消息节点上):{ stableCount, container, live, rawText } + * 无 marked.lexer / 分词异常 → 返回 false,调用方回退纯文本追加。 + */ +renderer.streamProject = function (body, text, state) { + if (!window.marked || typeof window.marked.lexer !== "function" || typeof window.marked.parser !== "function") { + return false; + } + ensureConfigured(); + try { + state.rawText = text; + // 首次进入流式:重建 body(mark span 元素 + md-stream 容器) + if (!state.container) { + body.innerHTML = ""; + body.appendChild(el("span", { class: "msg-assistant-mark" }, "✦ ")); + state.container = el("div", { class: "md-stream" }); + body.appendChild(state.container); + } + const tokens = window.marked.lexer(text); + const stable = tokens.slice(0, -1); + const live = tokens[tokens.length - 1] || null; + // 结构收缩(如尾随换行改变分词)→ 整体重投影,防错位 + if (stable.length < state.stableCount) { + state.container.innerHTML = ""; + state.live = null; + state.stableCount = 0; + } + // 旧 live 内容已被完整稳定块取代 → 清空待重建(保持 live 始终是最后一个子节点) + if (state.live && state.stableCount < stable.length) { + state.live.innerHTML = ""; + } + // 只渲染新增的稳定块(缓存不动 → 不打断选中/复制、不闪烁) + for (let i = state.stableCount; i < stable.length; i++) { + const block = el("div", { class: "md-block" }); + block.innerHTML = renderStableToken(stable[i]); + if (state.live) state.container.insertBefore(block, state.live); + else state.container.appendChild(block); + } + state.stableCount = stable.length; + // 尾部 live 块 + if (live) { + if (!state.live) { + state.live = el("div", { class: "md-block live" }); + state.container.appendChild(state.live); + } + state.live.innerHTML = renderLiveToken(live); + } else if (state.live) { + state.live.remove(); + state.live = null; + } + return true; + } catch (e) { + console.warn("streamProject failed:", e); + return false; + } +}; + +/** + * done 收尾:live 块转 full(一次性校正)。 + * 与既有 done 渲染同源(renderMarkdown 全量渲染),仅容器结构保留 mark span。 + */ +renderer.streamFinalize = async function (body, text) { + const html = await renderer.renderMarkdown(text); + const container = body.querySelector ? body.querySelector(".md-stream") : null; + if (container) { + container.innerHTML = html; + } else { + // 兜底:整段替换(重建 mark span + 容器) + body.innerHTML = ""; + body.appendChild(el("span", { class: "msg-assistant-mark" }, "✦ ")); + const c = el("div", { class: "md-stream" }); + c.innerHTML = html; + body.appendChild(c); + } +}; + function escapeHtml(s) { return String(s).replace(/&/g, "&").replace(//g, ">"); } diff --git a/emrg/gui/test/renderer.smoke.test.js b/emrg/gui/test/renderer.smoke.test.js index 03c17e0..a3cc644 100644 --- a/emrg/gui/test/renderer.smoke.test.js +++ b/emrg/gui/test/renderer.smoke.test.js @@ -363,6 +363,164 @@ test("rant 14:11:首条消息后欢迎屏立即隐藏(append 同步 updateEm assert.strictEqual(r.emptyHidden, true, "有消息时欢迎屏应隐藏(欢迎屏不得叠在消息区上方)"); }); +test("rant 21:00:28:块投影——流式中标题即时渲染,稳定块缓存不重建", async () => { + const { ctx } = makeSandbox(); + await tick(); + const r = await vm.runInContext(`(async function() { + App.state.sessionId = "s1"; + App.state.ownStreamRequestId = "rid-1"; + // 假 marked:空行分隔块,heading 行 → heading token,其余合并 paragraph + window.marked = { + use: () => {}, + parser: (tokens) => tokens.map((t) => t.type === "heading" ? "

" + t.text + "

" : "

" + t.text + "

").join(""), + parse: async (t) => "
" + t + "
", + lexer: (text) => { + const toks = []; + const blocks = text.split(/\\n\\n+/); + for (const b of blocks) { + const lines = b.split("\\n"); + if (/^#{1,6}\\s/.test(lines[0])) toks.push({ type: "heading", raw: lines[0], text: lines[0].replace(/^#+\\s*/, "") }); + else if (b.trim()) toks.push({ type: "paragraph", raw: b, text: b.replace(/\\n/g, " ") }); + } + return toks; + }, + }; + EMRG_Chat.handleDelta([{ request_id: "rid-1", content: "# 标题" }]); + const body = $("chat-view").children[0].querySelector(".msg-body"); + const stream = body.querySelector(".md-stream"); + const live1 = stream.children[stream.children.length - 1]; // 尾部 live 块 + const h2WhileStreaming = live1.className.includes("live") && live1.innerHTML.includes("

"); + EMRG_Chat.handleDelta([{ request_id: "rid-1", content: "\\n\\n正文段落" }]); + const stable = stream.children[0]; + const live2 = stream.children[stream.children.length - 1]; + const firstIsHeading = stable.innerHTML.includes("

"); + const liveIsParagraph = live2.innerHTML.includes("

正文段落

"); + // 稳定块缓存:再追加增量,首个稳定块节点身份不变(不重建 → 不闪烁/不打断选中) + EMRG_Chat.handleDelta([{ request_id: "rid-1", content: " 追加" }]); + const stableSame = (stream.children[0] === stable); + return { h2WhileStreaming, firstIsHeading, liveIsParagraph, stableSame }; + })()`, ctx); + assert.strictEqual(r.h2WhileStreaming, true, "流式过程中标题应即时渲染为 h2"); + assert.strictEqual(r.firstIsHeading, true, "稳定块应完整渲染标题"); + assert.strictEqual(r.liveIsParagraph, true, "live 块应渲染段落"); + assert.strictEqual(r.stableSame, true, "稳定块 DOM 应缓存复用(不重建)"); +}); + +test("rant 21:00:28:代码块围栏未闭合纯文本不高亮,闭合后转完整渲染", async () => { + const { ctx } = makeSandbox(); + await tick(); + const r = await vm.runInContext(`(function() { + App.state.sessionId = "s1"; + App.state.ownStreamRequestId = "rid-1"; + // 假 marked:整个输入视为一个 code token(raw 含未闭合/闭合围栏由内容决定) + window.marked = { + use: () => {}, + parser: (tokens) => tokens.map((t) => "
" + t.text + "
").join(""), + parse: async (t) => "
" + t + "
", + lexer: (text) => { + const m = /\`\`\`\\w*\\n([\\s\\S]*?)(\`\`\`\\s*)?$/.exec(text); + return [{ type: "code", raw: text, text: m ? m[1] : text }]; + }, + }; + EMRG_Chat.handleDelta([{ request_id: "rid-1", content: "before\\n\\\`\\\`\\\`python\\nprint(1)" }]); + const body = $("chat-view").children[0].querySelector(".msg-body"); + const live = body.querySelector(".md-stream"); + const liveDiv = live.children[live.children.length - 1]; // 尾部 live 块(单类选择器 mock 限制) + const unclosedPlain = liveDiv.className.includes("live") && liveDiv.innerHTML.startsWith("
") && !liveDiv.innerHTML.includes("hljs");
+    EMRG_Chat.handleDelta([{ request_id: "rid-1", content: "\\n\\\`\\\`\\\`" }]);
+    const liveDiv2 = live.children[live.children.length - 1];
+    const closedHighlight = liveDiv2.className.includes("live") && liveDiv2.innerHTML.includes("hljs") && !liveDiv2.innerHTML.includes("stream-code");
+    return { unclosedPlain, closedHighlight };
+  })()`, ctx);
+  assert.strictEqual(r.unclosedPlain, true, "围栏未闭合 → 纯文本不高亮(TUI fence_count%2 启发式一致)");
+  assert.strictEqual(r.closedHighlight, true, "围栏闭合 → 转完整渲染(高亮)");
+});
+
+test("rant 21:00:28:done 收尾 live 块转 full(mark span 保留,typing 移除)", async () => {
+  const { ctx } = makeSandbox();
+  await tick();
+  const r = await vm.runInContext(`(async function() {
+    App.state.sessionId = "s1";
+    App.state.ownStreamRequestId = "rid-1";
+    window.marked = {
+      use: () => {},
+      parser: (tokens) => tokens.map((t) => "

" + t.text + "

").join(""), + parse: async (t) => "
" + t + "
", + lexer: (text) => [{ type: "paragraph", raw: text, text }], + }; + EMRG_Chat.handleDelta([{ request_id: "rid-1", content: "部分" }]); + const node = $("chat-view").children[0]; + const body = node.querySelector(".msg-body"); + EMRG_Chat.handleDone({ request_id: "rid-1" }); + await new Promise((res) => setTimeout(res, 10)); // 等 streamFinalize microtask + const stream = body.querySelector(".md-stream"); + const fullRendered = stream.innerHTML.includes("class=\\"full\\""); + const markKept = (body.children[0].className || "").includes("msg-assistant-mark"); + const typingAfter = body.classList.contains("typing"); + return { fullRendered, markKept, typingAfter }; + })()`, ctx); + assert.strictEqual(r.fullRendered, true, "done 后容器应整体渲染(live 转 full 校正)"); + assert.strictEqual(r.markKept, true, "✦ mark span 应保留为元素(块语法不被前缀破坏)"); + assert.strictEqual(r.typingAfter, false, "done 后 typing 光标应移除"); +}); + +test("rant 21:00:28:真实 marked 集成——块投影与真实分词一致(未闭合围栏纯文本→闭合高亮→done full)", async () => { + const markedReal = require(path.join(__dirname, "..", "vendor", "marked.min.js")).marked; + const { ctx } = makeSandbox(); + await tick(); + ctx.marked = markedReal; // 注入真实 marked 到 vm 全局(window.marked) + const r = await vm.runInContext(`(async function() { + App.state.sessionId = "s1"; + App.state.ownStreamRequestId = "rid-1"; + const bodyOf = () => $("chat-view").children[0].querySelector(".msg-body"); + const liveOf = (b) => { + const s = b.querySelector(".md-stream"); + return s.children[s.children.length - 1]; // 尾部 live 块(单类选择器 mock 限制) + }; + const streamOf = (b) => b.querySelector(".md-stream"); + // 1. 流式标题即时渲染 + EMRG_Chat.handleDelta([{ request_id: "rid-1", content: "# 标题" }]); + const b1 = bodyOf(); + const liveHeading = liveOf(b1).innerHTML; // 应为

标题

(单个 #) + // 2. 追加段落 → 标题变稳定块,live 变段落 + EMRG_Chat.handleDelta([{ request_id: "rid-1", content: "\\n\\n正文段落" }]); + const s2 = streamOf(b1); + const stableHeading = s2.children[0].innerHTML; + const livePara = s2.children[s2.children.length - 1].innerHTML; + // 3. 未闭合代码围栏 → 纯文本不高亮 + EMRG_Chat.handleDelta([{ request_id: "rid-1", content: "\\n\\n\\\`\\\`\\\`python\\nprint(1)" }]); + const s3 = streamOf(b1); + const liveUnclosed = s3.children[s3.children.length - 1].innerHTML; + // 4. 闭合围栏 → 完整渲染(codeRenderer 容器 code-block) + EMRG_Chat.handleDelta([{ request_id: "rid-1", content: "\\n\\\`\\\`\\\`" }]); + const s4 = streamOf(b1); + const liveClosed = s4.children[s4.children.length - 1].innerHTML; + // 5. done → full 渲染(同源 renderMarkdown) + EMRG_Chat.handleDone({ request_id: "rid-1" }); + await new Promise((res) => setTimeout(res, 20)); + const s5 = streamOf(b1); + return { + liveHeading, + stableHeading, + livePara, + liveUnclosed, + liveClosed, + fullHtml: s5.innerHTML, + markKept: (b1.children[0].className || "").includes("msg-assistant-mark"), + }; + })()`, ctx); + assert.strictEqual(r.liveHeading, "

标题

\n", "流式中标题应即时渲染为 h1(真实 marked)"); + assert.strictEqual(r.stableHeading, "

标题

\n", "追加后标题应转稳定块完整渲染"); + assert.strictEqual(r.livePara, "

正文段落

\n", "live 块应渲染段落"); + assert.ok(r.liveUnclosed.includes("stream-code"), `未闭合围栏应纯文本,实际 ${r.liveUnclosed}`); + assert.ok(!r.liveUnclosed.includes("code-block"), "未闭合围栏不得出现 code-block(不高亮)"); + assert.ok(r.liveClosed.includes("code-block"), `闭合围栏应转完整渲染(codeRenderer),实际 ${r.liveClosed}`); + assert.ok(!r.liveClosed.includes("stream-code"), "闭合后不得再走纯文本路径"); + assert.ok(r.fullHtml.includes("

标题

"), "done 后 full 渲染应含标题"); + assert.ok(r.fullHtml.includes("code-block"), "done 后 full 渲染应含代码块"); + assert.strictEqual(r.markKept, true, "done 后 mark span 应保留"); +}); + test("rant 14:11:done 后残留 delta 被丢弃,不建孤儿节点", async () => { const { ctx } = makeSandbox(); await tick();