diff --git a/emrg/gui/main.js b/emrg/gui/main.js index a419f5c..56d8c77 100644 --- a/emrg/gui/main.js +++ b/emrg/gui/main.js @@ -5,7 +5,7 @@ * 安全:contextIsolation + nodeIntegration:false + sandbox:true(renderer 零网络权限)。 */ -const { app, BrowserWindow, dialog, ipcMain } = require("electron"); +const { app, BrowserWindow, dialog, ipcMain, shell } = require("electron"); const fs = require("fs"); const os = require("os"); const path = require("path"); @@ -438,6 +438,15 @@ vision = false return { ok: true }; }); + ipcMain.handle("emrg:openFile", async (_e, { filePath }) => { + // GUI / 指令 WorkBuddy P1:产物面板打开文件(系统默认程序) + if (typeof filePath !== "string" || !filePath.trim()) throw new Error("invalid file path"); + const p = path.resolve(filePath.trim()); + if (!fs.existsSync(p)) return { ok: false, error: "file_not_found" }; + const err = await shell.openPath(p); + return { ok: err === "", error: err || "" }; + }); + ipcMain.handle("emrg:listModels", async () => { const frame = await client.sendCommandAndWait("list_models", {}, 5000); return frame.models || []; diff --git a/emrg/gui/preload.js b/emrg/gui/preload.js index 87b5ada..5ced7cd 100644 --- a/emrg/gui/preload.js +++ b/emrg/gui/preload.js @@ -27,6 +27,7 @@ const api = { triggerTask: (payload) => ipcRenderer.invoke("emrg:triggerTask", payload), sendRant: (payload) => ipcRenderer.invoke("emrg:sendRant", payload), listModels: () => ipcRenderer.invoke("emrg:listModels"), + openFile: (payload) => ipcRenderer.invoke("emrg:openFile", payload), saveSettings: (payload) => ipcRenderer.invoke("emrg:saveSettings", payload), getSettings: () => ipcRenderer.invoke("emrg:getSettings"), cancel: () => ipcRenderer.invoke("emrg:cancel"), diff --git a/emrg/gui/renderer/css/layout.css b/emrg/gui/renderer/css/layout.css index f7ac96e..2e10d94 100644 --- a/emrg/gui/renderer/css/layout.css +++ b/emrg/gui/renderer/css/layout.css @@ -193,3 +193,171 @@ body.sidebar-collapsed #app { padding: var(--sp-2) var(--sp-3) var(--sp-3); } } + +/* ── 结果面板(右栏 Artifacts,WorkBuddy P1) ─────────────── */ +#result-panel { + width: 280px; + min-width: 240px; + display: flex; + flex-direction: column; + background: var(--bg-panel); + border-left: 1px solid var(--border); + overflow: hidden; + transition: width 0.2s cubic-bezier(.2,.8,.3,1), min-width 0.2s cubic-bezier(.2,.8,.3,1); +} +#result-panel.collapsed { + width: 0; + min-width: 0; + border-left: none; +} +#result-panel.collapsed .result-header, +#result-panel.collapsed .result-list { + display: none; +} + +.result-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--sp-3) var(--sp-4); + border-bottom: 1px solid var(--border); + white-space: nowrap; +} +.result-title { + font-size: var(--fs-secondary); + font-weight: 600; + color: var(--text-1); +} +.result-toggle { + background: none; + border: none; + color: var(--text-3); + font-size: 16px; + cursor: pointer; + padding: 2px 6px; + border-radius: 6px; +} +.result-toggle:hover { + color: var(--text-1); + background: var(--bg-soft); +} + +.result-list { + flex: 1; + overflow-y: auto; + padding: var(--sp-3); + display: flex; + flex-direction: column; + gap: var(--sp-2); +} +.result-empty { + color: var(--text-3); + font-size: var(--fs-secondary); + text-align: center; + padding: var(--sp-6) var(--sp-3); + line-height: 1.6; +} + +/* 产物卡片 */ +.result-item { + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg); + overflow: hidden; +} +.result-item.done { border-left: 3px solid var(--accent); } +.result-item.failed { border-left: 3px solid var(--red); } +.result-head { + display: flex; + align-items: center; + gap: var(--sp-2); + padding: var(--sp-2) var(--sp-3); + cursor: pointer; + user-select: none; +} +.result-tool { + font-size: var(--fs-secondary); + font-weight: 600; + color: var(--text-1); + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.result-elapsed { + font-size: 11px; + color: var(--text-3); +} +.result-chevron { + color: var(--text-3); + font-size: 10px; + transition: transform 0.15s; +} +.result-item.expanded .result-chevron { + transform: rotate(180deg); +} +.result-body { + padding: var(--sp-2) var(--sp-3) var(--sp-3); + font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + font-size: 11px; + color: var(--text-2); + border-top: 1px solid var(--border); + white-space: pre-wrap; + word-break: break-all; + max-height: 180px; + overflow-y: auto; + position: relative; +} +.result-copy { + position: sticky; + bottom: 4px; + float: right; + margin: 4px 0 0 8px; + font-size: 11px; + padding: 2px 8px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--bg-panel); + color: var(--text-2); + cursor: pointer; +} +.result-copy:hover { + color: var(--accent); + border-color: var(--accent); +} +.result-file { + padding: var(--sp-2) var(--sp-3); + border-top: 1px solid var(--border); + font-size: var(--fs-secondary); + cursor: pointer; + color: var(--blue); + display: flex; + align-items: center; + gap: var(--sp-2); +} +.result-file::before { + content: "📄"; + font-size: 12px; +} +.result-file:hover { + background: var(--accent-soft); + color: var(--accent); +} +.result-filename { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* 窄屏(<900px)自动隐藏 */ +@media (max-width: 900px) { + #result-panel { + width: 0; + min-width: 0; + border-left: none; + } + #result-panel .result-header, + #result-panel .result-list { + display: none; + } +} diff --git a/emrg/gui/renderer/index.html b/emrg/gui/renderer/index.html index b326c1e..105452b 100644 --- a/emrg/gui/renderer/index.html +++ b/emrg/gui/renderer/index.html @@ -63,6 +63,15 @@
EMRG 可能会犯错,请核对重要信息
+ + + @@ -281,6 +290,7 @@

后台任务

+ diff --git a/emrg/gui/renderer/js/app.js b/emrg/gui/renderer/js/app.js index 232295b..1082a39 100644 --- a/emrg/gui/renderer/js/app.js +++ b/emrg/gui/renderer/js/app.js @@ -801,6 +801,7 @@ const App = (() => { break; case "tool_finished": Chat.handleToolEnd(data); + ResultPanel.addToolResult(data); break; case "cancelled": state.busy = false; @@ -1042,6 +1043,7 @@ const App = (() => { Dialogs.initModelForm(); Dialogs.initRenameDialog(); initModelSwitcher(); + ResultPanel.init(); // WorkBuddy P1:结果面板(⌘\ 折叠 + 窄屏自动隐藏) } // ── 暴露 ───────────────────────────────── diff --git a/emrg/gui/renderer/js/result-panel.js b/emrg/gui/renderer/js/result-panel.js new file mode 100644 index 0000000..6896ee8 --- /dev/null +++ b/emrg/gui/renderer/js/result-panel.js @@ -0,0 +1,172 @@ +"use strict"; +/** + * result-panel.js — 结果面板(右栏 Artifacts,WorkBuddy P1,rant 21:35) + * + * 监听 tool_finished 事件,把工具输出/生成文件登记为产物卡片: + * - 工具输出(bash/read/write)→ 折叠卡片,可展开 + 复制 + * - 文件路径(write 产物)→ 文件条目,点击用系统默认程序打开 + * 三栏布局:sidebar | chat | result-panel;⌘\ 折叠/展开;窄屏自动隐藏。 + */ + +const ResultPanel = (() => { + const MAX_ITEMS = 50; + + function panel() { + return $("result-panel"); + } + function listEl() { + return $("result-list"); + } + + function isCollapsed() { + const p = panel(); + return p ? p.classList.contains("collapsed") : true; + } + + function setCollapsed(collapsed) { + const p = panel(); + if (!p) return; + p.classList.toggle("collapsed", collapsed); + try { + localStorage.setItem("emrg.resultPanel.collapsed", collapsed ? "1" : "0"); + } catch { /* ignore */ } + } + + function toggle() { + setCollapsed(!isCollapsed()); + } + + /** 空状态 */ + function renderEmpty() { + const list = listEl(); + if (!list) return; + if (list.children.length === 0) { + list.innerHTML = `
还没有产物,对话中生成的文件会出现在这里
`; + } + } + + /** 登记一个产物条目(tool_finished 事件) */ + function addToolResult(data) { + const list = listEl(); + if (!list) return; + // 空状态占位清除 + const empty = list.querySelector(".result-empty"); + if (empty) empty.remove(); + + const card = el("div", { class: "result-item" }); + + // 标题行:工具名 + 耗时 + const head = el("div", { class: "result-head" }); + const name = el("span", { class: "result-tool" }, data.tool_name || "tool"); + head.appendChild(name); + if (data.elapsed !== undefined) { + head.appendChild(el("span", { class: "result-elapsed" }, `${data.elapsed.toFixed(1)}s`)); + } + head.appendChild(el("span", { class: "result-chevron" }, "⌄")); + card.appendChild(head); + + const ok = !data.error; + card.classList.add(ok ? "done" : "failed"); + + // 尝试提取文件路径(write/bash 输出中的生成文件) + const content = String(data.content || ""); + const filePath = extractFilePath(data.tool_name, content); + + if (filePath) { + // 文件条目:点击打开 + const fileRow = el("div", { class: "result-file" }); + const namePart = el("span", { class: "result-filename" }, filePath.split(/[\\/]/).pop()); + fileRow.appendChild(namePart); + fileRow.addEventListener("click", async () => { + const res = await window.emrg.openFile({ filePath }); + if (res && res.ok === false) { + // 打开失败提示(文件可能已删除) + fileRow.title = res.error || "无法打开文件"; + } + }); + card.appendChild(fileRow); + } + + // 内容摘要(可展开) + const preview = content.slice(0, 200); + if (preview.trim()) { + const body = el("div", { class: "result-body hidden" }, preview); + card.appendChild(body); + // 复制按钮 + const copyBtn = el("button", { class: "result-copy", type: "button" }, "复制"); + copyBtn.addEventListener("click", (e) => { + e.stopPropagation(); + try { + navigator.clipboard.writeText(content).then(() => { + copyBtn.textContent = "✓ 已复制"; + setTimeout(() => { copyBtn.textContent = "复制"; }, 1500); + }); + } catch { /* clipboard unavailable */ } + }); + body.appendChild(copyBtn); + // 点击头部展开/收起 + head.addEventListener("click", () => { + body.classList.toggle("hidden"); + card.classList.toggle("expanded", !body.classList.contains("hidden")); + }); + } + + // 裁剪到 MAX_ITEMS + while (list.children.length >= MAX_ITEMS) { + list.removeChild(list.firstChild); + } + list.appendChild(card); + } + + /** 从工具输出提取生成的文件路径(write 工具 / bash 中的 >> 重定向 / 路径模式) */ + function extractFilePath(toolName, content) { + if (!content) return ""; + const lines = content.split("\n"); + if (toolName === "write" || toolName === "edit") { + // 常见格式:Wrote file: /path 或 /path/to/file.txt + const m = content.match(/(?:Wrote|wrote|Written|created|已写入|写入)[^\n::]*[::]\s*([^\s\n]+)/); + if (m) return cleanPath(m[1]); + const fm = content.match(/([^\s\n]+\/[^\s\n]+\.(?:py|js|ts|md|txt|json|yml|yaml|toml|sh|css|html|png|jpg|svg|log))/); + if (fm) return cleanPath(fm[1]); + } + if (toolName === "bash") { + // bash 输出通常不带路径;带 "Created" 或重定向时尝试 + const m = content.match(/(?:Created|created|Generated|generated)[^\n::]*[::]\s*([^\s\n]+)/); + if (m) return cleanPath(m[1]); + } + return ""; + } + + function cleanPath(p) { + return String(p).replace(/[`"',,。、;;]/g, "").trim(); + } + + function init() { + const p = panel(); + if (!p) return; + // 折叠按钮 + const btn = $("result-toggle"); + if (btn) btn.addEventListener("click", toggle); + // 键盘 ⌘\ 或 Ctrl\ + document.addEventListener("keydown", (e) => { + if (e.key === "\\" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + toggle(); + } + }); + // 窄屏自动隐藏(<900px 折叠) + const mq = window.matchMedia ? window.matchMedia("(max-width: 900px)") : null; + if (mq) { + const onMq = (ev) => { if (ev.matches) setCollapsed(true); }; + if (mq.addEventListener) mq.addEventListener("change", onMq); + if (mq.matches) setCollapsed(true); + } + // 恢复折叠状态 + try { + if (localStorage.getItem("emrg.resultPanel.collapsed") === "1") setCollapsed(true); + } catch { /* ignore */ } + renderEmpty(); + } + + return { init, addToolResult, toggle, isCollapsed }; +})(); diff --git a/emrg/gui/test/app-commands.test.js b/emrg/gui/test/app-commands.test.js index 957cd50..5424813 100644 --- a/emrg/gui/test/app-commands.test.js +++ b/emrg/gui/test/app-commands.test.js @@ -72,6 +72,8 @@ function makeSandbox(overrides = {}) { requestAnimationFrame: (cb) => cb(), requestIdleCallback: (cb) => cb(), crypto: { randomUUID: () => "mock-uuid" }, + localStorage: { getItem: () => null, setItem() {} }, + matchMedia: () => ({ matches: false, addEventListener() {} }), DOMPurify: { sanitize: (x) => x }, marked: null, hljs: null, @@ -93,13 +95,21 @@ function makeSandbox(overrides = {}) { setModel: async () => ({}), listHistory: async () => { calls.listHistory.push(1); return { messages: [{ record_index: 0, preview: "你好" }, { record_index: 1, preview: "帮我写代码" }] }; }, rewindSession: async (p) => { calls.rewind.push(p); return { removedCount: 2 }; }, + listMemories: async () => [], + readMemory: async () => ({}), + listSkills: async () => [], + listProjects: async () => [], + listTasks: async () => [], + triggerTask: async () => ({}), + sendRant: async () => ({}), + openFile: async () => ({ ok: true }), ...overrides, }, }; win.window = win; win.document = document; const ctx = vm.createContext(win); - for (const f of ["utils", "commands", "markdown", "copywriting", "chat", "sidebar", "dialogs", "app"]) { + for (const f of ["utils", "commands", "markdown", "copywriting", "chat", "sidebar", "dialogs", "result-panel", "app"]) { const code = fs.readFileSync(path.join(RENDERER_JS, f + ".js"), "utf8"); vm.runInContext(code, ctx, { filename: "renderer/js/" + f + ".js" }); } diff --git a/emrg/gui/test/renderer.smoke.test.js b/emrg/gui/test/renderer.smoke.test.js index 6d55992..6062cf5 100644 --- a/emrg/gui/test/renderer.smoke.test.js +++ b/emrg/gui/test/renderer.smoke.test.js @@ -77,6 +77,7 @@ const ELEMENT_IDS = [ "skills-dialog", "skills-list", "skills-close", "rant-dialog", "rant-message", "rant-project", "rant-cancel", "rant-submit", "tasks-dialog", "tasks-list", "tasks-close", + "result-panel", "result-list", "result-toggle", ]; /** 构造浏览器沙箱(win 即全局对象) */ @@ -127,13 +128,14 @@ function makeSandbox(overrides = {}) { newSession: async () => ({ session_id: "s2" }), deleteSession: async () => ({}), setModel: async () => ({}), + openFile: async () => ({ ok: true }), ...overrides, }, }; win.window = win; win.document = document; const ctx = vm.createContext(win); - for (const f of ["utils", "commands", "markdown", "copywriting", "chat", "sidebar", "dialogs", "app"]) { + for (const f of ["utils", "commands", "markdown", "copywriting", "chat", "sidebar", "dialogs", "result-panel", "app"]) { const code = fs.readFileSync(path.join(RENDERER_JS, f + ".js"), "utf8"); vm.runInContext(code, ctx, { filename: "renderer/js/" + f + ".js" }); } @@ -433,3 +435,38 @@ test("对话列表键盘导航:输入控件内不劫持(e.target 守卫,te assert.ok(src.includes("closest(\"input, textarea, select, [contenteditable]\")"), "输入控件内应跳过键盘导航"); assert.ok(src.includes("e.target.closest"), "应使用 e.target 守卫"); }); + +test("WorkBuddy P1:tool_finished → 产物卡片登记到结果面板", async () => { + const { ctx, els } = makeSandbox(); + await tick(); + // 空状态 + const empty = vm.runInContext('document.getElementById("result-list").innerHTML', ctx); + assert.ok(String(empty).includes("还没有产物"), "应有空状态占位"); + // tool_finished → ResultPanel.addToolResult + await vm.runInContext(` + ResultPanel.addToolResult({ tool_call_id: "t1", tool_name: "write", content: "Wrote file: /tmp/hello.py\\nHello", elapsed: 0.5 }); + `, ctx); + const items = vm.runInContext('document.getElementById("result-list").children.length', ctx); + assert.ok(items >= 1, `应登记产物卡片,实际 ${items}`); + // write 输出中的文件路径被识别(卡片 children 中含 .result-file) + const hasFile = vm.runInContext(` + (function(){ + const list = document.getElementById("result-list"); + for (const c of list.children) { + if (c.className && c.className.includes("result-file")) return true; + if (c.children) for (const g of c.children) if (g.className && g.className.includes("result-file")) return true; + } + return false; + })() + `, ctx); + assert.ok(hasFile, "write 产物应显示文件条目"); +}); + +test("WorkBuddy P1:ResultPanel 折叠切换(⌘\ 与按钮)", async () => { + const { ctx, els } = makeSandbox(); + await tick(); + await vm.runInContext("ResultPanel.toggle()", ctx); + assert.ok(els["result-panel"].classList.contains("collapsed"), "toggle 后应折叠"); + await vm.runInContext("ResultPanel.toggle()", ctx); + assert.ok(!els["result-panel"].classList.contains("collapsed"), "再次 toggle 应展开"); +});