diff --git a/emrg/gui/main.js b/emrg/gui/main.js index b630277f..af8b5447 100644 --- a/emrg/gui/main.js +++ b/emrg/gui/main.js @@ -334,6 +334,27 @@ vision = false return { ok: true }; }); + ipcMain.handle("emrg:listHistory", async (_e, { sessionId }) => { + // GUI / 指令 P2:/rewind — 获取会话历史消息点(daemon 协议 list_history 已存在) + if (!validateSessionId(sessionId)) throw new Error("invalid session_id"); + const frame = await client.sendCommandAndWait("list_history", { session_id: sessionId, cwd: projectDir }, 5000); + return { messages: frame.messages || [] }; + }); + + ipcMain.handle("emrg:rewindSession", async (_e, { sessionId, recordIndex }) => { + // GUI / 指令 P2:/rewind — 回退到指定历史消息点(daemon 协议 rewind_session 已存在) + if (!validateSessionId(sessionId)) throw new Error("invalid session_id"); + if (typeof recordIndex !== "number" || !Number.isInteger(recordIndex) || recordIndex < 0) { + throw new Error("invalid record_index"); + } + const frame = await client.sendCommandAndWait( + "rewind_session", + { session_id: sessionId, cwd: projectDir, record_index: recordIndex }, + 5000 + ); + return { ok: true, removedCount: frame.removed_count ?? 0 }; + }); + ipcMain.handle("emrg:setModel", async (_e, { model }) => { await client.sendCommandAndWait("set_model", { model }, 5000); return { ok: true }; diff --git a/emrg/gui/preload.js b/emrg/gui/preload.js index a853975b..a5ddbaa8 100644 --- a/emrg/gui/preload.js +++ b/emrg/gui/preload.js @@ -17,6 +17,8 @@ const api = { setModel: (payload) => ipcRenderer.invoke("emrg:setModel", payload), clearSession: (payload) => ipcRenderer.invoke("emrg:clearSession", payload), compactSession: (payload) => ipcRenderer.invoke("emrg:compactSession", payload), + listHistory: (payload) => ipcRenderer.invoke("emrg:listHistory", payload), + rewindSession: (payload) => ipcRenderer.invoke("emrg:rewindSession", payload), listModels: () => ipcRenderer.invoke("emrg:listModels"), saveSettings: (payload) => ipcRenderer.invoke("emrg:saveSettings", payload), getSettings: () => ipcRenderer.invoke("emrg:getSettings"), diff --git a/emrg/gui/renderer/index.html b/emrg/gui/renderer/index.html index db6a5a6f..f30212db 100644 --- a/emrg/gui/renderer/index.html +++ b/emrg/gui/renderer/index.html @@ -195,6 +195,30 @@

/ 指令帮助

+ + +
+

切换对话

+

点击切换,或输入 /resume <id> 直接切换。

+
+
+ +
+
+
+ + + +
+

回退到历史消息点

+

选择要保留到的消息点,之后的对话将被移除。

+
+
+ +
+
+
+ diff --git a/emrg/gui/renderer/js/app.js b/emrg/gui/renderer/js/app.js index 33e5d9ed..0be4bb65 100644 --- a/emrg/gui/renderer/js/app.js +++ b/emrg/gui/renderer/js/app.js @@ -54,6 +54,7 @@ const App = (() => { return; } if (init.sessions && init.sessions.length > 0) { + state.sessions = init.sessions; Sidebar.render(init.sessions); const current = init.sessions.find((s) => s.session_id === state.sessionId); if (current) { @@ -111,12 +112,12 @@ const App = (() => { } } - // ── / 指令(rant 19:44 P1)────────────────── - /** 执行 / 指令。phase 1 纯操作类已实现;phase 2+ 提示阶段未开放。 */ + // ── / 指令(rant 19:44 P1/P2)────────────────── + /** 执行 / 指令。phase 1 纯操作 + phase 2 会话管理已实现;phase 3+ 提示阶段未开放。 */ async function handleCommand(parsed) { const cmd = parsed.cmd; const meta = Commands.COMMANDS[cmd]; - if (!meta || meta.phase > 1) { + if (!meta || meta.phase > 2) { const phase = meta ? `(阶段 ${meta.phase},后续版本开放)` : ""; Chat.addSystemMessage(`指令 ${cmd} 暂未开放${phase}。`); return; @@ -151,6 +152,40 @@ const App = (() => { case "/image": Chat.addSystemMessage("请直接粘贴图片到输入框(Ctrl+V / ⌘V)。"); break; + case "/sessions": + case "/resume": + // P2:/resume 直接切换;无参数 → 会话列表对话框 + if (parsed.args.length > 0) { + await switchSession(parsed.args[0]); + } else { + showSessionsDialog(); + } + break; + case "/rename": + // P2:复用现有重命名对话框(右键菜单同款) + if (!state.sessionId) { + Chat.addSystemMessage("请先创建一个对话。"); + return; + } + const cur = state.sessions.find((s) => s.session_id === state.sessionId); + Dialogs.showRename(state.sessionId, cur ? cur.title : ""); + break; + case "/delete": + // P2:复用现有删除确认(右键菜单同款) + if (!state.sessionId) { + Chat.addSystemMessage("请先创建一个对话。"); + return; + } + Dialogs.showConfirm(EMRG_Copy.COPY.deleteConfirmTitle, EMRG_Copy.COPY.deleteConfirmBody, { + okText: "删除", + danger: true, + onOk: () => deleteSession(state.sessionId), + }); + break; + case "/rewind": + // P2:历史消息点选择对话框 + showRewindDialog(); + break; default: Chat.addSystemMessage(`指令 ${cmd} 暂未开放。`); } @@ -159,6 +194,82 @@ const App = (() => { } } + // /sessions /resume:会话列表对话框(复用 help-list 样式) + async function showSessionsDialog() { + const list = $("sessions-list"); + const dialog = $("sessions-dialog"); + if (!list || !dialog) return; + await refreshSessions(); // 确保 state.sessions 最新 + list.innerHTML = ""; + if (state.sessions.length === 0) { + list.innerHTML = `
还没有对话,输入内容即可开始。
`; + } + state.sessions.forEach((s) => { + const row = el("button", { class: "help-row", type: "button", style: "width:100%;text-align:left;cursor:pointer;background:none;border:none;" }); + const name = el("span", { class: "help-cmd" }, s.title || "(未命名)"); + const hint = el("span", { class: "help-hint" }, s.session_id === state.sessionId ? "当前" : ""); + row.appendChild(name); + row.appendChild(hint); + row.addEventListener("click", async () => { + dialog.close(); + await switchSession(s.session_id); + }); + list.appendChild(row); + }); + dialog.showModal(); + } + + // /rewind:历史消息点选择对话框(daemon list_history → 选择 → rewind_session) + async function showRewindDialog() { + const list = $("rewind-list"); + const dialog = $("rewind-dialog"); + if (!list || !dialog) return; + if (!state.sessionId) { + Chat.addSystemMessage("请先创建一个对话。"); + return; + } + list.innerHTML = `
加载中…
`; + dialog.showModal(); + try { + const { messages } = await window.emrg.listHistory({ sessionId: state.sessionId }); + list.innerHTML = ""; + if (!messages || messages.length === 0) { + list.innerHTML = `
没有可回退的历史消息。
`; + return; + } + // 倒序:最新消息点在最上 + [...messages].reverse().forEach((m) => { + const idx = m.record_index; + const row = el("button", { + class: "help-row", + type: "button", + style: "width:100%;text-align:left;cursor:pointer;background:none;border:none;", + }); + const name = el("span", { class: "help-cmd" }, `#${idx}`); + const hint = el("span", { class: "help-hint" }, (m.preview || m.content || "").slice(0, 60)); + row.appendChild(name); + row.appendChild(hint); + row.addEventListener("click", async () => { + dialog.close(); + await doRewind(idx); + }); + list.appendChild(row); + }); + } catch (e) { + list.innerHTML = `
加载历史失败:${escapeHtml(e.message)}
`; + } + } + + async function doRewind(recordIndex) { + try { + const res = await window.emrg.rewindSession({ sessionId: state.sessionId, recordIndex }); + Chat.clear(); + Chat.addSystemMessage(`已回退到消息点 #${recordIndex},移除了 ${res.removedCount ?? 0} 条记录。`); + } catch (e) { + Chat.addSystemMessage(`回退失败:${e.message}`); + } + } + // / 补全菜单:输入以 / 开头 → 显示匹配指令;↑↓ 导航、Enter/点击选择填充 function showCmdMenu(prefix) { const items = Commands.getCompletions(prefix); @@ -297,6 +408,7 @@ const App = (() => { async function refreshSessions() { try { const sessions = await window.emrg.listSessions(); + state.sessions = sessions; Sidebar.render(sessions); } catch { /* 忽略 */ } } @@ -521,6 +633,7 @@ const App = (() => { handleStatus(data); break; case "sessions": + state.sessions = data.sessions || []; Sidebar.render(data.sessions || []); break; case "disconnected": @@ -546,7 +659,10 @@ const App = (() => { Chat.groupNodes.delete(data.requestId); break; case "list_result": - if (data.type === "sessions_list") Sidebar.render(data.sessions || []); + if (data.type === "sessions_list") { + state.sessions = data.sessions || []; + Sidebar.render(data.sessions || []); + } break; case "command_result": if (data.type === "model_set") { @@ -607,6 +723,8 @@ const App = (() => { $("confirm-cancel").addEventListener("click", Dialogs.closeConfirm); $("confirm-ok").addEventListener("click", Dialogs.confirmOk); $("help-close").addEventListener("click", () => $("help-dialog").close()); + $("sessions-close").addEventListener("click", () => $("sessions-dialog").close()); + $("rewind-close").addEventListener("click", () => $("rewind-dialog").close()); // 设置/首启对话框:Enter 提交(与重命名/模型表单一致的交互) const enterToSave = (fn) => (e) => { diff --git a/emrg/gui/test/app-commands.test.js b/emrg/gui/test/app-commands.test.js new file mode 100644 index 00000000..dcbcb8a4 --- /dev/null +++ b/emrg/gui/test/app-commands.test.js @@ -0,0 +1,145 @@ +"use strict"; +/** + * app-commands.test.js — GUI / 指令 P2 路由测试(rant 19:44 P2:会话管理类)。 + * 验证 handleCommand 对 /sessions /resume /rename /delete /rewind 的路由: + * - /resume → switchSession(id) + * - /rewind → listHistory 被调用(历史消息点对话框) + * - phase 3+ 指令(/model)仍提示未开放 + * 复用 renderer.smoke.test.js 的沙箱模式(makeEl + document mock)。 + */ + +const { test } = require("node:test"); +const assert = require("node:assert"); +const fs = require("node:fs"); +const path = require("node:path"); +const vm = require("node:vm"); + +const RENDERER_JS = path.join(__dirname, "..", "renderer", "js"); + +/** 轻量 DOM 元素桩 */ +function makeEl(id) { + return { + id, + hidden: false, + innerHTML: "", + textContent: "", + children: [], + style: {}, + dataset: {}, + classList: { add() {}, remove() {}, toggle() {} }, + addEventListener() {}, + appendChild() {}, + removeChild() {}, + querySelectorAll: () => [], + getBoundingClientRect: () => ({ left: 0, right: 100, top: 0, bottom: 100, width: 100, height: 100 }), + showModal() { this.__open = true; }, + close() { this.__open = false; }, + focus() {}, + value: "", + scrollHeight: 10, + }; +} + +/** 构造 app 沙箱(含 P2 需要的 emrg API) */ +function makeSandbox(overrides = {}) { + const calls = { switchSession: [], listHistory: [], rewind: [] }; + const els = {}; + const document = { + getElementById: (id) => (els[id] = els[id] || makeEl(id)), + createElement: (tag) => makeEl(tag), + createTextNode: (t) => ({ text: t }), + querySelector: () => null, + querySelectorAll: () => [], + addEventListener() {}, + documentElement: { setAttribute() {}, removeAttribute() {} }, + body: { classList: { add() {}, remove() {}, toggle() {} } }, + }; + const win = { + console, + setTimeout, + clearTimeout, + Date, + Math, + JSON, + String, + Number, + Object, + Array, + RegExp, + Map, + Set, + Promise, + requestAnimationFrame: (cb) => cb(), + requestIdleCallback: (cb) => cb(), + crypto: { randomUUID: () => "mock-uuid" }, + DOMPurify: { sanitize: (x) => x }, + marked: null, + hljs: null, + emrg: { + // 注意:app.js 模块加载即 App.boot(),init 返回空 sessions 走 newSession 路径, + // 避免 boot 自动 switchSession 污染计数(非空 sessions 会触发 boot 切换) + init: async () => ({ config_exists: true, api_key_configured: true, project_dir: "/p", project_dir_valid: true, server_id: "srv", model: "m", evolution_count: 1, sessions: [] }), + onEvent() {}, + sendMessage: async () => ({}), + cancel: async () => ({}), + getSettings: async () => ({ apiKey: "k", baseUrl: "", model: "m", projectDir: "/p", models: [], modelDetails: [], theme: "system" }), + saveSettings: async () => ({}), + pickProjectDir: async () => null, + listSessions: async () => [{ session_id: "s1", title: "会话一" }, { session_id: "s2", title: "会话二" }], + switchSession: async (p) => { calls.switchSession.push(p); return {}; }, + newSession: async () => ({ session_id: "s3" }), + deleteSession: async () => ({}), + renameSession: async () => ({}), + 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 }; }, + ...overrides, + }, + }; + win.window = win; + win.document = document; + const ctx = vm.createContext(win); + for (const f of ["utils", "commands", "markdown", "copywriting", "chat", "sidebar", "dialogs", "app"]) { + const code = fs.readFileSync(path.join(RENDERER_JS, f + ".js"), "utf8"); + vm.runInContext(code, ctx, { filename: "renderer/js/" + f + ".js" }); + } + return { ctx, win, els, calls }; +} + +/** 等 microtask 完成(boot 异步路径) */ +const tick = () => new Promise((r) => setTimeout(r, 20)); + +test("P2:/resume 直接 switchSession(不带对话框)", async () => { + const { ctx, calls } = makeSandbox(); + await tick(); // boot 走 newSession 路径,无 switchSession 调用 + await vm.runInContext("App.handleCommand({ type: 'command', cmd: '/resume', args: ['abc123'] })", ctx); + assert.strictEqual(calls.switchSession.length, 1); + assert.strictEqual(calls.switchSession[0].sessionId, "abc123"); +}); + +test("P2:/rewind 打开历史选择对话框并调用 listHistory", async () => { + const { ctx, calls, els } = makeSandbox(); + await tick(); + vm.runInContext('App.state.sessionId = "s1";', ctx); + await vm.runInContext("App.handleCommand({ type: 'command', cmd: '/rewind', args: [] })", ctx); + assert.strictEqual(calls.listHistory.length, 1); + assert.ok(els["rewind-dialog"] && els["rewind-dialog"].__open === true, "rewind dialog opened"); +}); + +test("P2:/rename 复用现有重命名对话框(Dialogs.showRename 被调用)", async () => { + const { ctx, els } = makeSandbox(); + await tick(); + vm.runInContext('App.state.sessionId = "s1"; App.state.sessions = [{ session_id: "s1", title: "旧标题" }];', ctx); + await vm.runInContext("App.handleCommand({ type: 'command', cmd: '/rename', args: [] })", ctx); + assert.ok(els["rename-dialog"] && els["rename-dialog"].__open === true, "rename dialog opened"); + assert.strictEqual(els["rename-input"].value, "旧标题", "重命名输入框预填当前标题"); +}); + +test("P2:phase 3+ 指令(/model)仍提示未开放", async () => { + const { ctx, win } = makeSandbox(); + const out = await vm.runInContext( + "(async () => { let m = ''; const orig = EMRG_Chat.addSystemMessage; EMRG_Chat.addSystemMessage = (x) => { m = x; }; await App.handleCommand({ type: 'command', cmd: '/model', args: [] }); EMRG_Chat.addSystemMessage = orig; return m; })()", + ctx + ); + assert.ok(String(out).includes("暂未开放"), `phase 3 应提示未开放,实际: ${out}`); +}); diff --git a/emrg/gui/test/renderer.smoke.test.js b/emrg/gui/test/renderer.smoke.test.js index 028c0eb6..47c8b450 100644 --- a/emrg/gui/test/renderer.smoke.test.js +++ b/emrg/gui/test/renderer.smoke.test.js @@ -71,6 +71,8 @@ const ELEMENT_IDS = [ "model-list", "add-model-btn", "model-form", "model-form-name", "model-form-id", "model-form-vision", "model-form-save", "model-form-cancel", "back-to-bottom", "cmd-menu", "help-dialog", "help-list", "help-close", + "sessions-dialog", "sessions-list", "sessions-close", + "rewind-dialog", "rewind-list", "rewind-close", ]; /** 构造浏览器沙箱(win 即全局对象) */