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}${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 + "
").join(""), + parse: async (t) => "正文段落
"); + // 稳定块缓存:再追加增量,首个稳定块节点身份不变(不重建 → 不闪烁/不打断选中) + 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) => "
") && !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();