From ec0f801fca0561cbffd3fbbb8bd143efeb33abf3 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Tue, 11 Aug 2026 13:57:07 +0800 Subject: [PATCH] =?UTF-8?q?emrg:=20GUI=20workspace=20panel=20P3.3=20?= =?UTF-8?q?=E2=80=94=20viewer=20hljs=20highlight=20+=20markdown=20render?= =?UTF-8?q?=20+=20image=20display=20(rant=202026-08-11T12:20:35)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Agent.md | 4 +- README.cn.md | 2 +- README.md | 2 +- emrg/gui/renderer/css/layout.css | 27 +++++++++++ emrg/gui/renderer/index.html | 2 +- emrg/gui/renderer/js/result-panel.js | 69 ++++++++++++++++++++++++++-- emrg/gui/test/renderer.smoke.test.js | 58 +++++++++++++++++++++-- 7 files changed, 150 insertions(+), 14 deletions(-) diff --git a/Agent.md b/Agent.md index f181531..4e043ea 100644 --- a/Agent.md +++ b/Agent.md @@ -85,7 +85,7 @@ Usage: say "tool loop" for the whole process, "round N" for a single LLM request - 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` (202: 43 daemon_client + 19 conn-manager + 22 app-commands + 81 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` + - Unit tests `npm test` (205: 43 daemon_client + 19 conn-manager + 22 app-commands + 84 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state); 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) @@ -113,7 +113,7 @@ pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg ``` Python: `uv run pytest tests/ -v` (694) — import check: `uv run python -c "from emrg.client.app import run_client"` -GUI: `cd emrg/gui && npm test` (202: 43 daemon_client + 19 conn-manager + 22 app-commands + 81 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` +GUI: `cd emrg/gui && npm test` (205: 43 daemon_client + 19 conn-manager + 22 app-commands + 84 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state) — 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 b40829e..d8e8168 100644 --- a/README.cn.md +++ b/README.cn.md @@ -147,7 +147,7 @@ EMRG 不只是追赶——它自己追上来。 贡献指南、源码安装、架构、详细 FAQ → [DEVELOPMENT.md](DEVELOPMENT.md)。 -快速检查:`uv run pytest tests/ -v`(当前 694 项)· `cd emrg/gui && npm test`(202 项:43 daemon_client + 19 conn-manager + 22 app-commands + 81 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state) +快速检查:`uv run pytest tests/ -v`(当前 694 项)· `cd emrg/gui && npm test`(205 项:43 daemon_client + 19 conn-manager + 22 app-commands + 84 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state) --- diff --git a/README.md b/README.md index d6385fd..60402ae 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,7 @@ They're products. EMRG is an experiment in *closing the loop* — the AI improve Contributing, source installs, architecture, and the full FAQ → [DEVELOPMENT.md](DEVELOPMENT.md). -Quick checks: `uv run pytest tests/ -v` (currently 694 items) · `cd emrg/gui && npm test` (202: 43 daemon_client + 19 conn-manager + 22 app-commands + 81 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state) +Quick checks: `uv run pytest tests/ -v` (currently 694 items) · `cd emrg/gui && npm test` (205: 43 daemon_client + 19 conn-manager + 22 app-commands + 84 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state) --- diff --git a/emrg/gui/renderer/css/layout.css b/emrg/gui/renderer/css/layout.css index a3ca71e..b321215 100644 --- a/emrg/gui/renderer/css/layout.css +++ b/emrg/gui/renderer/css/layout.css @@ -550,6 +550,33 @@ body.sidebar-collapsed #app { white-space: pre; color: var(--text-1); } +/* P3.3:查看器图片直显 + md 渲染 */ +.viewer-img { + flex: 1; + object-fit: contain; + max-width: 100%; + padding: var(--sp-3); + box-sizing: border-box; +} +.viewer-md { + flex: 1; + overflow: auto; + padding: var(--sp-3); + font-size: var(--fs-secondary); + line-height: 1.6; + color: var(--text-1); +} +.viewer-md h1, .viewer-md h2, .viewer-md h3 { margin: var(--sp-2) 0; } +.viewer-md code { + background: var(--bg-soft); + border-radius: 4px; + padding: 1px 4px; +} +.viewer-md pre code { + display: block; + padding: var(--sp-3); + overflow-x: auto; +} /* ── P3.2:产物文件行(write/edit 成功文件,点击打开查看器 Tab) ── */ .artifact-row { diff --git a/emrg/gui/renderer/index.html b/emrg/gui/renderer/index.html index 2043db4..11b0604 100644 --- a/emrg/gui/renderer/index.html +++ b/emrg/gui/renderer/index.html @@ -2,7 +2,7 @@ - + EMRG diff --git a/emrg/gui/renderer/js/result-panel.js b/emrg/gui/renderer/js/result-panel.js index 81a93dc..65b16cd 100644 --- a/emrg/gui/renderer/js/result-panel.js +++ b/emrg/gui/renderer/js/result-panel.js @@ -201,14 +201,25 @@ const ResultPanel = (() => { if ((key || null) === currentSid) { renderTabbar(); activateTab(st.active, key); } } - // ── 文件查看器(P3.3 基础版:readFile 文本 / 二进制提示 / 系统工具打开) ── + // ── 文件查看器(P3.3:文本高亮 / md 渲染 / 图片直显 / 二进制提示) ── + const IMAGE_EXT = /\.(png|jpe?g|gif|svg|webp|bmp|ico)$/i; + + function isImagePath(path) { + return IMAGE_EXT.test(String(path).split(/[?#]/)[0]); + } + function isMarkdownPath(path) { + return /\.(md|markdown|mdown)$/i.test(String(path)); + } + async function loadFileTab(path) { const vp = viewerEl(); if (!vp) return; const st = stateFor(currentSid); const tab = st.tabs.find((t) => t.path === path); if (!tab || tab.loading) return; - if (tab.content !== undefined) { renderViewer(vp, tab); return; } + if (tab.content !== undefined || tab.image || tab.readError) { await renderViewer(vp, tab); return; } + // 图片不走 read_file(file:// 直显,P3.3) + if (isImagePath(path)) { tab.image = true; await renderViewer(vp, tab); return; } tab.loading = true; vp.innerHTML = ""; vp.appendChild(el("div", { class: "result-empty" }, _t("result.viewerLoading"))); @@ -225,10 +236,10 @@ const ResultPanel = (() => { } finally { tab.loading = false; } - renderViewer(vp, tab); + await renderViewer(vp, tab); } - function renderViewer(vp, tab) { + async function renderViewer(vp, tab) { vp.innerHTML = ""; const head = el("div", { class: "viewer-head" }); head.appendChild(el("span", { class: "viewer-path" }, tab.path)); @@ -246,11 +257,59 @@ const ResultPanel = (() => { vp.appendChild(el("div", { class: "result-empty" }, _t("result.viewerBinary"))); return; } + if (tab.image) { + const img = el("img", { class: "viewer-img", src: "file://" + tab.path, alt: tab.name || "" }); + img.addEventListener("error", () => { + img.style.display = "none"; + const hint = el("div", { class: "result-empty" }, _t("result.viewerError")); + vp.appendChild(hint); + }); + vp.appendChild(img); + return; + } + // md → markdown 渲染(DOMPurify sanitize,与聊天区同源) + if (isMarkdownPath(tab.path)) { + try { + const html = await window.emrgMarkdown.renderMarkdown(tab.content); + const mdBox = el("div", { class: "viewer-md" }); + mdBox.innerHTML = html; + vp.appendChild(mdBox); + return; + } catch { /* fall through to plain text */ } + } + // 文本:hljs 高亮(精确 lang,无 lang highlightAuto 兜底——与 markdown.js code renderer 同策略) const pre = el("pre", { class: "viewer-pre" }); - pre.appendChild(el("code", {}, tab.content)); + const code = el("code", {}, tab.content); + try { + const hljs = window.hljs; + if (hljs) { + const lang = detectLang(tab.path); + if (lang && hljs.getLanguage(lang)) { + code.innerHTML = hljs.highlight(tab.content, { language: lang, ignoreIllegals: true }).value; + } else { + code.innerHTML = hljs.highlightAuto(tab.content).value; + } + code.classList.add("hljs", "language-" + (lang || "plaintext")); + } + } catch { /* highlight failure → fall back to escaped text */ } + pre.appendChild(code); vp.appendChild(pre); } + /** 按文件扩展名推断语言(对齐 highlight.custom.js 已注册语言) */ + function detectLang(path) { + const ext = String(path).split(".").pop().toLowerCase(); + const map = { + py: "python", js: "javascript", jsx: "javascript", ts: "typescript", tsx: "typescript", + sh: "bash", bash: "bash", zsh: "bash", css: "css", html: "html", htm: "html", + json: "json", yml: "yaml", yaml: "yaml", toml: "ini", md: "markdown", + go: "go", rs: "rust", java: "java", c: "c", h: "c", cpp: "cpp", cc: "cpp", + sql: "sql", xml: "xml", ini: "ini", dockerfile: "dockerfile", diff: "diff", + }; + if (path.toLowerCase().includes("dockerfile")) return "dockerfile"; + return map[ext] || ""; + } + /** 切换会话 → Tab/产物状态按 sid 隔离(缺口 5) */ function switchSession(sid) { currentSid = sid || null; diff --git a/emrg/gui/test/renderer.smoke.test.js b/emrg/gui/test/renderer.smoke.test.js index e8d217d..725957c 100644 --- a/emrg/gui/test/renderer.smoke.test.js +++ b/emrg/gui/test/renderer.smoke.test.js @@ -28,7 +28,7 @@ function makeEl(id) { classList: { // className 为唯一事实源:el() 直接赋值 className 后 classList 操作不得清空既有类 _set() { node._cls = new Set((node.className || "").split(/\s+/).filter(Boolean)); }, - add(c) { node.classList._set(); node._cls.add(c); node._update(); }, + add(...cs) { node.classList._set(); for (const c of cs) node._cls.add(c); node._update(); }, remove(c) { node.classList._set(); node._cls.delete(c); node._update(); }, // 忠实 DOM:toggle(c, force)——force 指定时按 force 加/删(sidebar highlight 依赖) toggle(c, force) { @@ -1169,9 +1169,9 @@ test("P3:点击文件行 → 打开文件 Tab + 查看器加载内容", async await tick(); // openFileTab → activateTab → loadFileTab(async readFile) // 文件 Tab 打开 assert.ok([...els["result-tabbar"].children].some((c) => c.dataset.path === "/proj/README.md"), "文件 Tab 应打开"); - // 查看器 pane 激活 + 文本渲染 + // 查看器 pane 激活 + 内容渲染(README.md → P3.3 markdown 渲染) assert.ok(els["result-viewer"].classList.contains("active"), "查看器 pane 应激活"); - assert.ok(els["result-viewer"].querySelectorAll(".viewer-pre").length >= 1, "应渲染文本内容"); + assert.ok(els["result-viewer"].querySelectorAll(".viewer-md").length >= 1, "md 文件应渲染 markdown 内容"); // 静态 Tab 不被误激活 assert.ok(!els["result-tab-files"].classList.contains("active"), "文件 Tab 不应激活"); }); @@ -1179,7 +1179,7 @@ test("P3:点击文件行 → 打开文件 Tab + 查看器加载内容", async test("P3:查看器二进制文件 → 提示用系统工具打开", async () => { const { ctx, els } = makeSandbox({ readFile: async () => ({ content: "", binary: true }) }); await tick(); - await vm.runInContext('ResultPanel.openFileTab(null, "/img/logo.png")', ctx); + await vm.runInContext('ResultPanel.openFileTab(null, "/data/blob.bin")', ctx); // 非图片扩展名(.bin) await tick(); assert.ok(els["result-viewer"].classList.contains("active"), "查看器 pane 应激活"); const empty = els["result-viewer"].querySelectorAll(".result-empty"); @@ -1187,6 +1187,56 @@ test("P3:查看器二进制文件 → 提示用系统工具打开", async () = assert.ok(els["result-viewer"].querySelectorAll(".viewer-pre").length === 0, "二进制不渲染文本"); }); +test("P3.3:图片文件不走 read_file(file:// 直显 img)", async () => { + let readCalled = false; + const { ctx, els } = makeSandbox({ + readFile: async () => { readCalled = true; return { content: "", binary: false }; }, + }); + await tick(); + await vm.runInContext('ResultPanel.openFileTab(null, "/img/logo.png")', ctx); + await tick(); + assert.ok(els["result-viewer"].classList.contains("active"), "查看器 pane 应激活"); + assert.ok(els["result-viewer"].querySelectorAll(".viewer-img").length === 1, "应渲染 img 元素"); + assert.strictEqual(readCalled, false, "图片不得调用 read_file"); + // CSP 允许 file:(源级断言防回归) + const html = fs.readFileSync(path.join(__dirname, "..", "renderer", "index.html"), "utf8"); + assert.ok(/img-src 'self' data: file:/.test(html), "CSP img-src 应含 file:"); +}); + +test("P3.3:markdown 文件走 renderMarkdown 渲染", async () => { + const { ctx, els } = makeSandbox({ + readFile: async () => ({ content: "# Title\n\nbody", binary: false }), + }); + await tick(); + await vm.runInContext('ResultPanel.openFileTab(null, "/proj/README.md")', ctx); + await tick(); + assert.ok(els["result-viewer"].querySelectorAll(".viewer-md").length >= 1, "md 应渲染 viewer-md 容器"); + assert.ok(els["result-viewer"].querySelectorAll(".viewer-pre").length === 0, "md 不渲染纯文本 pre"); +}); + +test("P3.3:文本代码文件 → hljs 高亮(.py → python)", async () => { + const sb = makeSandbox({ + readFile: async () => ({ content: "def f():\n return 1", binary: false }), + }); + const { ctx, els } = sb; + // hljs 是 window 级全局(overrides 只进 emrg)→ 沙箱创建后注入 + sb.win.hljs = { + getLanguage: (l) => (l === "python" ? {} : null), + highlight: (code, opts) => ({ value: "def f():" }), + highlightAuto: (code) => ({ value: code }), + }; + await tick(); + await vm.runInContext('ResultPanel.openFileTab(null, "/proj/main.py")', ctx); + await tick(); + const pre = els["result-viewer"].querySelectorAll(".viewer-pre"); + assert.ok(pre.length === 1, "应渲染高亮 pre"); + // harness querySelector 只支持类选择器 → 直接取 pre 子元素(code) + const code = pre[0].children && pre[0].children[0]; + assert.ok(code, "pre 内应有 code"); + assert.ok((code.className || "").includes("hljs"), "code 应带 hljs 类"); + assert.ok((code.className || "").includes("language-python"), "应带 language-python 类"); +}); + test("P3:切会话 → 文件树根跟随(per-session)", async () => { const { ctx, els } = makeSandbox(); await tick();