From 674ab599f5e016442f5616ce5c75df8aa0407189 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Thu, 6 Aug 2026 20:17:00 +0800 Subject: [PATCH] =?UTF-8?q?emrg:=20GUI=20/=20=E6=8C=87=E4=BB=A4=20P1=20?= =?UTF-8?q?=E2=80=94=20=E8=A7=A3=E6=9E=90=E5=99=A8=20+=20=E8=A1=A5?= =?UTF-8?q?=E5=85=A8=E8=8F=9C=E5=8D=95=20+=20=E7=BA=AF=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E7=B1=BB=E6=8C=87=E4=BB=A4=20(rant=2019:44)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - commands.js 指令注册表:TUI 15 个 / 指令全部注册(hint + phase 分期) - parseInput 三态:message / command / unknown - / 补全菜单:输入即过滤,↑↓/Enter/点击选择,Esc 关闭 - 纯操作类已实现:/clear /compact /version /help /image(daemon 零改动,复用现有协议) - main.js IPC:emrg:clearSession / emrg:compactSession - 测试:commands.test.js 3 项 + smoke test 模块列表更新(47 pass) --- emrg/gui/main.js | 14 +++ emrg/gui/preload.js | 2 + emrg/gui/renderer/css/components.css | 52 +++++++++ emrg/gui/renderer/css/layout.css | 1 + emrg/gui/renderer/index.html | 14 +++ emrg/gui/renderer/js/app.js | 159 ++++++++++++++++++++++++++- emrg/gui/renderer/js/commands.js | 57 ++++++++++ emrg/gui/test/commands.test.js | 83 ++++++++++++++ emrg/gui/test/renderer.smoke.test.js | 8 +- 9 files changed, 385 insertions(+), 5 deletions(-) create mode 100644 emrg/gui/renderer/js/commands.js create mode 100644 emrg/gui/test/commands.test.js diff --git a/emrg/gui/main.js b/emrg/gui/main.js index 6f365eb..b630277 100644 --- a/emrg/gui/main.js +++ b/emrg/gui/main.js @@ -320,6 +320,20 @@ vision = false return { session_id: sid }; }); + ipcMain.handle("emrg:clearSession", async (_e, { sessionId }) => { + // GUI / 指令 P1:/clear — 清空当前会话(daemon 协议 clear_session 已存在) + if (!validateSessionId(sessionId)) throw new Error("invalid session_id"); + await client.sendCommandAndWait("clear_session", { session_id: sessionId, cwd: projectDir }, 5000); + return { ok: true }; + }); + + ipcMain.handle("emrg:compactSession", async (_e, { sessionId }) => { + // GUI / 指令 P1:/compact — 压缩当前会话历史(daemon 协议 compact 已存在) + if (!validateSessionId(sessionId)) throw new Error("invalid session_id"); + await client.sendCommandAndWait("compact", { session_id: sessionId, cwd: projectDir }, 5000); + return { ok: true }; + }); + 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 5c676cd..a853975 100644 --- a/emrg/gui/preload.js +++ b/emrg/gui/preload.js @@ -15,6 +15,8 @@ const api = { deleteSession: (payload) => ipcRenderer.invoke("emrg:deleteSession", payload), renameSession: (payload) => ipcRenderer.invoke("emrg:renameSession", payload), setModel: (payload) => ipcRenderer.invoke("emrg:setModel", payload), + clearSession: (payload) => ipcRenderer.invoke("emrg:clearSession", payload), + compactSession: (payload) => ipcRenderer.invoke("emrg:compactSession", payload), listModels: () => ipcRenderer.invoke("emrg:listModels"), saveSettings: (payload) => ipcRenderer.invoke("emrg:saveSettings", payload), getSettings: () => ipcRenderer.invoke("emrg:getSettings"), diff --git a/emrg/gui/renderer/css/components.css b/emrg/gui/renderer/css/components.css index 98d27a0..d076059 100644 --- a/emrg/gui/renderer/css/components.css +++ b/emrg/gui/renderer/css/components.css @@ -817,3 +817,55 @@ dialog::backdrop { .ctx-item.danger { color: var(--danger, #e5484d); } .ctx-item.danger:hover { background-color: color-mix(in srgb, var(--danger, #e5484d) 10%, transparent); } .ctx-item.danger.active { background-color: color-mix(in srgb, var(--danger, #e5484d) 10%, transparent); color: var(--danger, #e5484d); } + +/* ── / 指令补全菜单(rant 19:44 P1) ─────────────── */ +.cmd-menu { + position: absolute; + bottom: calc(100% + 8px); + left: 0; + right: 0; + max-height: 240px; + overflow-y: auto; + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: var(--radius-card); + box-shadow: var(--shadow-lg); + padding: 4px; + z-index: 100; + animation: fade-in var(--dur-fast) var(--ease); +} +.cmd-menu-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + width: 100%; + text-align: left; + padding: 8px 12px; + border: none; + background: none; + border-radius: 8px; + font-size: var(--fs-secondary); + color: var(--text-1); + cursor: pointer; + transition: background-color var(--dur-fast) var(--ease); +} +.cmd-menu-item:hover { background-color: var(--bg-soft); } +.cmd-menu-item.selected { background-color: var(--accent-soft); color: var(--accent); } +.cmd-menu-name { font-family: var(--font-mono, monospace); font-weight: 600; } +.cmd-menu-hint { color: var(--text-2); font-size: var(--fs-aux); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.cmd-menu-item.selected .cmd-menu-hint { color: var(--accent); } + +/* / 指令帮助对话框(/help) */ +.help-list { max-height: 300px; overflow-y: auto; } +.help-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 7px 4px; + border-bottom: 1px solid var(--border); +} +.help-row:last-child { border-bottom: none; } +.help-cmd { font-family: var(--font-mono, monospace); font-weight: 600; color: var(--accent); } +.help-hint { color: var(--text-2); font-size: var(--fs-secondary); } diff --git a/emrg/gui/renderer/css/layout.css b/emrg/gui/renderer/css/layout.css index 8e908a5..f7ac96e 100644 --- a/emrg/gui/renderer/css/layout.css +++ b/emrg/gui/renderer/css/layout.css @@ -109,6 +109,7 @@ /* 输入区 */ #composer-wrap { + position: relative; /* / 指令补全菜单锚点(rant 19:44 P1) */ padding: var(--sp-2) var(--sp-5) var(--sp-4); max-width: 808px; margin: 0 auto; diff --git a/emrg/gui/renderer/index.html b/emrg/gui/renderer/index.html index b052d8b..db6a5a6 100644 --- a/emrg/gui/renderer/index.html +++ b/emrg/gui/renderer/index.html @@ -50,6 +50,7 @@
+
加载中… @@ -182,7 +183,20 @@

重命名对话

+ + +
+

/ 指令帮助

+

以 / 开头的指令在输入框执行,或点击补全菜单选择。

+
+
+ +
+
+
+ + diff --git a/emrg/gui/renderer/js/app.js b/emrg/gui/renderer/js/app.js index 846c6f9..33e5d9e 100644 --- a/emrg/gui/renderer/js/app.js +++ b/emrg/gui/renderer/js/app.js @@ -23,6 +23,8 @@ const App = (() => { serverId: "", evolutionCount: null, autoScroll: true, + // GUI / 指令补全菜单(rant 19:44 P1):items=[{cmd,hint,phase}] index=当前高亮 + cmdMenu: { items: [], index: -1 }, }; // ── 启动 ───────────────────────────────── @@ -75,6 +77,15 @@ const App = (() => { const input = $("input"); const text = input.value.trim(); if (!text || state.busy) return; + // GUI / 指令(rant 19:44 P1):/ 开头 → 路由到指令 handler,不走 sendMessage + const parsed = Commands.parseInput(text); + if (parsed.type !== "message") { + input.value = ""; + input.style.height = "auto"; + hideCmdMenu(); + await handleCommand(parsed); + return; + } if (!state.sessionId) { Chat.addSystemMessage("请先创建一个对话。"); return; @@ -100,9 +111,118 @@ const App = (() => { } } + // ── / 指令(rant 19:44 P1)────────────────── + /** 执行 / 指令。phase 1 纯操作类已实现;phase 2+ 提示阶段未开放。 */ + async function handleCommand(parsed) { + const cmd = parsed.cmd; + const meta = Commands.COMMANDS[cmd]; + if (!meta || meta.phase > 1) { + const phase = meta ? `(阶段 ${meta.phase},后续版本开放)` : ""; + Chat.addSystemMessage(`指令 ${cmd} 暂未开放${phase}。`); + return; + } + try { + switch (cmd) { + case "/clear": + if (!state.sessionId) { + Chat.addSystemMessage("请先创建一个对话。"); + return; + } + await window.emrg.clearSession({ sessionId: state.sessionId }); + Chat.clear(); + Chat.addSystemMessage("已清空当前对话。"); + break; + case "/compact": + if (!state.sessionId) { + Chat.addSystemMessage("请先创建一个对话。"); + return; + } + await window.emrg.compactSession({ sessionId: state.sessionId }); + Chat.addSystemMessage("已压缩当前对话历史。"); + break; + case "/version": + Chat.addSystemMessage( + `EMRG GUI v0.2.7 · 实例 ${state.serverId || "未知"} · 模型 ${state.model || "未知"} · 已进化 ${state.evolutionCount ?? 0} 次` + ); + break; + case "/help": + showHelpDialog(); + break; + case "/image": + Chat.addSystemMessage("请直接粘贴图片到输入框(Ctrl+V / ⌘V)。"); + break; + default: + Chat.addSystemMessage(`指令 ${cmd} 暂未开放。`); + } + } catch (e) { + Chat.addSystemMessage(`指令 ${cmd} 执行失败:${e.message}`); + } + } + + // / 补全菜单:输入以 / 开头 → 显示匹配指令;↑↓ 导航、Enter/点击选择填充 + function showCmdMenu(prefix) { + const items = Commands.getCompletions(prefix); + const menu = $("cmd-menu"); + if (!menu) return; + if (items.length === 0) { + hideCmdMenu(); + return; + } + state.cmdMenu = { items, index: 0 }; + menu.innerHTML = ""; + items.forEach((it, i) => { + const row = el("button", { class: "cmd-menu-item", type: "button", dataset: { cmd: it.cmd } }); + row.innerHTML = `${escapeHtml(it.cmd)}${escapeHtml(it.hint)}`; + row.addEventListener("mousedown", (e) => { + e.preventDefault(); // 防输入框失焦 + selectCmd(it.cmd); + }); + menu.appendChild(row); + }); + menu.hidden = false; + highlightCmdMenu(); + } + + function hideCmdMenu() { + state.cmdMenu = { items: [], index: -1 }; + const menu = $("cmd-menu"); + if (menu) menu.hidden = true; + } + + function highlightCmdMenu() { + const menu = $("cmd-menu"); + if (!menu) return; + [...menu.children].forEach((c, i) => c.classList.toggle("selected", i === state.cmdMenu.index)); + } + + /** 选择补全项:填充输入框 + 关闭菜单(用户可继续回车执行) */ + function selectCmd(cmd) { + const input = $("input"); + input.value = cmd; + input.focus(); + input.style.height = "auto"; + input.style.height = Math.min(input.scrollHeight, 150) + "px"; + hideCmdMenu(); + } + + /** /help 帮助对话框:列出全部指令 + 说明 */ + function showHelpDialog() { + const list = $("help-list"); + if (!list) return; + list.innerHTML = ""; + Object.entries(Commands.COMMANDS).forEach(([cmd, meta]) => { + const row = el("div", { class: "help-row" }); + const name = el("span", { class: "help-cmd" }, cmd); + const hint = el("span", { class: "help-hint" }, meta.hint); + row.appendChild(name); + row.appendChild(hint); + list.appendChild(row); + }); + $("help-dialog").showModal(); + } + // ── 会话 ───────────────────────────────── - async function switchSession(sid, opts = {}) { - // G65:busy 即自有流进行中/发送中(IPC 往返窗口内 ownStreamRequestId 尚未赋值) + async function switchSession(sid, opts = {}) { // G65:busy 即自有流进行中/发送中(IPC 往返窗口内 ownStreamRequestId 尚未赋值) if (state.busy) { Chat.addSystemMessage(EMRG_Copy.COPY.sessionBusy); return; @@ -486,6 +606,7 @@ const App = (() => { $("welcome-save").addEventListener("click", Dialogs.saveWelcome); $("confirm-cancel").addEventListener("click", Dialogs.closeConfirm); $("confirm-ok").addEventListener("click", Dialogs.confirmOk); + $("help-close").addEventListener("click", () => $("help-dialog").close()); // 设置/首启对话框:Enter 提交(与重命名/模型表单一致的交互) const enterToSave = (fn) => (e) => { @@ -498,6 +619,32 @@ const App = (() => { const input = $("input"); input.addEventListener("keydown", (e) => { + // / 补全菜单键盘导航(rant 19:44 P1):↑↓ 移动、Enter 选择、Esc 关闭 + if (state.cmdMenu.items.length > 0) { + if (e.key === "ArrowDown") { + e.preventDefault(); + state.cmdMenu.index = (state.cmdMenu.index + 1) % state.cmdMenu.items.length; + highlightCmdMenu(); + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + state.cmdMenu.index = (state.cmdMenu.index - 1 + state.cmdMenu.items.length) % state.cmdMenu.items.length; + highlightCmdMenu(); + return; + } + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + const item = state.cmdMenu.items[state.cmdMenu.index]; + if (item) selectCmd(item.cmd); + return; + } + if (e.key === "Escape") { + e.preventDefault(); + hideCmdMenu(); + return; + } + } if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendMessage(); @@ -511,6 +658,13 @@ const App = (() => { input.addEventListener("input", () => { input.style.height = "auto"; input.style.height = Math.min(input.scrollHeight, 150) + "px"; + // / 指令补全:以 / 开头且无空格(仍处于指令词)→ 弹出菜单 + const v = input.value.trim(); + if (v.startsWith("/") && !v.includes(" ")) { + showCmdMenu(v); + } else { + hideCmdMenu(); + } }); const chatView = $("chat-view"); @@ -580,6 +734,7 @@ const App = (() => { state, boot, sendMessage, + handleCommand, switchSession, newSession, deleteSession, diff --git a/emrg/gui/renderer/js/commands.js b/emrg/gui/renderer/js/commands.js new file mode 100644 index 0000000..33a77f8 --- /dev/null +++ b/emrg/gui/renderer/js/commands.js @@ -0,0 +1,57 @@ +"use strict"; +/** + * commands.js — GUI / 指令注册表与解析器(rant 19:44 P1:解析器 + 补全 + 纯操作) + * 对齐 TUI 15 个 / 指令。指令解析在 GUI 侧(renderer),daemon 零改动(协议已存在)。 + * + * 阶段(分期): + * phase 1 — 纯操作类(/clear /compact /version /help /image 提示)→ 本轮实现 + * phase 2 — 会话类(/delete /rename /resume /rewind /sessions)→ P2 + * phase 3 — 模型/记忆/技能类(/model /memory /skills)→ P3 + * phase 4 — 演化类(/rant /trigger)→ P4 + */ + +const Commands = (() => { + // 指令注册表:cmd → { hint, phase } + const COMMANDS = { + "/clear": { hint: "清空当前对话", phase: 1 }, + "/compact": { hint: "压缩当前对话历史", phase: 1 }, + "/version": { hint: "显示版本与实例信息", phase: 1 }, + "/help": { hint: "查看全部指令说明", phase: 1 }, + "/image": { hint: "发送图片(请直接粘贴)", phase: 1 }, + "/delete": { hint: "删除当前对话", phase: 2 }, + "/rename": { hint: "重命名当前对话", phase: 2 }, + "/resume": { hint: "切换/恢复对话", phase: 2 }, + "/rewind": { hint: "回退到历史消息点", phase: 2 }, + "/sessions": { hint: "查看全部对话", phase: 2 }, + "/model": { hint: "切换模型", phase: 3 }, + "/memory": { hint: "浏览记忆", phase: 3 }, + "/skills": { hint: "查看已加载技能", phase: 3 }, + "/rant": { hint: "驱动 EMRG 进化", phase: 4 }, + "/trigger": { hint: "触发后台任务", phase: 4 }, + }; + + /** + * 解析输入:/ 开头且匹配注册表 → { type:"command", cmd, args }; + * / 开头未匹配 → { type:"unknown", cmd };否则 { type:"message" }。 + */ + function parseInput(text) { + const t = String(text || "").trim(); + if (!t.startsWith("/")) return { type: "message" }; + const [cmd, ...args] = t.split(/\s+/); + const key = cmd.toLowerCase(); + if (COMMANDS[key]) return { type: "command", cmd: key, args }; + return { type: "unknown", cmd: key }; + } + + /** / 前缀补全:返回匹配的指令(含 hint),供补全菜单过滤 */ + function getCompletions(prefix) { + const p = String(prefix || "").toLowerCase(); + return Object.entries(COMMANDS) + .filter(([cmd]) => cmd.startsWith(p)) + .map(([cmd, meta]) => ({ cmd, hint: meta.hint, phase: meta.phase })); + } + + return { COMMANDS, parseInput, getCompletions }; +})(); + +window.EMRG_Commands = Commands; diff --git a/emrg/gui/test/commands.test.js b/emrg/gui/test/commands.test.js new file mode 100644 index 0000000..c98cc48 --- /dev/null +++ b/emrg/gui/test/commands.test.js @@ -0,0 +1,83 @@ +"use strict"; +/** + * commands.test.js — / 指令注册表与解析器单测(rant 19:44 P1)。 + * 覆盖:15 指令注册完整性、parseInput 三态(message/command/unknown)、 + * getCompletions 前缀过滤 + hint 透传。 + */ + +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"); + +function loadCommands() { + const win = { window: null, console }; + win.window = win; + const ctx = vm.createContext(win); + const code = fs.readFileSync(path.join(RENDERER_JS, "commands.js"), "utf8"); + vm.runInContext(code, ctx, { filename: "renderer/js/commands.js" }); + return vm.runInContext("EMRG_Commands", ctx); +} + +test("注册表含 TUI 全部 15 个 / 指令(rant 19:44 验收)", () => { + const Commands = loadCommands(); + const expected = [ + "/clear", "/compact", "/delete", "/help", "/image", "/memory", "/model", + "/rant", "/rename", "/resume", "/rewind", "/sessions", "/skills", "/trigger", "/version", + ]; + for (const cmd of expected) { + assert.ok(Commands.COMMANDS[cmd], `缺指令 ${cmd}`); + } + assert.strictEqual(Object.keys(Commands.COMMANDS).length, 15); + // 每条指令都有 hint(补全菜单展示用) + for (const [cmd, meta] of Object.entries(Commands.COMMANDS)) { + assert.ok(meta.hint && meta.hint.length > 0, `${cmd} 缺 hint`); + assert.ok(meta.phase >= 1 && meta.phase <= 4, `${cmd} phase 应在 1-4`); + } +}); + +test("parseInput:普通消息 / 已知指令(含参数)/ 未知指令", () => { + const Commands = loadCommands(); + // vm 上下文对象跨 Realm → 逐字段断言(同 renderer.smoke.test.js 教训) + // 普通消息 + let r = Commands.parseInput("你好,帮我写周报"); + assert.strictEqual(r.type, "message"); + r = Commands.parseInput(" 你好 "); + assert.strictEqual(r.type, "message"); + // 已知指令(大小写不敏感) + r = Commands.parseInput("/clear"); + assert.strictEqual(r.type, "command"); + assert.strictEqual(r.cmd, "/clear"); + assert.strictEqual(r.args.length, 0); + r = Commands.parseInput("/Clear"); + assert.strictEqual(r.type, "command"); + assert.strictEqual(r.cmd, "/clear"); + r = Commands.parseInput("/rant 希望支持主题切换"); + assert.strictEqual(r.type, "command"); + assert.strictEqual(r.cmd, "/rant"); + assert.strictEqual(r.args.length, 1); + assert.strictEqual(r.args[0], "希望支持主题切换"); + // 未知指令 + r = Commands.parseInput("/foobar"); + assert.strictEqual(r.type, "unknown"); + assert.strictEqual(r.cmd, "/foobar"); +}); + +test("getCompletions:前缀过滤 + 排序 + hint 透传", () => { + const Commands = loadCommands(); + // 空前缀 → 全部 15 条 + assert.strictEqual(Commands.getCompletions("").length, 15); + // /r 前缀 → /rant /rename /resume /rewind(spread 转宿主 Realm 数组再比较) + const r = [...Commands.getCompletions("/r")].map((i) => String(i.cmd)).sort(); + assert.deepStrictEqual(r, ["/rant", "/rename", "/resume", "/rewind"].sort()); + // 精确匹配 /clear → 单条 + hint + const c = Commands.getCompletions("/clear"); + assert.strictEqual(c.length, 1); + assert.strictEqual(String(c[0].cmd), "/clear"); + assert.ok(String(c[0].hint).length > 0); + // 无匹配 → 空 + assert.strictEqual([...Commands.getCompletions("/zzz")].length, 0); +}); diff --git a/emrg/gui/test/renderer.smoke.test.js b/emrg/gui/test/renderer.smoke.test.js index 17e92c0..028c0eb 100644 --- a/emrg/gui/test/renderer.smoke.test.js +++ b/emrg/gui/test/renderer.smoke.test.js @@ -70,6 +70,7 @@ const ELEMENT_IDS = [ "rename-dialog", "rename-input", "rename-cancel", "rename-ok", "ctx-menu", "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", ]; /** 构造浏览器沙箱(win 即全局对象) */ @@ -126,7 +127,7 @@ function makeSandbox(overrides = {}) { win.window = win; win.document = document; const ctx = vm.createContext(win); - for (const f of ["utils", "markdown", "copywriting", "chat", "sidebar", "dialogs", "app"]) { + 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" }); } @@ -136,10 +137,10 @@ function makeSandbox(overrides = {}) { /** 等 microtask 完成 */ const tick = () => new Promise((r) => setTimeout(r, 20)); -test("7 模块按序加载且全局符号解析", () => { +test("8 模块按序加载且全局符号解析", () => { const { ctx } = makeSandbox(); const out = vm.runInContext( - "(function(){ return { App: typeof App, Chat: typeof EMRG_Chat, Copy: typeof EMRG_Copy, Sidebar: typeof EMRG_Sidebar, Dialogs: typeof EMRG_Dialogs, utils: typeof $ }; })()", + "(function(){ return { App: typeof App, Chat: typeof EMRG_Chat, Copy: typeof EMRG_Copy, Sidebar: typeof EMRG_Sidebar, Dialogs: typeof EMRG_Dialogs, Commands: typeof EMRG_Commands, utils: typeof $ }; })()", ctx ); // ⚠️ vm 上下文对象原型不同 Realm → 不用 deepStrictEqual,逐个字段断言 @@ -148,6 +149,7 @@ test("7 模块按序加载且全局符号解析", () => { assert.strictEqual(out.Copy, "object"); assert.strictEqual(out.Sidebar, "object"); assert.strictEqual(out.Dialogs, "object"); + assert.strictEqual(out.Commands, "object"); assert.strictEqual(out.utils, "function"); });