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 @@
/ 指令帮助
+
+
+
+
+
+
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 即全局对象) */