Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions emrg/gui/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
2 changes: 2 additions & 0 deletions emrg/gui/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
24 changes: 24 additions & 0 deletions emrg/gui/renderer/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,30 @@ <h2>/ 指令帮助</h2>
</div>
</dialog>

<!-- 会话列表对话框(/sessions /resume,GUI / 指令 P2) -->
<dialog id="sessions-dialog">
<div class="dialog-card" style="min-width:420px;">
<h2>切换对话</h2>
<p style="color:var(--text-2);font-size:var(--fs-secondary);margin:0 0 var(--sp-3);">点击切换,或输入 /resume &lt;id&gt; 直接切换。</p>
<div id="sessions-list" class="help-list"></div>
<div class="dialog-actions">
<button type="button" id="sessions-close" class="btn btn-ghost">关闭</button>
</div>
</div>
</dialog>

<!-- 历史回退对话框(/rewind,GUI / 指令 P2) -->
<dialog id="rewind-dialog">
<div class="dialog-card" style="min-width:480px;">
<h2>回退到历史消息点</h2>
<p style="color:var(--text-2);font-size:var(--fs-secondary);margin:0 0 var(--sp-3);">选择要保留到的消息点,之后的对话将被移除。</p>
<div id="rewind-list" class="help-list"></div>
<div class="dialog-actions">
<button type="button" id="rewind-close" class="btn btn-ghost">取消</button>
</div>
</div>
</dialog>

<script src="js/utils.js"></script>
<script src="js/commands.js"></script>
<script src="js/markdown.js"></script>
Expand Down
126 changes: 122 additions & 4 deletions emrg/gui/renderer/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -151,6 +152,40 @@ const App = (() => {
case "/image":
Chat.addSystemMessage("请直接粘贴图片到输入框(Ctrl+V / ⌘V)。");
break;
case "/sessions":
case "/resume":
// P2:/resume <id> 直接切换;无参数 → 会话列表对话框
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} 暂未开放。`);
}
Expand All @@ -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 = `<div class="help-row"><span class="help-hint">还没有对话,输入内容即可开始。</span></div>`;
}
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 = `<div class="help-row"><span class="help-hint">加载中…</span></div>`;
dialog.showModal();
try {
const { messages } = await window.emrg.listHistory({ sessionId: state.sessionId });
list.innerHTML = "";
if (!messages || messages.length === 0) {
list.innerHTML = `<div class="help-row"><span class="help-hint">没有可回退的历史消息。</span></div>`;
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 = `<div class="help-row"><span class="help-hint">加载历史失败:${escapeHtml(e.message)}</span></div>`;
}
}

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);
Expand Down Expand Up @@ -297,6 +408,7 @@ const App = (() => {
async function refreshSessions() {
try {
const sessions = await window.emrg.listSessions();
state.sessions = sessions;
Sidebar.render(sessions);
} catch { /* 忽略 */ }
}
Expand Down Expand Up @@ -521,6 +633,7 @@ const App = (() => {
handleStatus(data);
break;
case "sessions":
state.sessions = data.sessions || [];
Sidebar.render(data.sessions || []);
break;
case "disconnected":
Expand All @@ -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") {
Expand Down Expand Up @@ -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) => {
Expand Down
145 changes: 145 additions & 0 deletions emrg/gui/test/app-commands.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
"use strict";
/**
* app-commands.test.js — GUI / 指令 P2 路由测试(rant 19:44 P2:会话管理类)。
* 验证 handleCommand 对 /sessions /resume /rename /delete /rewind 的路由:
* - /resume <id> → 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 <id> 直接 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}`);
});
Loading
Loading