diff --git a/Agent.md b/Agent.md index 0a02ef0..5e2dfbf 100644 --- a/Agent.md +++ b/Agent.md @@ -66,7 +66,7 @@ EMRG is a self-evolving AI agent architecture experiment. Python implementation, - Streaming chat with delta rendering (16ms batching), markdown on done (marked + DOMPurify + local highlight.js subset), tool call status cards (2000-char truncation + expand) - Session list/switch/new/delete + right-click rename (context menu, #423) synced with daemon; own-stream busy lock (G65); broadcast streams from other clients tagged "来自其他客户端" - Disconnect/reconnect: red status dot, auto daemon respawn (stale-port detection), session resume, input bar restored on disconnect (no 30s fake-timeout) - - Unit tests `npm test` (167: 43 daemon_client + 17 conn-manager + 22 app-commands + 50 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config + 7 gui-state); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` + - Unit tests `npm test` (169: 43 daemon_client + 17 conn-manager + 22 app-commands + 52 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config + 7 gui-state); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` - **Auto project tracking** — Automatically detects and records working directories; project-scoped sessions - **Rant-driven evolution** — User feedback via `/rant` drives automatic self-improvement cycles - **Headless GitHub auth** — Non-interactive evolution auto-extracts `GH_TOKEN` from git credential store (osxkeychain / credential helper); PR comment/LGTM queries fall back to REST API (GraphQL needs `read:org` scope) @@ -94,7 +94,7 @@ pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg ``` Python: `uv run pytest tests/ -v` (680) — import check: `uv run python -c "from emrg.client.app import run_client"` -GUI: `cd emrg/gui && npm test` (167: 43 daemon_client + 17 conn-manager + 22 app-commands + 50 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config + 7 gui-state) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` +GUI: `cd emrg/gui && npm test` (169: 43 daemon_client + 17 conn-manager + 22 app-commands + 52 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config + 7 gui-state) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` CI: `uv run pytest` + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响) diff --git a/README.cn.md b/README.cn.md index e979c55..ee14d6f 100644 --- a/README.cn.md +++ b/README.cn.md @@ -282,7 +282,7 @@ uv run python -m emrg # 启动 TUI cd emrg/gui npm ci # 安装依赖(生产模式可 --omit=dev) npm start # 启动 GUI(自动拉起 daemon) -npm test # 运行 Node 测试(167 项:43 daemon_client + 17 conn-manager + 22 app-commands + 50 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config + 7 gui-state;集成测试在 CI 跑,本地可 npm run test:integration) +npm test # 运行 Node 测试(169 项:43 daemon_client + 17 conn-manager + 22 app-commands + 52 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config + 7 gui-state;集成测试在 CI 跑,本地可 npm run test:integration) ``` CI 通过 GitHub Actions 自动运行测试并检查冲突标记(`.github/workflows/test.yml`)。 diff --git a/README.md b/README.md index e8b09ce..af1c210 100644 --- a/README.md +++ b/README.md @@ -281,7 +281,7 @@ uv run python -m emrg # launch TUI cd emrg/gui npm ci # install deps (production: --omit=dev) npm start # launch GUI (auto-starts daemon) -npm test # run Node tests (167: 43 daemon_client + 17 conn-manager + 22 app-commands + 50 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config + 7 gui-state; integration runs in CI, local: npm run test:integration) +npm test # run Node tests (169: 43 daemon_client + 17 conn-manager + 22 app-commands + 52 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config + 7 gui-state; integration runs in CI, local: npm run test:integration) ``` CI runs tests and checks for conflict markers automatically via GitHub Actions (`.github/workflows/test.yml`). diff --git a/emrg/gui/main.js b/emrg/gui/main.js index edd0036..362a388 100644 --- a/emrg/gui/main.js +++ b/emrg/gui/main.js @@ -456,6 +456,25 @@ vision = false return frame.projects || []; }); + // P5(rant 15:07:19):某项目的会话列表(list_sessions(cwd=projectPath)) + ipcMain.handle("emrg:listProjectSessions", async (_e, { projectPath }) => { + if (typeof projectPath !== "string" || !projectPath) throw new Error("invalid project path"); + const frame = await requireConn().sendCommandAndWait("list_sessions", { cwd: projectPath }, 5000); + return { sessions: frame.sessions || [] }; + }); + + // P5:新建项目 = 轻量命令带 cwd → daemon 隐式 _touch_project 注册(零改动) + ipcMain.handle("emrg:registerProject", async (_e, { path: p }) => { + if (typeof p !== "string" || !p) throw new Error("invalid project path"); + try { + fs.accessSync(p, fs.constants.W_OK); // 目录可写校验(G121) + } catch { + throw new Error("project directory not writable"); + } + await requireConn().sendCommandAndWait("list_sessions", { cwd: p }, 5000); // 隐式注册 + return { ok: true, path: p }; + }); + ipcMain.handle("emrg:listTasks", async () => { // GUI / 指令 P4:/trigger — daemon list_tasks → tasks_list const frame = await requireConn().sendCommandAndWait("list_tasks", {}, 5000); diff --git a/emrg/gui/preload.js b/emrg/gui/preload.js index 14cafe1..636abcf 100644 --- a/emrg/gui/preload.js +++ b/emrg/gui/preload.js @@ -25,6 +25,8 @@ const api = { readMemory: (payload) => ipcRenderer.invoke("emrg:readMemory", payload), listSkills: () => ipcRenderer.invoke("emrg:listSkills"), listProjects: () => ipcRenderer.invoke("emrg:listProjects"), + listProjectSessions: (payload) => ipcRenderer.invoke("emrg:listProjectSessions", payload), + registerProject: (payload) => ipcRenderer.invoke("emrg:registerProject", payload), listTasks: () => ipcRenderer.invoke("emrg:listTasks"), triggerTask: (payload) => ipcRenderer.invoke("emrg:triggerTask", payload), sendRant: (payload) => ipcRenderer.invoke("emrg:sendRant", payload), diff --git a/emrg/gui/renderer/index.html b/emrg/gui/renderer/index.html index 2284e9f..2f7128c 100644 --- a/emrg/gui/renderer/index.html +++ b/emrg/gui/renderer/index.html @@ -268,6 +268,19 @@

切换对话

+ + +
+

打开会话

+

选择项目后选择要打开的会话(跨项目多开)。

+
+
+ + +
+
+
+
diff --git a/emrg/gui/renderer/js/app.js b/emrg/gui/renderer/js/app.js index fcf8dcc..177fab8 100644 --- a/emrg/gui/renderer/js/app.js +++ b/emrg/gui/renderer/js/app.js @@ -206,6 +206,10 @@ const App = (() => { showSessionsDialog(); } break; + case "/open": + // P5(rant 15:07:19):打开会话对话框(两步:项目 → 会话,跨项目多开) + Dialogs.showOpenSessionDialog(); + break; case "/rename": // P2:复用现有重命名对话框(右键菜单同款) if (!state.sessionId) { @@ -1273,6 +1277,7 @@ const App = (() => { $("send-btn").addEventListener("click", sendMessage); $("stop-btn").addEventListener("click", () => window.emrg.cancel().catch(() => {})); $("new-chat-btn").addEventListener("click", newSession); + Dialogs.initOpenSessionDialog(); // P5:打开会话对话框绑定 $("settings-btn").addEventListener("click", () => { loadEvolutionSummary(); // WorkBuddy P3(#502):打开设置时加载最近改进 Dialogs.showSettings(); diff --git a/emrg/gui/renderer/js/commands.js b/emrg/gui/renderer/js/commands.js index 8e5cdd6..4c85252 100644 --- a/emrg/gui/renderer/js/commands.js +++ b/emrg/gui/renderer/js/commands.js @@ -24,6 +24,7 @@ const Commands = (() => { "/resume": { hint: "cmd.resume.hint", phase: 2 }, "/rewind": { hint: "cmd.rewind.hint", phase: 2 }, "/sessions": { hint: "cmd.sessions.hint", phase: 2 }, + "/open": { hint: "cmd.open.hint", phase: 4 }, // P5:打开会话(跨项目) "/model": { hint: "cmd.model.hint", phase: 3 }, "/memory": { hint: "cmd.memory.hint", phase: 3 }, "/skills": { hint: "cmd.skills.hint", phase: 3 }, diff --git a/emrg/gui/renderer/js/dialogs.js b/emrg/gui/renderer/js/dialogs.js index 91f07db..91ac439 100644 --- a/emrg/gui/renderer/js/dialogs.js +++ b/emrg/gui/renderer/js/dialogs.js @@ -533,6 +533,82 @@ const Dialogs = (() => { $("confirm-dialog").showModal(); } + // ── P5(rant 15:07:19):打开会话对话框(两步:选项目 → 选会话,跨项目) ── + async function showOpenSessionDialog() { + const list = $("open-session-list"); + const dialog = $("open-session-dialog"); + if (!list || !dialog) return; + list.innerHTML = `
${_t("dlg.loading")}
`; + dialog.showModal(); + try { + const projects = await window.emrg.listProjects(); + list.innerHTML = ""; + if (!projects || projects.length === 0) { + list.innerHTML = `
${_t("openSession.noProjects")}
`; + return; + } + // 第一步:项目列表(按最近活跃倒序——daemon 已排;底部"新建项目…"按钮) + projects.forEach((p) => { + 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" }, p.name || p.path || ""); + const hint = el("span", { class: "help-hint" }, p.path || ""); + row.appendChild(name); + row.appendChild(hint); + row.addEventListener("click", () => showProjectSessions(p)); + list.appendChild(row); + }); + } catch (e) { + list.innerHTML = `
${_t("openSession.loadFailed", { msg: e.message })}
`; + } + } + + // 第二步:该项目会话列表(created_at 倒序)→ 点击打开(switchSession 复用连接) + async function showProjectSessions(project) { + const list = $("open-session-list"); + list.innerHTML = `
${_t("dlg.loading")}
`; + $("open-session-title").textContent = _t("openSession.titleProject", { project: project.name || project.path || "" }); + try { + const frame = await window.emrg.listProjectSessions({ projectPath: project.path }); + const sessions = frame.sessions || []; + list.innerHTML = ""; + if (sessions.length === 0) { + list.innerHTML = `
${_t("openSession.noSessions")}
`; + return; + } + 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 || _t("app.unnamed")); + const hint = el("span", { class: "help-hint" }, s.session_id === App.state.sessionId ? _t("app.current") : ""); + row.appendChild(name); + row.appendChild(hint); + row.addEventListener("click", async () => { + $("open-session-dialog").close(); + await App.switchSession(s.session_id); + }); + list.appendChild(row); + }); + } catch (e) { + list.innerHTML = `
${_t("openSession.loadFailed", { msg: e.message })}
`; + } + } + + function initOpenSessionDialog() { + $("open-session-cancel").addEventListener("click", () => $("open-session-dialog").close()); + $("open-session-new").addEventListener("click", async () => { + // P5:新建项目 = 选目录 → 轻量命令注册(daemon 隐式 _touch_project) + try { + const res = await window.emrg.pickProjectDir(); + if (res && res.path) { + await window.emrg.registerProject({ path: res.path }); + Chat.addSystemMessage(_t("openSession.projectCreated", { path: res.path })); + showOpenSessionDialog(); // 刷新项目列表 + } + } catch (e) { + Chat.addSystemMessage(_t("openSession.projectFailed", { msg: e.message })); + } + }); + } + function closeConfirm() { $("confirm-dialog").close(); confirmCb = null; @@ -555,6 +631,8 @@ const Dialogs = (() => { initGithubSection, initDeviceDialog, refreshGithubStatus, + initOpenSessionDialog, // P5:打开会话对话框初始化 + showOpenSessionDialog, // P5:两步打开会话 showRename, submitRename, showSettings, diff --git a/emrg/gui/renderer/js/i18n.js b/emrg/gui/renderer/js/i18n.js index 8a39114..9331f58 100644 --- a/emrg/gui/renderer/js/i18n.js +++ b/emrg/gui/renderer/js/i18n.js @@ -135,6 +135,15 @@ const I18N = (() => { // 会话 / 回退 / 记忆 / 技能对话框 "sessions.title": "切换对话", "sessions.desc": "点击切换,或输入 /resume 直接切换。", + "openSession.title": "打开会话", + "openSession.desc": "选择项目后选择要打开的会话(跨项目多开)。", + "openSession.titleProject": "打开会话 — {project}", + "openSession.noProjects": "还没有项目。点下方「新建项目…」选择一个文件夹。", + "openSession.noSessions": "该项目还没有会话,发送第一条消息会自动创建。", + "openSession.loadFailed": "加载失败:{msg}", + "openSession.newProject": "+ 新建项目…", + "openSession.projectCreated": "项目已注册:{path}", + "openSession.projectFailed": "新建项目失败:{msg}", "rewind.title": "回退到历史消息点", "rewind.desc": "选择要保留到的消息点,之后的对话将被移除。", "rewind.cancel": "取消", @@ -164,6 +173,7 @@ const I18N = (() => { "cmd.resume.hint": "切换/恢复对话", "cmd.rewind.hint": "回退到历史消息点", "cmd.sessions.hint": "查看全部对话", + "cmd.open.hint": "打开会话(跨项目)", "cmd.model.hint": "切换模型", "cmd.memory.hint": "浏览记忆", "cmd.skills.hint": "查看已加载技能", @@ -433,6 +443,15 @@ const I18N = (() => { // Sessions / rewind / memory / skills dialogs "sessions.title": "Switch conversation", "sessions.desc": "Click to switch, or type /resume to switch directly.", + "openSession.title": "Open session", + "openSession.desc": "Pick a project, then pick a session to open (multi-project tabs).", + "openSession.titleProject": "Open session — {project}", + "openSession.noProjects": "No projects yet. Use \"+ New project…\" below to pick a folder.", + "openSession.noSessions": "No sessions in this project yet — the first message creates one.", + "openSession.loadFailed": "Failed to load: {msg}", + "openSession.newProject": "+ New project…", + "openSession.projectCreated": "Project registered: {path}", + "openSession.projectFailed": "Failed to create project: {msg}", "rewind.title": "Rewind to a history point", "rewind.desc": "Choose the message point to keep — later messages will be removed.", "rewind.cancel": "Cancel", @@ -462,6 +481,7 @@ const I18N = (() => { "cmd.resume.hint": "Switch / resume a conversation", "cmd.rewind.hint": "Rewind to a history point", "cmd.sessions.hint": "View all conversations", + "cmd.open.hint": "Open session (cross-project)", "cmd.model.hint": "Switch model", "cmd.memory.hint": "Browse memory", "cmd.skills.hint": "View loaded skills", diff --git a/emrg/gui/test/commands.test.js b/emrg/gui/test/commands.test.js index c98cc48..54e65a6 100644 --- a/emrg/gui/test/commands.test.js +++ b/emrg/gui/test/commands.test.js @@ -22,16 +22,16 @@ function loadCommands() { return vm.runInContext("EMRG_Commands", ctx); } -test("注册表含 TUI 全部 15 个 / 指令(rant 19:44 验收)", () => { +test("注册表含 TUI 全部 15 个 / 指令 + /open(rant 19:44 验收 + P5 扩展)", () => { const Commands = loadCommands(); const expected = [ "/clear", "/compact", "/delete", "/help", "/image", "/memory", "/model", - "/rant", "/rename", "/resume", "/rewind", "/sessions", "/skills", "/trigger", "/version", + "/open", "/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); + assert.strictEqual(Object.keys(Commands.COMMANDS).length, 16); // 每条指令都有 hint(补全菜单展示用) for (const [cmd, meta] of Object.entries(Commands.COMMANDS)) { assert.ok(meta.hint && meta.hint.length > 0, `${cmd} 缺 hint`); @@ -68,8 +68,8 @@ test("parseInput:普通消息 / 已知指令(含参数)/ 未知指令", () test("getCompletions:前缀过滤 + 排序 + hint 透传", () => { const Commands = loadCommands(); - // 空前缀 → 全部 15 条 - assert.strictEqual(Commands.getCompletions("").length, 15); + // 空前缀 → 全部 16 条 + assert.strictEqual(Commands.getCompletions("").length, 16); // /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()); diff --git a/emrg/gui/test/renderer.smoke.test.js b/emrg/gui/test/renderer.smoke.test.js index e23705f..e6d691a 100644 --- a/emrg/gui/test/renderer.smoke.test.js +++ b/emrg/gui/test/renderer.smoke.test.js @@ -59,7 +59,8 @@ function makeEl(id) { clientHeight: 100, open: false, appendChild(c) { c.parentNode = this; this.children.push(c); return c; }, - addEventListener() {}, + addEventListener(type, fn) { this._listeners = this._listeners || {}; (this._listeners[type] = this._listeners[type] || []).push(fn); }, + click() { (this._listeners && this._listeners.click || []).forEach((fn) => fn({ preventDefault() {} })); }, querySelector(sel) { // 最小类选择器搜索(chat.js 用 ".msg-body"/".tool-spinner"):DFS 子节点 if (!sel || !sel.startsWith(".")) return null; @@ -106,6 +107,7 @@ function makeEl(id) { const ELEMENT_IDS = [ "chat-view", "input", "send-btn", "stop-btn", "conv-list", "open-sessions", "open-sessions-label", "status-dot", "settings-btn", + "open-session-dialog", "open-session-list", "open-session-title", "open-session-desc", "open-session-new", "open-session-cancel", "conn-banner", "empty-state", "model-switcher", "model-switcher-label", "brand-star", "new-chat-btn", "settings-dialog", "settings-cancel", "settings-save", "set-api-key", "set-base-url", "set-project-dir", "set-model", "pick-dir-btn", "theme-options", "welcome-dialog", "welcome-api-key", "welcome-base-url", @@ -1335,3 +1337,46 @@ test("P4 s2: boot init 携带 open_sessions + active_sid → 采用恢复的激 assert.strictEqual(va.classList.contains("active"), true, "restored session view activated"); assert.strictEqual(els["input"].disabled, false, "composer enabled after boot"); }); + +// ── P5(rant 15:07:19):打开会话对话框(两步:项目 → 会话) ──────── + +test("P5: showOpenSessionDialog 列项目(第一步)→ 点项目列会话(第二步)", async () => { + const projects = [ + { name: "emrg", path: "/p/emrg" }, + { name: "mem", path: "/p/mem" }, + ]; + const { ctx, els } = makeSandbox({ + listProjects: async () => projects, + listProjectSessions: async ({ projectPath }) => { + if (projectPath === "/p/emrg") return { sessions: [{ session_id: "s1", title: "S1" }, { session_id: "s2", title: "S2" }] }; + return { sessions: [] }; + }, + }); + await tick(); + await vm.runInContext("EMRG_Dialogs.showOpenSessionDialog()", ctx); + await tick(); + assert.strictEqual(els["open-session-dialog"].open, true, "dialog opened"); + assert.strictEqual(els["open-session-list"].children.length, 2, "two projects listed"); + // 点击 emrg 项目 → 第二步列出会话(按钮行子节点 [name, hint]) + els["open-session-list"].children[0].click(); + await tick(); + const rows = els["open-session-list"].children; + assert.strictEqual(rows.length, 2, "two sessions listed for project"); + const nameSpan = rows[0].children[0] || rows[0]; + assert.ok((nameSpan.textContent || "").includes("S1"), "session title shown"); +}); + +test("P5: /open 指令 → 打开会话对话框;无项目 → 提示新建", async () => { + const { ctx, els } = makeSandbox({ + listProjects: async () => [], + }); + await tick(); + await vm.runInContext('App.handleCommand({ cmd: "/open", args: [] });', ctx); + await tick(); + assert.strictEqual(els["open-session-dialog"].open, true, "dialog opened via /open"); + // 无项目 → innerHTML 字符串呈现提示(mock innerHTML 赋值不建子节点) + assert.ok( + (els["open-session-list"].innerHTML || "").includes("新建项目") || (els["open-session-list"].innerHTML || "").includes("New project"), + "no-projects hint" + ); +});