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
4 changes: 2 additions & 2 deletions Agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 路径不受影响)

Expand Down
2 changes: 1 addition & 1 deletion README.cn.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)。
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
19 changes: 19 additions & 0 deletions emrg/gui/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions emrg/gui/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
13 changes: 13 additions & 0 deletions emrg/gui/renderer/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,19 @@ <h2 data-i18n="sessions.title">切换对话</h2>
</div>
</dialog>

<!-- P5:打开会话对话框(两步:选项目 → 选会话;跨项目) -->
<dialog id="open-session-dialog">
<div class="dialog-card" style="min-width:440px;">
<h2 id="open-session-title" data-i18n="openSession.title">打开会话</h2>
<p style="color:var(--text-2);font-size:var(--fs-secondary);margin:0 0 var(--sp-3);" id="open-session-desc" data-i18n="openSession.desc">选择项目后选择要打开的会话(跨项目多开)。</p>
<div id="open-session-list" class="help-list"></div>
<div class="dialog-actions">
<button type="button" id="open-session-new" class="btn btn-ghost" data-i18n="openSession.newProject">+ 新建项目…</button>
<button type="button" id="open-session-cancel" class="btn btn-ghost" data-i18n="help.close">关闭</button>
</div>
</div>
</dialog>

<!-- 历史回退对话框(/rewind,GUI / 指令 P2) -->
<dialog id="rewind-dialog">
<div class="dialog-card" style="min-width:480px;">
Expand Down
5 changes: 5 additions & 0 deletions emrg/gui/renderer/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,10 @@ const App = (() => {
showSessionsDialog();
}
break;
case "/open":
// P5(rant 15:07:19):打开会话对话框(两步:项目 → 会话,跨项目多开)
Dialogs.showOpenSessionDialog();
break;
case "/rename":
// P2:复用现有重命名对话框(右键菜单同款)
if (!state.sessionId) {
Expand Down Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions emrg/gui/renderer/js/commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
78 changes: 78 additions & 0 deletions emrg/gui/renderer/js/dialogs.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `<div class="help-row"><span class="help-hint">${_t("dlg.loading")}</span></div>`;
dialog.showModal();
try {
const projects = await window.emrg.listProjects();
list.innerHTML = "";
if (!projects || projects.length === 0) {
list.innerHTML = `<div class="help-row"><span class="help-hint">${_t("openSession.noProjects")}</span></div>`;
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 = `<div class="help-row"><span class="help-hint">${_t("openSession.loadFailed", { msg: e.message })}</span></div>`;
}
}

// 第二步:该项目会话列表(created_at 倒序)→ 点击打开(switchSession 复用连接)
async function showProjectSessions(project) {
const list = $("open-session-list");
list.innerHTML = `<div class="help-row"><span class="help-hint">${_t("dlg.loading")}</span></div>`;
$("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 = `<div class="help-row"><span class="help-hint">${_t("openSession.noSessions")}</span></div>`;
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 = `<div class="help-row"><span class="help-hint">${_t("openSession.loadFailed", { msg: e.message })}</span></div>`;
}
}

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;
Expand All @@ -555,6 +631,8 @@ const Dialogs = (() => {
initGithubSection,
initDeviceDialog,
refreshGithubStatus,
initOpenSessionDialog, // P5:打开会话对话框初始化
showOpenSessionDialog, // P5:两步打开会话
showRename,
submitRename,
showSettings,
Expand Down
20 changes: 20 additions & 0 deletions emrg/gui/renderer/js/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,15 @@ const I18N = (() => {
// 会话 / 回退 / 记忆 / 技能对话框
"sessions.title": "切换对话",
"sessions.desc": "点击切换,或输入 /resume <id> 直接切换。",
"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": "取消",
Expand Down Expand Up @@ -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": "查看已加载技能",
Expand Down Expand Up @@ -433,6 +443,15 @@ const I18N = (() => {
// Sessions / rewind / memory / skills dialogs
"sessions.title": "Switch conversation",
"sessions.desc": "Click to switch, or type /resume <id> 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",
Expand Down Expand Up @@ -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",
Expand Down
10 changes: 5 additions & 5 deletions emrg/gui/test/commands.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand Down Expand Up @@ -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());
Expand Down
Loading
Loading