From 1a9e070e00d8a940bc53c620b9b82909c0368f59 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Thu, 6 Aug 2026 19:28:44 +0800 Subject: [PATCH] =?UTF-8?q?emrg:=20=E5=88=A0=E9=99=A4=20docs/=20=E8=BF=87?= =?UTF-8?q?=E6=97=B6=E8=AE=BE=E8=AE=A1=E6=96=87=E6=A1=A3=20=E2=80=94=2014?= =?UTF-8?q?=20=E4=B8=AA=E8=AE=BE=E8=AE=A1=E7=A8=BF=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E5=B7=B2=E5=85=A8=E9=83=A8=E5=AE=9E=E7=8E=B0=E5=8F=91=E5=B8=83?= =?UTF-8?q?=EF=BC=88rant=202026-08-06T19:24:42=EF=BC=8C=E5=AE=BF=E4=B8=BB?= =?UTF-8?q?=E7=A1=AE=E8=AE=A4=E6=B8=85=E7=90=86=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/design/compact-resilience.md | 506 -------------- docs/design/evolution-refactor.md | 353 ---------- docs/design/open-source-promotion.md | 209 ------ docs/design/packaged-installer.md | 674 ------------------ docs/design/phase1-websocket-protocol.md | 532 -------------- docs/design/phase2-daemon-manager.md | 554 --------------- docs/design/phase3-electron-gui.md | 845 ----------------------- docs/design/phase4-installer.md | 581 ---------------- docs/design/protocol-contract.md | 738 -------------------- docs/design/resume-session.md | 147 ---- docs/design/roadmap-electron.md | 207 ------ docs/design/roadmap.md | 205 ------ docs/design/windows-compatibility.md | 304 -------- 13 files changed, 5855 deletions(-) delete mode 100644 docs/design/compact-resilience.md delete mode 100644 docs/design/evolution-refactor.md delete mode 100644 docs/design/open-source-promotion.md delete mode 100644 docs/design/packaged-installer.md delete mode 100644 docs/design/phase1-websocket-protocol.md delete mode 100644 docs/design/phase2-daemon-manager.md delete mode 100644 docs/design/phase3-electron-gui.md delete mode 100644 docs/design/phase4-installer.md delete mode 100644 docs/design/protocol-contract.md delete mode 100644 docs/design/resume-session.md delete mode 100644 docs/design/roadmap-electron.md delete mode 100644 docs/design/roadmap.md delete mode 100644 docs/design/windows-compatibility.md diff --git a/docs/design/compact-resilience.md b/docs/design/compact-resilience.md deleted file mode 100644 index d040d720..00000000 --- a/docs/design/compact-resilience.md +++ /dev/null @@ -1,506 +0,0 @@ -# Compact 容错设计 - -## 问题分析 - -### 当前 compact 流程 - -``` -用户输入 /compact - → _handle_compact() - → _read_history() 读取全部 history.jsonl 记录 - → 拼接为纯文本 prompt (含 tool_call/tool_result) - → llm.chat(compact_prompt + history_text, tools=None) - → 成功 → session.compact(summary, keep_recent=5) 替换历史 -``` - -### 失效场景 - -当对话历史超过 LLM context window(DeepSeek-chat: 128K tokens)时: - -1. **场景 A:正常对话已无法继续** - - `get_messages_for_llm()` 读取全量 history,拼入 messages - - 发送给 LLM → API 返回 context length exceeded 错误 - - 此时 `/compact` 也走相同的路径 → **同样超限,compact 失败** - - **死锁**:无法对话,也无法 compact - -2. **场景 B:finish_reason == "length"** - - 当前工具循环中,`"length"` 只被当作普通 stop 处理(Case 3) - - 没有触发任何 compact 或警告 - - `"length"` 意味着达到 `max_tokens` 上限(当前配置 4096),输出被截断 - - 虽然不直接等于 context 超限,但是一个值得预警的信号 - -3. **场景 C:compact prompt 本身超限** - - `_handle_compact` 把全部 history 展开为纯文本(含 tool_call/tool_result) - - 纯文本可能比原始 JSON 消息更大(tool results 截断到 500 字符但积累起来仍然很大) - - 发送给 LLM → context length exceeded → compact 失败 - -## 设计方案 - -### 🔴 分层容错设计 - -#### Layer 1: Token 监测 + 自动 compact - -在 `_run_tool_loop` 每轮开始前,估算 prompt token 数。超过阈值时**自动触发 compact**,不需要用户手动 `/compact`。 - -**Token 估算**(不使用 tokenizer,基于字符数): -```python -def _estimate_tokens(messages: list[dict]) -> int: - """粗略估算 messages 的 token 数。 - - - 英文/代码: ~4 chars/token - - 中文: ~1.5 chars/token - - 保守取 3 chars/token - - 每条 message 额外 +3 tokens 用于 role/content 元数据 - """ - total = 0 - for m in messages: - total += 3 # role overhead - content = m.get("content") or "" - if isinstance(content, str): - total += len(content) // 3 - for tc in (m.get("tool_calls") or []): - tc_str = json.dumps(tc, ensure_ascii=False) - total += tc_str // 3 - return total -``` - -**阈值配置**(`config.toml`): - -```toml -[llm] -# ... 其他配置 ... -context_window = 131072 -auto_compact_threshold = 0.7 # 可选,不配置时默认 0.0(禁用自动 compact) -``` - -**默认值**(`LlmConfig` dataclass): -```python -auto_compact_threshold: float = 0.0 # 0.0 = 禁用;0.7 = 推荐值 -``` - -**行为**: -- 每轮 tool loop 开始前,`_estimate_tokens(messages)` 估算当前 prompt token 数 -- `auto_compact_threshold == 0.0`:跳过检查,不自动 compact -- `auto_compact_threshold > 0.0`:当 `estimated_tokens > context_window * auto_compact_threshold` 时触发 - -**为什么同步而非异步**:如果异步 compact(不等待),当前轮仍然会用超限的 messages 发送给 LLM → API 报错。同步 compact 确保下一轮对话用精简后的 history。 - -**阈值选择**: - -| 值 | 含义 | 适用场景 | -|---|------|---------| -| 0.7 | context_window 的 70% | 推荐,在超限前有充足缓冲 | -| 0.85 | 85% | 激进,最大化利用 context | -| 0.0 | 禁用(默认) | 不自动 compact,只手动 `/compact` | - -#### Layer 2: finish_reason == "length" 兜底 - -当 LLM 返回 `finish_reason == "length"`(达到 `max_tokens` 上限,输出被截断): -- 在 Case 3 分支增加检测 -- 自动触发 compact(后台异步,不阻塞当前响应) -- 客户端收到 compact_result 后显示 -- 注意:此时 prompt 可能还没到 auto_compact_threshold,但 `length` 是另一个信号——输出空间不够了 - -#### Layer 3: chunked compact(兜底) - -当常规 compact 的 LLM 调用失败(context too long)时,启用**按 token 量动态分片**的 compact。 - -**核心思想**:不按记录数分片(记录大小差异太大),而是按 token 估算值贪心分片,确保每个 chunk 的 prompt 能放入 context window。 - -**常量定义**(从配置推导,不硬编码): - -```python -# config.toml 中配置: -# context_window = 131072 # 模型的 context window (输入+输出总量) -# max_tokens = 4096 # 单次输出上限 -# auto_compact_threshold = 0.7 # 自动 compact 阈值 -# -# 推导: -# CTX_WINDOW = config.context_window -# MAX_PER_CHUNK = CTX_WINDOW - max_tokens - 2000 -# = 131_072 - 4096 - 2000 -# ≈ 124_000 -# (留下 max_tokens 给 summary 输出,2000 给 prompt 模板) -# AUTO_COMPACT_AT = CTX_WINDOW * auto_compact_threshold -# = 131_072 * 0.7 ≈ 91_750 - -CTX_WINDOW = config.llm.context_window -MAX_PER_CHUNK = CTX_WINDOW - config.llm.max_tokens - 2000 -AUTO_COMPACT_AT = int(CTX_WINDOW * config.llm.auto_compact_threshold) -MERGE_BATCH = MAX_PER_CHUNK -``` - -**`config.toml` 完整示例**: -```toml -[llm] -base_url = "https://api.deepseek.com" -api_key = "sk-..." -model = "deepseek-chat" -max_tokens = 4096 -context_window = 131072 # DeepSeek-chat: 128K (131072) -auto_compact_threshold = 0.7 # 可选,91K tokens 时自动 compact(不配置默认 0.0) -temperature = 0.7 -``` - -**分片算法**(token-aware 贪心装箱): - -``` -_chunked_compact(records, keep_recent=5): - to_compact = records[:-keep_recent] - - # Step 1: 贪心分片 — 按 token 估算填满每个 chunk - chunks = [] - current_chunk = [] - current_tokens = 0 - - for record in to_compact: - rec_tokens = _estimate_single(record) - if current_tokens + rec_tokens > MAX_PER_CHUNK and current_chunk: - chunks.append(current_chunk) - current_chunk = [] - current_tokens = 0 - current_chunk.append(record) - current_tokens += rec_tokens - if current_chunk: - chunks.append(current_chunk) - - # Step 2: 逐片总结 - summaries = [] - for idx, chunk in enumerate(chunks): - chunk_text = _records_to_text(chunk) - summary = await llm.chat( - "Summarize this conversation segment " - f"({idx+1}/{len(chunks)}):\n\n{chunk_text}", - tools=None - ) - summaries.append(summary) - - # Step 3: 递归合并 summaries(如果 summaries 太多也会超限) - return await _merge_summaries(summaries) -``` - -**合并算法**(递归,自适应 token 量): - -``` -_merge_summaries(summaries, max_per_chunk, merge_batch): - total_tokens = _estimate_text("\n---\n".join(summaries)) - - if total_tokens <= merge_batch: - # 单次合并即可 - return await llm.chat( - "Merge these conversation segment summaries " - "into one coherent summary:\n\n" + "\n---\n".join(summaries), - tools=None - ) - - # summaries 太多 → 分组合并,递归 - batches = [] - current = [] - current_tokens = 0 - for s in summaries: - st = _estimate_text(s) - if current_tokens + st > merge_batch and current: - batches.append(current) - current = [] - current_tokens = 0 - current.append(s) - current_tokens += st - if current: - batches.append(current) - - # 逐组合并(串行,因为每组合并可能涉及多次 LLM 调用) - merged = [] - for batch in batches: - m = await _merge_summaries(batch, max_per_chunk, merge_batch) # 递归 - merged.append(m) - - # 现在 merged 少了很多,继续递归 - if len(merged) == 1: - return merged[0] - return await _merge_summaries(merged, max_per_chunk, merge_batch) -``` - -**极端场景推演**(context_window=10000, max_tokens=4096 → MAX_PER_CHUNK=5904): - -``` -history: 200 条记录,每条 ~50 tokens = ~10K tokens - -Step 1 — 贪心分片 (MAX_PER_CHUNK=5904): - 10K tokens → ~2 个 chunk (5904 + 4096) - -Step 2 — 逐片总结: - 2 次 LLM 调用 ✓ - -Step 3 — 合并: - 2 个 summaries < 5904 → 单次合并 ✓ - → 总计 3 次 LLM 调用 -``` - -**真实场景**(context_window=131072, max_tokens=4096, history=200K tokens): - -``` -MAX_PER_CHUNK = 131072 - 4096 - 2000 = 124_976 - -Step 1 — 贪心分片: - 200K tokens → ~2 个 chunk (124K + 76K) - -Step 2 — 逐片总结: - 2 次 LLM 调用 ✓ - -Step 3 — 合并: - 2 个 summaries × ~500 tokens = 1K << 124K → 单次合并 ✓ - → 总计 3 次 LLM 调用 -``` - -**超长场景**(context_window=131072, max_tokens=4096, history=500K tokens): - -``` -MAX_PER_CHUNK = 124_976 - -Step 1 — 贪心分片: - 500K tokens → ~5 个 chunk - -Step 2 — 逐片总结: - 5 次 LLM 调用 ✓ - -Step 3 — 合并: - 5 个 summaries ≈ ~2.5K << 124K → 单次合并 ✓ - → 总计 6 次 LLM 调用 -``` - -**容错链条(修正后)**: -``` -compact 请求 - → 常规 compact (全量 LLM 调用) - → 成功 ✓ - → 失败 (context too long / 400 error) - → _chunked_compact (token-aware 贪心分片) - → MAX_PER_CHUNK 保证每个 chunk 都能放入 context - → 递归合并处理 summaries 过多的情况 - → 成功 ✓ - → 极端失败 (单条 record 就超过 MAX_PER_CHUNK) - → 截断该 record 的 content,标记 "truncated" - → 成功 ✓ (带截断标记) -``` - -## 修改文件 - -| 文件 | 变更 | -|------|------| -| `emrg/server/daemon.py` | 新增 `_estimate_tokens`、`_estimate_single`、`_records_to_text`、`_chunked_compact`、`_merge_summaries`、`_truncate_record`;修改 `_run_tool_loop`(length 检测 + 预警)、`_handle_compact`(容错回退) | -| `emrg/config.py` | `LlmConfig` 增加 `context_window: int = 131072`、`auto_compact_threshold: float = 0.0` 字段 | -| `emrg/client/app.py` | `compact_result` 处理增加 chunked 状态显示 | - -## 实现细节 - -### `_estimate_tokens(messages)` — 新增 - -- 输入:`list[dict]`(OpenAI 格式 messages) -- 输出:`int`(估算 token 数) -- 算法:`sum(len(json.dumps(m, ensure_ascii=False)) // 3 + 3 for m in messages)` - -### `_estimate_single(record)` — 新增 - -- 输入:单个 history record(dict) -- 输出:`int`(估算 token 数) -- 用途:贪心分片时计算每条 record 的开销 -- 算法:`len(_records_to_text([record])) // 3` - -### `_records_to_text(records)` — 抽取 - -- 输入:`list[dict]`(history records) -- 输出:`str`(紧凑文本表示,用于 compact prompt) -- 当前 `_handle_compact` 中的 history_text 拼接逻辑抽取为独立函数 -- tool_result 截断到 500 字符(保持现有逻辑) - -### `_run_tool_loop` 修改 - -1. **每轮开始前**:如果 `auto_compact_threshold > 0.0`,调用 `_estimate_tokens(messages)`,若超过 `context_window * auto_compact_threshold`: - - 通知客户端 "auto-compacting..." - - **同步**调用 `_handle_compact_inline(session)` 执行 compact - - compact 完成后用精简后的 messages 继续本轮 -2. **`auto_compact_threshold == 0.0`**(默认值):跳过自动 compact,只能手动 `/compact` - -### `_handle_compact` 修改 - -```python -async def _handle_compact(self, session, writer): - records = session._read_history() - if len(records) <= 5: - # 不足,跳过 - return - - # 尝试常规 compact - try: - summary = await self._do_compact(records) - except RuntimeError as e: - if "context" in str(e).lower() or "too long" in str(e).lower(): - # 回退到分片 compact - logger.warning("normal compact failed, trying chunked: %s", e) - try: - summary = await self._chunked_compact(records) - except Exception as e2: - # 分片也失败,通知客户端 - await self._send(writer, { - "type": "compact_result", - "session_id": session.session_id, - "messages_compacted": 0, - "error": f"Compact failed (both normal and chunked): {e2}", - }) - return - else: - raise - - count = session.compact(summary, keep_recent=5) - # 通知客户端... -``` - -### `_chunked_compact` — 新增(token-aware 贪心分片) - -```python -async def _chunked_compact(self, records, keep_recent=5): - """Token-aware 分片 compact。 - - 不按记录数分片(记录大小差异太大),而是按 token 估算贪心装箱。 - MAX_PER_CHUNK 由 config.context_window - config.max_tokens - 2000 动态推导。 - """ - max_per_chunk = self.llm.config.context_window - self.llm.config.max_tokens - 2000 - merge_batch = max_per_chunk - - to_compact = records[:-keep_recent] - total_tokens = sum(_estimate_single(r) for r in to_compact) - logger.info("chunked compact: %d records, ~%d tokens", len(to_compact), total_tokens) - - # Step 1: 贪心分片 - chunks = [] - current_chunk = [] - current_tokens = 0 - - for record in to_compact: - rec_tokens = _estimate_single(record) - # 单条记录超大 → 截断 content - if rec_tokens > MAX_PER_CHUNK: - record = _truncate_record(record, MAX_PER_CHUNK) - rec_tokens = _estimate_single(record) - if current_tokens + rec_tokens > MAX_PER_CHUNK and current_chunk: - chunks.append(current_chunk) - current_chunk = [] - current_tokens = 0 - current_chunk.append(record) - current_tokens += rec_tokens - if current_chunk: - chunks.append(current_chunk) - - logger.info("chunked compact: %d chunks created", len(chunks)) - - # Step 2: 逐片总结 - summaries = [] - for idx, chunk in enumerate(chunks): - chunk_text = _records_to_text(chunk) - msg = await self.llm.chat([{ - "role": "user", - "content": ( - f"Summarize this conversation segment ({idx+1}/{len(chunks)}). " - "Include key decisions, context, and unresolved items:\n\n" - f"{chunk_text}" - ), - }], tools=None) - summaries.append(msg.get("content", "")) - logger.debug("chunked compact: chunk %d/%d done", idx+1, len(chunks)) - - if len(summaries) == 1: - return summaries[0] - - # Step 3: 递归合并 - return await self._merge_summaries(summaries) - - -async def _merge_summaries(self, summaries, max_per_chunk, merge_batch): - """递归合并 summaries。 - - 如果所有 summaries 能放入一次 LLM 调用 → 直接合并。 - 否则分组合并,然后递归。 - """ - combined = "\n---\n".join(summaries) - if _estimate_text(combined) <= merge_batch: - msg = await self.llm.chat([{ - "role": "user", - "content": ( - "Merge these conversation segment summaries into one " - "coherent summary:\n\n" + combined - ), - }], tools=None) - return msg.get("content", "") - - # 分组 - batches = [] - current = [] - current_tokens = 0 - for s in summaries: - st = _estimate_text(s) - if current_tokens + st > merge_batch and current: - batches.append(current) - current = [] - current_tokens = 0 - current.append(s) - current_tokens += st - if current: - batches.append(current) - - logger.info("merge_summaries: %d summaries → %d batches", len(summaries), len(batches)) - - # 递归合并每批 - merged = [] - for batch in batches: - m = await self._merge_summaries(batch, max_per_chunk, merge_batch) - merged.append(m) - - if len(merged) == 1: - return merged[0] - return await self._merge_summaries(merged, max_per_chunk, merge_batch) - - -def _truncate_record(record, max_tokens): - """截断超大 record 的 content,使其不超过 max_tokens。""" - record = dict(record) - content = record.get("content", "") - max_chars = max_tokens * 3 - if len(content) > max_chars: - record["content"] = content[:max_chars] + "\n...[truncated for compact]" - return record -``` - -**复杂度分析**: - -| 场景 | chunks | 总结调用 | 合并调用 | 总计 | -|------|--------|----------|----------|------| -| history < 64K tokens | 1 | 1 | 0 | 1 | -| history = 200K tokens | ~4 | 4 | 1 | 5 | -| history = 1M tokens | ~16 | 16 | 3 | 19 | - -每次 LLM 调用输出约 200-1000 tokens(summary 不长),token 成本可控。 - -## token 使用约束 - -- `_estimate_tokens` 不调用外部 tokenizer,纯字符估算 -- 保守估算(3 chars/token)确保不会低估 -- 所有 threshold 为常量,可在代码中调整 - -## 测试命令 - -```bash -# 现有测试通过 -uv run pytest tests/ -v - -# 手动测试 chunked compact: -# 1. 构造一个包含大量消息的 session -# 2. 发送 /compact -# 3. 检查 daemon 日志中的 "trying chunked" 消息 -``` - -## 不做的 - -- ❌ 不引入 tiktoken 依赖(保持零依赖) -- ❌ 不在 compact 前强制等待(用户体验差) -- ❌ 不修改 history.jsonl 格式 -- ❌ 不做增量 compact(每次 compact 都是全量替换) diff --git a/docs/design/evolution-refactor.md b/docs/design/evolution-refactor.md deleted file mode 100644 index 1f803d39..00000000 --- a/docs/design/evolution-refactor.md +++ /dev/null @@ -1,353 +0,0 @@ -# 演化机制重构:BackgroundThread 作为内部客户端 - -## 问题分析 - -### 当前状态 - -`BackgroundThread` 的演化循环只是一个骨架: - -```python -def _summarize_state(self, seq: int) -> str: - return f"instance={...} host={...} evolutions={...} runtime_healthy=true" - -def _absorb_learnings(self, summary: str) -> str: - return "no-new-learnings-to-absorb" -``` - -- 没有调用 LLM -- 没有读取或修改任何文件 -- 唯一的产出是 `~/.emrg/logs/evolution-*.json`(内容几乎完全相同) -- `BackgroundThread` 和 `EmrgServer` 在同一进程,但没有共享任何能力 - -### 目标 - -让演化周期真正执行"自我改进":LLM 反思近期状态 → 调用工具读写文件 → 产出可观测的变化。 - -### 当前实现状态 - -**已完成 (commit f5d1702)**: -- `BackgroundThread._run_evolution_cycle()` — 通过 `connect_to_server()` 发送 `stream: true` 的 task 到 server,LLM 可调用完整工具链 -- `BackgroundThread._build_evolution_prompt()` — 从 `emrg/server/evolution_prompt.md` 读取模板,Python 字符串模板替换变量 -- `EmrgServer._touch_project(cwd)` — 每次用户交互后记录 `~/.emrg/projects.yml` -- `emrg/server/evolution_prompt.md` — 独立的 prompt 模板文件,源码的一部分 - -**待实现**: - -| 功能 | 说明 | -|------|------| -| 演化 session 的 history 上下文 | 演化周期走到 `_run_tool_loop` 时,`_get_or_create_session` 会加载已有 session → `get_messages_for_llm()` 会把之前的演化对话历史拼入 messages。但需要实际运行验证 | -| 演化后的 git 操作 | prompt 说"如果测试失败则回滚",但没有强制机制 | - -## 设计方案 - -### 核心思路:BackgroundThread 作为内部客户端 - -`BackgroundThread` 不注入 server 引用,而是通过 `connect.py` 提供的 `connect_to_server()` 函数连接自己的 server,就像一个普通客户端一样发送 `task` 消息。 - -``` -┌──────────────────────────────────────────────┐ -│ emrgd 进程 │ -│ │ -│ ┌─────────────┐ connect_to_server() ┌────┐│ -│ │ Background │ ── Unix socket / ───→│ ││ -│ │ Thread │ Named Pipe │Srv ││ -│ │ (内部客户端) │ ←── stream ──────────│ ││ -│ └─────────────┘ └────┘│ -│ │ -│ 演化 session: emrg-evolution │ -│ 工作目录: ~/.emrg/evolution │ -└──────────────────────────────────────────────┘ -``` - -**优势**: -- `BackgroundThread` 零侵入,不需要访问 server 内部 -- 复用全部 server 能力:LLM 调用、工具注册、session 管理、compact、memory -- 演化对话有完整历史记录(history.jsonl、llm.jsonl) -- 如果演化过程出错,日志和普通 session 一样可追溯 - -**无死锁**:`BackgroundThread` 和 `_handle_client` 都在同一个事件循环中,asyncio 的 server 实现为每个连接创建独立协程,同进程连接不会阻塞。 - -### 演化工作目录 - -``` -~/.emrg/evolution/ - .emrg/ - sessions/ - emrg-evolution/ # 固定 session - meta.json - history.jsonl # 演化对话历史 - history_YYMMDD.jsonl - llm.jsonl # LLM 原始调用记录 - memory/ # 演化自身产生的记忆 - MEMORY.md - memory/ # 演化产出的项目级记忆 - MEMORY.md - source/ # 演化自行管理的工作区 - emrg/ # emrg 源码(LLM 自行 git clone) - README.md # 说明此目录用途 -``` - -`cwd = ~/.emrg/evolution`,演化过程中的文件操作默认都落在自己的目录里。emrg 源码由 LLM 在演化时自行 `git clone` 到 `source/emrg/`,后续演化只需 `git pull` 更新。 - -### 演化 session - -- **固定 session_id**: `"emrg-evolution"` — 每次演化都在同一个 session 中,历史累积 -- **生命周期**: 不过期,由正常的 compact 机制管理历史长度 -- **首次运行**: `Session.create_with_id("emrg-evolution", evolution_cwd)` 自动创建 - -### 演化 prompt 模板 - -Prompt 模板存储在源码目录 `emrg/server/evolution_prompt.md`,是源码的一部分,随 git 分发。`_build_evolution_prompt()` 读取后用 Python 字符串模板替换变量。 - -**好处**: -- 模板跟着源码走,修改后在 git 里可追溯 -- 演化周期修改模板时可以走常规的 git diff → review → commit 流程 -- 不需要担心 `~/.emrg/evolution/` 目录不存在的情况 - -每次演化周期,从模板文件读取并替换变量后发送给 LLM: - -```markdown -## 演化周期 #{seq} - -你是 EMRG 的自我演化模块。你的任务是检查当前状态,发现可改进之处,并执行改进。 - -### 当前实例信息 -- instance_id: {instance_id} -- host: {host_name} -- 运行时间: {uptime} -- 已完成演化次数: {evolution_count} - -### 可用资源 -- **emrg 源码仓库**: `{emrg_repo_url}` — 如果 `source/emrg/` 不存在,用 `git clone` 下载;已存在则 `git pull` 更新 -- **本地源码路径**: `~/.emrg/evolution/source/emrg/` — clone 后在此处读取和修改代码 -- **用户活跃项目**: `~/.emrg/projects.yml` — 每个项目及其配置(name, path, repo, auto_evolve 等) -- **config**: `~/.emrg/config.toml` — 当前的配置文件 -- **本 session 历史**: 上面的消息中包含了之前的演化对话 - -### 如何发现改进点 - -Server 会在每次用户交互后自动记录活跃项目到 `~/.emrg/projects.yml`: - -```yaml -# ~/.emrg/projects.yml -- name: emrg - path: /Users/argszero/scm/github.com/argszero/emrg - repo: argszero/emrg - auto_evolve: true - last_active: "2026-07-15T10:35:00Z" -- name: other-project - path: /Users/argszero/scm/work/other-project - repo: "" - auto_evolve: false - last_active: "2026-07-14T08:00:00Z" -``` - -演化时按以下步骤分析: - -1. 读 `projects.yml`,按 `auto_evolve` 筛选,按 `last_active` 排序 -2. 对每个 cwd,深入分析: - - `.emrg/sessions/*/history.jsonl` — 最近的对话内容 - - `.emrg/sessions/*/llm.jsonl` — LLM 调用记录(是否有频繁错误) - - `.emrg/memory/MEMORY.md` — 项目累积的记忆和反馈 -3. 从分析中提取改进点: - - 用户是否有不满意的反馈?("不对"、"换个方案"、"还是用 B") - - 是否有反复出现的错误模式? - - 用户是否重复问相同的类型的问题,暗示缺了某个工具? - - 系统提示词是否限制了 LLM 的发挥? - -Server 端改动很小:`_handle_client` 收到 task 时通过 `_touch_project` 更新 `projects.yml`。(见下方实现方案。) - -### 首次运行的准备工作 -如果 `source/emrg/` 目录还不存在: -```bash -mkdir -p source -git clone {emrg_repo_url} source/emrg -``` -如果已存在,先更新: -```bash -cd source/emrg && git pull -``` - -### 你可以做的事情 -1. **分析用户交互**: 读 `~/.emrg/projects.yml`,找出项目,去对应的 `.emrg/sessions/` 下读对话历史,发现改进机会 -2. **反思**: 回顾之前的演化记录,看上次的改进是否有持续性效果 -3. **改进**: 修改 emrg 源代码(在 `source/emrg/` 下) -4. **记录**: 在 `~/.emrg/evolution/.emrg/memory/` 下创建/更新 memory 文件,记录这次演化发现了什么、改了什么、效果预期 -5. **清理**: 整理过时的日志文件、合并重复的 memory - -### 约束 -- 每次演化只做 1-3 件小事,不要大规模重构 -- 修改代码前先理解上下文 -- 改动后运行测试验证: `cd source/emrg && uv run pytest tests/ -v` -- 如果测试失败,回滚改动 -- 把你的决策记录到 `~/.emrg/evolution/.emrg/memory/` 中 -- 如果没有明确需要改进的地方,诚实地说 "nothing to evolve",不要强行找事做 - -### 指南(不是硬性要求) -- 优先修复之前演化引入的问题 -- 其次优化自身的系统提示词或演化逻辑 -- 再次改进工具实现或添加新工具 -``` - -### BackgroundThread 改造 - -```python -class BackgroundThread: - # 固定常量 - EVOLUTION_CWD = Path.home() / ".emrg" / "evolution" - EMRG_REPO_URL = "https://github.com/argszero/emrg.git" - SESSION_ID = "emrg-evolution" - - def __init__( - self, - identity: InstanceIdentity, - interval: int = 1800, - ) -> None: - self.identity = identity - self.interval = interval - self.evolutions: list[EvolutionLog] = [] - self._running = False - self._logs_dir = config_dir() / "logs" - self.EVOLUTION_CWD.mkdir(parents=True, exist_ok=True) - - async def _run_evolution_cycle(self, seq: int) -> None: - """Send evolution task to the server, read streaming response.""" - prompt = self._build_evolution_prompt(seq) - - try: - reader, writer = await connect_to_server() - except (ConnectionRefusedError, FileNotFoundError) as e: - logger.warning("evolution: cannot connect to server: %s", e) - return - - task_msg = json.dumps({ - "type": "task", - "id": f"evolution-{seq}", - "session_id": "emrg-evolution", - "cwd": str(self.EVOLUTION_CWD), - "prompt": prompt, - "stream": True, - "timestamp": datetime.now(timezone.utc).isoformat(), - }) + "\n" - - try: - writer.write(task_msg.encode()) - await writer.drain() - - # Read streaming responses until done - while True: - line = await reader.readline() - if not line: - break - resp = json.loads(line.strip()) - - if resp.get("done"): - logger.info("evolution cycle #%d complete", seq) - break - - # Log tool calls for observability - if "tool_name" in resp: - logger.debug( - "evolution #%d tool: %s (err=%s)", - seq, resp.get("tool_name"), resp.get("error"), - ) - except Exception as e: - logger.warning("evolution cycle #%d error: %s", seq, e) - finally: - writer.close() - try: - await writer.wait_closed() - except Exception: - pass - - # Write evolution log entry - log = EvolutionLog( - timestamp=datetime.now(timezone.utc).isoformat(), - trigger=f"background-cycle-#{seq}", - impact=[f"evolution-cycle-#{seq}-complete"], - operations=["llm-reflection", "tool-execution", "self-improvement"], - ) - await self._write_evolution_log(seq, log) - self.evolutions.append(log) - - def _build_evolution_prompt(self, seq: int) -> str: - """Read evolution prompt template from source dir (emrg/server/evolution_prompt.md). - - The template uses Python string formatting with these variables: - {seq}, {instance_id}, {host_name}, {uptime}, {evolution_count}, - {emrg_repo_url}, {evolution_cwd} - """ - template_path = self._prompt_template_path - template = template_path.read_text() - uptime_seconds = 0 # We don't track start time for now - uptime = f"{uptime_seconds // 3600}h {(uptime_seconds % 3600) // 60}m" - - return template.format( - seq=seq, - instance_id=self.identity.instance_id, - host_name=self.identity.host_name, - uptime=uptime, - evolution_count=len(self.evolutions), - emrg_repo_url=self.EMRG_REPO_URL, - evolution_cwd=str(self.EVOLUTION_CWD), - ) -``` - -### EmrgServer.serve() 修改 - -只改一行:`BackgroundThread` 构造时不再需要 `socket_path`。 - -```python -# Before -self._bg = BackgroundThread(self.identity, self.llm.config.evolution_interval) - -# After -self._bg = BackgroundThread(self.identity, self.llm.config.evolution_interval) -``` - -实际上参数签名变了但调用没变——`socket_path` 从参数中移除,因为 `connect_to_server()` 内部已经封装了平台自适应逻辑。BackgroundThread 不再需要知道底层传输细节。 -### 安全护栏 - -演化过程中 LLM 有完整的工具访问权限(bash、read、write、edit),以下几层保护: - -| 层级 | 措施 | -|------|------| -| **工作目录隔离** | cwd = `~/.emrg/evolution`,工具操作默认在此目录 | -| **源码隔离** | 演化工作区 `source/emrg/` 与运行中的 emrg 实例是两份独立的代码,演化改动不影响当前运行 | -| **Prompt 约束** | 明确告知可做的事和禁区 | -| **Git 安全网** | `source/emrg/` 在 git 管理下,改坏了可以 `git checkout` 恢复 | -| **测试自动验证** | prompt 要求改动后跑测试,失败则回滚 | -| **演化历史可追溯** | 所有演化对话记录在 `emrg-evolution` session 中 | -| **不自动提交** | 演化只做本地改动,不自动 commit/push(除非 config 中 `auto_commit = true`) - -### config.toml 扩展(可选) - -`emrg_repo_url` 写死在 `_build_evolution_prompt` 中,不需要从 config 读取。后续如果项目分叉或迁移,需要改代码中的 URL。 - -第一版不增加 `[evolution]` 配置段。后续可以加 `enabled`、`auto_commit` 等开关。 - -### 修改文件清单 - -| 文件 | 变更 | -|------|------| -| `emrg/server/daemon.py` | `BackgroundThread.__init__` 移除 `socket_path` 参数;`_run_evolution_cycle` 重写为 `connect_to_server()` 客户端;删除 `_summarize_state`、`_absorb_learnings`;新增 `_build_evolution_prompt`(读源码中的 `evolution_prompt.md` 模板文件) | -| `emrg/server/evolution_prompt.md` | **新增**。演化 prompt 模板文件,源码的一部分,使用 Python 字符串模板变量 | -| `emrg/connect.py` | (已完成)BackgroundThread 复用此模块的 `connect_to_server()`,无需改动 | -| `~/.emrg/evolution/` | 首次运行时自动创建完整目录结构 | - -### 复杂度评估 - -| 方面 | 评估 | -|------|------| -| 代码改动量 | ~80 行新增 + ~50 行删除 + ~10 行修改 | -| 新依赖 | 无 | -| 破坏性变更 | 无(BackgroundThread 是内部实现细节) | -| 测试影响 | 现有 30 个测试不受影响 | -| 风险 | 低 — 演化失败不影响 server 正常运行,有 try/except 包裹 | - -### 不做的事 - -- ❌ 不在 BackgroundThread 中注入 server 内部引用 -- ❌ 不让演化自动 commit/push(手动 review 后再决定) -- ❌ 不修改现有的 session 管理、工具注册逻辑 -- ❌ 不给 BackgroundThread 单独配 LLM client(复用 server 的) diff --git a/docs/design/open-source-promotion.md b/docs/design/open-source-promotion.md deleted file mode 100644 index 9e7e2d34..00000000 --- a/docs/design/open-source-promotion.md +++ /dev/null @@ -1,209 +0,0 @@ -# promote 任务类型设计 - -## 背景与目标 - -EMRG 的定时任务(evolution / paper / open-source)服务于各个项目,但项目在外部社区的认知度为零——没有人知道这些项目存在、解决什么问题、有什么价值。 - -推广是一个**独立的长期任务**:为 `config.project` 指向的项目,在外部社区以自然、非打扰的方式持续提升认知度,吸引更多人 star / fork / 使用 / 加入。 - -**推广不是某个任务类型的附加功能,而是一个独立的 task 类型(`type: promote`)。** 它有自己的节奏(interval)、自己的状态文件、自己的反思日志,与其他任务并行运行,互不干扰。 - -## 配置设计 - -```yaml -- name: openlocalrouter-promote - type: promote - config: - project: openlocalrouter # 要推广的项目(必填,须匹配 projects.yml 中的条目) - # 可选字段: - # platforms: ["reddit", "hn", "awesome"] # 限制渠道;缺省 = 全部主渠道 - # keywords: ["本地代理", "端口转发"] # 自定义搜索关键词;缺省 = 从 project 描述推断 - interval: 3600 # 推广节奏:建议 1-24 小时,默认 6 小时 - enabled: true -``` - -- `type: promote` 是任务类型,和其他类型平级 -- `config.project` 必填:指向 projects.yml 中已存在的项目 -- `interval`:推广是低频长期动作,建议 3600s(1h)以上,避免刷屏 -- TaskScheduler 的 HANDLERS 增加 `"promote": EvolutionHandler`(复用同一 handler,不同模板),TASK_TEMPLATES 增加 `"promote": "promote_prompt.md"` - -## 推广对象 - -推广对象 = `config.project`(如 openlocalrouter-promote 推广 openlocalrouter)。 - -任务启动时从 projects.yml 读取该项目的完整配置(path、name、自定义字段)作为上下文,让模板能引用 `{{ project.name }}`、`{{ project.description }}` 等。 - -## 推广红线(不可违反,违反即本轮失败) - -1. **价值先行**:推广必须建立在真实价值之上。没有价值的推广 = spam。推广的内容必须能给被推广的社区带来真实信息(解决问题、提供工具、分享经验),而不是"来看看我的项目"。 -2. **参与式推广,禁止硬广**:推广 = 在相关话题中作为参与者自然提及,**禁止**发纯广告帖("大家看看我的新项目!")、禁止在无关话题下强行插入、禁止重复刷同一位置。 -3. **不刷屏不骚扰**:每轮推广动作不超过 2 个;同一话题只参与一次;不向同一人重复私信。 -4. **诚实透明**:不伪装成人类,不隐瞒身份。以 EMRG 身份发言时明确说明"我是 EMRG,一个自我演进的 AI 智能体,参与维护 {project}";以项目维护者身份发言时如实说明。 -5. **尊重社区规则**:每个社区有自己的规则(禁止自荐、要求 Show HN、不允许推广等)。违反规则 → 该社区标记为"禁推",不再触碰。 -6. **不贬低竞品**:推广 project 时不贬低同类竞品。只讲 project 的差异化特点。 -7. **长期主义,不追短期效果**:推广是长期经营。发出推广后必须**持续跟踪**——有人回复要回复、有讨论要参与、有质疑要澄清。禁止"发了就跑"。短期(数天/数周)无效果正常,不因短期无响应而加大强度或放弃。 - -## 推广渠道(基于开源产品推广最佳实践) - -### 主渠道:相关话题社区(参与式) - -找到与 project 领域**直接相关**的社区,在相关话题下以参与者身份发言: - -| 渠道 | 适用场景 | 注意事项 | -|------|---------|---------| -| **Reddit** | 几乎所有项目。r/selfhosted(自托管)、r/programming、r/opensource、r/LocalLLaMA(LLM)、各领域子版块 | 每个子版块有自己的规则;r/selfhosted 允许自荐但有格式要求(注明"self-promotion");先潜水了解规则再发言 | -| **Hacker News** | 技术型项目。相关话题讨论中自然提及;项目成熟后可以 Show HN | Show HN 是官方认可的推广方式,但有质量门槛;讨论中提及要自然 | -| **Lobsters** | 技术型项目,社区氛围严谨 | 规则严格,先读社区指南 | -| **技术论坛/社区** | 各领域专属社区(如 V2EX、Stack Overflow 相关标签) | 参与讨论提供价值,结尾自然带链接 | -| **Discord/Slack** | 相关技术社区(如 LLM 工具社区) | 在相关频道参与讨论,帮人解决问题时自然提及 | -| **Dev.to / 技术博客** | 有内容输出能力时 | 写"用 X 做 Y 的实践",结尾附项目链接 | - -### 次渠道:列表与聚合(一次性的) - -| 渠道 | 做法 | -|------|------| -| **awesome lists** | 向 project 领域的 awesome 列表提交 PR(如 awesome-selfhosted、awesome-llm-tools) | -| **GitHub topics** | 确保 project 仓库打了正确的 topics 标签(如 `self-hosted`、`llm`)——这是被搜索到的前提 | -| **项目目录/对比站** | 如 awesome-selfhosted 的分类、各种 tools 对比列表 | - -### 不做的渠道 - -- 不自动创建/运营社交账号(Twitter/X、YouTube、TikTok)——那是另一个功能 -- 不购买 star / 刷 fork / 任何黑帽推广 -- 不向用户邮箱发推广邮件 -- 不在与 project 无关的话题下推广 - -## 参与式三步(每轮执行) - -每轮推广 = 以下三步,缺一不可: - -### 第 1 步:找话题(侦察) - -在推广渠道中搜索与 project 相关的话题: - -```bash -# Reddit 示例:搜索与 project 领域相关的话题 -curl -s "https://www.reddit.com/search.json?q=&sort=new&limit=20" -# 或浏览器 harness 访问 reddit.com/search?q=<关键词> -# HN 搜索 -curl -s "https://hn.algolia.com/api/v1/search?query=<关键词>&tags=story" -``` - -判断标准: -- 话题与 project 解决的问题**直接相关**(如 project 是本地路由代理 → 搜"本地代理"、"局域网转发") -- 话题有真实讨论(不是死帖) -- 该社区允许此类参与(读社区规则) - -### 第 2 步:参与讨论(自然提及) - -以真实参与者的身份发言,**先给价值,再自然提及 project**: - -- 好:用户在问"有没有工具能转发本地端口?" → 回复"我参与维护的一个项目 openlocalrouter 做了这件事,支持 X/Y/Z 特性,这里是文档链接。如果你需要的是 A 场景,它可能合适" -- 好:有人分享类似方案 → 回复"我们的项目 openlocalrouter 也遇到过这个问题,我们的做法是……(技术细节),欢迎交流" -- 差:无关联地发"推荐一下 openlocalrouter!" -- 差:只说一句"可以看看 openlocalrouter"不给任何技术价值 - -**判断标准**:如果删掉这条推广,回复依然是完整的、有价值的讨论——说明是合格的自然提及;如果删掉推广回复就不成立了,说明是硬广,不发。 - -### 第 3 步:跟踪(长期经营) - -推广发出后不是结束,是开始: - -- 记录到状态文件"推广跟踪"清单:链接 + 发出时间 + 下次检查时间(默认 3-7 个 cycle 后) -- 有人回复 → 及时回复(下个 cycle 优先);有质疑 → 澄清并补充证据;有深入讨论 → 参与并保持专业 -- 长期无回复 → 从跟踪清单移除,记录"沉寂"(正常衰减,不是失败) -- 绝不为了激活沉寂的推广而重复刷同一位置 - -### 第 4 步:反馈采集(推广的反向通道) - -推广是双向的——在推广和跟踪过程中,社区会对 project 产生真实反馈。**有价值的反馈主动写入 rants.jsonl,让该 project 对应的 evolution 任务去处理。** - -**什么算有价值反馈(写入 rant)**: - -| 类型 | 例子 | 价值 | -|------|------|------| -| 功能需求 | "要是能支持 X 就好了"、"有没有 CLI 接口?" | 直接的功能方向输入 | -| Bug 报告 | "用了 0.3.2 在 macOS 上崩溃" | 待修复问题 | -| 负面体验 | "文档不清楚"、"安装失败"、"配置太复杂" | 改进机会 | -| 竞品对比 | "我试了 A 和 B,你们的差异是……" | 定位/差异化信息 | -| 使用场景 | "我用它解决了 X 问题"(非平凡场景) | 用例/宣传素材 | -| 明确意向 | "这个项目正好解决我的问题" | 潜在用户信号 | - -**不写入**:单纯点赞/客套("不错!")、无关话题、重复已有反馈、低信息量回复。 - -**写入规则**(与现有 rant 管理一致): -- 追加到 `~/.emrg/rants.jsonl`,`project` 字段 = 被推广的 project 名(这样该 project 的 evolution 任务会读取并处理) -- `status: "pending"`,`message` 字段放最后(字段顺序约束:timestamp → project → status → progress → completed → message) -- 写入后全量读入、按 timestamp 升序排序写回 -- 使用 `json.dumps(..., ensure_ascii=False)`,禁止中文转义 -- 去重:与已有 pending rant 内容相似的不重复写入 -- 每条 rant 的 message 注明来源(渠道 + 链接),便于 evolution 任务回溯:`"社区反馈(Reddit r/selfhosted 讨论 https://...):用户在问是否支持 X"` - -**反馈去向**:写入 rants.jsonl 后,该 project 的 evolution 任务(如 openlocalrouter-task)在下一轮会读取并处理——需求进 backlog、bug 进修复队列、负面反馈进改进计划。推广任务本身不实现这些功能,只负责采集和转交。 - -## 状态文件 - -与 open-source 任务类似,promote 任务有独立状态文件: - -路径:`{{ evolution_cwd }}/promote_{{ project }}_state.md` - -```markdown -# Promote State: {project} -- 上次完成: <上一轮做了什么> -- 下一步: <本轮计划做什么> -- 阻塞: <什么在阻止进展?空=无阻塞> -- 推广目标: -- 推广记录: <最近 5 条推广动作:时间 + 渠道 + 链接 + 结果> -- 推广机会: <侦察阶段发现但未执行的潜在话题> -- 推广跟踪: <发出的推广是否有回复/讨论进行中/待澄清的质疑,每条含链接和待办> -- 禁推清单: <因违反规则被标记为不可推广的渠道> -``` - -## 反思日志 - -每轮结束写反思(参考 paper/open-source 的反思设计),追加到 `{{ evolution_cwd }}/promote_{{ project }}_reflections.md`,每轮必须回答: - -1. **本轮目标是什么?** — 本轮要推广什么、通过哪个渠道、针对哪个话题 -2. **理想结果是什么?** — 本轮"做成了"长什么样?(话题参与成功?有人回复?) -3. **实际做了什么?** — 具体操作:搜了哪些话题、在哪个渠道发了什么、跟踪了哪些旧推广、采集了哪些反馈 -4. **当前进度如何?** — 和理想对比:推广记录积累了几条?有几个正在跟踪的讨论?转交了几条反馈给 evolution? -5. **踩了哪些坑?** — 哪些话题没找到、哪个渠道被拒、哪条回复被忽略或负面 -6. **发现了哪些机会?** — 哪些话题讨论热烈值得深入、哪个渠道效果好、哪些新渠道值得尝试 -7. **下一步方向?** — 下轮重点:继续跟踪活跃讨论?换新渠道?调整关键词? - -规则:每轮必写(无事可做也要记录为什么)、只追加不修改、以日期时间头开头。反馈采集的动作和转交的 rant 摘要也记录在反思中。 - -## 长期效果追踪 - -每 7 个 cycle 一次(或手动触发时): - -```bash -gh repo view {owner}/{project} --json stargazerCount,forkCount -``` - -对比上次记录的 star/fork 数量。**这是长期趋势,不是短期 KPI。** 数周内无增长完全正常——推广的价值在于持续积累的可信度与曝光。短期波动不调整策略,不因短期无效果而放弃或加大强度。 - -## 实施范围 - -1. **新建 `emrg/server/promote_prompt.md`** — promote 任务的 prompt 模板: - - 当前状态(实例/项目/渠道/上次记录) - - 推广红线 7 条 - - 渠道清单(主渠道 + 次渠道 + 禁推) - - 参与式四步(找话题 → 参与 → 跟踪 → 反馈采集) - - 反馈采集规则:什么算有价值(功能需求/Bug/负面体验/竞品对比/使用场景/明确意向)、写入 rants.jsonl 的格式(project 字段=被推广项目、status=pending、message 最后、排序写回、ensure_ascii=False、注明来源链接、去重)、转交给 evolution 任务处理 - - 状态文件读写规则 - - 反思日志 7 问(含反馈采集记录) - - 长期效果追踪 -2. **`emrg/server/scheduler.py`**: - - `TASK_TEMPLATES` 增加 `"promote": "promote_prompt.md"` - - `HANDLERS` 增加 `"promote": EvolutionHandler`(复用同一 handler) -3. **`~/.emrg/tasks.yml`** — 用户手动添加 promote 任务(示例见上) -4. **依赖**:Jinja2 模板渲染(与现有模板一致) - -## 不做的事 - -- 不自动创建社交账号发帖推广 -- 不购买 star / 刷 fork / 任何黑帽推广 -- 不向用户邮箱发推广邮件 -- 不在无关话题下推广 -- 推广不改变其他任务类型的本职工作 diff --git a/docs/design/packaged-installer.md b/docs/design/packaged-installer.md deleted file mode 100644 index 2281cad7..00000000 --- a/docs/design/packaged-installer.md +++ /dev/null @@ -1,674 +0,0 @@ -# 独立安装包设计:从"脚本安装"到"下载 → 点击安装" - -## 问题分析 - -### 当前状态 - -EMRG 目前的安装方式是脚本引导: - -```bash -curl -sSL https://raw.githubusercontent.com/argszero/emrg/master/install.sh | bash -``` - -`install.sh` 会依次检查并安装 4 个外部依赖: - -| 依赖 | 用途 | 安装方式 | -|------|------|----------| -| git | 克隆源码、演化提交 | brew / apt / 自带 | -| python3 ≥ 3.11 | 运行解释器 | brew / apt | -| uv | 依赖管理 + 工具安装 | curl 脚本 | -| gh (推荐) | 演化系统的 GitHub 操作 | brew / 二进制下载 | - -安装后源码存放在 `~/.emrg/source/`,通过 `uv tool install -e .` 以 editable 模式挂载。 - -### 问题 - -1. **外部依赖多**:一台干净的机器需要先有 git + python3 + uv,install.sh 虽然会尝试自动装,但 brew/apt/sudo 每一步都可能失败或需要交互。 -2. **curl | bash 信任门槛高**:对非开发者用户不友好,"把远程脚本直接灌进 shell" 是安全反模式。 -3. **源码部署**:装的是源码树 + editable 挂载,用户机器上多了一个需要维护的 git 仓库;`emrg update` 依赖 git pull。 -4. **平台体验不一致**:macOS/Linux/Windows 各走各的路,没有统一的"安装应用"体验。 - -### 目标 - -- **用户视角**:像安装普通应用一样 —— 下载一个安装文件 → 双击/点击 → 完成,无需预装 python/uv/git。 -- **安装即完整**:Python 解释器 + 全部第三方库 + git + gh 全部捆绑进安装包,装完就是功能完整的 EMRG,不依赖任何外部环境。 -- **统一安装方式**:不区分开发者/非开发者,每平台只有一种官方安装方式、一个安装文件。 -- **卸载彻底**:一个卸载入口,清空全部安装痕迹(二进制、符号链接、PATH、数据目录),不留残留。 -- **双界面前瞻**:同一安装包同时提供 TUI 与 GUI 两种客户端(§7),共享同一个守护进程,安装一次两种界面都可用。 - ---- - -## 方案选型 - -| 方案 | 说明 | 捆绑 Python | 体积 | 成熟度 | 结论 | -|------|------|:---:|:---:|:---:|------| -| **A. PyInstaller + 平台安装器** | Python 应用打包为原生可执行文件,再用 pkg/exe/AppImage 包装 | ✅ | ~60-90MB | 极高(30 年历史) | ✅ **推荐** | -| B. Nuitka | Python→C 编译,启动快、更难反编译 | ✅ | ~40-60MB | 高但配置复杂 | 备选,Phase 2 评估 | -| C. uv standalone python + 源码 | 下载官方 standalone CPython + 依赖目录 | ✅ | ~50MB | 中 | 无原生安装体验,放弃 | -| D. Rust 重写 | 彻底去除 Python 依赖 | 无需 | ~10MB | 项目早期从 Rust 移植而来 | 远期路线,不在本文范围 | - -**选 A 的理由**: -- PyInstaller 是打包 Python CLI + 守护进程的标准方案,对 asyncio/httpx/rich/yaml/jinja2 生态支持成熟。 -- 产物是原生可执行文件,天然支持平台安装器包装。 -- 源码与打包产物解耦:打包只是构建期的附加步骤,不污染开发流程。 - ---- - -## 目标安装体验(统一) - -**每平台一种安装方式、一个安装文件。开发者与非开发者完全一致。** - -| 平台 | 安装文件 | 体验 | 安装位置 | -|------|----------|------|----------| -| macOS | `EMRG-.pkg` | 双击 → 安装向导 → 完成;GUI 入口出现在启动台/应用程序 | `~/.emrg/install/`(用户级)+ PATH | -| Windows | `EMRG-Setup-.exe` | 双击 → 安装向导 → 完成;开始菜单出现 GUI 快捷方式 | `%LOCALAPPDATA%\EMRG\` + PATH | -| Linux | `EMRG--linux-.AppImage` | 下载 → chmod +x → 双击运行(GUI);桌面环境图标 | 单文件,`emrg` 符号链接到 `~/.local/bin` | - -> **设计决策:用户级安装(免 sudo,跨平台统一)**。 -> - 系统级路径(/usr/local、Program Files)需要管理员权限,且 `emrg update` 自动更新会遇到权限问题; -> - macOS/Windows 的安装向导做的事完全一致:安置文件到用户目录 + 写 PATH; -> - Linux 用 **AppImage**(单文件、无需安装、chmod +x 即运行)——这是 Linux 生态里最接近"下载 → 双击"的格式,**不需要任何安装脚本**。首次运行后 `emrg` 自动在 `~/.local/bin` 建符号链接;用户也可以直接把 AppImage 移到任意目录运行; -> - 服务器/无 GUI 环境可用 `EMRG--linux-.tar.gz`(与 AppImage 同内容的解压版)作为补充,但官方统一体验是 AppImage。 - -### 安装产物目录结构(平台内统一) - -> AppImage / tarball / pkg 内部装载的都是同一套目录(AppImage 是 squashfs 封装,pkg 是目录 + 脚本,tarball 是压缩目录)。 - -``` -/ -├── bin/ -│ ├── emrg # TUI 客户端 + CLI(PyInstaller 引导器) -│ ├── emrg-gui # GUI 客户端(未来;见 §7 多客户端架构) -│ ├── emrgd # 守护进程(独立入口,生命本体) -│ ├── git # 捆绑 git(Windows: portable Git;macOS/Linux: 便携构建) -│ ├── gh # 捆绑 gh CLI(Go 单文件二进制) -│ └── _internal/ # PyInstaller 运行时(解释器 + 依赖 + 模板数据) -├── LICENSE -└── version.txt -``` - -数据目录不变:`~/.emrg/`(config.toml、sessions、memory、logs、projects.yml、tasks.yml、versions/、saturation/)。 - -> 注意 `install/`(安装产物)与 `~/.emrg/`(数据)同在一个父目录下,但语义严格分离:`install/` 只读可替换,数据目录由运行时读写。Linux 的 AppImage 与 tarball 内部结构相同,AppImage 仅多了自挂载封装。 - ---- - -## 架构设计 - -### 1. 双入口二进制 - -当前 daemon 由 client 通过 `subprocess` 启动: - -```python -# emrg/__main__.py:_start_daemon_background() 现状 -proc = subprocess.Popen([sys.executable, "-m", "emrg.server"], ...) -``` - -PyInstaller 下 `sys.executable` 指向打包引导器,`-m emrg.server` 不再可用。改造为: - -```python -# 改造后 -def _start_daemon_background() -> subprocess.Popen: - cleanup_server() - if getattr(sys, "frozen", False): - # 打包模式:启动同目录的 emrgd 二进制 - emrgd = Path(sys.executable).resolve().parent / "emrgd" - proc = subprocess.Popen([str(emrgd)], ...) - else: - # 源码模式:保持现状 - proc = subprocess.Popen([sys.executable, "-m", "emrg.server"], ...) - return proc -``` - -两个 PyInstaller 入口: -- `emrg` ← `emrg/__main__.py:main()`(CLI 全家桶:client / server stop / rant / update) -- `emrgd` ← `emrg/server/__main__.py`(守护进程,独立进程,长期运行) - -`emrg server`(前台运行)在打包模式下亦可用 `emrgd` 等价实现。 - -### 2. 数据文件打包清单 - -PyInstaller 默认只收集 Python 模块,以下资源文件必须显式声明为 `datas`: - -``` -emrg/server/prompts/system.j2 # 系统提示词模板 -emrg/server/evolution_prompt.md # 演化提示词 -emrg/server/promote_prompt.md # promote 任务提示词 -emrg/server/open_source_prompt.md # 开源任务提示词 -emrg/server/paper_prompt.md # paper 任务提示词 -``` - -注意 `daemon.py` 与 `scheduler.py` 中模板路径的解析方式: - -```python -# 现状:基于 __file__ 的文件系统路径(打包后 __file__ 在 _MEIPASS 内部,可行) -_jinja_env = jinja2.Environment( - loader=jinja2.FileSystemLoader(Path(__file__).parent / "prompts"), - ... -) -``` - -`__file__` 在 PyInstaller 下指向 `_MEIPASS` 内路径,FileSystemLoader 可正常工作,但必须: -- 用 `--collect-data emrg` 或 spec 文件 `datas` 显式收集; -- 为只读资源(运行时不会被修改的文件)验证只读路径——演化引擎当前会写 `promote_prompt.md` 状态文件,需确认这些状态文件写入的是 `~/.emrg/` 而非安装目录(见下文 §3)。 - -### 3. 运行时可变文件审计 - -打包后安装目录是只读的(用户可能无写权限)。审计所有运行时写入路径: - -| 写入点 | 当前目标 | 打包后 | 状态 | -|--------|----------|--------|------| -| `~/.emrg/config.toml` | 用户目录 | 不变 | ✅ 安全 | -| `~/.emrg/projects.yml` | 用户目录 | 不变 | ✅ 安全 | -| `~/.emrg/rants.jsonl` | 用户目录 | 不变 | ✅ 安全 | -| `~/.emrg/tasks.yml` | 用户目录 | 不变 | ✅ 安全 | -| `~/.emrg/logs/`、`saturation/`、`skills/` | 用户目录 | 不变 | ✅ 安全 | -| 会话/记忆(`/.emrg/`) | 工作目录 | 不变 | ✅ 安全 | -| `emrg/server/*.md` 状态文件(promote/open-source 任务的 reflection/state 文件) | 源码树内 `prompts/` 或 session 目录 | **需迁移到 session 目录** | ⚠️ 审计项 | - -> 已有进展:最近的 commit(`e78d355`)已把 promote 状态文件路径改为 session 目录。打包前需完成全量审计,确保**安装目录零写入**。 - -### 4. git / gh 捆绑(安装即完整) - -演化系统的核心能力(commit、PR、merge、上游监控)依赖 git 与 gh。既然"安装要完整",二者**随包捆绑**,不依赖用户预装: - -| 平台 | git 来源 | gh 来源 | -|------|----------|---------| -| Windows | 捆绑 Git for Windows(portable 版,官方单文件自解压) | gh 官方 release 单文件二进制 | -| macOS | 捆绑便携 git 构建(编译时随 CI 下载) | gh 官方 release 单文件二进制 | -| Linux | 捆绑静态/便携 git 构建 | gh 官方 release 单文件二进制 | - -**运行时解析顺序**(`git_utils.py` 与演化 prompt 统一走同一解析器): - -``` -定位 git/gh: - 1. 打包模式:优先 ~/.emrg/install/bin/git(捆绑版,版本一致、行为确定) - 2. 兜底:系统 PATH 中的 git/gh(用户自装且版本满足时) -``` - -- EMRG 启动时探测一次,把解析结果写入 `~/.emrg/install-info.json`,演化 prompt 通过模板变量注入(`{{ git_path }}`、`{{ gh_path }}`)。 -- **AppImage 特例**:Linux AppImage 运行时挂载到临时目录,`bin/git` 通过 `os.environ["APPIMAGE"]` / `/proc/self/mounts` 定位,或首次运行时把 git/gh 复制到 `~/.emrg/install/bin/`(数据目录,可写)。实现细节 Phase 1 验证。 -- gh 的认证仍由用户完成(`gh auth login`,OAuth 流程不可自动化)——首次运行演化任务时提示。捆绑 gh 解决的是"有 gh 可用",认证是用户账号层面的必然步骤。 -- **为什么不用 PATH 优先**:系统 git 版本参差(旧版缺 `git pull --rebase` 等行为差异),捆绑版保证演化引擎行为在所有机器上一致。 - -### 5. 自动更新改造 - -现状:`emrg update` = `git pull` + `uv tool install --reinstall -e .`。 - -打包后改为**二进制自更新**(用户级,免 sudo): - -``` -emrg update - → GET https://api.github.com/repos/argszero/emrg/releases/latest # 查版本 + SHA256 - → 从该 release 附件下载 emrg---.tar.gz(校验 SHA256) - → 解压到 ~/.emrg/versions//(新版本目录) - → 原子切换符号链接 ~/.emrg/bin/emrg → 新版 - → 重启守护进程(复用现有 emrg server restart 逻辑) - → 保留旧版本目录,失败自动回退 -``` - -- 版本目录 + 符号链接设计保证"更新中断"不会破坏现有安装(回滚 = 换回旧链接)。 -- `emrgd` 同理更新;若 daemon 正在运行,先优雅停止(现有 shutdown 协议)再替换。 -- 更新只替换 `install/` 下的文件,**不触碰数据目录**(sessions/memory/config 等)。 - -### 6. 卸载设计(全面清理) - -对齐 MANIFESTO 第十条【终止权】:生成终止报告、留存经验快照、删除运行时文件、卸载自身。**不删除宿主工作目录下的用户数据**(`/.emrg/` 属宿主数据)。 - -统一卸载入口: - -| 平台 | 卸载方式 | -|------|----------| -| macOS | 双击 pkg 卸载器(或 `emrg uninstall`) | -| Windows | 控制面板卸载(Inno Setup 生成 unins000.exe,或 `emrg uninstall`) | -| Linux | 删除 AppImage 文件 + `emrg uninstall`(或 tarball 解压版同理) | - -`emrg uninstall` 执行顺序(幂等,任一步失败可重跑): - -``` -1. 停止并关闭 daemon(现有 shutdown 协议,等待退出) -2. 生成终止报告 → ~/.emrg/logs/uninstall-report-.json - (实例身份、演化次数、会话数、记忆数、终止原因=用户主动) -3. 留存经验墓地快照(不可删除项): - → 打包记忆 + 会话 + 演化日志 → ~/.emrg/graveyard/emrg-data-.tar.gz - → 快照保留,卸载后用户可自行删除 -4. 删除运行时文件: - → ~/.emrg/install/(二进制、_internal、模板) - → ~/.emrg/versions/(历史版本目录) - → ~/.emrg 下其余运行时文件(config.toml、sessions、memory、logs、 - projects.yml、tasks.yml、rants.jsonl、saturation/、emrgd.sock/pid、 - install-info.json) -5. 清理环境痕迹: - → PATH 中移除 emrg 条目(macOS/Linux 从 shell rc 文件; - Windows 从注册表用户 PATH) - → Windows: 移除开始菜单/桌面快捷方式(如创建过) -6. 自校验:确认 emrg/emrgd 命令已不可用、目录已删除,输出清理清单 -``` - -**边界**: -- 宿主工作目录下的 `.emrg/`(会话、记忆的 project 副本)**不删除**——那是用户项目数据,由宿主决定去留,卸载报告会列出其位置。 -- 卸载幂等:重复执行不会报错,未找到的项目跳过并说明。 -- 与 `emrg update` 的关系:更新只动 `install/`,卸载才清数据——互不干扰。 - -### 7. 多客户端架构(TUI + GUI 双客户端) - -**愿景**:一个 EMRG 实例同时提供 TUI 与 GUI 两种界面。二者是**平等的客户端入口**,共享同一个守护进程(生命本体),不重复承载任何生命逻辑。用户可同时开着 GUI 和 TUI 连接同一 daemon。 - -``` - ┌──────────────────────────────────┐ - │ emrgd(唯一) │ - │ 生命本体:LLM 循环 / 工具执行 / │ - │ 演化引擎 / 会话 / 记忆 / 项目跟踪 │ - └──────────────────────────────────┘ - ▲ ▲ ▲ - Unix Socket / Named Pipe IPC(同一协议) - │ │ │ - ┌───────────┴──┐ ┌──────┴─────┐ ┌───┴────────┐ - │ emrg (TUI) │ │ emrg-gui │ │ 未来客户端 │ - │ 终端界面 │ │ 图形界面 │ │ (mobile/… )│ - └──────────────┘ └────────────┘ └────────────┘ -``` - -**架构基础已具备(零改造)**: -- daemon 用 `serve_forever()` 为每个连接创建独立 `_handle_client` 协程,每个连接有独立的工具任务(`_tool_task`)——多客户端**并发连接**开箱即用; -- IPC 协议(`protocol.py` + 长度前缀分帧)与传输(`connect.py`,Unix Socket / Named Pipe)完全平台无关,GUI 复用同一协议层; -- 会话、记忆、演化全部由 daemon 管理,客户端只做渲染与交互——GUI 不引入第二份状态。 - -**GUI 技术栈选型**(已定稿:**Electron**,2026-08-03 用户决策): - -| 方案 | 体积增量 | 平台自包含性 | 现代 UI 能力 | 结论 | -|------|:---:|------|:---:|------| -| **Electron** | ~80-200MB | ✅ 全平台捆绑 Chromium | 最强(HTML/CSS/JS 生态) | **已选**:UI 能力最强,Markdown/diff 现成生态;WebSocket 协议语言无关,Node 原生 `ws` 直连 | -| PySide6 (Qt) | ~80-120MB | ✅ 全平台捆绑 Qt 库 | 强(WebEngine/原生控件) | 备选(被 Electron 取代) | -| pywebview(系统 WebView) | ~10-30MB | macOS ✅ / Windows ✅(WebView2) / Linux ⚠️(需 WebKitGTK) | 强(HTML/CSS/JS) | 备选:体积小,但 Linux 引入系统依赖 | -| Tkinter(stdlib) | ~5MB | ✅ 随 Python 捆绑 | 弱(控件老旧) | 兜底:体积最小,体验一般 | - -> **决策**:Electron(2026-08-03 用户拍板,取代此前 PySide6 定稿)。理由:① UI 能力最强(HTML/CSS/JS 渲染 Markdown/diff 有现成生态);② WebSocket 协议语言无关,Node 原生 `ws` 库直连 daemon,无需 Python 桥;③ electron-builder 跨平台打包成熟(.dmg/.exe/.AppImage)。代价:体积 ~80-200MB(含 Chromium)、内存 ~200-500MB/实例——GUI 作为非开发者主入口,接受。Phase 2 的 daemon_manager.py 保留为**协议参考实现**,Node 薄客户端(daemon_client.js)照它写,行为一致。 - -**GUI 与打包的相互作用**: - -1. **入口发现**:`emrg-gui` 与 `emrg` 同目录,启动时复用 `_start_daemon_background` 逻辑拉起 `emrgd`(`sys.frozen` 分支),然后以普通客户端身份连接——**GUI 不内嵌 daemon,不 fork 进程,只连 IPC**。 -2. **平台表现**: - - macOS:pkg 安装后额外生成 `.app` 外壳(Info.plist + 图标),点击启动台图标 = 运行 `emrg-gui`;TUI 仍可在终端用 `emrg`; - - Windows:开始菜单/桌面快捷方式指向 `emrg-gui.exe`;终端用户用 `emrg`; - - Linux:AppImage 本身即 GUI 容器(.desktop 文件内 Exec=emrg-gui),终端用 `emrg`。 -3. **图标与资源**:`packaging/assets/` 统一维护图标(icns/ico/png 多尺寸),安装器与 AppImage 共用。 -4. **多客户端会话协调**:TUI 与 GUI 同时操作同一 session 时,历史文件以 daemon 为唯一写者(现状即如此),客户端只读历史、写操作经 IPC 提交——无竞争风险。 - -**对打包的影响**: -- electron-builder 打包 `emrg-gui`(替代 PyInstaller 的 GUI 入口;TUI/daemon 仍用 PyInstaller); -- GUI 技术栈(Electron)体积增大 80-200MB,风险表更新; -- 冒烟测试增加"GUI 启动 → 连接 daemon → 发消息 → 收到流式回复"用例。 - -### 7.1 极简 GUI 设计(最佳实践基准 + 功能极简) - -> 原则:**GUI 第一版只做"够用",把架构做对**。功能裁剪到最少,但通信、线程、状态管理、打包四件事一次做对,后续加功能只是加控件,不推翻架构。 - -#### 功能清单(v1 极简,明确砍掉什么) - -| ✅ 做 | ❌ 明确不做(v2+) | -|-------|-------------------| -| 聊天:输入 → daemon → 流式显示 | Markdown 渲染(纯文本 + 简单加粗/代码段)→ v2 引富文本 | -| 会话:列表切换 / 新建 / 删除 | 重命名 / 搜索 / 归档 | -| 设置对话框:API key / base_url / model | 多模型切换器 / 代理 / 高级参数 | -| 状态栏:daemon 状态 / 模型名 / 演化计数 | 会话记忆浏览 / /memory | -| 工具调用显示为一行状态(`🔧 bash …`) | 工具卡片 / diff 视图 / 展开折叠 | -| 图片:粘贴 → 显示"[图片已接收] 文本占位" | 图片内容识别(vision 放 TUI) | -| ESC/中断按钮:停止当前响应 | 命令补全 `/`、快捷键体系 | - -> 设计意图:**TUI 是"功能完整、键盘驱动、开发者向";GUI 是"零学习成本、鼠标驱动、非开发者向"**。GUI v1 不重复 TUI 的完整功能矩阵,而是覆盖 80% 日常使用(聊天 + 会话 + 工具状态),把 Markdown 渲染、图片、diff 留给 v2 按需生长(对齐 MANIFESTO 第十四条"机制在需求真实出现时才生长")。 - -#### 技术栈(具体到库) - -| 项 | 选型 | 理由 | -|----|------|------| -| GUI 框架 | **Electron**(main 进程 Node + renderer Chromium) | UI 能力最强(HTML/CSS/JS 生态);协议是 WebSocket,Node 原生 `ws` 库直连 daemon,语言无关 | -| 事件循环 | Node 事件循环(main)+ 浏览器事件循环(renderer) | main 进程 Node ws 客户端异步连 daemon;renderer 只渲染,经 IPC 通信 | -| 渲染 | React 或原生 JS + marked(Markdown)+ highlight.js(代码高亮) | 现成生态,Markdown/diff 渲染质量远超 QtWidgets | -| 配置 | renderer 读 `~/.emrg/config.toml`(main 代理 fs 访问) | 与 daemon 共享同一配置;改后 daemon 靠现有 mtime 检测自动重启 | - -#### 代码结构(`emrg/gui/`,约 8 个文件) - -``` -emrg/gui/ -├── package.json # Electron 入口 + 依赖(ws / marked / highlight.js) -├── main.js # main 进程:创建窗口、拉起 emrgd(sys.frozen 分支)、daemon 连接管理 -├── preload.js # contextBridge:renderer ↔ main 的 IPC 桥(安全沙箱) -├── renderer/ -│ ├── index.html # 主布局:左侧会话栏 + 右侧聊天区 + 底部输入条 + 状态栏 -│ ├── app.js # UI 逻辑:聊天渲染、会话列表、工具状态 -│ ├── markdown.js # marked + highlight.js 封装(流式增量渲染) -│ └── settings.js # 设置对话框(config.toml 读写,经 preload IPC) -└── daemon_client.js # Node ws 客户端:读 port 文件 + auth + 消息收发(协议参考 daemon_manager.py) -``` - -#### 复用层(关键设计:协议一致,Node 薄客户端) - -`daemon_client.js` 是唯一与 daemon 通信的模块(main 进程内)。**Python 的 `daemon_manager.py` 不可被 Electron 直接 import**——但它是协议的**参考实现**,Node 客户端照它写,行为一致: - -``` -emrg/client/daemon_manager.py # Python 参考实现(Phase 2 已实施,TUI 用) - ├── ensure_connected() # 拉起 + 建连 + auth(协议:读 port 文件 → ws://127.0.0.1:port → auth 首帧 → auth_ok) - ├── send_task / send_command # 消息封装(JSON type + params,ensure_ascii=False) - ├── recv / read_stream # 读流:yield (delta|tool_start|tool_end|done) - └── ConnectionClosed 传播 # 断连检测(同 R11 语义) - -emrg/gui/daemon_client.js # Node 薄客户端(main 进程): - ├── ensureDaemon() # 拉起 emrgd(spawn python -m emrg.server,sys.frozen 分支启动同目录二进制) - ├── connect() # 读 ~/.emrg/emrgd.port → ws 连接 → auth 首帧 → auth_ok - ├── sendTask(sessionId, prompt, images?) # type="task" - ├── sendCommand(type, params) # ping/list_*/set_*/rant/... - └── onEvent(cb) # 事件回调 → IPC 转发 renderer(message_delta / tool_started / tool_finished / done) -``` - -> **为什么 TUI 也要改(已由 Phase 2 完成)**:`client/app.py` 从 1994 行瘦身至 1796 行,协议客户端逻辑沉淀为 `daemon_manager.py`——它是 Node 客户端的行为参照。**Node 不复制 Python 代码,但复制协议语义**(相同 JSON 消息、相同 auth 流程、相同断连处理)。 - -#### 线程模型(双进程,IPC 桥) - -``` -Electron main 进程(Node) Electron renderer 进程(Chromium) - ├── daemon_client.js:ws 连接 ├── UI 渲染(React/原生 JS) - ├── 事件循环:异步收 daemon 帧 ├── 用户输入 → IPC → main → daemon - └── 事件 → IPC → renderer └── IPC ← main ← daemon 帧 → 增量渲染 -``` - -- main 进程是**唯一连 daemon 的进程**(daemon_client.js),renderer 零网络权限(contextBridge 隔离,安全沙箱)。 -- 断线/daemon 崩溃 → main 检测 ConnectionClosed → 状态栏变红 + 自动重连(复用"未运行则拉起"逻辑)。 -- 发送时 UI 不阻塞:输入条 disable + 状态栏"思考中…",收到 done 后恢复。 - -#### 会话数据流(复用 daemon 已有 IPC) - -``` -启动 → main 进程 daemon_client.js 确保 emrgd 运行(spawn python -m emrg.server) - → list_sessions?session_id=… → 左侧会话列表(无会话则自动新建) -发消息 → sendTask(sessionId, prompt, stream=true)(renderer → IPC → main → daemon) - → 流式帧 → main onEvent → IPC → renderer 增量追加 - → tool_start/tool_end → 状态栏一行「🔧 bash — 运行中… / 完成 1.2s」 -切换会话 → resume_session → chat_view 加载历史(daemon 返回 history_list) -``` - -会话历史、记忆、compact 全部由 daemon 管理——GUI 重启后从 daemon 恢复,**本地零状态**(对齐 MANIFESTO 服务端/客户端架构条款)。 - -#### 极简 UI 布局(ASCII 示意) - -``` -┌────────┬───────────────────────────────────────────────┐ -│ 会话 │ [EMRG — 你好,我是你的 AI 编程助手] │ -│ │ (流式文本逐字出现…) │ -│ ▼ 会话1 │ │ -│ 会话2 │ 🔧 bash ls -la — 完成 0.4s │ -│ 会话3 │ │ -│ ├───────────────────────────────────────────────┤ -│ + 新建 │ [输入消息… [发送] ⏹] │ -├────────┴───────────────────────────────────────────────┤ -│ ● daemon 运行中 · deepseek-chat · 演化 12 次 [设置] │ -└────────────────────────────────────────────────────────┘ -``` - -#### 首启引导(极简 GUI 的"最佳实践"细节) - -1. 首次启动 → 检测 `~/.emrg/config.toml` 无 api_key → 自动弹出设置对话框; -2. 填 api_key → 写 config.toml → daemon 自动重启(mtime 检测)→ 状态栏变绿; -3. 未填直接进主界面也可用(聊天会报错并提示去设置)——不阻塞探索。 - -#### 验收标准(GUI v1) - -- [ ] 全新环境:双击安装 → 打开 GUI → 首启引导填 key → 聊天 → 工具调用状态显示 → 正常 -- [ ] 流式响应无卡顿(renderer 增量渲染不阻塞) -- [ ] 会话切换/新建/删除 与 TUI 操作同一 daemon,数据一致 -- [ ] daemon 被杀 → 状态栏变红 → 自动拉起 → 重连成功 -- [ ] `emrg`(TUI)与 `emrg-gui` 同时连接,互不干扰 -- [ ] electron-builder 打包后 GUI 可运行(win/mac/linux) - ---- - -## 8. 远程连接(WebSocket 协议的自然延伸) - -> 现状:本机 IPC(UDS/Named Pipe)。 -> **前提**:协议已统一为 WebSocket(见 [`roadmap.md`](roadmap.md) Phase 1)——本机 `ws://127.0.0.1:`、远程 `wss://`,JSON 消息层不变。 -> 未来:**远程连接**(客户端在笔记本,daemon 跑在服务器/家庭主机)。 -> 原则:**协议层(JSON 消息)完全不动,远程只是给已统一的 WebSocket 协议加上 TLS + 认证**。 - -### 8.1 协议统一后的传输层 - -> 协议 WebSocket 化之后(roadmap Phase 1),`framing.py` 删除(业务改用 WS 原生 API)、`connect.py` 由 `websockets` 库统一替代为 TCP loopback:本机 `ws://127.0.0.1:`(动态端口 + token)、远程 `wss://host:8743`。 -> 本节(远程)构建在这一层之上,不再重复传输抽象细节——详见 roadmap。 - -远程与本地在协议上是**同一个 WebSocket 协议**,差异只在: - -| | 本机 | 远程 | -|--|------|------| -| URL | `ws://127.0.0.1:` | `wss://host:8743` | -| 加密 | 无(UDS 权限即边界) | **TLS 强制** | -| 认证 | 无(文件权限即认证) | token(+可选操作白名单) | -| daemon 管理 | 自动拉起/重启 | 不拉起/不重启,提示手动 | - -### 8.2 远程传输选型(先厘清"谁负责加密") - -**ws vs wss 之争的本质不是协议,是"谁负责加密"。** 加密是可以分层的——不需要每一层都加密,但必须有一层负责: - -| 路径 | 谁加密 | 传输内协议 | 安全前提 | -|------|--------|-----------|----------| -| **A. SSH 隧道** | SSH | **ws 明文均可** | SSH 已认证+加密,隧道内是可信通道(等价于本机 IPC)——加密在隧道层,内层不需要重复 | -| **B. 直连公网** | TLS | **wss** | TLS 强制;证书=自签名 + TOFU 指纹验证(见 8.3) | - -**裸 ws 明文直连公网不可取**——不是"协议不好",是威胁模型不允许: -- token 在连接首帧携带,**明文可重放**:偷听一次 = 永久拿着 token 冒充客户端; -- 而 daemon 的工具执行是 **RCE 级能力**(bash/read/write),风险等级极高; -- 相比之下 TLS 的成本≈0(一次握手 + 现代 CPU 可忽略的加解密),**没有理由省掉这一层**。 - -**设计**: -- **远程 = `wss://`(TLS)** + 自签名证书 TOFU 指纹验证 + token 认证(零 CA,见 8.3); -- **SSH 隧道是官方零代码路径**——用户已有 SSH 访问权时,`ssh -L 8743:localhost:8743 server` 即可,隧道内走 `ws://localhost:8743` 明文,**零额外代码**; -- 浏览器客户端(未来)天然复用 `wss://`——协议统一的价值在此:TUI / GUI / 未来 web 客户端共享同一协议。 - -### 8.3 安全边界(远程是安全敏感场景) - -远程暴露 daemon = 暴露 bash/read/write 工具 + LLM API key,**必须默认拒绝、显式开启**: - -1. **默认关闭**:config 不配置 `[transport] remote` 即纯本机模式,行为与今天完全一致。 -2. **直连模式 TLS 强制**:明文传输 API key、代码、token 不可接受。 -3. **服务端身份验证——不用 CA,用 SSH 模式(TOFU)**: - - 服务端自签名证书(`emrg remote enable` 一键生成,含私钥); - - 客户端首次连接显示证书指纹(`sha256:…`),**用户确认后写入** `~/.emrg/known_hosts`(或配置里手动钉死 `server_fingerprint`); - - 之后每次连接比对指纹,变化则警告并拒绝(防 MITM); - - **为什么不用 CA**:单用户自有主机场景,CA 签发链是纯负担——SSH 三十年证明了"自签名 + 首次确认"足够。 -4. **认证(身份)**:共享 token(`emrg remote enable` 自动生成,首帧携带)。token 可重放,所以**必须**配加密层(第 2 条)——两者缺一不可。 -5. **操作门控(可选,默认宽松)**:远程模式下可配置白名单(如仅允许聊天、禁用 bash),对齐 MANIFESTO 宿主授权精神;默认与本地一致(信任凭据持有者)。 -6. **端口**:默认 8743(非特权),可配置。 - -### 8.4 客户端配置(config.toml) - -```toml -[transport] -mode = "local" # local(默认)| remote -# 以下仅 remote 模式生效: -# host = "my-server.example.com" -# port = 8743 -# token = "…" # emrg remote enable 生成 -# server_fingerprint = "sha256:…" # 自签名证书指纹(首次连接确认后写入,或手动钉死) -# 或 server_cert = "~/.emrg/server.pem" # 直接信任该自签名证书(等效信任锚) -# scheme = "wss" # wss(默认)| ws(仅 SSH 隧道内使用) -``` - -GUI/TUI 共享同一 config——远程连接对两种客户端同时生效,无需分叉。 - -### 8.5 远程模式下的行为差异(客户端侧) - -| 行为 | 本地 | 远程 | -|------|------|------| -| daemon 未运行 | 自动拉起 | **不自动拉起**(远端可能没有 emrg 安装)→ 提示连接失败/配置错误 | -| mtime 变更自动重启 | ✅ | ❌(不能重启远端进程)→ 提示"远端 daemon 需手动更新" | -| 会话/记忆位置 | 本地 `/.emrg/` | **远端** `/.emrg/`(跟随 daemon 所在机器) | -| `emrg update` | 更新本地 | 更新本地客户端二进制;远端 daemon 由远端自行更新 | -| 工具执行(bash 等) | 本地 | **远端**(cwd 是远端路径)——这是远程的核心价值:远端有完整环境 | - -> 重要语义:远程模式下,**会话、记忆、工具执行全部发生在 daemon 所在机器**。客户端只是瘦终端。这与 MANIFESTO"服务端是生命本体,客户端只是入口"完全一致——远程连接是这个架构的自然推论,而非特例。 - -### 8.6 与打包的关系 - -- 远程是**传输层特性**,不改变安装包结构(零新增捆绑——TLS 走 stdlib); -- daemon 侧:`emrgd` 增加 `--listen remote` 启动参数(或 config 驱动),监听 `wss://`(TLS)而非本机 `ws://127.0.0.1:`; -- 冒烟测试增加:本机起 daemon(remote 模式)→ 本机客户端以 remote 配置连接 → 全功能验证(TLS + token 认证)。 - ---- - -## 分发与托管 - -**结论:不需要任何额外服务器,全部托管在 GitHub Releases。** - -| 分发项 | 托管位置 | 成本 | -|--------|----------|------| -| 平台安装包(.pkg / .exe / .AppImage) | GitHub Releases(本仓库 `releases/latest` 的附件) | 免费(单文件 ≤ 2GB) | -| 版本元数据(最新版本号、SHA256、下载 URL) | `https://api.github.com/repos/argszero/emrg/releases/latest` | 免费 | -| 下载统计 | GitHub API `download_count` 字段 | 免费 | -| 更新检查 | `emrg update` 直接查 Releases API | 免费 | - -**下载入口**: - -``` -安装: 浏览器访问 github.com/argszero/emrg/releases/latest - → 下载 EMRG-.pkg / EMRG-Setup-.exe / EMRG--linux-.AppImage - → 双击安装(Linux: chmod +x 后运行) -更新: emrg update(自动下载新版本,见 §5) -卸载: emrg uninstall 或平台卸载器(见 §6) -``` - -> **旧的在线 install.sh 废弃**。仓库根目录的 `install.sh` 将删除,README 不再宣传 `curl | bash`。所有用户(无论是否开发者)走同一条路径:下载对应平台的安装文件。贡献者从 GitHub 克隆源码参与开发,但**安装**只有一种方式。 - -**为什么不引入 CDN/对象存储**: -- GitHub Releases 对开源项目免费且无限速(单文件上限 2GB,本包 <100MB 远低于限制); -- 与源码、CI、Issues 同处一个平台,发布流程(打 tag → Actions 构建 → 自动 attach)零额外配置; -- 需要自建镜像时(国内下载慢、企业内网),后续可加"镜像源"配置项:`~/.emrg/config.toml` 中 `[update] mirror_url`,默认指向 GitHub,用户可切换。**此项列为 Phase 4 可选项,不阻塞主流程**。 - ---- - -## 构建流水线(GitHub Actions) - -``` -workflow: build-release.yml -trigger: tag v* 推送 / workflow_dispatch - -matrix: - - os: macos-15 → 产物: EMRG-.pkg - - os: macos-13 → 产物: EMRG-.pkg(Intel,若仍需要) - - os: ubuntu-24.04 → 产物: EMRG--linux-x86_64.AppImage - - os: ubuntu-24.04-arm → 产物: EMRG--linux-aarch64.AppImage - - os: windows-2025 → 产物: EMRG-Setup-.exe - -每步: - 1. uv sync --frozen - 2. uv run pytest tests/ # 回归 - 3. pyinstaller emrg.spec # 双入口 + datas - 4. 捆绑 git(下载便携构建)+ gh(官方 release 二进制)→ bin/ - 5. 平台包装(pkgbuild / innosetup / AppImage / tar.gz 兜底) - 6. 上传 artifact + 附加到 release -``` - -**PyInstaller spec 要点**(`packaging/emrg.spec`): -- `Analyze(['emrg/__main__.py', 'emrg/server/__main__.py'])` → 两个 EXE(emrg、emrgd);**emrg-gui 用 electron-builder 单独打包**(不走 PyInstaller) -- `datas`:上述 5 个模板文件 + LICENSE + `packaging/assets/`(图标) -- `hiddenimports`:`yaml`(C 扩展)、`jinja2`、`httpx`、`rich`、skills loader 的动态 import 模块 -- `--noupx`(规避杀软误报) -- onedir 模式(daemon 长期运行 + 更新替换需要,避免 onefile 的临时解压目录与运行中文件替换冲突) - -**签名与公证**(凭据注入 CI secrets): -- macOS:Developer ID Application 签名 → `notarytool` 公证 → `stapler` 装订。无证书时 CI 跳过签名,产物标注 `unsigned`,文档说明右键打开方式。 -- Windows:EV 代码签名证书(可选)。未签名时 SmartScreen 会有提示,属预期。 -- Linux:无强制签名;tarball 附 SHA256 校验。 - ---- - -## 分阶段实施计划 - -### Phase 1 — 打包跑通(本地) -- [ ] `packaging/emrg.spec` 双入口构建,本地产出可用 tarball -- [ ] `sys.frozen` 分支:`_start_daemon_background` 启动 `emrgd`;`emrg update` 走自更新分支 -- [ ] 从 `client/app.py` 提取 `client/daemon_manager.py`(daemon 管理 + 协议读写,TUI/GUI 共用) -- [ ] git/gh 解析器:优先捆绑版、兜底系统版,探测结果写入 `install-info.json` -- [ ] 全量审计运行时写入路径,消除安装目录写入(promote/open-source 状态文件迁移) -- [ ] 冒烟测试:全新 macOS 容器(无 python/uv/git)解压 → 启动 daemon → 聊天 + 工具调用 + 会话持久化 + 演化任务跑通 -- [ ] `scripts/build.sh` 本地构建脚本 - -**验收**:干净的 macOS 上,解压即用,`/help`、工具调用、`/rant`、演化周期全部正常。 - -### Phase 2 — CI 发布矩阵 -- [ ] GitHub Actions matrix 构建(3 平台 × 架构) -- [ ] release 自动化:打 tag → 自动构建 + 附加资产 + 生成 SHA256 -- [ ] 打包冒烟测试纳入 CI(在无 Python 的 runner 镜像上跑 `emrg --version` + `emrg server stop/restart` + 演化干跑) - -### Phase 3 — 平台安装器 + 卸载 -- [ ] macOS `.pkg`(pkgbuild + productbuild,安装向导 + 卸载器);有证书则签名公证 -- [ ] Windows Inno Setup `.exe`(安装向导 + unins000.exe + PATH + 快捷方式) -- [ ] Linux AppImage(linuxdeploy 封装,首次运行建 `~/.local/bin/emrg` 符号链接)+ tarball 兜底 -- [ ] `emrg uninstall` 全流程实现(终止报告 + 墓地快照 + 清理 + 自校验) -- [ ] README 安装/卸载章节重写:分平台"下载 → 点击安装/卸载"指引 -- [ ] 删除根目录 install.sh - -### Phase 4 — 自动更新闭环 -- [ ] `emrg update` 自更新实现(版本目录 + 符号链接 + 原子切换 + 回滚) -- [ ] 更新检查提示(启动时比对最新 release 版本,可选开关) - -### Phase 5 — GUI 客户端(极简 v1,并行推进,不阻塞 1-4) -- [ ] 技术栈定稿:Electron + Node `ws` 客户端,验证 electron-builder 打包 -- [ ] `emrg/gui/` 按 §7.1 结构落地:main.js / preload.js / renderer(app.js + markdown.js + settings.js)/ daemon_client.js -- [ ] 协议接入:daemon_client.js 照 daemon_manager.py 写(读 port 文件 + auth + 消息收发),行为一致 -- [ ] 功能 v1 验收:聊天流式 + 会话切换/新建/删除 + 工具状态行 + 设置对话框(首启引导填 key) -- [ ] `emrg-gui` 入口接入 daemon 启动逻辑(spawn python -m emrg.server,sys.frozen 分支) -- [ ] 平台外壳:macOS `.app`(Info.plist+图标)、Windows 快捷方式、Linux AppImage `.desktop`(Exec=emrg-gui) -- [ ] GUI 冒烟测试:启动 → 连 daemon → 流式对话 + 工具调用 → daemon 被杀自动重连 -- [ ] 多客户端同开验证:TUI + GUI 同时连接,会话/记忆一致 -- [ ] v2 立项清单(不实现):diff 视图、图片 vision、/命令补全、记忆浏览(Markdown 渲染用 marked 已含) - -### Phase 6 — 远程连接(wss + 认证,协议已在 Phase 1 统一) -- [ ] 前提:协议 WebSocket 化已完成(roadmap Phase 1)——本机 `ws://127.0.0.1:` + token 已就绪 -- [ ] remote 实现:`wss://`(websockets + ssl)+ token 认证 -- [ ] `emrg remote enable`(服务端):一键生成自签名证书 + token + 打印指纹 -- [ ] `emrg remote connect`(客户端):首次连接 TOFU 确认指纹 → 写入 known_hosts -- [ ] config `[transport]` 支持:mode/host/port/token/server_fingerprint(或 server_cert) -- [ ] 客户端行为分支:远程不自动拉起/不重启 daemon、提示手动更新 -- [ ] `emrgd --listen remote` 启动参数 -- [ ] 安全验收:无 TLS 拒绝启动、token 错误拒绝连接、指纹变更拒绝连接、明文抓包无敏感数据 -- [ ] SSH 隧道官方文档路径(`ssh -L` 桥接,隧道内 ws 明文,零代码) -- [ ] 远程冒烟测试:本机 remote 模式全功能验证(聊天 + 工具 + 会话) - ---- - -## 风险与对策 - -| 风险 | 影响 | 对策 | -|------|------|------| -| PyInstaller 漏收动态 import(skills loader 用 importlib 按路径加载) | 运行时 ModuleNotFound | spec 中显式 hiddenimports;冒烟测试覆盖 `/skills` | -| 打包体积 ~60-90MB/平台(含 git/gh) | 下载变慢 | 接受(用户换取零依赖);tarball 用 gzip;后续评估 Nuitka 压缩 | -| 捆绑 git/gh 的许可证/分发合规 | 法律风险 | git: GPL-2.0(独立可执行,非链接,合规);gh: MIT。随包附带各自 LICENSE 文件 | -| Windows 杀软误报(PyInstaller 常见) | 安装被拦截 | `--noupx`;代码签名证书;提交杀软白名单申诉 | -| 无 Apple 证书则 macOS Gatekeeper 拦截 | 首次运行需右键打开 | 文档说明;社区版标注;有账号后启用公证 | -| 安装目录被误改 | daemon 崩溃 | 安装目录只读 + 运行时零写入审计;升级用版本目录隔离 | -| 测试套件(pytest)不打包 | 打包产物无回归保障 | CI 中先跑全量 pytest 再打包;产物跑冒烟脚本 | -| 捆绑 git 构建失败(某平台无便携版) | 该平台安装不完整 | CI 构建时验证捆绑产物存在;缺失则构建失败(fail-fast),不发布残缺包 | -| 卸载误删宿主数据 | 不可逆损失 | 卸载只删 `~/.emrg/`;`/.emrg/` 项目数据仅列位置不删除;删除前强制墓地快照 | -| GUI 框架(Electron)体积 +80-200MB | 下载变大、安装变慢 | 接受(GUI 是主入口,体验优先);Chromium 自带(无额外系统依赖) | -| Linux 上 GUI 依赖 | 违背零依赖目标 | Electron 捆绑 Chromium,规避系统 WebKit 依赖 | -| GUI 与 TUI 同时操作同一会话 | 状态不一致 | daemon 为唯一写者(现状),客户端只读历史、写操作经 IPC 提交 | -| 两个 PyInstaller 入口(emrg/emrgd)+ electron-builder(emrg-gui)打包互扰 | 产物残缺 | 各自独立打包;冒烟测试逐个验证入口 | -| Electron main 进程 Node ws 断连检测遗漏 | 断连不重连 | daemon_client.js 监听 ws close/error(同 TUI 的 ConnectionClosed 语义);状态栏变红 + 自动重连 | -| 从 app.py 提取 daemon_manager 引入 TUI 回归 | TUI 功能受损 | 提取后 TUI 全量 pytest + 手动冒烟;共享逻辑用独立单测覆盖 | -| 远程模式暴露 bash/API key(安全敏感) | 凭据与代码泄漏 | 默认关闭 + 直连 TLS 强制 + token + TOFU 指纹;安全验收含抓包无明文 | -| TOFU 首次确认被用户跳过 → MITM | 中间人接管连接 | 首次连接强提示(非交互环境则拒绝连接);支持 `server_fingerprint` 手动钉死;指纹变更即拒连 | -| 远程 daemon 版本与本地客户端不匹配 | 协议/行为错乱 | 连接时交换版本号(ping 响应已有 `started_at`/`model`,补 `version`);不匹配则警告 | - ---- - -## 决策记录 - -1. **打包工具**:PyInstaller(方案 A),Nuitka 为 Phase 2 评估项。 -2. **统一安装方式**:每平台一个安装文件(macOS .pkg / Windows .exe / Linux AppImage),开发者与非开发者无差别;旧的在线 install.sh 删除,无任何安装脚本。 -3. **捆绑一切**:Python + 第三方库 + git + gh 全部入包,安装即完整;gh 认证仍由用户 `gh auth login`(不可自动化)。 -4. **用户级安装**:macOS/Windows 装到 `~/.emrg/install/`(Windows: `%LOCALAPPDATA%\EMRG\`);Linux 为 AppImage 单文件 + `~/.local/bin` 符号链接。全程免 sudo,三平台行为一致。 -5. **双二进制**:`emrg` + `emrgd`,onedir 模式,`sys.frozen` 分支兼容源码/打包两种运行方式。 -6. **自更新**:版本目录 + 符号链接原子切换,失败回滚,免 sudo,只动安装目录不动数据。 -7. **卸载全面**:`emrg uninstall` + 平台卸载器,终止报告 + 墓地快照 + 清理运行时 + 环境痕迹 + 自校验,幂等可重跑。 -8. **数据与安装目录严格分离**:安装目录只读,一切可变数据在 `~/.emrg/` 与工作目录 `.emrg/`。 -9. **TUI + GUI 双客户端**:`emrg`(TUI)与 `emrg-gui`(GUI)是平等入口,共享唯一 `emrgd`;协议(WebSocket)复用,GUI 零状态只渲染。 -10. **GUI v1 极简(2026-08-03 改版:Electron)**:Electron(main Node + renderer Chromium)+ Node `ws` 客户端(daemon_client.js,照 daemon_manager.py 写);功能只做聊天/会话/工具状态行/设置,diff/图片/补全留 v2;**Phase 2 的 daemon_manager.py 是协议参考实现,Node 薄客户端复制协议语义不复制 Python 代码**。 -11. **远程连接 = WebSocket 协议的自然延伸**:协议统一(roadmap Phase 1,本机 `ws://127.0.0.1:`、远程 `wss://`)后,远程只加 TLS + token + **自签名证书 TOFU 指纹验证(SSH known_hosts 模式,无 CA)**;SSH 隧道为官方零代码路径(隧道内 ws 明文——加密分层,不叠加)。默认关闭、直连 TLS 强制。远程模式下会话/记忆/工具执行全在 daemon 侧,客户端是瘦终端(MANIFESTO 架构条款的自然推论)。 diff --git a/docs/design/phase1-websocket-protocol.md b/docs/design/phase1-websocket-protocol.md deleted file mode 100644 index 1521e0ca..00000000 --- a/docs/design/phase1-websocket-protocol.md +++ /dev/null @@ -1,532 +0,0 @@ -# Phase 1 设计:协议 WebSocket 化 - -> 主路线图:[`roadmap.md`](roadmap.md) Phase 1 -> 本文是该阶段的完整设计文档:现状分析、目标形态、改动清单、测试策略、验收标准。 -> 关联文档:`packaged-installer.md` §7(GUI 复用本协议)、§8(远程 wss 建立在本协议之上)。 - ---- - -## 1. 现状:字节流协议全貌 - -### 1.1 三层结构 - -``` -┌─ 消息层 protocol.py JSON 对象(TaskRequest/ToolStart/ToolEnd/…) -├─ 帧层 framing.py 4 字节大端长度前缀 + body(16MB 上限) -└─ 传输层 connect.py UDS (Unix) / Named Pipe (Windows) -``` - -### 1.2 调用面盘点(WebSocket 化必须覆盖的全部连接点) - -| 位置 | 用途 | 帧调用 | -|------|------|--------| -| `server/daemon.py` | 服务端:`_handle_client(reader, writer)` 读循环 + **63 处** `self._send(writer, data)` 流式响应 + **1 处裸 `write_frame(writer, err)`**(daemon.py:226 JSON 解析错误分支——`_send` 之外,改造为 `self._send(ws, {...})`) | `read_frame(reader)` / `write_frame(writer, …)` / `_send(writer, …)` | -| `client/app.py` | TUI:`read_server()` 读循环 + **30+ 处** `write_frame(writer, …)` 直接调用 + 多处 `read_frame` | 发送任务/cancel/rant + 接收流式帧 | -| `server/scheduler.py` | 演化引擎:以内部客户端身份连 daemon | `write_frame` + `read_frame` | -| `__main__.py` | CLI:`server stop` / `ping` / `rant` | `write_frame` + `read_frame` | -| `connect.py` | 传输层:`start_server` / `connect_to_server` / `is_server_running_sync` / `cleanup_server` / `get_server_path` | — | - -### 1.3 关键约束 - -- `daemon.py` 有 **13 个函数**接收 `writer` 参数并传递(含发送入口 `_send`):`_handle_client`(196) / `_send`(323) / `_process_message`(523) / `_run_chat_once`(951) / `_run_tool_loop`(1010) / `_handle_compact`(1514) / `_handle_list_sessions`(1722) / `_handle_list_projects`(1732) / `_handle_list_models`(1763) / `_handle_set_model`(1794) / `_handle_resume_session`(1837) / `_handle_list_memories`(1868) / `_handle_read_memory`(1911)。(注:`_generate_session_title`(1958) **不带 writer**,无需改。)`_send(writer, data)` 返回 `bool`(False = 客户端断开,调用方停止)——63 处调用中 **7 处检查返回值**(流式/工具循环,断开即停),56 处不检查(`_process_message` 一次性响应)。 -- `client/app.py` 的 `read_server()` 是核心读循环,含**超时轮询(`wait_for(…, 0.1)`)+ 断线重连**逻辑。 -- 消息边界语义:一条 JSON 消息 = 一个帧(长度前缀保证消息不被截断,解决过 64KB NDJSON 限制)。 - ---- - -## 2. 目标 - -1. **协议统一为 WebSocket**:JSON 消息直接映射为 WS 消息(每个 WS 消息 = 一个 JSON 对象),不再自造长度前缀帧。 -2. **本机/远程同一套传输逻辑**:本机 `ws://127.0.0.1:`、远程 `wss://:`——差异只在"要不要 TLS + token",URL 规则一条,无平台分叉。 -3. **远程就绪**:同一协议未来加 TLS + token 即 `wss://`(Phase 5),本机路径零改动。 -4. **换路彻底,不留旧路痕迹**:删除 `framing.py`(长度前缀层)与 `WsStream`(StreamReader 伪装)——字节流时代的产物在 WS 时代没有存在意义。业务代码直接用 WS 原生 `ws.recv()` / `ws.send()`。 - ---- - -## 3. 核心设计:WS 原生语义(彻底换路) - -### 3.1 设计原则 - -长度前缀帧(`framing.py`)、`(reader, writer)` 参数形态、`read_frame`/`write_frame` 函数名——**全部是字节流时代的产物**。WS 时代它们没有存在意义: - -- **`framing.py` 删除**——"帧"是字节流的词,WS 的消息边界由协议保证,长度前缀是死代码; -- **`read_frame`/`write_frame` 不存在**——业务直接 `ws.recv()` / `ws.send()`(WS 原生消息语义,一条消息 = 一个 JSON 帧); -- **`WsStream` 适配层不需要**——不再伪装 StreamReader,handler 直接收 ws 连接对象; -- **`(reader, writer)` 元组不存在**——`connect_to_server()` 返回单个 ws 对象。 - -> **原则**:换路要彻底,一次性付清迁移成本(改调用点),而不是用适配层把旧 API 续命——续命就是给未来留垃圾补丁。 - -**改动面其实是收敛的**(并非 40+ 处):daemon 的 63 处 `_send(writer, …)` 全部收敛在 `_send` 一个入口,机械替换参数名 `writer→ws` 即可,pytest 兜底。 - -### 3.2 WS 原生形态(目标代码长什么样) - -**服务端(daemon.py)**——`_handle_client` 直接收 ws,首帧认证: - -```python -# websockets 12.x asyncio 接口:serve(handler, host, port),handler 收 1 参 (ws) -from websockets.asyncio.server import serve -from websockets.exceptions import ConnectionClosed -import secrets, json, asyncio - -async def _handle_client(self, ws): # 签名从 (reader, writer) 改为 (ws) - # ⚠️ _handle_client 直接作 websockets handler(收 1 参 ws)——无需 _ws_handler 包装层 - last_session_id = last_cwd = None - _tool_task = _cancel_event = None - try: - # ── 首帧认证(本机/远程统一)── - # 必须有超时:否则恶意/异常客户端连接后不发 auth,此协程永久悬挂(socket+协程泄漏) - try: - auth_msg = await asyncio.wait_for(ws.recv(), timeout=10) - auth = json.loads(auth_msg) - except (ConnectionClosed, json.JSONDecodeError, asyncio.TimeoutError): - await ws.close(); return - # ⚠️ auth 必须是 dict:json.loads 可能返回 list/str,.get() 会 AttributeError - if not isinstance(auth, dict) or auth.get("type") != "auth" or not secrets.compare_digest( - str(auth.get("token", "")), self._auth_token - ): - logger.warning("auth failed — rejecting connection") - await ws.close(); return - - # ⚠️ 认证成功必须发确认帧:否则客户端无法感知认证结果(见 §3.3 客户端 auth 验证)。 - # 没有 auth_ok,客户端 connect_to_server 只能"发完 auth 就返回"——认证失败时服务端 - # close,客户端要等到首次业务交互才抛 ConnectionClosed,会被 _reconnect 当普通断线无限重连。 - await self._send(ws, {"type": "auth_ok"}) - - while True: - try: - msg = await ws.recv() # 一条 WS 消息 = 一个 JSON 帧 - except ConnectionClosed: # 断开语义:异常,而非 None - break - try: - data = json.loads(msg) # WS 消息必非空,无空消息分支 - # ⚠️ 显式检查 dict:json.loads 合法 JSON 可能是 list/str(如 [1,2]、"hi"), - # 现状隐式依赖 msg.get AttributeError → 外层 except 关连接;WS 版显式处理更清晰 - if not isinstance(data, dict): - await self._send(ws, {"error": "message must be a JSON object"}) - continue - except json.JSONDecodeError as e: - await self._send(ws, {"error": f"invalid json: {e}"}) - continue - ... - await self._send(ws, {...}) # 63 处调用点仅参数名 writer→ws - finally: - if _tool_task and not _tool_task.done(): # 断连时取消运行中的工具任务(现状语义) - if _cancel_event: - _cancel_event.set() - _tool_task.cancel() - try: - await _tool_task - except asyncio.CancelledError: - pass - try: - await ws.close() # 现状 writer.close()+wait_closed() 有 try/except 保护,保持 - except Exception: - pass - # ⚠️ 保留断连记忆整合(现状 daemon.py:316-321 关键逻辑,勿漏): - if last_session_id and last_cwd: - try: - await self._consolidate_session_memories(last_session_id, Path(last_cwd)) - except Exception: - logger.debug("session memory consolidation failed", exc_info=True) -``` - -> **为什么首帧必须认证**:loopback 端口对本机其他用户可见,token(存 600 权限文件)是对齐原 UDS 文件权限隔离的唯一机制。**不校验 = 认证形同虚设**。本机与远程(Phase 5)走同一认证逻辑——本机 token 客户端自动读取,远程 token 显式配置。 - -> **服务端健壮性已验证**:`_handle_client` 内未捕获异常 → websockets 关闭该连接(客户端收 `ConnectionClosedError`),**服务端继续运行**(`server.is_serving()` 仍 True,实测 17.x)——与现状"外层 `except Exception` 兜底关连接"行为一致,无需额外 try/except 包裹整个读循环。但非 dict JSON(list/str)应显式检查(见上),不依赖隐式 AttributeError。 - -```python -async def _send(self, ws, data: dict) -> bool: # 内部从 write_frame 改为 ws.send - """发送一条 JSON 消息。返回 False 表示客户端已断开。""" - try: - await ws.send(json.dumps(data, ensure_ascii=False)) - return True - except (ConnectionClosed, OSError): - # ConnectionClosed 覆盖:客户端主动关 / 服务端关(ConnectionClosedOK/Error 均子类,实测); - # OSError 兜底:半开连接等边缘场景。两类都不是 OSError 的 ConnectionClosed 主类在 12.x 需确认, - # 稳妥起见两者都捕(现状 _send 捕 ConnectionResetError/BrokenPipeError/OSError) - logger.debug("client disconnected during send") - return False -``` - -**客户端(app.py / scheduler.py / __main__.py)**——直接用 ws: - -```python -# 连接:返回单个 ws 对象(不再是 (reader, writer) 元组) -ws = await client_connect_to_server() - -# 发送(connect_to_server 内部已发 auth 首帧,这里是认证后的第一条业务消息) -await ws.send(json.dumps({"type": "ping"})) - -# 读循环(read_server)——WS 消息必非空,无空消息分支 -while True: - try: - msg = await asyncio.wait_for(ws.recv(), timeout=0.1) - except asyncio.TimeoutError: - continue - except ConnectionClosed: # 断开 → 重连 - await _reconnect() - continue - data = json.loads(msg) - ... - -# 关闭 -await ws.close() -``` - -**调用点变化总览**: - -| 现状 | 改造后 | -|------|--------| -| `from emrg.framing import read_frame, write_frame` | 删除 import(`emrg/framing.py` 删除) | -| `reader, writer = await connect_to_server()` | `ws = await connect_to_server()`(**全部解包点**:__main__.py:136/166/241、app.py:82/293/421、scheduler.py:321——后两者经 `client_connect_to_server()`/`connect_to_server()` 返回) | -| `frame = await read_frame(reader)` + `json.loads(frame)` | `data = json.loads(await ws.recv())` | -| `await write_frame(writer, json.dumps(x).encode())` | `await ws.send(json.dumps(x))`(发 str 或 bytes 均可——服务端 `json.loads` 两者兼容;scheduler 现状发 `task_msg.encode()` bytes,可保留) | -| `_send(writer, data)`(63 处) | `_send(ws, data)`(仅参数名) | -| `frame is None`(断开) | `except ConnectionClosed`(断开) | -| `writer.close()` + `await writer.wait_closed()` | `await ws.close()`(__main__ 3 处:143/169/257;app.py 3 处:85/416/1924) | -| daemon `shutdown` 分支(daemon.py:884-892):`writer.close()` + `wait_closed()` | `await ws.close()`;**`self._server.close()` 不变**(websockets `Server.close()` 是同步的,已验证 17.x)——注意 `ws.close()` 是 async coroutine,与现状 `writer.close()` 同步不同 | - -> **断连语义的分端差异(勿照抄)**:三端读循环对 `ConnectionClosed` 的处理不同—— -> - **client(TUI)**:`except ConnectionClosed: await _reconnect()`(重连) -> - **scheduler(演化)**:`except ConnectionClosed: break`(正常收尾,不重连——演化周期完成即断开;现状 `frame is None: break` 语义保留) -> - **daemon(服务端)**:`except ConnectionClosed: break`(客户端走了,结束本连接处理) -> 实现时按各端现状语义对应转换,不要统一成重连。 - -> **`_reconnect()` 的重连语义(现状 app.py:405-430)**:关闭旧连接 → 循环 `client_connect_to_server()`(内部已发 auth 首帧)→ **成功后立即发 `{"type":"ping"}` 验证**(认证后第一条业务消息)→ 更新状态。改造后仅 `write_frame(writer, …)` → `ws.send(...)`,流程不变。注意:`read_server` 里的 `reader`/`writer` 是 **nonlocal 变量**(`_reconnect` 闭包引用),改造后为单个 `ws` 变量,`_reconnect` 闭包同样引用它——nonlocal 语义保留。 - -**scheduler(演化引擎)改造示例**(读循环无超时、断连 break——现状语义保留): - -```python -# 现状 scheduler.py:345-366 -task_bytes = task_msg.encode() -await write_frame(writer, task_bytes) -while True: - frame = await read_frame(reader) - if frame is None: - break - resp = json.loads(frame.decode()) - if resp.get("done"): ... break - ... - -# 改造后 -ws = await connect_to_server() # 内部已发 auth 首帧 -await ws.send(task_msg) # 发 str 或 bytes 均可(task_msg 现状是 dict,直接 json.dumps) -while True: - try: - resp = json.loads(await ws.recv()) - except ConnectionClosed: # 断开 = 正常收尾(现状 frame is None: break 语义) - break - if resp.get("done"): ... break - ... -# finally: await ws.close() -``` - -> scheduler 断开后走"git HEAD 未变 → empty_cycles+1"(现状语义)——改造后 ConnectionClosed → break → 同样路径,**语义一致**。断开被当作"空周期"是现状行为,非本次改动引入。 - -### 3.3 连接面变化 - -**服务端(daemon.py `serve()`)**: - -```python -# 现状 -self._server = await start_server(self._handle_client) # connect.py 封装 asyncio.start_unix_server -await self._server.serve_forever() - -# 改造后(统一 TCP loopback,Unix/Windows 同一路径)——serve() 完整形态,其余逻辑保持现状 -# ⚠️ 本代码块是完整 serve():除标注的改动外,PID 文件、scheduler、finally 清理全部保留—— -# 照抄实现时勿只抄监听 6 行(会丢掉演化引擎与清理逻辑)。 - -# ── PID 文件创建/僵尸检测(现状 daemon.py:117-168 整体保留)── -# ⚠️ 唯一改动:僵尸检测里的 sock_path = runtime_dir / "emrgd.sock" → "emrgd.port" -# 现状: sock_path = runtime_dir / "emrgd.sock" -# if not sock_path.exists(): → force-kill 旧 daemon(socket 没了=僵尸) -# 改造: sock_path = runtime_dir / "emrgd.port" ← 关键! -# if not sock_path.exists(): → force-kill 旧 daemon(port 文件没了=僵尸) -# 不改这里 = 严重 bug:emrgd.sock 永远不存在 → 每次启动都 force-kill 活着的旧 daemon -# (force-kill 分支、stale PID 清理、拒绝二次启动等逻辑原样保留,不赘述) - -# ── 监听层(唯一实质改动:start_server → websockets serve)── -from websockets.asyncio.server import serve # ← asyncio 新接口,handler 收 1 参 (ws) -self._server = await serve(self._handle_client, host="127.0.0.1", port=0, - max_size=16 * 1024 * 1024) # ⚠️ 服务端也要设(默认 1MB,接收大消息会断连) -port = self._server.sockets[0].getsockname()[1] -self._auth_token = secrets.token_urlsafe(32) # 实例属性,_handle_client 校验用 -_atomic_write_bytes(f"{port}\n{self._auth_token}", config_dir() / "emrgd.port", mode=0o600) # 见下 - -# ── scheduler 启动(现状 daemon.py:176-177,位置与顺序不变)── -self._scheduler = TaskScheduler(self.identity) -self._scheduler.load_and_start() - -try: - await self._server.serve_forever() -except asyncio.CancelledError: - pass -finally: - # ── 清理块(现状 daemon.py:183-198 整体保留,一行不改)── - self._scheduler.stop_all() - await self._scheduler.wait_all() - await self.llm.close() - cleanup_server() # 改造后删 emrgd.port(含 token)——语义与现状删 socket 文件一致 - # PID 文件删除(现状逻辑保留,不赘述) -``` - -> **⚠️ websockets `serve()` 返回时已 `is_serving()`**(实测 17.x)——**不需要**像 asyncio 那样先 `start_serving()`/`serve_forever()` 才开始监听,`serve()` 一返回即可接受连接。文档保留 `await server.serve_forever()` 是**阻塞保持 daemon 运行**(与现状 asyncio 语义一致),不是启动监听。`server.close()`(同步)后 `serve_forever()` 返回——shutdown 流程(daemon.py:892)兼容,已验证与 asyncio 行为一致。 - -> **`_atomic_write_bytes` 需新增**:现有 `emrg/server/atomic.py` 的 `atomic_write_yaml` 只支持 `list[dict]` + YAML(不适用文本 port 文件)。需在 atomic.py 新增通用函数: -> ```python -> def atomic_write_bytes(data: str, target: Path, *, mode: int = 0o600) -> None: -> """原子写文本:tmp 文件 + os.replace + chmod(复用 atomic_write_yaml 的 mkstemp 模式)。""" -> target.parent.mkdir(parents=True, exist_ok=True) -> fd, tmp_path = tempfile.mkstemp(dir=str(target.parent), prefix=".atomic_", suffix=".tmp") -> try: -> with os.fdopen(fd, "w", encoding="utf-8") as f: -> f.write(data) -> os.chmod(tmp_path, mode) -> os.replace(tmp_path, target) -> except OSError: -> try: os.unlink(tmp_path) -> except OSError: pass -> ``` -> 同时 `cleanup_server()` 删 port 文件、`get_server_path()` 返回 port 路径都依赖此文件(§4 归宿表)。 -``` - -**客户端(connect.py `connect_to_server()`)**: - -```python -# 现状 -return await asyncio.open_unix_connection(str(sock_path)) # Unix -return await asyncio.open_connection(host, port, path=...) # Windows - -# 改造后(统一,返回单个 ws)——统一用 asyncio 接口(见 §6,避免顶层接口版本漂移) -from websockets.asyncio.client import connect -port, token = (config_dir() / "emrgd.port").read_text().split() -ws = await connect( - f"ws://127.0.0.1:{port}", - max_size=16 * 1024 * 1024, # ⚠️ 客户端也要设!默认 1MB,不设则接收大消息会断连 -) -await ws.send(json.dumps({"type": "auth", "token": token})) # 首帧认证 -# ⚠️ 验证认证结果(闭环):服务端认证成功发 auth_ok 确认帧(§3.2),失败直接 close。 -# 等 auth_ok:收到 → 连接就绪;ConnectionClosed(被拒)/ 超时(服务端无响应)→ 抛 AuthError。 -try: - ack = json.loads(await asyncio.wait_for(ws.recv(), timeout=10)) -except (ConnectionClosed, asyncio.TimeoutError): - await ws.close() - raise AuthError("authentication failed — check token / daemon version") -if ack.get("type") != "auth_ok": # 防御:意外帧也视为失败 - await ws.close() - raise AuthError(f"unexpected auth response: {ack!r}") -return ws -``` - -> **客户端 auth 失败处理(auth_ok 闭环,勿省略)**:`connect_to_server()` 发完 auth 后**必须等待 auth_ok 确认帧**(§3.2 服务端认证成功即发;认证失败服务端 close → 客户端 recv 抛 ConnectionClosed → 抛 `AuthError`)。没有这一步,认证失败会被 `_reconnect()` 的 `except Exception: continue` 当成普通断线**无限重连**——token 不匹配是配置/安装问题,重连只会循环刷日志。`AuthError` 需在客户端侧定义(`connect.py` 内,如 `class AuthError(Exception)`),并在各调用点与普通断线区分:`_reconnect()`(app.py)与 `__main__.py` 的 `except Exception: continue` 循环中,`except AuthError: raise`(报错退出);`ConnectionRefusedError`/`OSError`/`FileNotFoundError`(daemon 未启动)则继续重连。TUI 的 `_reconnect()` 只应在"daemon 重启/网络瞬断"时触发,不该在认证失败时触发。 - -> **连接失败异常已验证兼容**:`websockets.connect` 连接被拒抛 `ConnectionRefusedError`(`OSError` 子类)——scheduler.py:323、__main__.py:137/242、app.py:131 的 `except (ConnectionRefusedError, FileNotFoundError, OSError)` 捕获**无需改动**。`FileNotFoundError` 在新模式下对应"port 文件缺失"(daemon 未运行),语义一致。实测 websockets 17.x 确认。 - -> **⚠️ `ConnectionClosed` 不是 `OSError` 子类(严重,实测确认)**:MRO 是 `ConnectionClosed → WebSocketException → Exception`——**现有 `except (OSError, …)` 捕不到它**。受影响(**__main__.py 共 4 处**): -> - `__main__.py:151`(`_send_shutdown` 的 except)——`emrg server stop` 时服务端关闭连接,客户端 `ws.recv()` 抛 ConnectionClosed,**若不加捕获会未捕获异常崩溃**; -> - `__main__.py:187`(**`_stop_daemon` 的外层 except**——`_get_pid`(165-173) 内部无 except,`asyncio.run(_get_pid())` 抛 ConnectionClosed 冒泡到此,但 187 的 `except (OSError, …)` 捕不到)——fallback SIGTERM 路径(daemon 不响应 shutdown 时)会崩; -> - `__main__.py:260`(`_send_rant` 读响应后的 except); -> - (还有 app.py 读循环的 `except ConnectionClosed` 已按 §3.2 处理,不属此类) -> 这些 except 需**追加 `ConnectionClosed`**(`from websockets.exceptions import ConnectionClosed`)到元组; -> - 对比:`ConnectionRefusedError`(连接被拒,发生在握手前)是 OSError 子类,现有捕获兼容——**两类异常要区分对待**。 - -> **`wait_for(ws.recv(), timeout)` 超时语义已验证**:`asyncio.wait_for(ws.recv(), timeout)` 超时后**连接仍可用,可继续 recv**(实测 17.x)——这是 TUI `read_server` 的 0.1s 轮询、`__main__` rant 的 5s 超时读能工作的前提。`_send_rant` 改造后:`asyncio.wait_for(ws.recv(), timeout=5)` + `await ws.close()`,语义不变(__main__.py:256-260)。 - -**健康探测 `is_server_running_sync()`**:改 TCP connect 到 port 文件中的端口(Unix/Windows 统一)。**语义:只探测"daemon 是否活着",不携带 token、不做认证**——探测是低成本存在性检查;认证发生在真实连接的首帧。该函数语义("daemon 是否活着")不变。 - -> **`is_server_running_sync` 是同步函数(现状 connect.py:105)**——在事件循环外调用(`app.py:54` 的 `_try_connect`),不能 `await`。改造后保持同步:用 `socket.create_connection(("127.0.0.1", port), timeout)`,**只读 port 文件第一行(端口),不读 token**(`read_text().splitlines()[0]`)。Windows 现状恒返回 False,改造后统一走 TCP 探测,可去除 `os.name == "nt"` 分支。 - -### 3.4 本机连接:TCP loopback + 端口文件 + token(全平台统一) - -| | 本机(Unix 与 Windows 完全一致) | -|--|------| -| 传输 | `ws://127.0.0.1:`(daemon 启动时动态分配) | -| 端口/token 位置 | `~/.emrg/emrgd.port`(`port\n token`,600 权限) | -| 安全边界 | loopback(仅本机)+ **token 首帧认证**(对齐原 UDS 文件权限边界) | -| 认证流程 | 客户端读 port 文件 → 连接 → 首帧发 `{"type":"auth","token":…}` → daemon 校验后进入正常协议 | - -> **为什么本机也要 token**:loopback TCP 端口对本机其他用户可见,token(存 600 权限文件)对齐了原 UDS 的文件权限隔离。同时它与远程(Phase 5)共用同一认证机制——本机 token 自动读取用户无感,远程 token 显式配置。**一套认证逻辑,两种来源**。 - -> **实现注意(port 文件)**:`emrgd.port` 写入用**原子写**(tmp 文件 + rename),避免客户端在写入中途读到半个文件(`split()` 失败)。权限 600(`os.chmod`,`write_text` 默认 644 受 umask 影响)。若读取时文件损坏/缺失,视为"daemon 未运行"处理。 - ---- - -## 4. 改动清单 - -| 文件 | 改动 | 规模 | -|------|------|------| -| `emrg/framing.py` | **删除**(长度前缀层废弃,WS 消息边界取代) | 删除 | -| `emrg/connect.py` | **重写**:TCP loopback + port 文件 + token 首帧(无平台分叉)+ **auth_ok 验证(新增 `AuthError` 异常)** + 健康探测,返回单个 ws;`cleanup_server`/`get_server_path` 改造(见下) | ~130 行 | -| `emrg/server/daemon.py` | `serve()` 监听换 websockets;**12 个函数签名 `writer` 参数 → ws 对象**(除 `_send` 外的 12 个:`_handle_client` / `_process_message` / `_run_chat_once` / `_run_tool_loop` / `_handle_compact` / `_handle_list_sessions` / `_handle_list_projects` / `_handle_list_models` / `_handle_set_model` / `_handle_resume_session` / `_handle_list_memories` / `_handle_read_memory`,见 §1.3 行号)+ `_send` 内部 `write_frame→ws.send` + 首帧认证校验;63 处 `writer→ws` 机械替换 | ~100 行改动 | -| `emrg/client/app.py` | `read_server` 读循环改 `ws.recv()`;断线 `None→ConnectionClosed`;`(reader,writer)→ws`;`client_connect_to_server()`(app.py:135)返回单个 ws,调用点 293/421 解包改 `ws =`;**`_check_and_restart_if_stale`(app.py:69-133)适配**:`Path(server_path).exists()` 的 socket 检查 → port 文件存在检查(注意 port 文件存在≠daemon 活着,是僵尸残留可能,靠后续 ping 判定);**`if os.name != "nt"` 分支可去除**(统一 TCP loopback 后 Windows 也有 port 文件,无需平台分支);`reader,writer=connect_to_server()` → `ws=`;`write_frame(ping)` → `ws.send(ping)`;`writer.close()+wait_closed()` → `await ws.close()` | ~45 行改动 | -| `emrg/server/scheduler.py` | 演化客户端改 `ws.recv()` / `ws.send()` | ~10 行 | -| `emrg/server/atomic.py` | **新增** `atomic_write_bytes(data, target, mode)`——现有 `atomic_write_yaml` 只支持 list[dict]+YAML,port 文件是文本需通用版本(§3.3) | +10 行 | -| `emrg/__main__.py` | CLI(shutdown/ping/rant)改 `ws.recv()` / `ws.send()`;**3 处 except 追加 `ConnectionClosed`**(`_send_shutdown`:151 / `_stop_daemon` 外层:187——`_get_pid` 内部无 except,ConnectionClosed 冒泡到此 / `_send_rant`:260——`ws.recv()` 在服务端关连接时抛 ConnectionClosed,非 OSError 子类,现有 except 捕不到) | ~20 行 | -| `pyproject.toml` | 增加依赖 `websockets>=12`(**实施第一步:`uv add websockets`**——当前项目 venv 未装,所有验证需在装好后重跑) | 1 行 | -| `tests/test_framing.py` | **删除**(framing.py 删除) | 删除 | -| `tests/test_connect.py` | **重写**:port 文件读写、token 首帧、健康探测 | 重写 | -| `tests/test_ws_e2e.py` | **新增**:端到端 WS 连接全流程(真实 daemon + websockets) | 新增 | -| `tests/test_daemon.py` | **零改动**(37 个单测:prompt 构建、项目发现——不涉及传输层) | 0 行 | - -> **为什么 client/app.py 不是零改动**:换路彻底意味着 `read_frame`/`write_frame`/`(reader,writer)` 全部消失,业务代码改为 WS 原生 API。daemon 的 63 处 `_send` 收敛于一个入口(机械替换),client 的读循环与发送点是有限几处,全部在可控范围内。pytest 兜底回归。 - -**connect.py 公共函数归宿**(5 个函数逐一交代,不留隐式行为): - -> **daemon 启动函数(两套,均零改动)**:`client/app.py:58` 的 `start_server_daemon()`(async,客户端启动用)与 `__main__.py:119` 的 `_start_daemon_background()`(同步 Popen,`emrg server restart` 用)——两者都调 `cleanup_server()` + 启动 `python -m emrg.server` + 轮询 `is_server_running()`。改造后**子进程启动命令不变**,仅它们依赖的 `is_server_running()`/`cleanup_server()` 内部实现变(§3.3)。无需改动这两处本身。 - -| 函数 | 改造后 | 调用点 | -|------|--------|--------| -| `start_server(handler)` | **删除**——daemon `serve()` 直接调 `websockets.asyncio.server.serve`,不再经 connect.py 包装 | daemon.py:170 | -| `connect_to_server()` | **重写**:读 port 文件 → 连接 → auth 首帧 → 返回单个 ws | app / scheduler / __main__ | -| `is_server_running_sync()` | **重写**:TCP connect 到 port 文件端口,不带 token 不做认证 | app.py:54 | -| `cleanup_server()` | **改语义**:原删除 socket 文件 → 现删除 `~/.emrg/emrgd.port` 文件(daemon 停止/重启时清理) | daemon.py:187、app.py:60/117/138、__main__.py:121/183 | - -> **`cleanup_server` 的调用时序**:`_check_and_restart_if_stale`(app.py:117)在 SIGTERM 旧 daemon 后调用它——删 port 文件后,`is_server_running()`(app.py:120,TCP connect 探测)会因 port 文件缺失而 FileNotFoundError → False(误判"已死")。但此时旧 daemon 正在退出、新 daemon 将写新 port 文件,时序上可接受(现状删 socket 文件同理)。实现时确认此路径不引入竞态。 -| `get_server_path()` | **改语义**:原返回 socket 路径 → 现返回 `~/.emrg/emrgd.port` 路径(`_check_and_restart_if_stale` 用它判断 daemon 是否已启动) | app.py:71 | - -> **为什么 `start_server` 删除而非保留**:websockets 的 serve 返回对象类型与 asyncio 不同,包装层只会变成"假抽象"(同 §3.1 原则)。daemon 直接用 `websockets.asyncio.server.serve`,connect.py 只保留客户端侧函数。 - ---- - -## 5. 测试策略 - -### 5.1 分层 - -| 层 | 测试 | 覆盖 | -|----|------|------| -| 单元 | `test_connect.py`(重写) | port 文件读写、token 首帧、健康探测(无平台分叉) | -| 集成 | `test_ws_e2e.py`(新增) | 起真实 daemon(WS)→ ping / task 流式 / tool_start/tool_end / cancel / rant / compact | -| 回归 | 既有 429 项(删 test_framing,其余保留) | 全绿(`test_daemon.py` 等单测不受传输层影响) | - -> **⚠️ e2e 测试必须 mock LLM**(关键,勿漏):起真实 daemon 后发 task 会触发 `_run_tool_loop` → `self.llm.chat_stream()` → **真实 LLM API 调用**(花钱、慢、CI 无网失败)。现状测试的处理方式(test_memory_reflection.py:19-22): -> ```python -> server.llm = AsyncMock() # 替换整个 LlmClient -> ``` -> e2e 测试同样构造 `EmrgServer(LlmConfig(...))` 后 `server.llm = AsyncMock()`,再 `await server.serve()`。**mock `chat_stream` 需 yield 与 llm.py 一致的 delta 格式**(`_run_tool_loop` 消费这些字段,daemon.py:1125-1155): -> ```python -> async def fake_chat_stream(messages, tools=None): -> yield {"content": "你好", "tool_calls": None, "finish_reason": None, "usage": None} -> yield {"content": None, "tool_calls": [ -> {"index": 0, "id": "call_1", "function": {"name": "bash", "arguments": '{"command":"echo hi"}'}} -> ], "finish_reason": "tool_calls", "usage": None} -> yield {"content": "完成", "tool_calls": None, "finish_reason": "stop", "usage": {"prompt_tokens": 10, "completion_tokens": 5}} -> server.llm.chat_stream = fake_chat_stream -> ``` -> **验收用例里所有"流式 task / tool_start / tool_end"都是 mock LLM 的产物,不是真实 API 响应**。 - -> **⚠️ e2e 测试的环境隔离**(关键,勿漏):`serve()` 会调用 `self._scheduler.load_and_start()`(daemon.py:176-177),读 `~/.emrg/tasks.yml` 启动演化调度器——**测试会污染真实用户配置**。处理: -> - 测试前 mock `TaskScheduler`:`server._scheduler = AsyncMock()` 或 `patch` 掉 `load_and_start`; -> - 或设置隔离环境变量让 `config_dir()` 指向临时目录(如 `EMRG_HOME`/monkeypatch `config_dir`),port 文件、tasks.yml 全进 tmp; -> - 测试结束 `await server.close()` 确保 socket/port 清理。 -> 参考现状 test_memory_reflection.py 的 `EmrgServer.__new__` 绕过 `__init__`(但它不测 serve(),e2e 需要真实 serve,所以用上述 mock 调度器的方式)。 - -### 5.2 端到端验收用例 - -- [ ] `ping` → pong(identity/uptime/model) -- [ ] **认证**:无 token / 错 token 连接被拒绝;正确 token 通过后进入正常协议 -- [ ] **认证健壮性**:auth 帧非 JSON / 非 dict(如 `[1,2]`、`"hi"`)被拒且服务端不崩;连接后不发 auth 的连接在 10s 内被关闭(无悬挂协程) -- [ ] 非法 JSON 消息 → 返回 error 帧(服务端不崩) -- [ ] **非 dict JSON 消息**(`[1,2]`/`"hi"`)→ 返回"message must be a JSON object" error 帧(服务端不崩、连接不关) -- [ ] 流式 task:多段 delta → done,工具调用 tool_start → tool_end → 最终回答 -- [ ] **大消息**:构造 >1MB 的 JSON 消息(客户端 → 服务端,如超大 `content` 字段,绕过工具层直接发 `write_frame` 等价物 `ws.send`)往返无截断。**注意**:工具输出被 `MAX_OUTPUT_CHARS=200_000` 截断(bash_tool.py:16,UTF-8 最坏 ~800KB < 1MB),**无法通过真实工具产生 >1MB 消息**——大消息测试必须用构造 payload(这正是 max_size 保护的对象,对齐现状 `_MAX_FRAME_BYTES` 16MB) -- [ ] CJK 消息往返 -- [ ] ESC 中断(cancel 消息)→ cancelled 响应,工具循环终止 -- [ ] 断线重连:杀 daemon → client `read_server` 走 `_reconnect()` → 拉起 → 恢复 -- [ ] 演化引擎(scheduler)内部客户端正常连、跑周期、断开 -- [ ] CLI:`emrg server stop`(shutdown 消息)、`emrg rant` 正常 -- [ ] Windows 冒烟(CI matrix 若可用):port 文件、token 首帧认证 - -### 5.3 回退保障 - -- 全部改动先合入 feature 分支,跑全量测试 + 手动冒烟通过后才合 master; -- 如 websockets 库出现不可解问题,回退 = git revert 全部改动(framing.py 恢复、connect/daemon/app/scheduler/__main__ 还原),恢复原长度前缀实现; -- **不留过渡空壳**:`framing.py` 直接删除而非保留 re-export——换路彻底,不留旧路痕迹。 - ---- - -## 6. websockets 库选型 - -| 项 | 选择 | 理由 | -|----|------|------| -| 库 | `websockets` (asyncio 实现,≥12.x) | asyncio 原生、成熟(10+ 年)、纯 Python 可打包(PyInstaller 友好)、走标准 `ws://` 路径 | -| 替代 | `aiohttp` | 太重(含 HTTP 服务端),仅需 WS 客户端/服务端 | -| 替代 | `websockets` 同步版 | 项目全 asyncio,不需要 | -| 版本锁定 | `websockets>=12` | 12.x 起 `websockets.asyncio.server.serve(handler, host, port)` 稳定 | - -**版本风险**:websockets 库 API 在 10→12 间有较大变化(legacy 接口 vs asyncio 接口)。设计锁定 12.x 的 `websockets.asyncio.*` 新接口(已用 17.x 验证 API 形态): -- **统一用 `websockets.asyncio.server.serve` / `websockets.asyncio.client.connect`**——**任何版本**(≥12)都明确是 asyncio 接口(handler 收 1 参); -- **⚠️ `websockets.asyncio` 是 lazy 子模块,必须显式 `import websockets.asyncio` 或 `from websockets.asyncio.server import serve`**——只 `import websockets` 后 `websockets.asyncio` 是 AttributeError(实测 17.x); -- ⚠️ **顶层 `websockets.serve` / `websockets.connect` 的接口随版本漂移**:12-13.x 是 legacy(handler 收 2 参 `(ws, path)`)、**14+ 已是 asyncio(1 参,实测 17.x `websockets.serve is websockets.asyncio.server.serve`)**。为避免版本漂移,一律显式用 `websockets.asyncio.*`; -- `server.sockets[0].getsockname()[1]` 取动态端口——`Server.sockets` 属性存在,但实现时首步验证(若 API 差异,可改为绑定固定端口或从 `server.sockets` 调试确认); -- **客户端 `connect` 与服务端 `serve` 的 `max_size` 默认都是 1MB**——两端都要显式设 16MB(已验证 17.x 默认值); -- **16MB 上限绰绰有余(已审计)**:所有消息来源上限——bash 工具输出 `MAX_OUTPUT_CHARS=200_000`(bash_tool.py:16)、glob 500 条、grep 200 条、项目上下文 8000 字符截断(daemon.py:482)、记忆无单独上限但单文件远小于 16MB——实际最大消息约 200KB,16MB 余量充足(对齐现状 `_MAX_FRAME_BYTES`); -- **自动 ping/pong 保活(净收益,无需手动处理)**:websockets 默认 `ping_interval=20` / `ping_timeout=20`(实测 17.x)——两端每 20s 自动 ping,库内自动回 pong,**TUI 空闲(用户思考中)连接不会断**。现状 TCP 流式无保活(半开连接只能靠 send 失败才发现),这是 WS 化的行为增强。ping/pong 由库内部处理,**不会出现在业务 `ws.recv()` 中**,与 TUI 的 `wait_for(recv, 0.1)` 轮询、服务端首帧 `wait_for(recv, 10)` 认证超时均无冲突(认证超时 10s < ping 间隔 20s,先触发); -- **多客户端并发(与现状一致,非新问题)**:websockets `serve` 每连接一个 handler 协程,与现状 `start_unix_server` 相同——TUI + CLI(`emrg server stop`/`rant`)+ scheduler(演化引擎)可同时连接 daemon,互不干扰;`_handle_client` 的会话状态(`last_session_id`/`last_cwd`)是协程局部变量(§3.2),无实例级串扰; -- PyInstaller 打包时验证 hiddenimport(Phase 4 处理)。 - ---- - -## 7. 与后续 Phase 的关系 - -| Phase | 依赖本阶段的点 | -|-------|---------------| -| Phase 2(daemon_manager 提取) | 在 WS 协议之上提取共享客户端层——协议已稳,提取无后顾之忧 | -| Phase 3(GUI) | GUI 用同一 `ws://127.0.0.1:` 连接,复用 daemon_manager | -| Phase 4(打包) | websockets 进入 hiddenimports;PyInstaller 冒烟覆盖 WS 连接 | -| Phase 5(远程) | **直接加 `wss://` 监听 + TLS + token**:`serve(handler, host, port, ssl=ctx)`;客户端 `connect("wss://…", ssl=…)`。本机 `ws://127.0.0.1` 路径不受影响——这就是"协议统一"的红利 | - -> **Phase 5 前瞻(images 的远程语义)**:`TaskRequest.to_dict()` 的 `images` 含本地 `path`(`protocol.py:45`),远程连接时该路径在 daemon 所在机器无效。Phase 5 需处理(图片经 WS 传输或改为引用/占位)——Phase 1 本机不受影响,标记待办。 - ---- - -## 8. 风险与对策 - -| 风险 | 影响 | 对策 | -|------|------|------| -| websockets Server 生命周期与 asyncio 差异 | daemon 启动/关闭异常 | **已验证兼容**(实测 17.x):`serve()` 返回即 serving(无需 start_serving)、`serve_forever()` 阻塞保持运行、`close()` 后 `serve_forever()` 返回——与现状 asyncio 行为一致(§3.3) | -| PID 文件僵尸检测漏迁移(daemon.py:137 仍查 `emrgd.sock`) | **每次启动新 daemon 都 force-kill 活着的旧 daemon**(sock 永远不存在 → `if not exists` 恒 True) | 同步改为查 `emrgd.port`(§3.3 已标注 ⚠️);验收含"daemon 已在运行时二次启动不被杀" | -| serve() 改造只抄监听 6 行(漏 scheduler 启动 / finally 清理 / PID 文件逻辑) | 演化引擎不启动、清理逻辑丢失(llm 不关、scheduler 不停、port/PID 文件残留) | §3.3 给出**完整 serve() 形态**:监听层为唯一实质改动,scheduler 启动与 finally 清理保留现状位置(照抄完整代码块) | -| websockets API 用错接口(顶层 `websockets.serve` 随版本漂移:12-13.x legacy 收 2 参、14+ asyncio 收 1 参) | handler 签名不匹配 TypeError | 一律显式用 `websockets.asyncio.server.serve` / `websockets.asyncio.client.connect`(§6);e2e 测试起真实 daemon 兜底 | -| daemon `shutdown` 分支漏改(`writer.close()`/`wait_closed()` 在 ws 上不存在) | shutdown 时 AttributeError | 改 `await ws.close()`;`self._server.close()` 同步不变(§3.2 调用点总览) | -| websockets 库行为差异(recv 超时、连接关闭语义、ping/pong 保活) | 流式/断线行为异常 | 端到端测试重点覆盖读循环 + `_reconnect()`;`wait_for(recv, 0.1)` 超时语义已验证;自动 ping/pong 保活(默认 20s)为净收益,库内处理不干扰业务 recv(§6) | -| 断线语义变化(`frame is None` → `ConnectionClosed` 异常) | 重连逻辑漏改 | 逐一审计读循环:daemon / client / scheduler / __main__ 四处;**scheduler/daemon 是 `break`(收尾)、client 是 `reconnect`(重连)**——按各端现状语义对应转换,勿统一成重连(§3.2) | -| 断连记忆整合漏保留(daemon `_consolidate_session_memories`) | 会话记忆不整合 | `_handle_client` finally 保留断连整合逻辑(§3.2 已标 ⚠️);验收含"断开后会话记忆已整合" | -| 首帧认证漏实现或漏校验 | 认证形同虚设(任何本机进程可连) | `_handle_client` 首帧强制 auth 校验,失败即拒连(§3.2);集成测试覆盖"无 token 连接被拒" | -| 认证首帧无超时(恶意客户端连而不发) | 协程+socket 永久悬挂(资源泄漏) | 首帧 `wait_for(recv, timeout=10)`,超时即拒连(§3.2) | -| 客户端 auth 失败后无限重连 | 循环刷日志、无进展(`_reconnect` 的 `except Exception: continue` 把 ConnectionClosed 当普通断线) | **auth_ok 确认帧闭环**:`connect_to_server` 等待 auth_ok,失败抛 `AuthError`;调用点 `except AuthError: raise`(报错不重连),仅 `ConnectionRefusedError`/`OSError`/`FileNotFoundError` 继续重连(§3.3) | -| port 文件写入非原子 / 权限不对 | 客户端读到半个文件 / token 泄露 | 原子写(tmp+rename)+ chmod 600(§3.4 实现注意) | -| 空消息防御逻辑残留(字节流遗留 `if not msg`) | 死代码混淆语义 | 审查所有读循环:WS 消息必非空,删除空消息分支 | -| 非 dict JSON 消息(`[1,2]`/`"hi"`)未显式检查 | 隐式 AttributeError 依赖外层兜底(行为不清晰) | `_handle_client` 显式 `isinstance(data, dict)` 检查,非 dict 返回 error 帧(§3.2);e2e 覆盖 | -| loopback 端口被其他进程占用 | daemon 起不来 | 动态端口(port=0)+ 端口文件,天然无冲突 | -| loopback 无文件权限隔离 | 本机其他用户可连 | 本机 token 首帧认证(见 §3.4),Unix/Windows 统一,对齐原 UDS 安全边界 | -| 大消息(WS 默认 max_size=1MB)被拒 | 大消息(>1MB,仅构造 payload 可达——工具输出被 200K 字符截断)发送/接收失败 | **服务端和客户端都要** `max_size=16MB`(§3.3 两处代码均已含;客户端 `connect` 默认也是 1MB,漏设则接收大输出断连)——对齐现状 `_MAX_FRAME_BYTES` | -| token 比较用 `!=`(时序攻击) | 理论上有被猜 token 的风险 | 用 `secrets.compare_digest`(§3.2),本机低危但属安全最佳实践 | -| 演化引擎连接被 WS 握手影响 | 演化周期失败 | scheduler 走同一 connect_to_server,端到端用例覆盖 | -| 删除 framing.py 导致漏改 import | import 断裂 | 全仓 grep `framing` 确认无残留;CI 的 import 检查(`test_imports.py`)兜底 | -| 漏改 13 个函数签名(`writer` → ws,含 `_send` + 12 个传递函数) | 类型标注错误/传递链断裂 | §1.3 已列出全部 13 个函数 + 行号(`_handle_set_model`(1794) 易漏) | -| `_check_and_restart_if_stale` 漏适配(socket 存在检查、解包、close) | TUI 启动时 mtime 检查/重启逻辑失效 | §4 已列适配点;port 文件存在≠daemon 活着,靠后续 ping 判定 | -| `is_server_running_sync` 误做成 async(现状是同步函数,事件循环外调用) | 事件循环外 await 崩溃 | 保持同步:`socket.create_connection`;只读 port 第一行(§3.3) | -| `ConnectionClosed` 逃逸现有 `except (OSError, …)`(非 OSError 子类,实测 MRO: Exception) | `emrg server stop`/`rant` 未捕获异常崩溃 | __main__.py 3 处 except 追加 `ConnectionClosed`(:151 `_send_shutdown` / :187 `_stop_daemon` 外层——`_get_pid` 无内部 except / :260 `_send_rant`);e2e 覆盖 shutdown 流程 | -| e2e 测试忘 mock LLM(发 task 触发真实 API) | 花钱、慢、CI 无网失败 | e2e 强制 `server.llm = AsyncMock()`(§5.1 ⚠️) | -| e2e 测试污染用户配置(serve() 启动演化调度器读 `~/.emrg/tasks.yml`) | 测试改动真实用户数据 | mock `TaskScheduler` 或隔离 `config_dir` 到临时目录(§5.1 ⚠️) | - ---- - -## 9. 验收标准(对齐 roadmap Phase 1) - -- [ ] 既有测试全绿(删除 test_framing 后,协议相关改写为 WS 断言) -- [ ] `framing.py` 已删除,全仓无 `from emrg.framing import …` 残留 -- [ ] `(reader, writer)` 元组已消失,`connect_to_server()` 返回单个 ws 对象 -- [ ] PID 文件僵尸检测已迁移到 `emrgd.port`;daemon 已在运行时二次启动不被 force-kill -- [ ] 首帧认证强制:无 token / 错 token 连接被拒绝(集成测试覆盖);认证成功收到 `auth_ok` 确认帧 -- [ ] **认证失败不重连**:错 token 时客户端抛 `AuthError` 报错退出(不进入 `_reconnect` 无限循环) -- [ ] 认证超时:连接后不发 auth 的连接在 10s 内被关闭(无悬挂协程) -- [ ] 读循环无空消息防御残留(`if not msg` 已清理) -- [ ] 本机全功能无回退:聊天流式 / 工具调用 / 会话持久化 / `/rant` / 演化周期 -- [ ] 大消息(构造 >1MB JSON payload 往返无截断;客户端/服务端 `max_size` 均 16MB) -- [ ] ESC 中断、cancel 语义与现状一致 -- [ ] `emrg server stop`(shutdown 消息)正常——daemon shutdown 分支 `await ws.close()` 无 AttributeError;**客户端 `_send_shutdown` 捕获 ConnectionClosed 不崩** -- [ ] 断线 → 自动重连恢复(TUI 读循环) -- [ ] Windows(若可测):port 文件 + token 首帧认证 -- [ ] `ws://127.0.0.1` 连接成功、token 首帧认证通过、`websockets` 依赖正常打包(为 Phase 4 预验证) - diff --git a/docs/design/phase2-daemon-manager.md b/docs/design/phase2-daemon-manager.md deleted file mode 100644 index bce43e18..00000000 --- a/docs/design/phase2-daemon-manager.md +++ /dev/null @@ -1,554 +0,0 @@ -# Phase 2 设计:共享客户端层提取(daemon_manager) - -> 主路线图:[`roadmap.md`](roadmap.md) Phase 2 -> 关联文档:[`packaged-installer.md`](packaged-installer.md) §7.1(GUI 复用层)、[`phase1-websocket-protocol.md`](phase1-websocket-protocol.md)(协议基础,已实施) -> 本文是 Phase 2 的完整设计:现状分析、提取边界、目标形态、改动清单、测试策略、验收标准。 -> 修订:v20(review R1-R66 全部采纳,见 §6 决策记录) - ---- - -## 1. 现状分析 - -### 1.1 代码规模与耦合 - -`emrg/client/app.py` 当前 **1994 行**,把三类职责揉在一起: - -| 职责 | 占比(估算) | 典型代码 | -|------|-------------|---------| -| **daemon 生命周期管理** | ~6% | `start_server_daemon`(58-67)、`_check_and_restart_if_stale`(69-133)、`client_connect_to_server`(135-141),合计 **116 行** | -| **协议读写 + 消息封装** | ~15% | 30 处 `writer.send`/`json.dumps`、read_server 函数(401-960)的 recv/解析 | -| **TUI 渲染与交互** | ~79% | `interactive()` 主体、事件循环、ChatHistory/InputWidget/选择器 | - -**行数测算(对应验收 1,R62 精确核算)**:提取 6 个生命周期函数(116 行)+ 30 处 send 块压缩为单行(**实测省 81 行**——30 处块共 111 行,其中 14 处已是单行无压缩空间)+ recv 下沉(5 行)≈ 总省 **202 行** → 1994 − 202 ≈ **1792 行**。**验收目标 ≤1800**(1792 + 8 余量);1600/1200 留待 Phase 3(GUI 复用后 app.py 还会瘦身)。 - -### 1.2 协议消息全清单(提取封装对象) - -**客户端 → 服务器**(30 处 `writer.send`,分布在 app.py 各处): - -| type | 次数 | 参数 | 封装去向 | -|------|------|------|---------| -| `task` | 1(1841 行) | session_id, cwd, prompt, stream, images?(TaskRequest.to_dict) | `send_task()` | -| `ping` | 4 | — | `send_command("ping")` | -| `trigger_task` | 2 | name, session_id?, cwd? | `send_command` | -| `list_sessions` | 3 | cwd, session_id? | `send_command` | -| `set_model` | 2 | model, session_id | `send_command` | -| `resume_session` | 2 | session_id, cwd | `send_command` | -| `rant` | 2 | message, project, timestamp | `send_command` | -| `delete_session` | 2 | session_id, cwd | `send_command` | -| `rewind_session` | 1 | session_id, cwd | `send_command` | -| `rename_session` | 1 | session_id, title | `send_command` | -| `read_memory` | 1 | scope, memory_id, session_id, cwd | `send_command` | -| `list_tasks` | 1 | cwd | `send_command` | -| `list_projects` | 1 | cwd | `send_command` | -| `list_models` | 1 | —(daemon 端 `_handle_list_models` 只读 config,无需 session_id) | `send_command` | -| `list_memories` | 1 | scope, session_id, cwd | `send_command` | -| `list_history` | 1 | session_id | `send_command` | -| `init_auto_evolve` | 1(303-314 特例:发+读响应) | cwd | `send_command` + 手动 `recv` | -| `compact` | 1 | session_id, cwd | `send_command` | -| `clear_session` | 1 | session_id, cwd | `send_command` | -| `cancel` | 1 | — | `send_command` | - -**⚠️ `task` vs `trigger_task` 区分**(R3):聊天发送走 `TaskRequest`(type=`"task"`,参数 `prompt` 单条 + `images` 数组,支持 /image 粘贴图);`trigger_task` 是 `/trigger` 命令(daemon.py:666),参数 `name`/`session_id`/`cwd`,**不是**聊天发送。二者都封装,但 `send_task` 只对应 `task`。 - -**服务器 → 客户端**(read_server 主循环解析): - -| type | 处理 | -|------|------| -| `uptime_seconds`(握手/identity) | 显示 server_id + 欢迎语 | -| `tool_start` | ToolCard 创建 | -| `tool_end` | ToolCard 更新 + diff/write 摘要 | -| `TaskResponse`(delta/done) | StreamingMarkdown 流式渲染 | -| `error` / `clear_result` | 系统消息 | -| 列表类响应(`memory_*`/`history`/`projects`/`models`/`sessions`/`tasks`) | 选择器数据填充 | - -### 1.3 现有测试覆盖 - -- `tests/test_connect.py`:覆盖 `get_server_path`/`AuthError`/`cleanup_server`/`is_server_running_sync`——**只测了 connect.py,没测 app.py 的 daemon 管理逻辑** -- `tests/test_app_widgets.py`:覆盖 ChatHistory/选择器等纯渲染组件 -- `tests/test_ws_e2e.py`:端到端(起真实 daemon)——**回归保障靠它** - -**缺口**:`start_server_daemon`/`_check_and_restart_if_stale`/`client_connect_to_server` 这些**有副作用的生命周期逻辑零单测**,只有 e2e 间接覆盖。提取时补上。 - ---- - -## 2. 提取边界(设计核心) - -### 2.1 目标形态 - -``` -emrg/client/ -├── daemon_manager.py # ★ 新文件:daemon 生命周期 + 协议客户端封装(可被 GUI 复用) -├── app.py # 瘦身:只留 TUI 渲染与交互(目标 ≤1800 行) -├── widgets.py # 不变:ChatHistory/InputWidget/选择器 -└── python_tui/ # 不变:终端渲染内核 -``` - -`daemon_manager.py` 模块内容: - -``` -emrg/client/daemon_manager.py - ├── is_running() # 薄封装 is_server_running_sync(原 app.py:53-56) - ├── start_daemon() # 拉起 emrgd(原 start_server_daemon,58-67) - ├── check_and_restart_if_stale() # source/config mtime 变更 → 重启(原样搬迁,69-133) - ├── ensure_connected() # 全流程:check_stale + 拉起 + 建连(原 client_connect_to_server,135-141)。**不含 ping**(R28:与原行为一致,ping 由调用方负责) - ├── DaemonConnection 类 - │ ├── send_task(session_id, cwd, prompt, stream, images?) # 仅对应 type="task" - │ ├── send_command(type_, **params) # 通用:ping/list_*/set_*/rant/... - │ ├── recv(timeout) # 单帧读取(返回 dict;超时返回 None) - │ ├── read_stream() # 事件流 yield(GUI 桥接用,R35) - │ └── close() # 关闭连接(原 writer.close) - └── 独立函数(保留 connect.py 兼容层,见 §2.3) -``` - -> **不提取** `shutdown()`(R4):`_send_shutdown` 在 `__main__.py:133`,不在 app.py;`__main__.py` 继续走 connect.py,本模块不重复实现。 -> **不提取** `ensure_running()`(R5):`ensure_connected()` 已含拉起逻辑(原 `client_connect_to_server` 就是全流程),无需第二个入口。 - -### 2.2 提取的六个函数 + 30 处消息封装(从 app.py 原样搬迁 + 封装) - -| # | 现有函数(app.py) | 去向 | 说明 | -|---|-------------------|------|------| -| 1 | `_get_server_source_mtime`(26-38) | daemon_manager 内部 | 原样 | -| 2 | `_get_config_mtime`(41-51) | daemon_manager 内部 | 原样 | -| 3 | `_try_connect`/`is_server_running`(53-56) | daemon_manager `is_running()` | 薄封装 `is_server_running_sync` | -| 4 | `start_server_daemon`(58-67) | daemon_manager `start_daemon()` | 原样 | -| 5 | `_check_and_restart_if_stale`(69-133) | daemon_manager `check_and_restart_if_stale()` | 原样 | -| 6 | `client_connect_to_server`(135-141) | daemon_manager `ensure_connected()` | 原样 | -| 7 | 30 处 `writer.send(json.dumps({...}))` | DaemonConnection 方法 | 逐条封装(§3.2) | - -> **内部交叉调用同步改名(R38)**:3 个函数内部互相引用旧名,搬迁时必须同步: -> - `start_daemon()` 内 `is_server_running()`(63 行)→ `is_running()` -> - `check_and_restart_if_stale()` 内 `is_server_running()`(121 行)→ `is_running()` -> - `ensure_connected()` 内 `_check_and_restart_if_stale()` → `check_and_restart_if_stale()`、`is_server_running()` → `is_running()`、`start_server_daemon()` → `start_daemon()` -> 否则照抄旧名 → daemon_manager 内 NameError。 - -### 2.3 与 `emrg/connect.py` 的关系(重要) - -- `connect.py`(109 行)是**传输层**:`connect_to_server`(建连+token 握手)、`cleanup_server`、`is_server_running_sync`。**不动**。 -- `daemon_manager.py` 是**生命周期+协议层**:在其上封装 daemon 拉起/重启/消息读写。 -- 分层:`app.py`/GUI → `daemon_manager.py` → `connect.py` → websockets。 -- `connect.py` 保持无 app 依赖(它已被 `__main__.py` 直接使用,如 `_send_rant`/`_send_shutdown`——那些调用点**不改**,继续走 connect.py 原始函数)。 -- `__main__.py` 的 `_send_shutdown`(133 行)保持现状,**Phase 2 不涉及**(R4 已定不提取 shutdown)。 -- **依赖方向单向(R48)**:`daemon_manager → connect/protocol`;`app.py`/GUI → `daemon_manager`。**daemon_manager 不得 import app.py**(防循环依赖)——分层图中 daemon_manager 在最底层。 - -### 2.4 关键决策:类 vs 模块级函数 - -**倾向:模块级函数 + 轻量 `DaemonConnection` 类**(不是大状态类)。 - -理由: -1. **TUI 现状是无状态函数集**(`writer` 在 `interactive()` 闭包里传来传去)。提取为类会改变调用风格,引入 `self` 状态,回归风险大。 -2. **GUI 需要的是"连接对象"语义**(一个连接 = 一个信号桥),`DaemonConnection` 恰好承载。 -3. **daemon 生命周期函数是无状态的**(谁调用都行),保持模块级函数,GUI/TUI 直接 `daemon_manager.ensure_connected()`。 - -**`DaemonConnection.recv` 超时语义**(R7): -- 签名 `async def recv(timeout: float | None = None) -> dict | None` -- `timeout=None`:阻塞直到有帧 -- `timeout=N`:`asyncio.wait_for(recv(), N)`,**超时返回 `None`**(静默,不抛) -- 当前 read_server 循环(401-960 行,recv 在 432 行)是 0.1s 轮询(为响应键盘/渲染),封装后调用方写 `data = await conn.recv(0.1); if data is None: continue`,语义等价。 -- `json.loads` 在 recv 内部完成;**解析失败(含空帧/空白帧)返回 `None` 并记录 warning**(R53/R54)——**不返回 `{"error": "invalid_json"}`**,否则 error dict 会进入 read_server 循环的 `if "error" in data` 分支(566 行),在 TUI 显示 "Error: invalid_json" 让用户看到莫名的错误。返回 None 静默丢弃,与现状崩循环相比是改进(不崩、无用户可见错误)。 -- **⚠️ `recv()` 不捕获 `ConnectionClosed`(R11)**:断连异常必须向上传播,由调用方(read_server 循环)处理。若 recv 内部吞掉 ConnectionClosed 返回 None,断连检测将永久丢失——TUI 会卡在"已断连但无反馈"状态,**断线重连功能被破坏**。 - -**read_server 循环的断连检测(R11,必须保留)**: -```python -while True: - try: - data = await conn.recv(0.1) - except ConnectionClosed: - await _reconnect() - continue - if data is None: continue - # ...分发逻辑 -``` - -### 2.5 明确不提取的内容(防止过度设计) - -- ❌ **不提取** `interactive()` 主体、键盘事件处理、渲染逻辑(留在 app.py) -- ❌ **不提取** ToolCard 的 diff 渲染、write 摘要逻辑(这是 TUI 视图层,GUI 有自己的展示方式) -- ❌ **不提取** `_detect_clipboard_image`/`_extract_clipboard_image`(纯 TUI 功能) -- ❌ **不新建** `gui/daemon_client.py`(Phase 3 再做信号桥,本阶段只提供纯 asyncio 接口) -- ❌ **不动** server 端(daemon.py 零改动) -- ❌ **不动** connect.py 传输层 -- ℹ️ **`read_stream` 是 Phase 3 预留接口(R52)**:Phase 2 TUI 不调用它(保持 recv 单帧循环),但 roadmap/packaged-installer 明确承诺此接口,且单测覆盖(R39)——**接受此轻微超前,换取 GUI 零复制网络代码**。实施者勿删。 - ---- - -## 3. 改动清单 - -### 3.1 `emrg/client/daemon_manager.py`(新文件,约 200 行) - -```python -"""Daemon 生命周期管理 + 协议客户端封装。 - -供 TUI(app.py)与 GUI(Phase 3)共用。分层: - app.py/GUI → daemon_manager → connect.py → websockets -""" -from __future__ import annotations -import asyncio, json, logging, os, signal, subprocess, sys -from datetime import datetime -from pathlib import Path -from typing import AsyncIterator -from websockets.exceptions import ConnectionClosed -from emrg.connect import (connect_to_server, cleanup_server, - is_server_running_sync, get_server_path) -from emrg.protocol import TaskRequest - -logger = logging.getLogger(__name__) - -# ── daemon 生命周期 ────────────────────────── -def is_running() -> bool: ... # 原 app.py:53-56 -async def start_daemon() -> subprocess.Popen: ... # 原 app.py:58-67 -async def check_and_restart_if_stale() -> None: ... # 原 app.py:69-133 -async def ensure_connected() -> "DaemonConnection": ... # 原 app.py:135-141(含拉起) - -# ── 协议客户端封装 ──────────────────────────── -class DaemonConnection: - """一条已认证的 daemon 连接。""" - def __init__(self, writer): ... # writer = websockets 连接 - async def send_task(self, session_id, cwd, prompt, stream=True, images=None): - """聊天发送:TaskRequest(type="task")。images 支持 /image 粘贴图。 - - 内部 `json.dumps(req.to_dict(), ensure_ascii=False)` 以 **str 发送** - (websockets 原生支持,不再 `.encode()`——与 daemon 端 `ws.recv()` - 返回 str 一致;现状 1841 行的 bytes 发送行为等价)。 - """ - ... - async def send_command(self, type_, **params): - """通用命令:ping/list_*/set_*/rant/compact/... 只发不读。 - - 内部 `json.dumps({"type": type_, **params}, ensure_ascii=False)` - ——统一关转义(兼容中文,现状 5 处 ensure_ascii=False 的行为)。 - - ⚠️ **params 不得含 `type` 键(R61)**:`{"type": type_, **params}` - dict 展开后者覆盖前者,`send_command("task", type="x")` 会把 type - 覆盖成 "x"。当前 30 处调用点均正确,封装层仍需防呆注释。 - """ - ... - async def recv(self, timeout: float | None = None) -> dict | None: - """单帧读取。超时返回 None(不抛);json.loads 内部完成;不捕获 ConnectionClosed。""" - ... - async def read_stream(self) -> AsyncIterator[dict]: - """事件流:yield 服务器下发的每一帧(uptime/tool_start/tool_end/TaskResponse/...)。 - - 供 GUI(Phase 3)桥接 Qt Signal(R35,对齐 roadmap.md:115 / - packaged-installer.md:351 的 read_stream 承诺)。内部用 recv(None) - 阻塞读(**R42:`recv(None)` = `asyncio.wait_for(coro, None)` 无超时 - 阻塞,非"超时返回 None"语义**),不吞 ConnectionClosed(R11)。 - **TUI 保持 recv 单帧循环不动**(现状 0.1s 轮询),read_stream 是给 - GUI 的公共接口。 - """ - while True: - data = await self.recv(None) - if data is None: continue # 防御性(recv(None) 实际不超时) - yield data - async def close(self): ... -``` - -**注意**:`ensure_connected()` 返回 `DaemonConnection`(封装 writer),而不是裸 writer——这样 app.py 里的 `writer.send(json.dumps(...))` 全部改为 `conn.send_command("list_sessions", cwd=cwd)`,类型清晰、GUI 可直接复用。 - -### 3.2 app.py 逐处替换(30 处 send + read_server 循环 + 3 处 close) - -**替换规则**: - -| 原代码 | 新代码 | -|--------|--------| -| `writer = await client_connect_to_server()` | `conn = await daemon_manager.ensure_connected()` | -| interactive 初始化 conn 声明(293 行) | `conn = await daemon_manager.ensure_connected()`(R66——首次连接,read_server 循环外;漏改则闭包引用 conn 但声明 writer → NameError) | -| `await writer.send(json.dumps({"type": "ping"}))` | `await conn.send_command("ping")` | -| `await writer.send(json.dumps(req.to_dict(), ...))`(1841 行,聊天发送) | `await conn.send_task(session_id, cwd, text, stream=True, images=...)` | -| `await writer.send(json.dumps({"type": "trigger_task", ...}))` | `await conn.send_command("trigger_task", name=..., session_id=..., cwd=...)` | -| `await writer.send(json.dumps({"type": "list_sessions", "cwd": cwd}))` | `await conn.send_command("list_sessions", cwd=cwd)` | -| ...(其余 send 逐一对应) | | -| `frame = await asyncio.wait_for(writer.recv(), timeout=3)` | ⚠️ **不在 app.py 替换范围(R57)**——这是 app.py:83,在已提取的 `check_and_restart_if_stale` 内(daemon_manager 保持裸 ws 操作,无 conn,见 R44) | -| init_auto_evolve 特例内 recv(312 行,timeout=5) | `await conn.recv(timeout=5)`(R58,见 R6/R32) | -| `read_server` 循环内 `writer.recv()`(timeout=0.1 轮询) | `data = await conn.recv(0.1); if data is None: continue` | -| `_reconnect` 内 `writer = await client_connect_to_server()` | `conn = await daemon_manager.ensure_connected()` | -| `_reconnect` 内 `await writer.send(json.dumps({"type": "ping"}))`(422 行) | `await conn.send_command("ping")`(重连后刷新 server_id/uptime。**只发不读**(R33)——ping 响应由 read_server 循环异步消费,勿在 _reconnect 内 recv,否则抢走循环的帧) | -| `read_server` 循环内 rewind 分支的 ping(622 行) | `await conn.send_command("ping")`(rewind 成功后刷新状态——**易漏**,注意它在循环内非 _reconnect) | -| `await writer.close()`(85/416/1921 三处) | `await conn.close()`(416 行 _reconnect 内保持 try/except 包裹——关闭的是已断连的旧连接) | - -**rant 两处 payload 变量展开**(R22 + R40,1429/1735 行): -```python -# 原:payload = {...}; await writer.send(json.dumps(payload, ensure_ascii=False)) -# 注意 1735 行结构:project 是条件字段(`if project: payload["project"] = project`) -payload = {"message": text, "timestamp": datetime.now().isoformat()} -if project: payload["project"] = project -await conn.send_command("rant", **payload) -# 不能直接 send_command("rant", project=None)——daemon 端 project.strip() 对 None 会 AttributeError -``` - -**nonlocal 声明同步替换**(R17):3 处 `nonlocal ... writer` 改为 `nonlocal ... conn`: -- 403:`nonlocal _last_center, _elapsed_task, writer` → `... conn` -- 407:`nonlocal writer, busy, _elapsed_task` → `nonlocal conn, busy, _elapsed_task` -- 1006:`nonlocal inp, status, history, paste_mode, stream_buffer, writer, chat, ...` → `... conn, chat, ...` - -**images 过滤逻辑留在 app.py**(R18):app.py:1836-1839 的 `_pending_images` 过滤(`img.get("label") in inp.text`)依赖 TUI 输入框状态,是视图层逻辑——**留在 app.py**,`send_task` 只透传已过滤的 images 数组。 - -**TaskRequest 构造下沉到 send_task**(R27):原 app.py:1835 的 `req = TaskRequest(session_id=..., cwd=..., prompt=text, stream=True)` 删除——app.py 只组装参数(text/images 数组),`conn.send_task(session_id, cwd, text, stream=True, images=过滤后列表)` 内部构造 TaskRequest。 - -**app.py import 清理(R47)**——提取后同步调整头部 import: -- **删**:`from emrg.connect import connect_to_server, cleanup_server, is_server_running_sync, get_server_path`(整行——这些函数全部走 daemon_manager,app.py 不再直接用) -- **删**:`TaskRequest`(从 `from emrg.protocol import TaskRequest, TaskResponse, ToolEnd, ToolStart` 中移除——构造已下沉 send_task,R27) -- **留**:`TaskResponse, ToolEnd, ToolStart`(read_server 分发仍用,459/475/520 行) -- **留**:`from websockets.exceptions import ConnectionClosed`(read_server 循环 `except ConnectionClosed` 仍需,R11) -- **加**:`from emrg.client import daemon_manager` - -**init_auto_evolve 特例**(R6 + R32,app.py:303-314):`send_command("init_auto_evolve", cwd=...)` 后**必须读走响应帧**——daemon 端(daemon.py:634-654)会**同步回一帧** `{"ok": True/False, ...}`,若不读会残留连接缓冲,被 read_server 循环误当成下一条消息处理(`{"ok": ...}` 会被 `TaskResponse.from_dict` 解析失败或走 error 分支)。写法:`await conn.recv(timeout=5)`(响应立即到达,超时只是兜底)。send_command 本身**只发不读**。 - -**两种 ping 语义(R44,必须区分)**: -- **`check_and_restart_if_stale` 内部**(已提取到 daemon_manager,app.py:80-88):ping 是**发-读配对**——`send("ping")` → `recv(timeout=3)` 拿 `started_at`/`pid` 判断是否重启。**必须读**。 -- **`_reconnect`/read_server 循环内**(app.py:422/622):ping **只发不读**(R33)——响应由 read_server 循环异步消费。 - -> ⚠️ **清零范围仅限 app.py**(R44):§4.3 的 `writer.send`/`recv`/`close` = 0 仅针对 `emrg/client/app.py`;daemon_manager 内部(check_and_restart_if_stale 原样搬迁的裸连接探测)**允许裸 websockets 操作**——它发生在建立 DaemonConnection 之前,无法走封装。 - -**cancel 只发不读**(R37):`send_command("cancel")` 后**不读**——daemon 端(daemon.py:293)会同步回 `{"type": "cancelled", "session_id": ...}` 帧,由 read_server 循环静默消化(循环无专门 cancelled 分支,落 `TaskResponse.from_dict` 容错构造空响应,无害)。与 ping(R33)同原则。 - -**read_server 主循环改造**(函数 401-960 行,recv 在 432 行): -- 原:`frame = await writer.recv()` → `text.strip()`/`if not text`/`json.loads`(438-441 行)→ 分发 -- 新:`data = await conn.recv()`(内部已 json.loads,返回 dict)→ 分发。**删除原 438-441 行**(`text.strip()`/`if not text`/`json.loads`——recv 内部已处理空帧/坏 JSON),循环体直接从 `data.get("type")` 分发开始(否则 data 是 dict 无 `.strip()` 会 AttributeError) -- **断连检测保留**(R11):循环必须包 `try/except ConnectionClosed → _reconnect()`(recv 不吞异常) -- **分发逻辑本身(tool_start/tool_end/TaskResponse/rewind/compact 等全部 type 分支,401-960)留在 app.py 不动**——只把"取帧 + 解析"下沉到封装 - -### 3.3 `_reconnect` 保留在 app.py - -断线重连的 UI 反馈("⏸ server connection lost — reconnecting...")是 TUI 视图行为,留在 app.py。但重连的**底层操作**(`ensure_connected()`)走 daemon_manager。GUI 的断线处理(Phase 3)会是信号桥版本,不共享此函数。 - -### 3.4 测试(tests/ 新增 2 文件) - -**`tests/test_daemon_manager.py`**(mock websockets,不依赖真实 daemon。通用 mock 设施:假 writer 类(`send`/`recv`/`close` 三件套),`recv` 返回预置帧。**mock 路径(R36)**:均为 `emrg.client.daemon_manager.<符号>`——模块级 `from emrg.connect import connect_to_server` 绑定在 daemon_manager 命名空间,patch 原模块 `emrg.connect.connect_to_server` 会静默失效;同理 `asyncio.create_subprocess_exec` 应 patch `emrg.client.daemon_manager.asyncio.create_subprocess_exec`): -- `is_running()`:port 文件缺失/损坏/连接拒绝 → False -- `start_daemon()`:mock `create_subprocess_exec`,验证启动参数 + 等待就绪 -- `check_and_restart_if_stale()`(用假 writer 的 `recv` 返回带 `started_at`/`pid` 的 ping 响应帧): - - source mtime > server started_at → 发送 SIGTERM + 等待退出 - - config mtime > server started_at → 同上 - - mtime 未变 → 不重启 - - 服务器不可达 → 静默 pass(不抛异常) -- `ensure_connected()`:**mock 三件套(R50)**——`connect_to_server`(返回假 writer)+ `is_running`(返回 True 跳过拉起)+ `start_daemon`(防止真起进程)。验证 DaemonConnection 包装(**不含 ping**——与原行为一致,ping 由调用方负责)。⚠️ 只 mock connect_to_server 不够:`is_running()` 走真实 TCP 探测,port 文件缺失时会真执行 `start_daemon()` 拉起真实 emrgd -- `DaemonConnection.send_task`:mock writer.send,验证 `type="task"` + prompt/images 字段 -- `DaemonConnection.send_command`:mock writer.send,验证 JSON payload 正确(含 `type` + kwargs) -- `DaemonConnection.recv`:mock writer.recv,验证返回 dict、超时返回 None、坏 JSON/空帧返回 None(静默丢弃,不产生 error dict)、**ConnectionClosed 向上传播(不吞)** -- `DaemonConnection.read_stream`:mock writer.recv 依次返回多帧,验证逐帧 yield;ConnectionClosed 向上传播(不吞) -- `DaemonConnection.close`:验证 writer.close 被调用 - -**`tests/test_daemon_manager_e2e.py`**(起真实 daemon,复用 test_ws_e2e 的模式。**⚠️ 不经 `ensure_connected()`(R51)**——它内部 `is_running()` 读真实 `~/.emrg/emrgd.port`,本机有真实 emrgd 时会连错 daemon。改为复用 `_boot_server`:**async 函数,返回 `(server, serve_task)`,内含 config_dir 隔离 + `EmrgServer(...).serve()` 后台任务 + 等 port 文件**(R65)——R51 的手动隔离已由它完成,然后 `DaemonConnection(await connect_to_server())` 直连。**跨文件复用(R63)**:`from tests.test_ws_e2e import _make_config, _boot_server, _make_fake_chat_stream`(不复制三件套)): -- ensure_connected → send_command("ping") → 收到响应(**ServerPong 结构**:`identity`(instance_id/host_name/fork_source/branch_id)+ `uptime_seconds` + `evolution_count`) -- send_command("list_models") → 收到模型列表 -- send_task(stream=True) → 收到 delta 流 → done(**复用 test_ws_e2e 的 `_make_fake_chat_stream` 模式**:`server.llm = AsyncMock()` + `server.llm.chat_stream = _make_fake_chat_stream()`,不调真实 LLM) - -### 3.5 文档同步 - -- `roadmap.md` Phase 2 验收项打勾(在全部满足后) -- `Agent.md` 架构树补 `daemon_manager.py` -- `packaged-installer.md` §7.1 复用层图更新为实际模块结构(若与设计有出入) - ---- - -## 4. 测试策略与风险 - -### 4.1 回归保障 - -| 层 | 手段 | 覆盖 | -|----|------|------| -| 单测 | test_daemon_manager.py(mock) | 生命周期逻辑全分支 | -| 单测 | test_app_widgets.py(既有,不改) | 渲染组件不受影响 | -| e2e | test_ws_e2e.py(既有) + test_daemon_manager_e2e.py(新) | 真实 daemon 协议往返 | -| 全量 | pytest 全量(当前 441) | 无回退 | - -### 4.2 风险与对策 - -| 风险 | 对策 | -|------|------| -| app.py 30 处 send + 3 处 close 改签名,漏改/错改 | 逐处替换 + e2e 全量回归;替换后 grep 确认 `writer.send` 在 app.py 中仅剩 0 处、`writer.close` 仅剩 0 处 | -| `_reconnect` 闭包变量(`writer` → `conn`)牵连 | 只改赋值与调用点,闭包结构不动 | -| ensure_connected 返回类型变化(裸 writer → DaemonConnection)破坏现有调用 | 编译期无法检出(Python),靠 e2e + grep 全量扫描 `client_connect_to_server(` 调用点确认清零 | -| DaemonConnection.recv 的 json.loads 异常处理(行为改进:坏 JSON 不再崩循环) | 封装内 try/except 返回 None + 记录 warning;e2e 覆盖坏 JSON/空帧路径 | -| 聊天发送丢 images(/image 功能) | send_task 显式带 images 参数,单测覆盖 images 非空时的 payload | -| 提取函数内部交叉调用旧名 → NameError(R38/R43) | 搬迁后 grep `is_server_running\|_check_and_restart_if_stale\|start_server_daemon` 确认 daemon_manager 内清零 | - -### 4.3 验收标准 - -- [ ] **app.py 行数 ≤1800**(当前 1994;提取 116 行生命周期 + send 压缩 81 行 + recv 下沉 5 行 = 1792,R62 精确核算。1600/1200 留待 Phase 3 GUI 复用后再瘦身) -- [ ] `daemon_manager.py` 独立单测覆盖全部生命周期函数 + DaemonConnection 方法(mock,不起 daemon) -- [ ] 全量 pytest 全绿(441 + 新增 ≥15,**覆盖 §3.4 全部条目**——质量优先于数量) -- [ ] e2e:真实 daemon 下 `ensure_connected → send_command("ping") → send_command("list_models") → send_task(stream)` 全链路通 -- [ ] TUI 手动冒烟:聊天流式 / 工具调用 / 会话切换 / `/rant` / ESC 中断 / 断线重连 / **/image 粘贴图** 全功能无回退 -- [ ] grep 确认:`emrg/client/app.py` 中 `writer.send` + `writer.recv` + `writer.close` 出现次数 = 0(全部走封装);`json.dumps` 仅剩 `_format_args`(UI 层,须保留) -- [ ] grep 确认(R49):`client_connect_to_server(` / `is_server_running(` / `cleanup_server(` / `connect_to_server(` 在 `emrg/client/app.py` 中 = 0(均走 daemon_manager) -- [ ] `emrg/connect.py` 零改动、`emrg/server/*` 零改动 - ---- - -## 5. 与 Phase 3 的衔接 - -- GUI(Phase 3)直接 `from emrg.client.daemon_manager import DaemonConnection, ensure_connected` -- GUI 侧新增 `gui/daemon_client.py`:`async for event in conn.read_stream():` 把服务器事件桥接到 Qt Signal(message_delta / tool_started / tool_finished / done)——**read_stream 是为此准备的公共接口**(R35) -- 线程模型:单线程 asyncio(qasync),`DaemonConnection` 天然适配(纯协程接口) - ---- - -## 6. 决策记录 - -1. **提取为 `daemon_manager.py` 新模块**,app.py 瘦身至 ≤1800 行(R62 精确核算 1792;1600/1200 留 Phase 3);connect.py 传输层不动。 -2. **模块级函数 + DaemonConnection 类**(非大状态类):生命周期函数无状态保持函数式,连接对象语义清晰供 GUI 复用。 -3. **裸 writer 全面替换为 DaemonConnection**:类型清晰、json 解析下沉、GUI 白拿。 -4. **read_server 的分发逻辑(UI 处理)留在 app.py**:只下沉"取帧 + 解析"。 -5. **`_reconnect` UI 反馈留在 app.py**,底层重连走 daemon_manager;GUI 断线处理 Phase 3 信号桥版本。 -6. **不新建 gui/daemon_client.py**(Phase 3 再做);**不改 server**;**不改 connect.py**。 - -### v2 修订记录(review R1-R10 全部采纳) - -| # | 修订 | -|---|------| -| R1 | 验收 1:≤1200 → **≤1600**(附行数测算:提取 116 行 + send 封装省 124 行 + recv 下沉 ≈ 1750 实际,1600 为安全目标;1200 留 Phase 3) | -| R2 | 验收 6:`json.dumps` 清零 → **`writer.send`/`writer.recv`/`writer.close` 清零**;`json.dumps` 仅剩 UI 层 `_format_args`(1988 行,须保留) | -| R3 | §1.2 区分 `task`(聊天,TaskRequest.to_dict,参数 prompt/images)与 `trigger_task`(/trigger 命令,daemon.py:666);§3.1 send_task 签名改为 `(session_id, cwd, prompt, stream=True, images=None)` | -| R4 | §2.1 删除 shutdown():`_send_shutdown` 在 `__main__.py:133`,不在 app.py,不重复实现 | -| R5 | §2.1 删除 ensure_running():ensure_connected() 已含拉起全流程 | -| R6 | §3.2 补 init_auto_evolve 特例(send_command + 手动 recv(timeout=5));写明 send_command 只发不读 | -| R7 | §2.4 定义 recv(timeout) 超时返回 None;坏 JSON 返回 error dict(行为改进,非现状崩循环) | -| R8 | §3.2 补 writer.close() 3 处(85/416/1921)→ conn.close() | -| R9 | §3.4 补 check_and_restart_if_stale 的假 writer mock 细节 | -| R10 | §1.2 服务器→客户端清单补全:error/clear_result + 列表类响应(memory_*/history/projects/models/sessions/tasks) | - -### v3 修订记录(review v2 R11-R16 全部采纳) - -| # | 修订 | -|---|------| -| R11 | §2.4 + §3.1 明确:`recv()` **不捕获 ConnectionClosed**(向上传播);read_server 循环保留 `try/except ConnectionClosed → _reconnect`(附代码示例)——否则断线重连功能被破坏 | -| R12 | §3.2 补 `_reconnect` 内的 `await conn.send_command("ping")`(422 行,重连后刷新 server_id/uptime) | -| R13 | §2.4 补一句:坏 JSON 返回 error dict 是行为改进,验收需确认不掩盖真实错误(如 server 端 bug 返回非 JSON 帧) | -| R14 | §2.2 标题改"六个函数 + 31 处消息封装"(删除 ensure_running 后从七个变六个) | -| R15 | §3.1 代码骨架补 import 行(connect.py/protocol.py/websockets) | -| R16 | §3.4 合并重复 mock 描述(假 writer 三件套提为通用设施;补 recv 不吞 ConnectionClosed 的测试断言) | - -### v4 修订记录(review v3 R17-R21 全部采纳) - -| # | 修订 | -|---|------| -| R17 | §3.2 补 3 处 nonlocal 声明替换(403/407/1006):`writer` → `conn` | -| R18 | §3.2 补 images 过滤逻辑(app.py:1836-1839)留在 app.py,send_task 只透传 | -| R19 | §3.1 import 行补 `logging`/`datetime`/`logger = logging.getLogger(__name__)` | -| R20 | §3.2 close 行补说明:416 行 _reconnect 内的 close 保持 try/except 包裹 | -| R21 | §3.4 e2e 测试引用 test_ws_e2e 的 `_make_fake_chat_stream` mock 模式 | - -### v5 修订记录(review v4 R22-R24 全部采纳) - -| # | 修订 | -|---|------| -| R22 | §3.1 send_command 统一 `json.dumps({"type": type_, **params}, ensure_ascii=False)`(兼容中文);§3.2 补 rant 两处 payload 变量展开为 kwargs | -| R23 | §3.2 替换表补 622 行循环内 rewind 分支的 ping(易漏) | -| R24 | §3.1 send_task 注明内部 str 发送替代 bytes(与 daemon 端 ws.recv() 返回 str 一致,行为等价) | - -### v6 修订记录(review v5 R25-R27 全部采纳) - -| # | 修订 | -|---|------| -| R25 | 修正 read_server 范围引用:432-620 → **401-960**(函数全范围;432 仅为 recv 行)。§1.1/§2.4/§3.2 三处同步更新 | -| R26 | §2.3 补一句 `_send_shutdown`(`__main__.py:133`)保持现状,Phase 2 不涉及 | -| R27 | §3.2 补 TaskRequest 构造下沉到 send_task(app.py:1835 删除,app.py 只组装参数) | - -### v7 修订记录(review v6 R28-R31 全部采纳) - -| # | 修订 | -|---|------| -| R28 | 明确 `ensure_connected()` **不含 ping**(与原行为一致,ping 由调用方负责);§3.4 测试去掉"ping 握手"断言 | -| R29 | 全部"31 处"→"30 处"(实测 `writer.send` 仅 30 处;31 是 json.dumps 数,含 UI 层 `_format_args`)。§1.1/§1.2/§2.2/§3.2/§4.2/§4.3 共 9 处 | -| R30 | 第 6 行修订标注 v2 → v6 | -| R31 | §1.2 表格 `list_models` 参数 "session_id" → "—"(daemon 端只读 config) | - -### v8 修订记录(review v7 R32-R34 全部采纳) - -| # | 修订 | -|---|------| -| R32 | R6 强化:init_auto_evolve 响应**必须读走**(daemon 同步回 ok 帧,残留会污染 read_server 循环);超时只是兜底 | -| R33 | R12 补:_reconnect 内 ping **只发不读**(响应由 read_server 循环消费,勿在 _reconnect 内 recv 抢帧) | -| R34 | §3.4 e2e ping 断言补全 ServerPong 结构(identity + uptime_seconds + evolution_count) | - -### v9 修订记录(review v8 R35-R37 全部采纳) - -| # | 修订 | -|---|------| -| R35 | §3.1 补 `read_stream()` 事件流方法(yield 每帧,供 GUI 桥接 Qt Signal,对齐 roadmap.md:115 / packaged-installer.md:351 承诺);§5 衔接更新为 `async for event in conn.read_stream()` | -| R36 | §3.4 补 mock patch 路径:`emrg.client.daemon_manager.<符号>`(模块级 import 绑定,patch 原模块会静默失效) | -| R37 | §3.2 补 cancel 只发不读 + cancelled 帧由 read_server 循环静默消化(TaskResponse.from_dict 容错) | - -### v10 修订记录(review v9 R38-R40 全部采纳) - -| # | 修订 | -|---|------| -| R38 | §2.2 补内部交叉调用改名说明:is_server_running→is_running、_check_and_restart_if_stale→check_and_restart_if_stale、start_server_daemon→start_daemon(否则照抄旧名 NameError) | -| R39 | §3.4 单测补 read_stream 条目(逐帧 yield + ConnectionClosed 传播) | -| R40 | §3.2 rant 展开示例改为条件 project(1735 行结构),防 daemon 端 `project.strip()` 对 None AttributeError | - -### v11 修订记录(review v10 R41-R43 全部采纳) - -| # | 修订 | -|---|------| -| R41 | §4.3 验收"新增 ≥20"→"**≥15**(覆盖 §3.4 全部条目)"——按 §3.4 计划实际可凑 18-21 个,≥20 偏紧会诱导凑数 | -| R42 | §3.1 read_stream 注释补 `recv(None)` = `asyncio.wait_for(coro, None)` 无超时阻塞语义 | -| R43 | §4.2 风险表补"内部交叉调用旧名 → NameError"行(对策:grep 确认旧名清零) | - -### v12 修订记录(review v11 R44-R46 全部采纳) - -| # | 修订 | -|---|------| -| R44 | §3.2 补两种 ping 语义区分:check_and_restart 内部**发-读配对**(recv 拿 started_at)vs _reconnect/循环**只发不读**;§4.3 明确清零范围仅限 app.py(daemon_manager 内部裸 ws 探测允许) | -| R45 | 第 6 行修订标注 v6 → v11 | -| R46 | §4.1 "当前 441" 核对确认(pytest --collect-only = 441,无需改) | - -### v13 修订记录(review v12 R47-R49 全部采纳) - -| # | 修订 | -|---|------| -| R47 | §3.2 补 app.py import 清理清单:删 connect 整行 + TaskRequest;留 TaskResponse/ToolEnd/ToolStart + ConnectionClosed;加 `from emrg.client import daemon_manager` | -| R48 | §2.3 补依赖方向单向声明:daemon_manager 不得 import app.py(防循环) | -| R49 | §4.3 补函数名 grep 清零验收(client_connect_to_server/is_server_running/cleanup_server/connect_to_server = 0) | - -### v14 修订记录(review v13 R50-R52 全部采纳) - -| # | 修订 | -|---|------| -| R50 | §3.4 ensure_connected 单测改 **mock 三件套**(connect_to_server + is_running + start_daemon)——只 mock connect 会触发真实 start_daemon | -| R51 | §3.4 e2e 补"不经 ensure_connected"(避免读真实 port 文件连错 daemon),改直接 EmrgServer().serve() + DaemonConnection(connect_to_server()) | -| R52 | §2.5 补 read_stream 为 Phase 3 预留接口的说明(接受轻微超前,换取 GUI 零复制;单测已覆盖,实施者勿删) | - -### v15 修订记录(review v14 R53-R55 全部采纳) - -| # | 修订 | -|---|------| -| R53 | §3.1 recv 坏 JSON 改返回 **None**(静默丢弃 + log warning),不返回 `{"error": "invalid_json"}`——否则 error dict 进 `if "error" in data` 分支在 TUI 显示莫名错误。R7/R13 同步 | -| R54 | §3.1 一并明确空帧/空白帧 → None(json.loads 失败统一处理) | -| R55 | §3.2 补删除原 438-441 行 text 检查(dict 无 .strip() 会 AttributeError),循环体从 data.get("type") 分发开始 | - -### v16 修订记录(review v15 R57-R59 全部采纳) - -| # | 修订 | -|---|------| -| R57 | 替换表删除 254 行(app.py:83 在已提取的 check_and_restart 内,保持裸 ws,不在 app.py 范围)+ 注明 | -| R58 | 替换表补 312 行 init_auto_evolve 特例 recv(timeout=5)→ conn.recv(timeout=5) | -| R59 | 第 6 行修订标注 v11 → v15 | - -### v17 修订记录(review v16 R60-R61 全部采纳) - -| # | 修订 | -|---|------| -| R60 | §3.1 import 行补 `from typing import AsyncIterator`(read_stream 返回注解引用) | -| R61 | §3.1 send_command docstring 补 type 参数防呆(dict 展开后者覆盖前者) | - -### v18 修订记录(review v17 R62 全部采纳) - -| # | 修订 | -|---|------| -| R62 | 行数验收 ≤1600 → **≤1800**(Python AST 精确核算:提取 116 + send 压缩 81 + recv 5 = 202 行,1994−202=1792。R1 误估 send 省 124 行,实测 30 处块共 111 行、压缩省 81)。§1.1/§2.1/§4.3/§6 决策 1 同步更新 | - -### v19 修订记录(review v18 R63-R64 全部采纳) - -| # | 修订 | -|---|------| -| R63 | §3.4 补 e2e 跨文件复用:`from tests.test_ws_e2e import _make_config, _boot_server, _make_fake_chat_stream`(不复制三件套) | -| R64 | 第 6 行修订标注 v15 → v18 | - -### v20 修订记录(review v19 R65-R66 全部采纳) - -| # | 修订 | -|---|------| -| R65 | §3.4 补 `_boot_server` 是 async + 返回 `(server, serve_task)` + 内含 config_dir 隔离(R51 的隔离已由它完成) | -| R66 | §3.2 替换表补 293 行 interactive 初始化 conn 声明(漏改则闭包 NameError) | diff --git a/docs/design/phase3-electron-gui.md b/docs/design/phase3-electron-gui.md deleted file mode 100644 index 4add1ade..00000000 --- a/docs/design/phase3-electron-gui.md +++ /dev/null @@ -1,845 +0,0 @@ -# Phase 3 设计:Electron GUI 客户端(emrg-gui) - -> 主路线图:[`roadmap.md`](roadmap.md) Phase 3 -> 关联文档:[`packaged-installer.md`](packaged-installer.md) §7.1(GUI 设计定稿)、[`phase2-daemon-manager.md`](phase2-daemon-manager.md)(协议参考实现,已实施 PR #320) -> 本文是 Phase 3 的完整设计:现状分析、技术栈、架构、代码结构、协议客户端、UI 设计、测试策略、验收标准。 -> 技术栈:**Electron**(2026-08-03 用户决策,取代 PySide6,见项目记忆 `gui-stack-electron-decision.md`) -> 修订:v9 + 自动 review R1-R77(G1-G132 已采纳,见 §9 修订记录);定稿阶段 - ---- - -## 1. 现状分析 - -### 1.1 已就绪的基础(全部保留) - -| 基础 | 状态 | GUI 如何用 | -|------|------|-----------| -| WebSocket 协议(Phase 1) | ✅ PR #311-318 | 语言无关,Node `ws` 库直连 | -| 协议契约(token + auth_ok 首帧) | ✅ `docs/design/protocol-contract.md` | Node 照读 `~/.emrg/emrgd.port` + 发 auth | -| daemon_manager.py(Phase 2) | ✅ PR #320 | **协议参考实现**——Node 客户端照它写,行为一致 | -| 广播模型(多客户端同 session) | ✅ PR #318 | TUI + GUI 同开同 session 天然支持 | -| session 级锁(session busy) | ✅ | GUI 发 task 时收到 `{"error": "session busy"}` 需处理 | - -### 1.2 为什么 Electron(2026-08-03 决策) - -- **UI 能力最强**:HTML/CSS/JS 渲染 Markdown(marked)、代码高亮(highlight.js)、diff 有现成生态 -- **协议语言无关**:WebSocket 是标准协议,Node 原生 `ws` 库直连 daemon,无需 Python 桥 -- **打包成熟**:electron-builder 跨平台(.dmg/.exe/.AppImage) -- **代价(已接受)**:体积 ~80-200MB(含 Chromium)、内存 ~200-500MB/实例——GUI 是非开发者主入口,体验权重高 - -### 1.3 GUI 定位(与 TUI 的差异) - -| 维度 | TUI | GUI | -|------|-----|-----| -| 目标用户 | 开发者(键盘驱动) | 非开发者(鼠标驱动,零学习成本) | -| 功能矩阵 | 完整(/命令、记忆浏览、skills) | v1 只做 80% 日常(聊天/会话/工具状态/设置) | -| 渲染 | 终端 Markdown | Chromium 富渲染(marked + highlight.js) | -| 状态 | 零状态只渲染 | 同——daemon 是唯一状态源 | - ---- - -## 2. 架构总览 - -### 2.1 进程模型 - -``` -┌─────────────────────────────────────────────────────────┐ -│ emrg-gui (Electron) │ -│ ┌───────────────────┐ ┌─────────────────────┐ │ -│ │ main 进程 (Node) │ IPC │ renderer (Chromium)│ │ -│ │ ┌──────────────┐ │ ◄──────► │ ┌───────────────┐ │ │ -│ │ │daemon_client │ │ preload │ │ app.js (UI) │ │ │ -│ │ │ .js (ws) │ │ 桥 │ │ markdown.js │ │ │ -│ │ └──────────────┘ │ │ │ settings.js │ │ │ -│ └───────────────────┘ └─────────────────────┘ │ -└─────────────────────────────────────────────────────────┘ - ▲ ws://127.0.0.1: + token -┌─────────────┴───────────────────────────────────────────┐ -│ emrgd (唯一 daemon) │ -│ 生命本体:LLM / 工具执行 / 演化 / 会话 / 记忆 │ -└─────────────────────────────────────────────────────────┘ -``` - -**核心原则**: -1. **main 进程是唯一连 daemon 的进程**——`daemon_client.js` 在 main 内,renderer 零网络权限(安全沙箱) -2. **renderer 只渲染**——所有 daemon 交互经 preload IPC 桥转发 main -3. **不内嵌 daemon**——main 启动时检查/拉起 `emrgd`(spawn `python -m emrg.server`),复用 TUI 的 daemon 生命周期逻辑 -4. **窗口关闭 ≠ daemon 退出(G40)**:GUI 关窗只断开 ws(daemon 是独立进程继续运行,同 TUI 退出语义,架构原则"服务端不随客户端退出而终止")。main 的 `window-all-closed` 按 Electron 惯例:macOS 不退出(dock 再激活),其它平台 `app.quit()`。退出前 `daemon_client.close()` 优雅断连 -5. **单实例锁(G85+G120)**:main 用 `app.requestSingleInstanceLock()`——第二个 GUI 实例启动时直接退出并 focus 已有窗口。理由:① 两个 GUI 连同一 daemon、可同时操作**同一会话**(会话锁只挡并发 task,不挡 UI 状态竞争);② **daemon spawn 竞争**——两实例同时判定 port 文件缺失 → 双 spawn。**但注意(G120)**:daemon 自身已有 pid 文件原子互斥(daemon.py:130-141 O_CREAT|O_EXCL,已运行则退出)——**双 spawn 不会起两个 daemon**(第二个自我退出),G85 ② 的实际风险是**双客户端各自连上后 UI 竞争**(①)而非 daemon 重复。GUI+TUI 同开时同理:TUI 已拉起 daemon,GUI 的 ensureConnected 读 port 文件直连即可,**无竞态**(daemon 互斥兜底)。 -6. **菜单与 DevTools(G86)**:**保留默认菜单的编辑项**(macOS Cmd+C/V/X/A 依赖「Edit」菜单,`Menu.setApplicationMenu(null)` 会废掉剪贴板快捷键);生产(app.isPackaged)禁 DevTools(`webContents.openDevTools` 不响应 + `F12`/`Cmd+Alt+I` 不绑定);开发模式可开。菜单可精简为:应用(macOS)/ 编辑 / 视图(重载/DevTools 开发时)/ 窗口 -7. **窗口状态(G87)**:v1 记住窗口 bounds(`win.getBounds()` 存 `~/.emrg/gui-window.json`,启动时 `setBounds` 恢复)——非必需但成本极低;不持久化则每次居中默认 1200×800 -8. **renderer 崩溃恢复(G101)**:监听 `render-process-gone`(renderer 崩溃/被系统杀)→ 显示"界面已崩溃,正在恢复…" → 重新 `loadFile`(renderer 无状态,main 重新走 init 流程即可——窗口对象、daemon_client、IPC handler 都在 main 不受影响)。`unresponsive`(卡死)→ 提示可"重新加载"或等待。 -9. **IPC 输入校验(G102+G114)**:main 对 renderer 传入的 IPC 参数做基本校验(纵深防御——CSP/沙箱防了 XSS,但渲染层被攻破后仍能调 IPC):`sessionId` 必须匹配 `/^s_\d{6}_\d{4}_[0-9a-f]{4,8}$/`;`text` 长度 ≤ 20000 字符;`config` 只接受 **`{apiKey, baseUrl, model, projectDir}` 四键白名单**(G114:G112 后 SettingsDialog 有 4 字段——原三键白名单会剥掉 projectDir,保存丢目录;剥掉多余键);非法参数 → reject + 记日志,不 panic - -### 2.2 cwd 来源(G6,架构级) - -TUI 用 `os.getcwd()`(终端启动目录)决定 session_id 生成、项目跟踪、任务执行目录。**GUI 无终端 cwd 概念**——v1 设计: - -| 方案 | 说明 | -|------|------| -| **A. 首启选项目目录**(v1 主方案) | 首启引导时弹目录选择器,存入 `~/.emrg/config.toml` 的 `[gui] project_dir`;所有操作(newSession/sendTask/listSessions)用它做 cwd | -| **B. 默认兜底** | 用户跳过选择时默认 `~/.emrg/evolution`(演化项目) | -| C. 会话级多目录 | 每个会话关联不同目录(v2+,不在本文) | - -main 进程读 config 的 `project_dir` → 作为 `DaemonClient` 的默认 cwd → renderer 经 `emrg:init` 拿到并显示(状态栏可显示项目名)。 - -### 2.3 目录结构 - -``` -emrg/gui/ -├── package.json # Electron 入口 + 依赖 + scripts(G53/G54) -│ # runtime deps: ws / smol-toml / marked / highlight.js / dompurify(G44 消毒) -│ # devDeps: electron / electron-builder / vitest 或 node:test(mock ws 单测) -│ # scripts: start(electron .)/ test(单测)/ test:integration / e2e(Playwright for Electron)/ dist(electron-builder) -├── main.js # main 进程:窗口创建、daemon 生命周期、daemon_client 管理 -├── preload.js # contextBridge:renderer ↔ main 安全 IPC 桥 -├── daemon_client.js # Node ws 客户端(协议参考 daemon_manager.py) -├── renderer/ -│ ├── index.html # 主布局:会话栏 + 聊天区 + 输入条 + 状态栏 -│ ├── app.js # UI 逻辑:消息渲染、会话列表、工具状态、事件绑定 -│ ├── markdown.js # marked + highlight.js 封装(流式增量渲染) -│ ├── settings.js # 设置对话框(config.toml 读写,经 preload IPC) -│ └── styles.css # 样式(暗色主题) -└── assets/ # 图标(icns/ico/png)——见 G39:统一引用 packaging/assets/,不重复维护 -``` - ---- - -## 3. daemon_client.js(核心,协议参考 daemon_manager.py) - -### 3.1 职责与接口 - -```javascript -// daemon_client.js — main 进程内唯一与 daemon 通信的模块 -// 协议语义完全对照 emrg/client/daemon_manager.py(Phase 2 参考实现) - -class DaemonClient { - // ── daemon 生命周期(对照 daemon_manager.is_running/start_daemon/ensure_connected)── - async ensureConnected() // 读 port 文件 → ws 连接 → auth 首帧 → auth_ok;未运行则拉起 - async startDaemon() // spawn python -m emrg.server(sys.frozen 分支:同目录二进制) - isRunning() // TCP 探测(对照 is_server_running_sync)——⚠️ G43:不可简化为"port 文件存在" - // (stale port 文件 ≠ daemon 存活;漏判死 daemon 会导致永不拉起) - - // ── 消息发送(对照 DaemonConnection.send_task/send_command)── - sendTask({ sessionId, cwd, prompt, stream = true, images = null }) - // ⚠️ 内部生成 request_id(uuid)并缓存为"当前发起流"(G7)—— - // 收到 done 帧时比对 request_id 清除;非匹配的 delta/done 帧 = 广播流(他人发起,G3) - // ⚠️ 缓存清理时机(G124):除 done 外,**G94 超时兜底(30s 无 done)、G89/G119 断连、 - // 下一次 sendTask 覆盖**都必须清"当前发起流"缓存——残留会导致:① 旧 request_id 仍匹配 - // 后续广播帧(误判自有流);② pending 表(G93)残留旧条目。清理统一走 `clearActiveStream()` - // (清缓存 + 分组 + 超时 timer),sendTask 前置调用。 - // ⚠️ request_id 必须作为 task 帧的 id 字段发出(G32)——daemon 只回显 req.id 不自生成 - // (daemon.py:1074 `"request_id": req.id`;TaskRequest.id 默认 uuid4)。若不发 id, - // daemon 侧生成默认 uuid → 回显的 request_id 与本地缓存不匹配 → 流式无法结束/自有流与广播流混淆 - // ⚠️ stream 必须显式放进 payload(G96)——daemon 读 data.get("stream", False)(daemon.py:326), - // 漏发 stream:true → 走非流式路径(_run_chat_once)→ GUI 收不到任何 delta、只有 done 帧, - // 且 G7 的"流式分组/节流"全部失效。payload 恒含 stream:true(对照 TaskRequest.to_dict, - // protocol.py:35-47:type/id/session_id/cwd/prompt/timestamp/stream 全量字段) - // ⚠️ images 为 v2 预留(G18)——v1 恒 null(无图片 UI);单测覆盖 images 非空时 payload 正确 - sendCommand(type, params = {}) // ping/list_*/set_*/rant/... 只发不读 - - // ── 事件接收(对照 read_stream)── - onEvent(callback) // 注册帧回调:delta / tool_start / tool_end / done / error - close() // 关闭 ws -} -``` - -### 3.2 连接流程(对照 connect.py:49-75) - -```javascript -// ⚠️ Node main 进程用 require('ws')(G10)——Electron 内置 Node 无原生 WebSocket, -// ws 库 API 兼容浏览器(onmessage/onclose/onerror),但需显式引入 -// ⚠️ maxPayload(G62+G105 修正):new WebSocket(url, { maxPayload: 16 * 1024 * 1024 }) -// ⚠️ G105 更正:bash 工具输出有 MAX_OUTPUT_CHARS=200_000 截断(bash_tool.py:16,200KB), -// read/grep 同理有上限(read_tool.py 256KB / grep_tool.py 200 条)——**tool_end 帧不会超 16MB**。 -// 原 G105"放宽 64MB"是过度设计(误判工具输出可达数 MB)——维持 16MB(对齐 Python max_size=16MB, -// connect.py:65,双向一致)。G91 的 2000 字符截断显示仍保留(200KB 渲染也卡)。 -const WebSocket = require('ws'); - -// startDaemon 的 Python 路径(G16+G61): -// - 源码运行(npm start):spawn 项目 .venv/bin/python -m emrg.server(G59:项目根 = path.resolve(__dirname, '../..'), -// 即 emrg/gui/ 上溯两级;无 EMRG_ROOT env——TUI 用 __file__ 定位,Node 用 __dirname) -// ⚠️ G61 跨平台:.venv/bin/python 仅 macOS/Linux;Windows 为 .venv\Scripts\python.exe(path.join 处理) -// 兜底:.venv 缺失时依次尝试 PATH 上的 python3 / python(找不到则报错提示创建 .venv) -// - 打包后(app.isPackaged):spawn process.resourcesPath/emrgd(PyInstaller 产物,见 G17/G38) -// ⚠️ G68 spawn 选项:{ stdio: 'ignore', detached: true } + child.unref()—— -// 对照 Python start_daemon 的 DEVNULL + start_new_session=True(daemon 脱离 GUI 进程组, -// GUI 退出不带走 daemon,架构原则"服务端不随客户端退出而终止");stdio ignore 防 GUI 持有管道阻塞 -// ⚠️ G125 spawn 设置 cwd=project_dir:daemon 启动时 load_skills() 用 Path.cwd()(daemon.py:120) -// 加载【项目级】skills(project_dir/.emrg/skills/)——GUI 不设 cwd 则 daemon 继承 GUI 启动目录 -// (可能是 / 或主目录)→ 项目 skills 加载不到。对照 TUI:用户在项目目录启动,daemon cwd 自然正确; -// GUI 从任意目录启动(app 双击)必须显式设 cwd=project_dir(从 config 读,G6)恢复同语义。 -// 首启时 daemon 是保存 config 后才拉起(G123)——此时 project_dir 已定,可安全传 cwd。 -async function ensureConnected() { // G31:独立函数需 function 关键字 - // 1. 读 ~/.emrg/emrgd.port → { port, token }(两行:port\n token) - // 2. 若 port 文件不存在 → startDaemon() 拉起(等最多 5s) - // 3. new WebSocket(`ws://127.0.0.1:${port}`) - // 4. 首帧发 {"type": "auth", "token": token} - // 5. 等 auth_ok(10s 超时)→ 就绪;否则报错 - // 6. 注册 message 监听 → JSON.parse → 分发到事件队列 - // ⚠️ G43(stale port 文件):ws 连接失败/超时(ECONNREFUSED/ETIMEDOUT)—— - // 即使 port 文件存在,也须删 ~/.emrg/emrgd.port → startDaemon() 拉起 → 重试连接。 - // 对照 Python ensure_connected:is_running(TCP 探测)为 false → cleanup_server + start_daemon -} -``` - -**⚠️ 关键语义(对照 daemon_manager)**: -- **坏 JSON 帧**:忽略(log warning),不崩——同 daemon_manager.recv(R53) -- **断连(ws close/error)**:触发重连回调(同 ConnectionClosed 传播语义,R11)——状态栏变红 + 自动重连 -- **session busy**:daemon 会回 `{"error": "session busy"}`(session 级锁)——UI 提示"该会话正忙" -- **ping 时机(G19)**:`ensureConnected()` 成功**后发一次 ping**(拿 server_id/model/evolution_count 填充状态栏),重连成功后同样发一次。**不做定期轮询**(TUI 也不轮询,演化计数是快照) -- **server_id 格式(G108)**:对照 TUI app.py:326-328——`{instance_id[:8]} @ {host_name}`(pong identity 含 instance_id/host_name/fork_source/branch_id,daemon.py:616-617);状态栏显示 `server_id + [model]`(如 `3f2a9b1c @ MacBook-Pro [deepseek-chat]`)。model 字段直接读 pong 的 `model`(daemon.py:624,无需 list_models)。**窗口标题同步(G109)**:TUI 切会话同步终端标题(app.py setTitle 同款)——GUI 窗口标题 v1 固定 `EMRG`,**切会话时可加 ` — {会话显示名}`**(title 或 session_id 兜底,G27);grow 到 OS 任务栏可辨识,成本极低(`win.setTitle`)。 - -**session_id 生成(G14,对照 emrg/session.py:33)**: -```javascript -// daemon 无 new_session 消息——session_id 由客户端本地生成(对照 Python generate_session_id,session.py:33-46) -function generateSessionId(cwd) { - // 格式 s_YYMMDD_HHMM_xxxx,查重 cwd/.emrg/sessions/ - const now = new Date(); - const yymmdd = String(now.getFullYear()).slice(2) + pad(now.getMonth()+1) + pad(now.getDate()); - const hhmm = pad(now.getHours()) + pad(now.getMinutes()); - for (let i = 0; i < 100; i++) { - const suffix = randomHex(4); // 4 hex chars(对照 secrets.token_hex(2)[:4]) - const sid = `s_${yymmdd}_${hhmm}_${suffix}`; - if (!fs.existsSync(path.join(cwd, '.emrg', 'sessions', sid))) return sid; - } - // G81:100 次碰撞兜底——返回 8 hex 长后缀(对照 Python session.py:45-46 fallback) - // 原实现循环外无返回 → undefined 会流到 sendTask payload(session_id 缺失 → daemon 报 task requires session_id) - return `s_${yymmdd}_${hhmm}_${randomHex(8)}`; -} -// daemon 侧 _get_or_create_session 惰性创建——GUI 直接用它发消息即可 -``` - -### 3.3 事件流(对照 daemon read_stream + TUI read_server 分类) - -**⚠️ 帧分类逻辑(G1)**:服务器帧**不都有 `type` 字段**——`TaskResponse` 帧(delta/done)靠 `delta`/`done`/`request_id` 标志区分。Node 端分类: - -```javascript -// daemon_client.js 事件分类(对照 TUI read_server 的 TaskResponse.from_dict 语义) -function classify(frame) { - if (frame.type === 'tool_start') return { event: 'tool_started', data: frame }; - if (frame.type === 'tool_end') return { event: 'tool_finished', data: frame }; - if (frame.type === 'cancelled') return { event: 'cancelled', data: frame }; // G4 - if (frame.type === 'sessions_list' || frame.type === 'models_list' || - frame.type === 'history_list' || frame.type === 'tasks_list') { - return { event: 'list_result', data: frame }; // G4 - } - if (frame.type === 'resume_result' || frame.type === 'model_set' || - frame.type === 'session_deleted' || frame.type === 'clear_result') { - return { event: 'command_result', data: frame }; // G57:命令结果帧(原落 unknown,switchSession meta 拿不到) - } - if (frame.done) return { event: 'done', data: frame }; // G2:三种形态 - if (frame.delta) return { event: 'message_delta', data: frame }; - if (frame.error) return { event: 'error', data: frame }; - if (frame.uptime_seconds !== undefined) return { event: 'pong', data: frame }; - return { event: 'unknown', data: frame }; -} -``` - -**done 帧三种形态(G2)**: -| 形态 | 帧结构 | UI 处理 | -|------|--------|---------| -| 正常 | `{request_id, content: 全文, done: true, delta: false, session_id}` | 结束流式 + 恢复输入条 | -| LLM 错误 | `{done: true, request_id}`(无 content,先发了 error 帧) | 结束流式 + 恢复输入条;**不得清空已显示内容**(error 帧已提示) | -| 取消 | `{request_id, content: "", done: true, cancelled: true}` | 结束流式 + 提示"已中断" | - -> **G63(done 帧字段)**:正常 done 帧**带 `delta: false` 和 `session_id`**(daemon.py:1469-1474)——classify 先查 `frame.done` 再查 `frame.delta`(顺序正确,false 不会误入 delta 分支);session_id 可用于 debug/过滤。取消 done 帧**无 delta 字段**(daemon.py:1163-1168:`{request_id, content:"", done:true, cancelled:true, session_id}`)——classify 同样先命中 done,无歧义。G4 取消形态实为「cancelled 帧后跟 done 帧」:daemon cancel 处理回 `{"type": "cancelled", session_id}`(daemon.py:292)→ 任务协程被取消 → `_run_tool_loop_locked` 广播带 `cancelled: true` 的 done 帧。 - -> **G64(auth_ok 消费)**:`{"type": "auth_ok"}` 帧由 `ensureConnected()` 第 5 步**消费**(对齐 connect.py:71-77 的 wait_for auth_ok)——**不进入 onEvent 分发**;classify() 无需(也不应)识别 auth_ok,若事件流中出现说明连接流程实现有误。 - -**⚠️ 广播场景(G3)**:同 session 的所有订阅者收到**所有帧**(包括其他客户端发起的 task)。GUI 必须: -1. 区分"自己发起的流"(`request_id` 匹配当前发送)vs "广播来的流"(他人发起) -2. 广播来的 delta 按 `request_id` 分组——新 request_id 建新消息节点并标注"(来自其他客户端)",同 request_id 追加到同一节点 -3. GUI 自己不在 busy 状态也收到广播 delta——UI 需支持"被动接收流" - -**分组生命周期(G83+G104)**:每个 request_id 分组的状态(节点引用 + 消息缓冲)在**流结束时清理**——done(三形态,G2)/ cancelled 帧到达即删除该分组 + 标记消息完成(「来自其他客户端」标注保留在消息节点上,**分组缓存删除**)。防泄漏护栏: -- **超时兜底**:分组创建后 10 分钟无任何帧 → 强制删除(陈旧广播残留——TUI 侧任务可能已死但 GUI 未收到 done) -- **上限保护**:同时活跃分组 > 20 → 丢弃最老分组(拒绝服务保护,广播风暴场景) - -> **G104(分组在 tool_start 时也创建)**:LLM 先出 tool_calls 再出文本(工具调用在前)——`tool_start` **可能先于首个 delta 到达**。因此**新 request_id 的 tool_start 同样触发建分组**(不只在 delta 时,G3)。工具卡片挂在**该分组的消息节点内**(tool_start/tool_end 都带 `request_id`,daemon.py:1383-1388/1438-1443):自有流 → 当前 assistant 节点;广播流 → 「来自其他客户端」分组节点。tool_end 匹配 tool_call_id 更新卡片(G20)不受分组清理影响(卡片已完成、分组缓存删)。**无 request_id 的 tool 帧**(理论上 daemon 总会带)→ 挂到当前分组或丢弃 + log warning。 - -| 服务器帧 | 事件 | UI 动作 | -|---------|------|---------| -| `uptime_seconds`(ping 响应,无 type) | `pong` | 状态栏更新(server_id/model/evolution_count) | -| `tool_start`(有 type) | `tool_started` | 工具状态行「🔧 bash — 运行中…」——**记住 tool_call_id(G20)** | -| `tool_end`(有 type) | `tool_finished` | **按 tool_call_id 匹配**更新卡片(G20):完成/失败(error:true 红色)+ 输出折叠 | -| delta 帧(无 type,`delta:true`) | `message_delta` | 聊天区增量追加(按 request_id 分组) | -| done 帧(无 type,`done:true`) | `done` | 结束流式;恢复输入条(三形态见上) | -| `{"type": "cancelled"}` | `cancelled` | 结束流式;提示"已中断" | -| `resume_result`/`model_set`/`session_deleted`/`clear_result`(有 type) | `command_result`(G57) | switchSession 的 meta(message_count/title)填充占位;**model_set 是广播帧(daemon.py:409 发全部连接)→ 状态栏 model 名自动同步**(TUI 切模型 GUI 跟着变);删除/清空结果提示 | -| `{"error": ...}` | `error` | 错误提示。**两类区分(G42)**:① session busy(`{"error": "session busy"}`,无流式)→ 立即恢复输入条 + 提示"该会话正忙";② 流中错误(先 error 后 done)→ 提示但不结束流式(等 done 帧)。**session busy 帧带 session_id(G128)**:daemon.py:315 的 busy error 含 `session_id` 字段——UI 可据此**定位到具体会话**显示「会话 X 正忙(其他客户端/任务占用中)」;多会话场景下不误导用户(若当前打开的就是该会话 → 输入条禁用提示;若是其他会话 → 提示"该会话正忙"且不打断当前操作) | - -**流结束兜底(G94)**:daemon 的 LLM 错误路径会发 done(daemon.py:1272-1282),但**wrapper 层未捕获异常 / daemon 进程崩溃**时**无 done 帧**(_run_tool_loop_locked 只释放锁不发 done,daemon.py:1480+)→ 自有流会永久"生成中"。护栏:**最后一个帧(delta/error/tool_end)后 30s 无 done** → 强制结束流式 + 提示"响应超时(连接可能已中断)";广播流同(可复用 G83 超时兜底,广播用 10 分钟、自有流用 30s)。 - -**协议健壮性(G95)**: -- **pong 超时**:init 序列的 ping 发出后 5s 无 pong → 视为连接异常 → 走 §3.4 重连(对照 Python check_and_restart 的 3s ping 超时,daemon_manager.py:105) -- **旧 daemon 兼容**:daemon 对未知消息回 `{"error": "unknown message type", "received": ...}`(daemon.py:975-979)——若 GUI 用了新消息而 daemon 是旧版:error 帧经 G93 的 pending 表 reject → UI 提示"daemon 版本过旧,请更新"。**v1 只提示,不做自动降级**(GUI 与 daemon 同包发布,Phase 4 起版本同步) -- **unknown 帧**:classify 落 unknown → main log warning + 丢弃(不崩,不打扰 renderer) -| `sessions_list`/`models_list`/`history_list`/`tasks_list` | `list_result` | 会话/模型/任务列表填充 | - -> **tool_call_id 关联(G20)**:tool_start 建卡片时存 `tool_call_id` → tool_end 按它匹配更新(对照 TUI app.py:475-510)。tool_end 的 `error: true` 表示工具失败,卡片显示红色。tool_start 的 `arguments` 用于显示命令(如 bash 的 command 字段)。 - -> **cancel 语义(G33)**:daemon 的 `_tool_task` 是**每连接**状态(daemon.py:229,连接协程内局部变量)——`cancel` 只取消**本连接发起**的任务(daemon.py:283-297)。GUI 在广播流(他人发起)上按 ⏹ 无效(daemon 仍回 `cancelled` 帧但实际任务继续跑)。**UI 应只在"自己发起流"(request_id 匹配)时显示 ⏹**(G3 的区分逻辑可复用)。`cancelled` 帧只发给 cancel 发起连接(`self._send(ws, ...)`,不广播给 session 订阅者)——但被取消任务的 done 帧(`cancelled: true` 形态)照常广播。 - -### 3.4 断连重连(对照 TUI _reconnect) - -``` -ws close/error → 状态栏变红("daemon 连接断开") -→ 状态栏显示"重连中…"(对齐 TUI "reconnecting...",app.py:296) -→ 每 1s 尝试 ensureConnected() -→ 成功 → 状态栏变绿 + 重新 list_sessions + **重新 resume 当前打开的会话**(G41) -→ 若 daemon 进程也死了 → startDaemon() 拉起 → 重连 -``` - -> **G41(重连恢复会话订阅)**:daemon 的广播订阅是**连接级**(`_session_subscribers` 按 ws 对象,daemon.py:270-278)——重连 = 新 ws = 订阅全丢。重连成功后除 list_sessions 外,main 必须重新 `resume_session` **当前打开的 session_id**(恢复订阅 + 刷新 meta.message_count),否则该会话的新消息/广播收不到。 - -> **G88(auth 失败 ≠ 瞬断,停止自动重试)**:对照 connect.py:36-42 `AuthError`——「token 不匹配/daemon 版本不匹配是配置问题,重连也修不好」。ensureConnected 第 5 步**收到 auth_ok 前 ws 就 close**(daemon 拒绝)→ 判定 auth 失败:**停止 1s 重试循环**,状态栏显示"认证失败(请检查 ~/.emrg/emrgd.port 或重启 daemon)" + 仅用户手动"重试"才再连。区分依据:**连接成功但 auth 被拒**(收到 close 无 auth_ok)vs **连接失败**(ECONNREFUSED 等,继续重试)。对照 Python:AuthError 抛出、非 ConnectionRefusedError。 - -> **G129(无协议版本协商——现状确认)**:`auth_ok` 帧是纯 `{"type": "auth_ok"}`(daemon.py:250),**不含版本号**——GUI 无法在 auth 时做版本协商。协议兼容完全依赖 G95 兜底(未知消息 → unknown message type → pending reject + 版本提示)。**v1 接受此现状**(GUI 与 daemon 同包发布,Phase 4 同步版本;协议稳定、帧结构多年未变)。**不实现**"auth 带版本"的 daemon 改动(违反"零改动 Python 核心"原则,G5 同源)。 - -> **G89(断连时进行中流的处理)**:对照 TUI `busy = False # pending request is lost`(app.py:295)——断连时正在流式的消息(自有流 + 广播流)**标记「(连接中断)」**,流式状态清除,输入条恢复。**v1 不自动恢复**(G12 无历史加载,重连后无法重建内容)——用户看到中断标记后可自行重发;重连成功不补发。 - -> **G97(断连时工具卡片与分组清理)**:tool_start 已建卡片、tool_end 未到(断连丢失)→ 卡片**标记「(结果未知——连接中断)」**,不再显示"运行中"旋转态。同步清理:G83 广播分组 + G93 pending 命令队列 + 自有流缓存**全部清空**——重连后状态干净,避免"幽灵"卡片/陈旧 pending 与重连后 list_sessions/resume(G41)拿到的真实状态混淆。工具卡片无"重试"语义(工具已在 daemon 侧执行完毕/失败,重发会重复执行副作用)——只标记,不自动重跑。 - ---- - -## 4. IPC 桥(preload.js + main) - -### 4.1 安全模型 - -- `contextIsolation: true` + `nodeIntegration: false`(默认安全) -- `preload.js` 用 `contextBridge.exposeInMainWorld('emrg', api)` 暴露白名单 API -- renderer **无法**直接访问 fs/网络——所有操作经 IPC -- **CSP(G26)**:`index.html` 加 `Content-Security-Policy` 响应头(`default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'`)——marked/highlight.js 以本地文件加载(非 CDN),禁止外联;防 XSS 注入(聊天内容渲染 Markdown 时必须消毒——**用 DOMPurify(G44)**;marked 的 `sanitize` 选项已在 v8 移除,勿写) -- **sandbox(G45)**:webPreferences 加 `sandbox: true`——preload 只 import ipcRenderer/contextBridge(contextIsolation 已开),不需要 Node API -- **外部导航(G46)**:`setWindowOpenHandler` 一律 deny(禁 window.open/新窗口);Markdown 渲染出的链接点击默认不导航,或经白名单协议(http/https)`shell.openExternal` 交给系统浏览器 -- **config 路径(G26)**:main 进程读 `~/.emrg/emrgd.port` 和 `~/.emrg/config.toml`(对照 `emrg/config.py` `config_dir()` = `Path.home()/'.emrg'`);Node 用 `os.homedir()` - -### 4.2 IPC 通道清单 - -| 通道 | 方向 | 参数 | 返回 | -|------|------|------|------| -| `emrg:init` | renderer→main | — | 初始状态(server_id/model/会话列表/**api_key_configured**/**config_exists**/**project_dir**)。**内部序列(G34+G71)**:① 检查 config.toml 存在性(**缺失 → 不拉起 daemon,直接返回缺配置状态**——daemon 在 config 缺失时启动即崩,__main__.py:29)② ensureConnected ③ ping(拿 model/演化计数)④ list_sessions(project_dir)——renderer 一次性拿到三件套 + 首启引导判定(G36)**+ project_dir(G112,G6 明确"renderer 经 emrg:init 拿到并显示"但此前返回列表遗漏)** | -| `emrg:sendMessage` | renderer→main | {sessionId, text} | 请求已接受。**⚠️ cwd 由 main 从 config.project_dir 注入(G22)**——renderer 不传 | -| `emrg:listSessions` | renderer→main | — | 会话列表。**cwd 由 main 注入 project_dir**(daemon 校验 list_sessions requires cwd,daemon.py:710) | -| `emrg:switchSession` | renderer→main | {sessionId} | resume 结果 + **meta(消息数)**——历史不通过 resume 返回(G13,见 §5.4)。**⚠️ 订阅切换副作用(G66)**:daemon 的广播订阅按「消息携带的 session_id」切换(daemon.py:270-278)——main 必须发 `resume_session` 才能把本连接的订阅从旧会话移到新会话;只 list_sessions 不 resume 则新会话消息/广播收不到。**⚠️ 流式进行中切换策略(G65)**:见 §5.4 下方 | -| `emrg:deleteSession` | renderer→main | {sessionId} | 删除结果 | -| `emrg:newSession` | renderer→main | — | 新 session_id。**⚠️ cwd 由 main 注入 project_dir(G112,同 G22 语义)**——renderer 不传(原表 `{cwd}` 与 G22/preload 无参矛盾,修正)。**⚠️ 本地生成(G14)**——daemon 无 new_session 消息,session_id 由客户端生成(对照 `emrg/session.py` `generate_session_id`:`s_YYMMDD_HHMM_xxxx` + 查重 `cwd/.emrg/sessions/`);daemon 侧 `_get_or_create_session` 惰性创建 | -| `emrg:setModel` | renderer→main | {model} | model_set 结果 | -| `emrg:listModels` | renderer→main | — | 模型列表(**G25**——设置对话框显示可用模型用;daemon 返回 models_list) | -| `emrg:saveSettings` | renderer→main | {config} | 写 config.toml 结果。**⚠️ 保存后 daemon 可能重启(mtime 检测,G11)**——GUI 走 §3.4 重连流程 + 状态栏提示"配置已保存,daemon 重启中…"。**⚠️ 重启期间状态(G119)**:保存后 daemon 约 1-3s 内自杀重启(check_and_restart 流程)——期间 ws 会 close(G88 重连逻辑触发);**pending 命令全 reject(G93 规则 4)**;**流式中的任务被 daemon 自杀打断**(daemon 重启 = 任务丢失,同 G89 断连语义)→ 消息标「连接中断」。**UI 提示层级(G119)**:状态栏「配置已保存,daemon 重启中…」→ 重连成功 → 状态栏恢复 + **list_models 刷 model(G98)** + list_sessions + resume 当前会话(G41)。**⚠️ 避免"保存→立即重连但 daemon 还在自杀"竞态**:保存后不主动调 ensureConnected,等 ws close 事件自然触发重连循环(G88)——主动重连会连上"正在关闭"的旧 daemon。**⚠️ 重连完成后刷新模型显示(G98)**:daemon 重启后 model 可能已变(新配置生效)——重连成功(G41 的 list_sessions/resume 之外)补一次 `list_models`(拿新 `current`)→ 状态栏 model 更新;否则状态栏显示旧 model(TUI 保存配置也有同样行为,重启后模型名跟随新配置)。**⚠️ 全量读-改-写(G60)**:用 smol-toml 解析现有文件 → 只改 api_key/base_url/model 字段 → 全量序列化写回——**保留 [llm] 全部现有键**(max_tokens/temperature/context_window/vision/models/...)与其它未知段(如 [gui]);只写三个字段会丢配置。**⚠️ 写入权限 0o600(G69)**:config.toml 含明文 api_key,fs.writeFile mode 0o600(对照 emrgd.port 的 0o600) | -| `emrg:getSettings` | renderer→main | — | 当前 config。**返回形状(G118)**:`{apiKey, baseUrl, model, projectDir, models}`——**camelCase 对齐 G115**(main 从 TOML 读后转驼峰,renderer 不感知 snake_case);`models` = `[[llm.models]]` 的 name 列表(给 G111 的 model 下拉用,**复用 init 的 list_models 结果亦可**,但 getSettings 返回保证对话框独立可用)。**api_key 返回明文(G118)**:沙箱 renderer 可读(主进程信任 renderer,G102 纵深防御在写入侧),对话框 type=password 掩码显示(G51);「已配置但为占位符 sk-...」时返回空串(G117 排除占位符)——用户看到空 key 输入框即知需填 | -| `emrg:cancel` | renderer→main | — | 发 cancel 到 daemon。**无参数(G24)**——daemon 端 cancel 不读 session_id,直接取消当前任务(daemon.py:283) | -| `emrg:event` | main→renderer | {type, data} | 推流式事件(delta/tool/done) | -| `emrg:pickProjectDir` | renderer→main | — | 首启选项目目录(**G70**)——main 用 `dialog.showOpenDialog({properties: ['openDirectory']})`(renderer 无 fs/dialog 权限),返回所选目录路径;确认后写入 config 的 `[gui] project_dir`(G6) | - -**main 进程日志(G84)**:GUI main 进程日志写 `~/.emrg/emrg-gui.log`——对照 TUI 的 `emrg-client.log` 与 daemon 的 `emrgd.log`(RotatingFileHandler:10MB × 3,`emrg/server/__main__.py` 同款)。**打包后无终端,console.log 输出全丢**——main 内所有调试用 `console.log` 统一走日志封装(appendFile 或 electron-log 库),至少记录:连接事件(connect/auth_ok/close/error)、daemon spawn/退出、IPC 异常、未捕获异常(`process.on('uncaughtException')`)。renderer 的 console 经 `webContents.on('console-message')` 转发 main 日志(v1 可省略,先记 main 侧)。 - -### 4.3 preload API 形状(G67) - -renderer 经 `window.emrg` 调用的 API(contextBridge 暴露),全部 promise 化(invoke/handle 模式): - -```javascript -// preload.js — contextBridge.exposeInMainWorld('emrg', ...) -window.emrg = { - init: () => ipcRenderer.invoke('emrg:init'), // → 初始状态 - sendMessage: (sessionId, text) => ipcRenderer.invoke('emrg:sendMessage', { sessionId, text }), - listSessions: () => ipcRenderer.invoke('emrg:listSessions'), - switchSession: (sessionId) => ipcRenderer.invoke('emrg:switchSession', { sessionId }), - newSession: () => ipcRenderer.invoke('emrg:newSession'), - deleteSession: (sessionId) => ipcRenderer.invoke('emrg:deleteSession', { sessionId }), - setModel: (model) => ipcRenderer.invoke('emrg:setModel', { model }), - listModels: () => ipcRenderer.invoke('emrg:listModels'), - saveSettings: (config) => ipcRenderer.invoke('emrg:saveSettings', { config }), - getSettings: () => ipcRenderer.invoke('emrg:getSettings'), - cancel: () => ipcRenderer.invoke('emrg:cancel'), - pickProjectDir: () => ipcRenderer.invoke('emrg:pickProjectDir'), // G70 - onEvent: (cb) => { // 事件订阅(ipcRenderer.on 包装) - ipcRenderer.on('emrg:event', (_e, payload) => cb(payload)); - }, -}; -``` - -**注意(G67)**:renderer 侧 `onEvent` 收到的是 main 已分类的事件(`{type: 'message_delta'|'tool_started'|..., data}`)——**raw 帧分类只在 main 的 classify() 做一次**,renderer 不重复解析协议(架构原则 2:renderer 只渲染)。 - -### 4.4 命令-响应配对(G93,关键实现机制) - -**问题**:daemon 协议是**异步响应**——`sendCommand("list_sessions")` 只发不读(对照 daemon_manager.py:194-200),响应 `sessions_list` 帧**不带 request_id**(daemon.py:713 `{"type": "sessions_list", "sessions": ...}`)。§4.2 各 IPC 通道的"返回"(listSessions 返回会话列表、init 返回初始状态…)**如何把 invoke 的 promise 和异步帧配对**?文档此前未定义——实施者会卡在这。 - -**方案:pending 命令队列(type 配对 + FIFO)**: - -```javascript -// main 进程 -const pending = new Map(); // frameType → { resolve, reject, timer } - -function sendCommandAndWait(frameType, payload, timeoutMs = 5000) { - return new Promise((resolve, reject) => { - pending.set(frameType, { resolve, reject, - timer: setTimeout(() => { pending.delete(frameType); reject(new Error(`timeout waiting ${frameType}`)); }, timeoutMs) }); - ws.send(JSON.stringify({ type: payload.type, ...payload.params })); - }); -} -// classify() 里 list_result/command_result/pong 分支:查 pending 表 → -// 有匹配 → resolve + 清 timer;无匹配 → 广播给 renderer(如 model_set 广播帧) -``` - -**配对规则(G93+G103)**: -1. **按帧 type 配对 + FIFO**:同连接消息顺序保证(daemon 顺序处理)——发 list_sessions 后收到的第一个 `sessions_list` 帧即响应。**不允许多个同 type 命令并发未决**(GUI 逻辑保证:init 序列串行、listSessions 由 UI 事件串行触发) -2. **未决命令超时 5s** → reject → renderer 提示"操作超时"(daemon 可能忙/异常) -3. **广播帧不占 pending**:`model_set`/delta/done/tool_* 等广播帧**不注册 pending**(classify 直接推 renderer);只有**显式等响应的命令**(ping/list_*/resume/delete/…)走 sendCommandAndWait。`resume_session` 的响应 `resume_result` 由 switchSession 通道 consume(promise resolve),**不同时推 renderer**(G13 meta 用途) -4. **断连时 pending 全部 reject**(重连循环会重新 list_sessions/resume,G41) -5. **error 帧配对(G103)**:daemon 的错误响应**无 type 字段**(`{"error": "session busy"}`,daemon.py:306/314/976)——**pending 表按 type 查不到**。规则:收到 error 帧时若 pending 表**有未决命令** → **FIFO reject 最早未决的那个**(daemon 顺序处理保证 error 是对最近命令的响应)→ renderer 拿到真实错误信息(如"会话不存在"而非"操作超时");pending 表为空 → error 是流式/广播上下文(如 session busy 是 task 拒绝)→ 推 renderer 按 G42 处理。**注意**:task 的 `session busy` 拒绝**不是命令响应**(sendTask 不注册 pending)→ 走广播分支(G42 ①)。 - ---- - -## 5. UI 设计(renderer) - -### 5.1 布局(对齐 packaged-installer §7.1) - -``` -┌────────┬───────────────────────────────────────────────┐ -│ 会话 │ [EMRG — 你好,我是你的 AI 编程助手] │ -│ │ (流式文本逐字出现…) │ -│ ▼ 会话1 │ │ -│ 会话2 │ 🔧 bash ls -la — 完成 0.4s │ -│ 会话3 │ │ -│ ├───────────────────────────────────────────────┤ -│ + 新建 │ [输入消息… [发送] ⏹] │ -├────────┴───────────────────────────────────────────────┤ -│ ● daemon 运行中 · deepseek-chat · 演化 12 次 [设置] │ -└────────────────────────────────────────────────────────┘ -``` - -### 5.2 组件 - -| 组件 | 实现 | 说明 | -|------|------|------| -| ChatView | `
` + markdown.js | 流式增量:delta 追加到当前消息节点;工具调用折叠卡片 | -| SessionPanel | `
    ` | list_sessions 填充;点击切换;右键删除。**显示名(G27)**:`title` 优先、`session_id` 兜底(对照 `session.py` `Session.title` = `meta.title or session_id`);显示 message_count 副标签。**v1 无重命名(G35)**——title 由 TUI `/rename` 或 daemon 自动生成(rename_session 空 title 时 LLM 生成,daemon.py:741)填充;重命名入口留 v2。**⚠️ title 不会自动出现(G130)**:`_generate_session_title` **只在 `rename_session` 带空 title 时被调**(daemon.py:741)——而 TUI 只在 `/rename` 命令时发 rename_session(app.py:1335-1339)、**无人自动触发**。因此**新会话的 title 恒为 session_id 兜底显示**(G27),除非用户在 TUI 手动 `/rename`。GUI v1 不自动生成(对齐 TUI 现状,避免实施者误以为"daemon 会自动命名")。**sessions_list 元素字段(G72)**:`session_id/created_at/updated_at/cwd/message_count/compact_count/last_compact_at/title`(title 可能缺失——G27 兜底;list_sessions 直接返回 meta.json,session.py:530-555) | -| ToolStatus | 工具消息卡片 | tool_start 显示运行中 → tool_end 更新结果 | -| SettingsDialog | `` | api_key/base_url/model + **project_dir(G112)** 读写 config.toml(**经 main 进程用 smol-toml 解析/序列化**,G9——Node 无内置 TOML)。**api_key 输入 type=password(G51)**;config.toml 不存在时 getSettings 返回默认值(空 key + 默认 base_url,G52)。**project_dir 字段**:显示当前目录路径 + 「选择…」按钮(复用 `emrg:pickProjectDir`,G70)——G82"用户可在设置对话框改 project_dir"此前缺 UI 入口;G6-B 默认 `~/.emrg/evolution` 时也在此改。保存走 G60 全量读改写(含 `[gui] project_dir`) | - -**model 切换双通道(G50+G111)**:设置对话框保存 model = 写 config.toml → daemon mtime 重启生效(G11);`emrg:setModel` 是**运行时切换**(不写盘、不重启,对应 TUI `/model`)——v1 可不在 UI 暴露(保留通道与单测),避免"保存了 model 但走了 setModel 不生效"的实现歧义。**model 名校验(G111)**:daemon 的 set_model 只在 model ∈ {默认 model} ∪ `[[llm.models]]` 时解析 context_window/vision(daemon.py:1905-1915,任意名会保留旧 context_window)——**设置对话框的 model 下拉只列 `list_models` 返回的合法模型**(默认 + `[[llm.models]]`,daemon.py:1870-1887);**不提供自由文本输入**(用户要加新模型 → 手动编辑 config.toml 或 v2 加"模型管理")。api_key/base_url 自由文本;model 下拉 + 默认值选中。 - -**首启引导(G36+G71+G82,重设计)**: - -> **⚠️ 前提事实(G71)**:`emrg/server/__main__.py:29` 在模块顶层直接 `load_config()`——`~/.emrg/config.toml` **不存在时 daemon 进程直接崩溃**(FileNotFoundError,非优雅退出)。因此首启引导**不能在 config 缺失时先拉起 daemon**。 - -``` -main 启动 → 检查 ~/.emrg/config.toml 是否存在 -├─ 不存在(全新安装)→ init 返回 {config_exists: false, api_key_configured: false} -│ → renderer 弹首启引导对话框(⚠️ 不调用 ensureConnected——避免拉起即崩的 daemon) -│ Step 1: 选项目目录(emrg:pickProjectDir,G70)→ 存 [gui] project_dir -│ Step 2: 填 API Key / base_url / model -│ → 保存 → main 用 smol-toml 写最小 config.toml([llm] 段 + [gui] 段) -│ ⚠️ 优先方案(G116):先 spawn python 一次性跑 `ensure_config()`(python -c "from emrg.config import ensure_config; ensure_config()") -│ → 生成官方默认模板(含 [[llm.models]] 预置模型,config.py:99-130)→ smol-toml 改 api_key 等字段 -│ → 自研写最小文件会丢失模板里的 [[llm.models]](deepseek-v3/r1 预置,G111 的 model 下拉依赖它) -│ → 再调一次 init → config 存在 → 正常序列 -└─ 存在 → ensureConnected → ping → list_sessions → 完整初始状态(api_key 为空则弹设置,G36) -``` - -> **G116(首启模板复用 ensure_config)**:TUI 客户端启动时调 `ensure_config()` 自动写默认 config.toml(client/__main__.py:12)——**GUI 首启保存前先复用该函数**(spawn python 一次性执行 `python -c "from emrg.config import ensure_config; ensure_config()"`,走 G59 的 .venv 定位)→ 生成官方模板(含 `[[llm.models]]` deepseek-v3/r1 预置,config.py:99-130)→ 再 smol-toml 改 api_key/base_url/model/[gui] project_dir。**自研写最小文件会丢 [[llm.models]] → G111 的 model 下拉只有默认模型一项**。**占位符坑(G117)**:模板 api_key 是 `"sk-..."` 占位符(config.py:108)——GUI 判断"是否已配 key"必须**排除占位符**(`api_key 为空 或 == "sk-..."` → 视为未配置);否则用户从不改 key 也会被当已配置,首启引导跳过、实际 key 无效。 - -> **G82(首启含目录选择)**:G6 方案 A 的"首启选项目目录"必须落在首启引导里——全新安装时 config 缺失(G71 分支)正好是唯一一次必然走首启的机会。两步合一对话框(目录 + API 设置),用户可跳过目录(**跳过 → 默认 `~/.emrg/evolution` 兜底,G6-B**——不写 [gui] 段)。**`[gui] project_dir` 写入**:config.py 不解析 `[gui]` 段(tomllib 只取 `data.get("llm")`,Python 侧安全忽略),GUI 写入/更新它复用 G60 全量读-改-写(smol-toml 解析 → 设 `gui.project_dir` → 全量序列化写回,保留 `[llm]` 全部键与其它段)。main 每次读 config 取 `project_dir` 作为默认 cwd;**config 已存在但无 `[gui]` 段** → 用默认 `~/.emrg/evolution` 兜底 + 状态栏显示「默认目录」,用户可在设置对话框改。**字段名映射(G115)**:renderer 的 `projectDir`(camelCase,G114 白名单)→ main 落盘 `[gui] project_dir`(snake_case,TOML 惯例)——映射只在 main 的 saveSettings handler 做一次,renderer 不感知 TOML 键名。`pickProjectDir` 只返回用户所选路径(**不写盘**)——由用户点「保存」才经 saveSettings 落盘(对话框「取消」不丢旧值)。 - -**init 必须先行判断 config 存在性(G71)**——`emrg:init` 内部序列(G34)前置一步:config.toml 不存在时**跳过 ensureConnected/startDaemon**(拉起即崩),直接返回缺配置状态;renderer 据此弹首启设置。保存成功后 renderer 重新 `init()`。 - -**首启保存 vs 常规保存的重连差异(G123)**:G119"保存后不主动重连,等 ws close"**仅适用于常规保存(daemon 已在运行)**——首启保存(config 从无到有)时 **daemon 尚未运行、没有 ws 可 close**,必须**主动**走 ensureConnected(拉起 daemon)。区分:saveSettings 返回 `{daemonWasRunning: bool}`(main 在保存前用 isRunning TCP 探测)→ renderer 据此:wasRunning=true → 等重连事件;wasRunning=false → 主动 `emrg:init`(触发拉起)。**G71 流程图的"再调一次 init"即此主动路径**——此前 G119 的"不主动重连"会与首启衔接冲突,特此澄清。 - -**project_dir 失效处理(G121)**:daemon 的 `_get_or_create_session` 对**任意 cwd 路径**都执行 `mkdir(parents=True, exist_ok=True)`(session.py:55,Session 构造)——**GUI 传一个已被删除/移动的 project_dir,daemon 会在该路径自动重建目录并创建会话**(静默数据错位:会话文件出现在用户已不用的路径)。**main 侧防护(G121)**:`emrg:init` 时校验 `project_dir` 存在且可写(`fs.existsSync` + `fs.accessSync(W_OK)`)——不存在则:① 状态栏显示「⚠️ 项目目录不存在」;② **禁用发送**(输入条置灰);③ 弹设置对话框引导改目录(G112 的 project_dir 字段)。**不自动 fallback 到 ~/.emrg/evolution**(用户可能误操作——显式提示优于静默换目录)。v1 不处理"中途目录被删"(发送时才校验,失败即提示)。 - -**「+ 新建」行为(G37)**:点击 → `emrg:newSession`(cwd 由 main 注入 project_dir)→ main 本地生成 `s_YYMMDD_HHMM_xxxx`(查重)→ 返回新 session_id → renderer 切换为新会话(聊天区清空 + 无占位)+ 刷新 SessionPanel。新建后首条消息即惰性创建 daemon 侧会话(`_get_or_create_session`)。 - -**空态与确认(G76)**: -- **会话列表空**(project_dir 无 .emrg/sessions 或空):SessionPanel 显示「暂无会话」占位 + 引导「+ 新建」——不显示空列表(不可点击区域) -- **删除会话确认**:右键删除 → `confirm()` 对话框(「删除后不可恢复」)→ 确认后才发 `emrg:deleteSession`;删除当前会话后自动切换到剩余最近会话(无剩余则显示空态 + 聊天区欢迎语) -- **被动删除恢复(G106)**:`delete_session` 用 `self._send` 非 `_broadcast`(daemon.py:866-897)——**其他客户端(TUI `/delete`)删除会话时 GUI 收不到通知**,SessionPanel 残留已删条目。处理:switchSession 收到 `resume_result` 带 `error`("Session not found")→ **自动 list_sessions 刷新列表** + 提示"该会话已被删除,已切换到最近会话"(若有剩余)或空态。v1 不轮询列表(零成本可接受残留,点击即自愈)。 -- **首启欢迎语**:聊天区在无会话时显示欢迎块(「你好,我是 EMRG——选择会话或新建开始」)——对齐 TUI welcome message -- **设置对话框**:无 api_key 时输入框聚焦 + 红色提示「API Key 必填」;保存时校验非空 -| StatusBar | `