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
4 changes: 2 additions & 2 deletions Agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 路径不受影响)

Expand Down
4 changes: 2 additions & 2 deletions README.cn.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)。
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
25 changes: 22 additions & 3 deletions emrg/gui/renderer/js/chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
Expand All @@ -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*/, "");
Expand Down
131 changes: 127 additions & 4 deletions emrg/gui/renderer/js/markdown.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {};
Expand Down Expand Up @@ -34,13 +38,17 @@ function codeRenderer(code, infostring, escaped) {
return `<div class="code-block"><div class="code-head"><button type="button" class="code-copy" title="${window.EMRG_I18N ? window.EMRG_I18N.t("md.copyCode") : "复制代码"}">${window.EMRG_I18N ? window.EMRG_I18N.t("chat.copyCode") : "复制"}</button></div><pre>${codeHtml}</pre></div>`;
}

/** 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);
Expand All @@ -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 `<pre class="stream-code">${escapeHtml(token.text || token.raw || "")}</pre>`;
}
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
Expand Down
Loading
Loading