diff --git a/Agent.md b/Agent.md index 4e043ea5..f713002a 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` (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` + - Unit tests `npm test` (212: 43 daemon_client + 19 conn-manager + 22 app-commands + 91 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` (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` +GUI: `cd emrg/gui && npm test` (212: 43 daemon_client + 19 conn-manager + 22 app-commands + 91 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 d8e81683..be021fea 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`(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) +快速检查:`uv run pytest tests/ -v`(当前 694 项)· `cd emrg/gui && npm test`(212 项:43 daemon_client + 19 conn-manager + 22 app-commands + 91 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state) --- diff --git a/README.md b/README.md index 60402aea..fab248b9 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` (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) +Quick checks: `uv run pytest tests/ -v` (currently 694 items) · `cd emrg/gui && npm test` (212: 43 daemon_client + 19 conn-manager + 22 app-commands + 91 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state) --- diff --git a/emrg/gui/main.js b/emrg/gui/main.js index 145fb141..678d7787 100644 --- a/emrg/gui/main.js +++ b/emrg/gui/main.js @@ -5,10 +5,11 @@ * 安全:contextIsolation + nodeIntegration:false + sandbox:true(renderer 零网络权限)。 */ -const { app, BrowserWindow, dialog, ipcMain, shell } = require("electron"); +const { app, BrowserWindow, dialog, ipcMain, shell, WebContentsView } = require("electron"); const fs = require("fs"); const os = require("os"); const path = require("path"); +const { pathToFileURL } = require("url"); const { spawn } = require("child_process"); const { parse: parseToml, stringify: stringifyToml } = require("smol-toml"); const { generateSessionId, SESSION_ID_RE } = require("./daemon_client"); @@ -45,6 +46,13 @@ function main() { let openSessions = new Map(); let guiStateTimer = null; const GUI_STATE_DEBOUNCE_MS = 1000; + // P2.3(rant 12:20:35):HTML 预览 WebContentsView——懒创建、单实例复用、右对齐 bounds 同步。 + // renderer 崩溃 reload 后由 renderer 侧拉取(emrg:getPreviewState)恢复(main 是真相源)。 + let previewView = null; // WebContentsView 实例(首次打开 HTML tab 才创建) + let previewAdded = false; // 已 addChildView(防重复添加) + let previewPath = null; // 当前加载的预览文件绝对路径(null = 无) + let previewVisible = false; // 当前是否显示(激活 HTML tab) + let previewLayout = { width: 280, collapsed: false, contentTop: 0 }; // renderer 上报的面板布局 // ── 窗口 ──────────────────────────────────────────────── @@ -61,12 +69,17 @@ function main() { }, }); win.loadFile(path.join(__dirname, "renderer", "index.html")); + // P2.3:窗口尺寸变化 → 预览 bounds 跟随(右对齐矩形) + win.on("resize", () => updatePreviewBounds()); // G87:窗口状态持久化 restoreWindowBounds(win); win.on("close", () => saveWindowBounds(win)); // G101:renderer 崩溃恢复 win.webContents.on("render-process-gone", () => { logger.warn("[gui] renderer gone — reloading"); + // P2.3:预览 view 是独立 WebContents,崩溃不影响它;reload 完成后 renderer + // 经 emrg:getPreviewState 拉取当前预览路径重新开 Tab(恢复 bounds/loadURL 一致, + // 防预览与 Tab 栏不匹配,R5-④) win.loadFile(path.join(__dirname, "renderer", "index.html")); }); win.webContents.on("unresponsive", () => { @@ -462,6 +475,38 @@ vision = false return { content: frame.content || "", binary: !!frame.binary, truncated: !!frame.truncated, totalLines: frame.total_lines }; }); + // ── P2.3 + P3.4(rant 12:20:35):HTML 预览 WebContentsView ────────── + // 混合模型:非 HTML 走 renderer DOM 查看器;.html/.htm 走内嵌浏览器预览。 + // 懒创建(R7-⑤)+ 单实例复用(一次只显示一个预览,切换 = 重新加载 R7-⑥)。 + ipcMain.handle("emrg:previewHtml", async (_e, { path: p } = {}) => showPreview(p)); + + ipcMain.handle("emrg:closePreview", async (_e, { path: p } = {}) => { + // 关闭/切走 HTML tab → 隐藏预览(懒销毁:窗口关闭自动回收,R5-⑤) + const target = typeof p === "string" && p.trim() ? path.resolve(p.trim()) : null; + if (previewVisible && (!target || previewPath === target)) { + previewVisible = false; + previewPath = null; + updatePreviewBounds(); + } + return { ok: true }; + }); + + // renderer 上报面板布局(宽度/折叠/内容区顶部 = Tab 栏高)→ main 调 setBounds(R2-⑧) + ipcMain.handle("emrg:panelResized", async (_e, { width, collapsed, contentTop } = {}) => { + previewLayout = { + width: typeof width === "number" && width > 0 ? width : previewLayout.width, + collapsed: collapsed === true, + contentTop: typeof contentTop === "number" && contentTop >= 0 ? contentTop : previewLayout.contentTop, + }; + updatePreviewBounds(); + return { ok: true }; + }); + + // renderer 崩溃 reload 后拉取当前预览状态(main 是真相源,R5-④ 恢复通道) + ipcMain.handle("emrg:getPreviewState", async () => ({ + path: previewVisible && previewPath ? previewPath : null, + })); + ipcMain.handle("emrg:listSkills", async () => { // GUI / 指令 P3:/skills — 读取技能列表(TUI 本地 load_skills 等价物,daemon 无协议) // 技能在 ~/.emrg/skills/*.md(user)与 /.emrg/skills/*.md(project) @@ -1017,6 +1062,72 @@ vision = false } } + // ── P2.3 + P3.4:HTML 预览 WebContentsView ──────────────────────── + + /** 懒创建预览 view(R7-⑤):sandbox + contextIsolation 对齐主窗口;安全清单(缺口 8): + * setWindowOpenHandler 禁新窗 + will-frame-navigate 仅允许 file:// 主框架导航(防 HTML + * 内跳转远程 URL)。程序化 loadURL 不走 will-frame-navigate——入口只有 showPreview, + * 已校验扩展名 + 绝对路径 + 文件存在,无绕过路径。 */ + function ensurePreviewView() { + if (previewView) return previewView; + previewView = new WebContentsView({ + webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false }, + }); + previewView.webContents.setWindowOpenHandler(() => ({ action: "deny" })); + previewView.webContents.on("will-frame-navigate", (event, url, isInPlace, isMainFrame) => { + if (isMainFrame && !url.startsWith("file:")) event.preventDefault(); + }); + return previewView; + } + + /** 右对齐矩形(R4-②):x = winW - panelW、y = contentTop(Tab 栏高)、w = panelW、 + * h = winH - contentTop。折叠 → 置不可见区域(0 尺寸 + 不可见)。纯函数,便于单测。 */ + function previewRect(winW, winH, layout) { + if (layout.collapsed) return { x: winW, y: 0, width: 0, height: 0 }; + const width = Math.max(40, Math.min(layout.width || 280, winW)); + const contentTop = layout.contentTop || 0; + return { + x: Math.max(0, winW - width), + y: contentTop, + width, + height: Math.max(0, winH - contentTop), + }; + } + + function updatePreviewBounds() { + if (!previewView || !win || win.isDestroyed()) return; + if (!previewVisible) { previewView.setVisible(false); return; } + const cb = win.getContentBounds(); + const r = previewRect(cb.width, cb.height, previewLayout); + previewView.setBounds(r); + previewView.setVisible(true); + } + + /** 显示 HTML 预览:校验 → 懒创建 → loadURL(切换 = 重新加载 R7-⑥)→ bounds 同步 */ + async function showPreview(p) { + if (typeof p !== "string" || !p.trim()) return { ok: false, error: "invalid path" }; + const abs = path.resolve(p.trim()); + if (!/\.html?$/i.test(abs)) return { ok: false, error: "not_html" }; + if (!fs.existsSync(abs)) return { ok: false, error: "file_not_found" }; + try { + const view = ensurePreviewView(); + if (!previewAdded && win && !win.isDestroyed()) { + win.contentView.addChildView(view); + previewAdded = true; + } + if (previewPath !== abs) { + await view.webContents.loadURL(pathToFileURL(abs).href); + previewPath = abs; + } + previewVisible = true; + updatePreviewBounds(); + return { ok: true }; + } catch (e) { + logger.warn(`[gui] preview load failed: ${e.message}`); + return { ok: false, error: String(e.message || "load_failed") }; + } + } + // ── 应用生命周期 ──────────────────────────────────────── app.on("second-instance", () => { @@ -1073,6 +1184,11 @@ vision = false cancelReconnect(); persistGuiStateNow(); // P4:退出前冲刷未落盘的打开会话状态(防抖 timer 取消) connManager?.closeAll(); // P2:关闭全部会话连接 + daemon 级连接 + // P2.3:窗口关闭自动销毁子 view(R5-⑤ 无需手动清理);复位状态防二次使用 + previewView = null; + previewAdded = false; + previewPath = null; + previewVisible = false; if (process.platform !== "darwin") app.quit(); }); diff --git a/emrg/gui/preload.js b/emrg/gui/preload.js index ff803b68..0859df3b 100644 --- a/emrg/gui/preload.js +++ b/emrg/gui/preload.js @@ -25,6 +25,10 @@ const api = { readMemory: (payload) => ipcRenderer.invoke("emrg:readMemory", payload), listFiles: (payload) => ipcRenderer.invoke("emrg:listFiles", payload), // 右栏工作区 P1:目录树 readFile: (payload) => ipcRenderer.invoke("emrg:readFile", payload), // 右栏工作区 P1:文件查看器 + previewHtml: (payload) => ipcRenderer.invoke("emrg:previewHtml", payload), // P2.3:HTML 预览(WebContentsView) + closePreview: (payload) => ipcRenderer.invoke("emrg:closePreview", payload), // P2.3:关闭/切走 HTML 预览 + panelResized: (payload) => ipcRenderer.invoke("emrg:panelResized", payload), // P2.3:面板布局上报 → bounds 同步 + getPreviewState: () => ipcRenderer.invoke("emrg:getPreviewState"), // P2.3:renderer 崩溃恢复拉取 listSkills: () => ipcRenderer.invoke("emrg:listSkills"), listProjects: () => ipcRenderer.invoke("emrg:listProjects"), listProjectSessions: (payload) => ipcRenderer.invoke("emrg:listProjectSessions", payload), diff --git a/emrg/gui/renderer/css/layout.css b/emrg/gui/renderer/css/layout.css index b3212150..3a8adcf9 100644 --- a/emrg/gui/renderer/css/layout.css +++ b/emrg/gui/renderer/css/layout.css @@ -577,6 +577,18 @@ body.sidebar-collapsed #app { padding: var(--sp-3); overflow-x: auto; } +/* P3.4:HTML 预览占位(混合模型——WebContentsView 叠加其上;折叠/隐藏时占位可见) */ +.viewer-html { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: var(--sp-3); + box-sizing: border-box; +} +.viewer-html .result-empty { + max-width: 100%; +} /* ── P3.2:产物文件行(write/edit 成功文件,点击打开查看器 Tab) ── */ .artifact-row { diff --git a/emrg/gui/renderer/js/i18n.js b/emrg/gui/renderer/js/i18n.js index 7034a932..6856649d 100644 --- a/emrg/gui/renderer/js/i18n.js +++ b/emrg/gui/renderer/js/i18n.js @@ -57,6 +57,7 @@ const I18N = (() => { "result.viewerBinary": "二进制文件,用系统工具打开", "result.viewerError": "无法读取文件(可能已被删除或无权限)", "result.viewerOpen": "用系统工具打开", + "result.htmlPreview": "HTML 预览(内嵌浏览器)", // 设置 "settings.title": "设置", @@ -397,6 +398,7 @@ const I18N = (() => { "result.viewerBinary": "Binary file — open with system tool", "result.viewerError": "Cannot read file (deleted or no permission)", "result.viewerOpen": "Open with system tool", + "result.htmlPreview": "HTML preview (embedded browser)", // Settings "settings.title": "Settings", diff --git a/emrg/gui/renderer/js/result-panel.js b/emrg/gui/renderer/js/result-panel.js index 65b16cdd..11d7ba75 100644 --- a/emrg/gui/renderer/js/result-panel.js +++ b/emrg/gui/renderer/js/result-panel.js @@ -63,6 +63,7 @@ const ResultPanel = (() => { p.style.width = cw + "px"; try { localStorage.setItem(LS_WIDTH, String(cw)); } catch { /* ignore */ } updateResizerPos(); + notifyPanelResized(); // P2.3:宽度变化 → main 预览 bounds 同步 } function isCollapsed() { const p = panel(); @@ -82,6 +83,7 @@ const ResultPanel = (() => { p.classList.toggle("collapsed", collapsed); try { localStorage.setItem(LS_COLLAPSED, collapsed ? "1" : "0"); } catch { /* ignore */ } updateResizerPos(); + notifyPanelResized(); // P2.3:折叠/展开 → main 隐藏/恢复预览 } function toggle() { setCollapsed(!isCollapsed()); @@ -172,6 +174,8 @@ const ResultPanel = (() => { if (lp) lp.classList.toggle("active", tabId === "artifacts"); if (vp) vp.classList.toggle("active", isFileTab); renderTabbar(); + // P2.3:激活 HTML tab → previewHtml;切走 → closePreview(bounds 同步随 panelResized) + syncPreview(tabId); if (isFileTab) loadFileTab(tabId.slice(5)); } @@ -201,7 +205,7 @@ const ResultPanel = (() => { if ((key || null) === currentSid) { renderTabbar(); activateTab(st.active, key); } } - // ── 文件查看器(P3.3:文本高亮 / md 渲染 / 图片直显 / 二进制提示) ── + // ── 文件查看器(P3.3:文本高亮 / md 渲染 / 图片直显 / 二进制提示;P3.4:HTML 预览) ── const IMAGE_EXT = /\.(png|jpe?g|gif|svg|webp|bmp|ico)$/i; function isImagePath(path) { @@ -210,6 +214,40 @@ const ResultPanel = (() => { function isMarkdownPath(path) { return /\.(md|markdown|mdown)$/i.test(String(path)); } + /** P3.4:.html/.htm → WebContentsView 内嵌浏览器预览(不走 read_file) */ + function isHtmlPath(path) { + return /\.(html?)$/i.test(String(path).split(/[?#]/)[0]); + } + + // ── P2.3:面板布局上报(宽度/折叠/内容区顶部)→ main 侧 preview bounds 同步 ── + function panelLayout() { + const vp = viewerEl(); + let contentTop = 0; + if (vp && typeof vp.getBoundingClientRect === "function") { + try { contentTop = Math.round(vp.getBoundingClientRect().top) || 0; } catch { contentTop = 0; } + } + return { width: getWidth(), collapsed: isCollapsed(), contentTop }; + } + function notifyPanelResized() { + try { + const e = window.emrg; + if (e && typeof e.panelResized === "function") e.panelResized(panelLayout()); + } catch { /* 测试沙箱无 IPC → 忽略 */ } + } + + /** P2.3:HTML tab 激活/切走时同步 main 侧预览(预览内容切换 = 重新加载 R7-⑥) */ + function syncPreview(tabId) { + try { + const e = window.emrg; + if (!e) return; + if (typeof tabId === "string" && tabId.startsWith("file:") && isHtmlPath(tabId.slice(5))) { + if (typeof e.previewHtml === "function") { e.previewHtml({ path: tabId.slice(5) }); notifyPanelResized(); } + } else if (typeof e.closePreview === "function") { + // 切到非 HTML(files/artifacts/其他文件)→ 隐藏预览(main 侧比对当前路径;无预览时 no-op) + e.closePreview({}); + } + } catch { /* ignore */ } + } async function loadFileTab(path) { const vp = viewerEl(); @@ -217,7 +255,9 @@ const ResultPanel = (() => { const st = stateFor(currentSid); const tab = st.tabs.find((t) => t.path === path); if (!tab || tab.loading) return; - if (tab.content !== undefined || tab.image || tab.readError) { await renderViewer(vp, tab); return; } + if (tab.html || tab.content !== undefined || tab.image || tab.readError) { await renderViewer(vp, tab); return; } + // HTML → 内嵌浏览器预览(不走 read_file;loadURL 由 activateTab 的 syncPreview 触发) + if (isHtmlPath(path)) { tab.html = true; await renderViewer(vp, tab); return; } // 图片不走 read_file(file:// 直显,P3.3) if (isImagePath(path)) { tab.image = true; await renderViewer(vp, tab); return; } tab.loading = true; @@ -253,6 +293,13 @@ const ResultPanel = (() => { vp.appendChild(el("div", { class: "result-empty" }, _t("result.viewerError"))); return; } + if (tab.html) { + // P3.4 混合模型:DOM 占位(WebContentsView 叠加其上;折叠时占位可见) + const ph = el("div", { class: "viewer-html" }); + ph.appendChild(el("div", { class: "result-empty" }, _t("result.htmlPreview"))); + vp.appendChild(ph); + return; + } if (tab.binary) { vp.appendChild(el("div", { class: "result-empty" }, _t("result.viewerBinary"))); return; @@ -319,6 +366,16 @@ const ResultPanel = (() => { renderArtifacts(); // 产物 pane 按当前会话桶恢复(后台 tool_finished 只入桶不渲染) } + /** P2.3: renderer crash-reload recovery — reopen the preview file tab that main is still showing. + * main is the source of truth; prevents preview/tab-bar mismatch (R5-④). + * (renderer 崩溃 reload 后从 main 拉取预览状态,重开预览文件 Tab) */ + function handlePreviewState(path, sid) { + const key = sid !== undefined ? sid : currentSid; + const st = stateFor(key); + if (st.tabs.some((t) => t.path === path)) { activateTab(tabIdFor(path), key); return; } + openFileTab(key, path); + } + // ── 产物登记(P3.2:只登记 write/edit 成功文件,per-session 去重,点击打开查看器 Tab) ── function renderEmpty() { const list = listEl(); @@ -443,9 +500,21 @@ const ResultPanel = (() => { renderTabbar(); activateTab(stateFor(currentSid).active); renderArtifacts(); + notifyPanelResized(); // P2.3:启动即上报布局(main 预览 bounds 初始同步) + // P2.3:renderer 崩溃 reload 后从 main 拉取预览状态恢复(main 是真相源;延迟到 + // 当前同步初始化完成后执行,避免与 boot 的 switchSession 竞争) + setTimeout(async () => { + try { + const e = window.emrg; + if (e && typeof e.getPreviewState === "function") { + const st = await e.getPreviewState(); + if (st && st.path && isHtmlPath(st.path)) handlePreviewState(st.path, currentSid); + } + } catch { /* ignore */ } + }, 0); } - return { init, addToolResult, toggle, isCollapsed, switchSession, openFileTab, closeFileTab, activateTab, getWidth, setWidth }; + return { init, addToolResult, toggle, isCollapsed, switchSession, openFileTab, closeFileTab, activateTab, getWidth, setWidth, handlePreviewState }; })(); // ⚠️ 必须暴露到 window:app.js 作为独立