加载中…
⌄
@@ -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 = ``;
+ 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");
});