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
14 changes: 14 additions & 0 deletions emrg/gui/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
2 changes: 2 additions & 0 deletions emrg/gui/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
52 changes: 52 additions & 0 deletions emrg/gui/renderer/css/components.css
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
1 change: 1 addition & 0 deletions emrg/gui/renderer/css/layout.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
14 changes: 14 additions & 0 deletions emrg/gui/renderer/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@

<!-- 输入区 -->
<div id="composer-wrap">
<div id="cmd-menu" class="cmd-menu" hidden></div>
<div class="model-switcher" id="model-switcher" title="切换模型">
<span id="model-switcher-label">加载中…</span>
<span style="font-size:10px;">⌄</span>
Expand Down Expand Up @@ -182,7 +183,20 @@ <h2>重命名对话</h2>
<script src="../vendor/marked.min.js"></script>
<script src="../vendor/dompurify.min.js"></script>
<script src="../vendor/highlight.custom.js"></script>
<!-- 帮助对话框(/help,GUI / 指令 P1) -->
<dialog id="help-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);">以 / 开头的指令在输入框执行,或点击补全菜单选择。</p>
<div id="help-list" class="help-list"></div>
<div class="dialog-actions">
<button type="button" id="help-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>
<script src="js/copywriting.js"></script>
<script src="js/chat.js"></script>
Expand Down
159 changes: 157 additions & 2 deletions emrg/gui/renderer/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
};

// ── 启动 ─────────────────────────────────
Expand Down Expand Up @@ -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;
Expand All @@ -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 = `<span class="cmd-menu-name">${escapeHtml(it.cmd)}</span><span class="cmd-menu-hint">${escapeHtml(it.hint)}</span>`;
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;
Expand Down Expand Up @@ -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) => {
Expand All @@ -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();
Expand All @@ -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");
Expand Down Expand Up @@ -580,6 +734,7 @@ const App = (() => {
state,
boot,
sendMessage,
handleCommand,
switchSession,
newSession,
deleteSession,
Expand Down
57 changes: 57 additions & 0 deletions emrg/gui/renderer/js/commands.js
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading