Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.token; python -m emrg

Python: `uv run pytest tests/ -v` (1231) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (100: 44 daemon_client + 20 conn-manager + 7 integration + 7 nav-policy + 7 gui-state + 6 build-config + 4 boot-contract + 3 preload-api + 2 theme-guard) — syntax: `node --check main.js preload.js daemon_client.js`
Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (511: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 11 markdown + 21 transcript + 10 TranscriptView + 15 history + 31 composer + 41 Composer + 6 LinkDialog + 16 sidebar + 17 Sidebar + 9 fileTree + 9 FileTree + 16 resultPanel + 8 ResultPanel + 27 workspaceView + 10 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 8 openSession + 6 WelcomeDialog + 9 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 18 daemonBridge + 7 DaemonBridgeProvider + 30 Shell + 15 DialogHost + 20 SettingsPanel + 6 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown) + `npm run build` → `renderer/dist/`
Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (514: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 13 markdown + 21 transcript + 11 TranscriptView + 15 history + 31 composer + 41 Composer + 6 LinkDialog + 16 sidebar + 17 Sidebar + 9 fileTree + 9 FileTree + 16 resultPanel + 8 ResultPanel + 27 workspaceView + 10 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 8 openSession + 6 WelcomeDialog + 9 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 18 daemonBridge + 7 DaemonBridgeProvider + 30 Shell + 15 DialogHost + 20 SettingsPanel + 6 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown) + `npm run build` → `renderer/dist/`
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 路径不受影响)
Git-over-https 兜底: `python scripts/sync-master-from-api.py [--repo owner/name] [--ref master]` — 受限网络下 github.com:443 不可达而 api.github.com 可达时,用 Git Data API 的 verification payload + signature 字节级重建上游 commit(含 web-flow GPG 签名 squash merge,reconstruct_commit 经 hermetic 测试验证 sha 一致)并推进本地 refs;内容对象缺失时 fail-loud 提示改用 git fetch(10+ 周期实证的恢复路径)
Expand Down
136 changes: 132 additions & 4 deletions emrg/gui/renderer/src/components/TranscriptView.test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { describe, expect, it } from "vitest";
import { act } from "react";
import { render, screen } from "@testing-library/react";
import { render, screen, waitFor } from "@testing-library/react";
import { TranscriptView } from "./TranscriptView";
import { createTranscriptStore, type TranscriptStore } from "../lib/transcript";
import { I18nProvider } from "../lib/i18n";
import type { MarkdownRenderer } from "../lib/markdown";
import {
createMarkdownRenderer,
type MarkdownRenderer,
type MarkedLike,
} from "../lib/markdown";

/**
* TranscriptView.test.tsx — 聊天区 React 组件测试(Batch 2,设计 §5 Batch 2 项 6 验收)。
Expand All @@ -18,10 +22,95 @@ const fakeMd: MarkdownRenderer = {
streamFinalize: async () => {},
};

function setup(store: TranscriptStore, sid?: string | null) {
/**
* 文本驱动 fake marked(rant 2026-09-02T21:07:35 验收):按 ``` 围栏切分 段落/code token,
* 闭合状态由是否存在收尾围栏决定——让块投影引擎在「闭合围栏 → 渲染 / 未闭合围栏 →
* live 纯文本」之间真实切换(与真实 marked lexer 语义对齐:code token 的 raw 含未闭合
* 围栏时 isFenceClosed 为 false)。
*/
function makeTextDrivenMarked(): { marked: MarkedLike; dompurify: { sanitize(h: string): string } } {
let codeRenderer: ((code: string, infostring: string, escaped: boolean) => string) | null = null;
const dompurify = { sanitize: (h: string) => h.replace(/<script[\s\S]*?<\/script>/gi, "") };

function tokenize(text: string): Array<Record<string, unknown>> {
const lines = text.split("\n");
const tokens: Array<Record<string, unknown>> = [];
let fence: string | null = null; // 当前围栏起始标记(``` 或 ~~~)
let lang = "";
const codeBuf: string[] = [];
const paraBuf: string[] = [];
const flushPara = () => {
const t = paraBuf.join("\n").trim();
if (t) tokens.push({ type: "paragraph", raw: t, text: t });
paraBuf.length = 0;
};
for (const line of lines) {
const m = /^(`{3,}|~{3,})[ \t]*([A-Za-z0-9_+-]*)[ \t]*$/.exec(line);
if (!fence && m) {
flushPara();
fence = m[1];
lang = m[2] || "";
continue;
}
if (fence && m && m[1][0] === fence[0] && m[1].length >= fence.length) {
tokens.push({
type: "code",
raw: fence + "\n" + codeBuf.join("\n") + "\n" + fence,
text: codeBuf.join("\n"),
lang,
});
fence = null;
continue;
}
if (fence) {
codeBuf.push(line);
continue;
}
paraBuf.push(line);
}
if (fence) {
// 未闭合:raw 无收尾围栏 → engine isFenceClosed 为 false → live 纯文本路径
tokens.push({ type: "code", raw: fence + "\n" + codeBuf.join("\n"), text: codeBuf.join("\n"), lang });
} else {
flushPara();
}
return tokens;
}

const renderToken = (tok: Record<string, unknown>): string => {
if (tok.type === "code") {
const code = String(tok.text ?? "");
const lg = String(tok.lang ?? "");
return codeRenderer ? codeRenderer(code, lg, false) : `<pre>${code}</pre>`;
}
return `<p>${String(tok.text ?? "")}</p>`;
};

return {
dompurify,
marked: {
parse(text: string) {
// 全量渲染:EOF 未闭合围栏也按 code 块收尾(与真实 marked 语义一致)
return tokenize(text).map(renderToken).join("");
},
lexer(text: string) {
return tokenize(text);
},
parser(tokens: Array<Record<string, unknown>>) {
return tokens.map(renderToken).join("");
},
use(opts) {
if (opts.renderer?.code) codeRenderer = opts.renderer.code;
return undefined;
},
},
};
}

function setup(store: TranscriptStore, sid?: string | null, renderer?: MarkdownRenderer) {
return render(
<I18nProvider lang="zh">
<TranscriptView store={store} sid={sid} renderer={fakeMd} />
<TranscriptView store={store} sid={sid} renderer={renderer ?? fakeMd} />
</I18nProvider>,
);
}
Expand Down Expand Up @@ -72,6 +161,45 @@ describe("TranscriptView", () => {
expect(container.querySelector(".msg-assistant-mark")).toHaveTextContent("✦");
});

it("流式期间块投影:闭合代码围栏即时渲染、未闭合围栏保持纯文本,done 全量校正(rant 2026-09-02T21:07:35 方案 B)", async () => {
const { marked, dompurify } = makeTextDrivenMarked();
const streamMd = createMarkdownRenderer({ marked, DOMPurify: dompurify });
const store = createTranscriptStore({ t: (k) => k });
store.handleDelta([{ request_id: "r1", content: "intro\n\n```js\nconst a = 1;\n```" }], "s1");
const { container } = setup(store, "s1", streamMd);
// typing 中:引擎已在 host 里投影「✦ + .md-stream」——不再纯文本等 done
const body = container.querySelector(".msg-body")!;
expect(body).toHaveClass("typing");
expect(container.querySelector(".md-stream")).not.toBeNull();
expect(container.querySelector(".md-stream-host .msg-assistant-mark")).toHaveTextContent("✦");
// 闭合 js 围栏在流式期间已渲染成 code-block(含复制按钮)
expect(container.querySelector(".md-block .code-block")).not.toBeNull();
expect(body.textContent).not.toContain("```js");
// 追加未闭合 py 围栏 → live 块保持纯文本(.stream-code,无 code-block)
act(() => {
store.handleDelta([{ request_id: "r1", content: "\n\n```py\nstill typing..." }], "s1");
});
expect(container.querySelector(".msg-body")).toHaveClass("typing");
const live = container.querySelector(".md-block.live") as HTMLElement;
expect(live).not.toBeNull();
expect(live.querySelector(".code-block")).toBeNull();
expect(live.querySelector(".stream-code")?.textContent).toContain("still typing...");
// 之前的稳定块被缓存:intro 段 + 闭合 js 块为稳定 .md-block,live 块独立
expect(container.querySelectorAll(".md-block:not(.live)")).toHaveLength(2);
expect(container.querySelectorAll(".code-block")).toHaveLength(1); // 未重复渲染
// done → typing 移除 + streamFinalize 全量校正(live/.stream-code 消失,整段全量 markdown)
act(() => {
store.handleDone({ request_id: "r1" }, "s1");
});
expect(container.querySelector(".msg-body")).not.toHaveClass("typing");
await waitFor(() => {
expect(container.querySelector(".stream-code")).toBeNull();
});
expect(container.querySelectorAll(".md-block")).toHaveLength(0); // finalize 整段替换
expect(container.querySelectorAll(".code-block")).toHaveLength(2); // js + py(EOF 收尾)
expect(container.querySelector(".msg-body")?.textContent).toContain("still typing...");
});

it("工具行 running → done:label 切换 + ✓ + 耗时 + 输出默认隐藏、点击展开", () => {
const store = createTranscriptStore({ t: (k) => k });
store.handleToolStart(
Expand Down
87 changes: 76 additions & 11 deletions emrg/gui/renderer/src/components/TranscriptView.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import type { ReactNode } from "react";
import {
type AssistantEntry,
Expand All @@ -8,7 +8,7 @@ import {
type TranscriptEntry,
type TranscriptStore,
} from "../lib/transcript";
import { createMarkdownRenderer, type MarkdownRenderer } from "../lib/markdown";
import { createMarkdownRenderer, type MarkdownRenderer, type StreamState } from "../lib/markdown";
import { toolPhrases } from "../lib/copywriting";
import { useI18n, type TranslateFn } from "../lib/i18n";

Expand All @@ -19,9 +19,13 @@ import { useI18n, type TranslateFn } from "../lib/i18n";
* .tool-row / .tool-group / .history-load-bar 等类名不变,Batch 5 切换时复用
* vanilla CSS,无需改样式表)。
*
* - 流式(typing)→ 纯文本;done → createMarkdownRenderer 整体渲染(与旧
* done 渲染同源 renderMarkdown;vendor marked/DOMPurify/hljs 构造注入,
* 无 vendor 时降级转义)。✦ 标记独立于渲染文本,避免破坏块语法解析。
* - 流式(typing)→ 块投影即时 markdown 渲染(rant 2026-09-02T21:07:35 方案 B:引擎
* markdown.ts streamProject 把已稳定块完整渲染并缓存 DOM,尾部 live 块只渲染已稳定
* 部分;未闭合代码围栏保持纯文本,与 TUI fence_count%2 启发式一致——流式期间不再
* 整段等 done 才格式化,与 TUI / react-markdown 竞品对齐)。done → streamFinalize
* 全量校正;渲染器无 marked / 投影异常 → 回退纯文本 + renderMarkdown 整体渲染(旧
* 路径;vendor marked/DOMPurify/hljs 构造注入,无 vendor 时降级转义)。✦ 标记独立于
* 渲染文本,避免破坏块语法解析。
* - 工具行:running 转圈 → done ✓ / failed;耗时 · Ns(仅成功);输出默认隐藏,
* 点击行展开(G91/G131 >2000 字符截断 + 展开全文按钮)。
* - 合并组:bar 摘要(chat.toolGroupSummary)+ 收起/展开,user-expanded 后不再自动收起。
Expand Down Expand Up @@ -170,6 +174,15 @@ function AssistantView({ entry, t, md }: { entry: AssistantEntry; t: TranslateFn
);
}

/**
* 助手文本段(每 rid 内按工具封存分段)。
* typing(流式)期间:React 只挂一个空 host 节点,块投影引擎(markdown.ts
* streamProject)把「✦ + div.md-stream(稳定块缓存 + 尾部 live 块)」增量写进 host——
* 已闭合的段落/列表/代码围栏即时按 markdown 渲染,未闭合围栏(esp. 代码围栏)保持
* 纯文本,不再等 done 才格式化。渲染器无 marked / 投影异常(返回 false)→ 回退纯文本
* 追加(旧行为)。done/封存:project 模式 streamFinalize 一次性全量校正;plain 模式由
* MarkdownText 整体渲染兜底。
*/
function AssistantSegmentView({
entry,
segment,
Expand All @@ -181,15 +194,67 @@ function AssistantSegmentView({
t: TranslateFn;
md: MarkdownRenderer;
}) {
// 块投影 host:引擎把「✦ + .md-stream」写入此节点(streamProject 返回 true 后显示;
// React 不管理其 children,避免与引擎增量 DOM 冲突)
const hostRef = useRef<HTMLDivElement | null>(null);
const stateRef = useRef<StreamState | null>(null);
const projectedRef = useRef<boolean | null>(null); // null=未探测
const [projected, setProjected] = useState<boolean | null>(null);

// 流式每帧:把累计文本投影进 host。useLayoutEffect → 投影发生在浏览器绘制前,
// typing 首帧即显示渲染结果(无「先纯文本后跳变」闪烁)。
useLayoutEffect(() => {
if (!segment.typing) return;
const el = hostRef.current;
if (!el) return;
if (projectedRef.current === false) return; // 引擎不可用 → React 侧纯文本回退
if (!stateRef.current) stateRef.current = { stableCount: 0, rawText: "" };
let ok = false;
try {
ok = md.streamProject(el, segment.text, stateRef.current);
} catch {
ok = false;
}
if (!ok) {
// 投影失败:清掉可能的部分写入,回退纯文本(assistant-plain 显示完整文本)
stateRef.current = null;
el.innerHTML = "";
}
if (projectedRef.current !== ok) {
projectedRef.current = ok;
setProjected(ok);
}
}, [segment.typing, segment.text, md]);

// done/封存(typing 移除):project 模式 → streamFinalize 把 live 块一次性校正为全量
// markdown(与旧 renderMarkdown 整体渲染同源);plain 模式由 MarkdownText 兜底。
useEffect(() => {
if (segment.typing || projectedRef.current !== true) return;
const el = hostRef.current;
if (!el) return;
md.streamFinalize(el, segment.text).catch(() => {
/* finalize 失败不阻断(引擎内部已有转义降级) */
});
}, [segment.typing, segment.text, md]);

return (
<div className="msg assistant">
<div className={segment.typing ? "msg-body typing" : "msg-body"}>
<span className="msg-assistant-mark">✦ </span>
{segment.typing ? (
// 流式纯文本(chat.js 无投影时的 textContent 追加路径;投影优化在浏览器接线批次)
<span className="assistant-plain">{segment.text}</span>
) : (
<MarkdownText text={segment.text} md={md} />
<div
ref={hostRef}
className="md-stream-host"
style={{ display: projected === true ? undefined : "none" }}
/>
{projected === true ? null : (
<>
<span className="msg-assistant-mark">✦ </span>
{segment.typing ? (
// 流式纯文本(chat.js 无投影时的 textContent 追加路径——引擎不可用时回退)
<span className="assistant-plain">{segment.text}</span>
) : (
<MarkdownText text={segment.text} md={md} />
)}
</>
)}
</div>
</div>
Expand Down
Loading
Loading