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 numberDiff line numberDiff 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` (142: 43 daemon_client + 17 conn-manager + 22 app-commands + 32 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py`
- Unit tests `npm test` (146: 43 daemon_client + 17 conn-manager + 22 app-commands + 36 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config); 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 DownExpand 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` (142: 43 daemon_client + 17 conn-manager + 22 app-commands + 32 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js`
GUI: `cd emrg/gui && npm test` (146: 43 daemon_client + 17 conn-manager + 22 app-commands + 36 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config) — 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 numberDiff line numberDiff 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 测试(142 项:43 daemon_client + 17 conn-manager + 22 app-commands + 32 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config;集成测试在 CI 跑,本地可 npm run test:integration)
npm test # 运行 Node 测试(146 项:43 daemon_client + 17 conn-manager + 22 app-commands + 36 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config;集成测试在 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 numberDiff line numberDiff 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 (142: 43 daemon_client + 17 conn-manager + 22 app-commands + 32 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config; integration runs in CI, local: npm run test:integration)
npm test # run Node tests (146: 43 daemon_client + 17 conn-manager + 22 app-commands + 36 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config; 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
29 changes: 16 additions & 13 deletions emrg/gui/renderer/js/app.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -961,33 +961,36 @@ const App = (() => {
// ── 事件处理(main 已分类) ─────────────
async function handleEvent(evt) {
const { type, data } = evt;
// P3(rant 15:07:19):main 事件桥附带 sid(#629 起)——Chat 按会话隔离状态;
// 无 sid(单会话过渡期)→ 默认桶,行为与改造前一致。
const sid = evt.sid;
switch (type) {
case "message_delta":
Chat.handleDelta(data.chunks || [data]);
Chat.handleDelta(data.chunks || [data], sid);
break;
case "done":
Chat.handleDone(data);
Chat.handleDone(data, sid);
if (data.request_id && (state.ownStreamRequestId === data.request_id || data.timeout)) {
state.busy = false;
state.ownStreamRequestId = null;
setComposerDisabled(false);
}
break;
case "tool_started":
Chat.handleToolStart(data);
Chat.handleToolStart(data, sid);
break;
case "tool_finished":
Chat.handleToolEnd(data);
Chat.handleToolEnd(data, sid);
ResultPanel.addToolResult(data);
break;
case "cancelled":
Chat.clearTyping(); // rant 14:11:取消时移除在途节点 typing 光标(无 request_id,全清)
Chat.clearTyping(sid); // rant 14:11:取消时移除在途节点 typing 光标(无 request_id,全清)
state.busy = false;
state.ownStreamRequestId = null;
setComposerDisabled(false);
break;
case "error":
handleError(data);
handleError(data, sid);
break;
case "pong":
state.serverId = data.identity?.instance_id || state.serverId;
Expand All@@ -1011,10 +1014,10 @@ const App = (() => {
state.busy = false;
state.ownStreamRequestId = null;
setComposerDisabled(false);
// G97:广播分组缓存清理(DOM 保留;仅清 Map 引用)
Chat.groupNodes.clear();
// P3:广播分组缓存清理按会话隔离(DOM 保留;仅清该会话 Map 引用;无 sid → 默认桶
Chat.groupNodesFor(sid).clear();
// 进行中的工具行 → 结果未知(工具副作用不可重放)
for (const row of Chat.toolRows.values()) {
for (const row of Chat.toolRowsFor(sid).values()) {
if (row.classList.contains("running")) {
row.classList.remove("running");
row.classList.add("failed");
Expand DownExpand Up@@ -1043,15 +1046,15 @@ const App = (() => {
}
}

function handleError(data) {
function handleError(data, sid) {
if (data.error && String(data.error).includes("session busy")) {
Chat.addSystemMessage(EMRG_Copy.COPY.sessionBusy);
Chat.addSystemMessage(EMRG_Copy.COPY.sessionBusy, sid);
state.busy = false;
state.ownStreamRequestId = null;
setComposerDisabled(false);
} else {
Chat.clearTyping(); // rant 14:11:流式错误时移除在途节点 typing 光标
Chat.addSystemMessage(_t("app.error", { msg: data.error || _t("app.unknownError") }));
Chat.clearTyping(sid); // rant 14:11:流式错误时移除在途节点 typing 光标
Chat.addSystemMessage(_t("app.error", { msg: data.error || _t("app.unknownError") }), sid);
}
}

Expand Down
120 changes: 76 additions & 44 deletions emrg/gui/renderer/js/chat.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,12 +5,34 @@
*/

const Chat = (() => {
// requestId → 消息 DOM 节点(广播分组)
const groupNodes = new Map();
// tool_call_id → 工具行 DOM 节点
const toolRows = new Map();
// rant 14:11:已 done 的 request_id(UUID 不复用)——残留 delta 直接丢弃,防孤儿节点
const doneRids = new Set();
// P3(rant 15:07:19):会话级状态隔离——每会话一份 groupNodes/toolRows/doneRids。
// sid=null 为旧版单会话桶(无 sid 事件/旧调用方 → 行为与改造前完全一致)。
// 容器路由:registerContainer(sid, el) 后该 sid 渲染进独立容器;未注册 → 默认 $("chat-view")
// (P4 openSessions 前实际只存在一个激活会话,容器切换随 P4 落地)。
const sessionState = new Map(); // sid|null → { groupNodes, toolRows, doneRids }
const containers = new Map(); // sid → 容器元素(P4 起每会话一个 chat-view)

function st(sid) {
const key = sid || null;
if (!sessionState.has(key)) {
sessionState.set(key, { groupNodes: new Map(), toolRows: new Map(), doneRids: new Set() });
}
return sessionState.get(key);
}

/** 该会话的渲染容器:已注册的独立容器优先,否则默认聊天区(P3 过渡期行为) */
function chatContainer(sid) {
return containers.get(sid) || $("chat-view");
}

/** P4 起:为新打开的会话注册独立容器;关闭时 unregister 清引用 */
function registerContainer(sid, el) {
containers.set(sid, el);
}
function unregisterContainer(sid) {
containers.delete(sid);
sessionState.delete(sid); // 会话关闭 → 释放其分组/工具行/已 done 状态
}

/** 复制代码按钮(设计 §3.3):事件委托在聊天区,CSP 无内联 handler */
function initCodeCopy() {
Expand DownExpand Up@@ -51,36 +73,37 @@ const Chat = (() => {
}
initCodeCopy(); // 模块级绑定一次(boot 可重复调用,防 listener 泄漏)

/** 追加节点到聊天区并滚动 */
function append(node) {
$("chat-view").appendChild(node);
scrollToBottom();
/** 追加节点到该会话聊天区并滚动 */
function append(node, sid) {
chatContainer(sid).appendChild(node);
scrollToBottom(sid);
// rant 14:11:任何消息增删都重新评估欢迎屏显隐(此前只在切会话时评估 → 首条消息后欢迎屏不隐藏)
App.updateEmptyState?.();
}

function scrollToBottom() {
const cv = $("chat-view");
function scrollToBottom(sid) {
const cv = chatContainer(sid);
cv.scrollTop = cv.scrollHeight;
}

function clear() {
$("chat-view").innerHTML = "";
groupNodes.clear();
toolRows.clear();
doneRids.clear();
function clear(sid) {
const key = sid || null;
chatContainer(key).innerHTML = "";
st(key).groupNodes.clear();
st(key).toolRows.clear();
st(key).doneRids.clear();
App.updateEmptyState?.(); // rant 14:11:清空(切会话/新会话)也同步欢迎屏显隐
}

/** 用户消息:右对齐柔和气泡 */
function addUserMessage(text) {
function addUserMessage(text, sid) {
const node = el("div", { class: "msg user" }, text);
append(node);
append(node, sid);
return node;
}

/** EMRG 消息:全宽 + ✦ 标识(流式节点,body 先 textContent 后整体 marked) */
function createAssistantNode(isOwn) {
function createAssistantNode(isOwn, sid) {
const node = el("div", { class: "msg assistant" });
if (!isOwn) {
node.appendChild(el("div", { class: "remote-label" }, EMRG_Copy._t("chat.fromOtherClient")));
Expand All@@ -89,30 +112,31 @@ const Chat = (() => {
const body = el("div", { class: "msg-body typing" });
body.appendChild(mark);
node.appendChild(body);
append(node);
append(node, sid);
return node;
}

/** 系统消息(温和置中) */
function addSystemMessage(text) {
append(el("div", { class: "msg system" }, text));
function addSystemMessage(text, sid) {
append(el("div", { class: "msg system" }, text), sid);
}

/** 流式 delta(G122 main 已按 chunks 批量) */
function handleDelta(chunks) {
/** 流式 delta(G122 main 已按 chunks 批量)——按会话隔离分组/已 done 集合 */
function handleDelta(chunks, sid) {
const { groupNodes, doneRids } = st(sid);
for (const chunk of chunks) {
const rid = chunk.request_id;
if (!rid || doneRids.has(rid)) continue; // rant 14:11:已 done 的流丢弃残留 delta,不建孤儿节点
let group = groupNodes.get(rid);
if (!group) {
const isOwn = App.state.ownStreamRequestId === rid;
const node = createAssistantNode(isOwn);
const node = createAssistantNode(isOwn, sid);
group = { node, nodes: [node], hasText: false, sealed: false };
groupNodes.set(rid, group);
} else if (group.sealed) {
// rant 21:57:10:上一文本段被工具行"封存"→ 新文本段开新节点(旧节点保留在 DOM 原位)
const isOwn = App.state.ownStreamRequestId === rid;
const node = createAssistantNode(isOwn);
const node = createAssistantNode(isOwn, sid);
group.node = node;
group.nodes.push(node);
group.sealed = false;
Expand All@@ -128,13 +152,13 @@ const Chat = (() => {
if (!window.emrgMarkdown.streamProject(body, raw, stream)) {
body.textContent += content;
}
scrollToBottom();
scrollToBottom(sid);
}
}

/** 取消/错误收尾:移除所有在途节点的 typing 光标(cancelled 事件无 request_id,只能全清) */
function clearTyping() {
for (const group of groupNodes.values()) {
/** 取消/错误收尾:移除该会话所有在途节点的 typing 光标(cancelled 事件无 request_id,只能全清) */
function clearTyping(sid) {
for (const group of st(sid).groupNodes.values()) {
for (const node of group.nodes) {
const body = node.querySelector(".msg-body") || node;
body.classList.remove("typing");
Expand All@@ -143,7 +167,8 @@ const Chat = (() => {
}

/** done:整体 Markdown 渲染(requestIdleCallback 调度,G127)——该 rid 的全部文本段逐个渲染 */
function handleDone(data) {
function handleDone(data, sid) {
const { groupNodes, doneRids } = st(sid);
const rid = data.request_id;
if (rid) {
doneRids.add(rid);
Expand All@@ -157,7 +182,7 @@ const Chat = (() => {
const stream = node.__stream;
if (stream && stream.container) {
const render = () => {
window.emrgMarkdown.streamFinalize(body, stream.rawText).then(() => scrollToBottom());
window.emrgMarkdown.streamFinalize(body, stream.rawText).then(() => scrollToBottom(sid));
};
if (window.requestIdleCallback) {
window.requestIdleCallback(render, { timeout: 2000 });
Expand All@@ -174,7 +199,7 @@ const Chat = (() => {
window.emrgMarkdown.renderMarkdown(text).then((html) => {
body.innerHTML = html;
body.insertBefore(el("span", { class: "msg-assistant-mark" }, "✦ "), body.firstChild);
scrollToBottom();
scrollToBottom(sid);
});
};
if (window.requestIdleCallback) {
Expand All@@ -187,24 +212,25 @@ const Chat = (() => {
}
}
if (data.timeout) {
addSystemMessage(EMRG_Copy._t("chat.timeoutWarn"));
addSystemMessage(EMRG_Copy._t("chat.timeoutWarn"), sid);
}
// 工具调用次数上限中断(跨项目教训:截断的工作不提示 = 用户拿半成品)
// 对齐 TUI:明确提示结果可能不完整 + 可继续(TUI 已有 "Try '继续' to resume")
if (data.content && /exceeded/i.test(data.content) && /max|limit|round/i.test(data.content)) {
addSystemMessage(EMRG_Copy._t("chat.maxRoundsHint"));
addSystemMessage(EMRG_Copy._t("chat.maxRoundsHint"), sid);
}
}

/** 工具友好状态行(进行中 → 完成/失败,默认折叠,点开展示原始输出) */
function handleToolStart(data) {
function handleToolStart(data, sid) {
const { groupNodes, toolRows } = st(sid);
const rid = data.request_id;
if (rid) {
let group = groupNodes.get(rid);
if (!group) {
// G104:tool_start 也建组(LLM 先出 tool_calls 后出文本)
const isOwn = App.state.ownStreamRequestId === rid;
const node = createAssistantNode(isOwn);
const node = createAssistantNode(isOwn, sid);
group = { node, nodes: [node], hasText: false, sealed: false };
groupNodes.set(rid, group);
} else if (group.hasText) {
Expand All@@ -230,12 +256,12 @@ const Chat = (() => {
row.classList.toggle("expanded", !out.classList.contains("hidden"));
}
});
append(row);
append(row, sid);
toolRows.set(data.tool_call_id, row);
}

function handleToolEnd(data) {
const row = toolRows.get(data.tool_call_id);
function handleToolEnd(data, sid) {
const row = st(sid).toolRows.get(data.tool_call_id);
if (!row) return;
// rant 21:08:工具执行完成后 spinner 必须停止——移除转圈元素(CSS 亦有
// .tool-row:not(.running) 隐藏兜底),只保留 ✓ 完成标记,防止"对号前面一直转圈"。
Expand DownExpand Up@@ -273,7 +299,7 @@ const Chat = (() => {
row.appendChild(btn);
}
}
scrollToBottom();
scrollToBottom(sid);
}

return {
Expand All@@ -287,11 +313,17 @@ const Chat = (() => {
handleToolStart,
handleToolEnd,
clearTyping,
registerContainer,
unregisterContainer,
chatContainer,
// P3:会话级状态访问器(sid=null → 旧版单会话桶,兼容既有调用方)
groupNodesFor: (sid) => st(sid).groupNodes,
toolRowsFor: (sid) => st(sid).toolRows,
get groupNodes() {
return groupNodes;
return st(null).groupNodes;
},
get toolRows() {
return toolRows;
return st(null).toolRows;
},
};
})();
Expand Down
Loading
Loading