From ae06fbce38dc3e8ad02e13b611881689d7208583 Mon Sep 17 00:00:00 2001 From: Nat-xu <19707027371@163.com> Date: Sat, 18 Jul 2026 22:22:20 +0800 Subject: [PATCH 01/22] refactor prompt context helpers --- app/services/agent_prompt_context.py | 114 ++++++++++++++++++++++ app/services/agent_service.py | 102 +------------------ app/services/test_agent_prompt_context.py | 54 ++++++++++ 3 files changed, 173 insertions(+), 97 deletions(-) create mode 100644 app/services/agent_prompt_context.py create mode 100644 app/services/test_agent_prompt_context.py diff --git a/app/services/agent_prompt_context.py b/app/services/agent_prompt_context.py new file mode 100644 index 0000000..2db16eb --- /dev/null +++ b/app/services/agent_prompt_context.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import json +from typing import Any + + +def build_attachment_context(attachments: list[dict[str, Any]] | None) -> tuple[str, list[dict[str, Any]]]: + if not attachments: + return "", [] + + blocks: list[str] = [] + clean: list[dict[str, Any]] = [] + max_text_len = 12000 + + for idx, item in enumerate(attachments, start=1): + name = str(item.get("name", f"file_{idx}")) + file_type = str(item.get("type", "text/plain")) + size = int(item.get("size", 0) or 0) + content = str(item.get("content", "")) + + is_image = file_type.startswith("image/") or name.lower().endswith((".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg")) + if is_image: + preview = content[:180] + blocks.append( + f"[附件图片 {idx}] name={name}, type={file_type}, size={size}\n" + f"data_url_prefix={preview}" + ) + else: + trimmed = content[:max_text_len] + ext = name.split(".")[-1] if "." in name else "text" + blocks.append( + f"[附件文件 {idx}] name={name}, type={file_type}, size={size}\n" + f"```{ext}\n{trimmed}\n```" + ) + + clean.append({"name": name, "type": file_type, "size": size}) + + return "\n\n".join(blocks), clean + + +def build_quote_context(quote_references: list[dict[str, Any]] | None) -> str: + """Format quoted chat messages for prompt injection.""" + if not quote_references: + return "" + + blocks: list[str] = [] + for idx, qr in enumerate(quote_references, start=1): + original_sender = str(qr.get("originalSender", "unknown")) + original_timestamp = str(qr.get("originalTimestamp", "")) + quoted_text = str(qr.get("quotedText", "")) + is_full_message = bool(qr.get("isFullMessage", False)) + + truncation_note = "" + display_text = quoted_text + if len(quoted_text) > 2000: + display_text = quoted_text[:2000] + "\n… [已截断]" + truncation_note = " (已截断)" + + msg_type = "完整消息" if is_full_message else "消息片段" + + blocks.append( + f"[引自历史消息 {idx}] 发送者: {original_sender}, " + f"时间: {original_timestamp}, 类型: {msg_type}{truncation_note}\n" + f"---\n{display_text}\n---" + ) + + return "[用户引用的历史消息]\n\n" + "\n\n".join(blocks) + + +def format_conversation_for_prompt(conversation: list[dict]) -> str: + """Format a multi-turn conversation for prompt injection.""" + parts: list[str] = [] + for turn in conversation: + role = turn.get("role", "") + if role == "user": + parts.append(f"【用户消息】\n{turn.get('content', '')}") + elif role == "assistant" and "tool_calls" in turn: + for tc in turn["tool_calls"]: + parts.append( + "【工具调用】\n" + f"调用工具: {tc.get('name', 'unknown')}\n" + f"参数: {json.dumps(tc.get('arguments', {}), ensure_ascii=False)}" + ) + elif role == "tool": + from app.services.tool_executor import tool_executor + + parts.append(tool_executor.build_tool_result_context(turn.get("results", []))) + elif role == "assistant": + parts.append(f"【助手回复】\n{turn.get('content', '')}") + return "\n\n".join(parts) + + +def estimate_token_usage(user_text: str, model_output: str) -> tuple[int, int, int]: + """Estimate token counts with a simple CJK-aware heuristic.""" + + def _is_cjk(ch: str) -> bool: + codepoint = ord(ch) + return ( + 0x3400 <= codepoint <= 0x4DBF + or 0x4E00 <= codepoint <= 0x9FFF + or 0xF900 <= codepoint <= 0xFAFF + or 0x3040 <= codepoint <= 0x30FF + or 0xAC00 <= codepoint <= 0xD7AF + ) + + def _count_tokens(text: str) -> int: + cjk = sum(1 for ch in text if _is_cjk(ch)) + non_cjk = len(text) - cjk + return max(1, int(cjk / 1.5 + non_cjk / 4)) + + prompt_tokens = _count_tokens(user_text) + completion_tokens = _count_tokens(model_output) + total_tokens = prompt_tokens + completion_tokens + return prompt_tokens, completion_tokens, total_tokens diff --git a/app/services/agent_service.py b/app/services/agent_service.py index 5bf16f2..3265687 100644 --- a/app/services/agent_service.py +++ b/app/services/agent_service.py @@ -15,6 +15,7 @@ from app.services.adapter_manager import adapter_manager from app.services.auth.service import AuthService from app.services.codegen_service import write_generated_files +from app.services import agent_prompt_context as prompt_context from app.services.prompt_cache import prompt_cache from app.services.secret_service import decrypt_secret from app.services.text_processing import ( @@ -400,70 +401,11 @@ def _intent_from_domain(domain: str, _content: str = "") -> str: def _build_attachment_context(attachments: list[dict[str, Any]] | None) -> tuple[str, list[dict[str, Any]]]: - if not attachments: - return "", [] - - blocks: list[str] = [] - clean: list[dict[str, Any]] = [] - max_text_len = 12000 - - for idx, item in enumerate(attachments, start=1): - name = str(item.get("name", f"file_{idx}")) - file_type = str(item.get("type", "text/plain")) - size = int(item.get("size", 0) or 0) - content = str(item.get("content", "")) - - is_image = file_type.startswith("image/") or name.lower().endswith((".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg")) - if is_image: - preview = content[:180] - blocks.append( - f"[附件图片 {idx}] name={name}, type={file_type}, size={size}\\n" - f"data_url_prefix={preview}" - ) - else: - trimmed = content[:max_text_len] - ext = name.split(".")[-1] if "." in name else "text" - blocks.append( - f"[附件文件 {idx}] name={name}, type={file_type}, size={size}\\n" - f"```{ext}\\n{trimmed}\\n```" - ) - - clean.append({"name": name, "type": file_type, "size": size}) - - return "\\n\\n".join(blocks), clean + return prompt_context.build_attachment_context(attachments) def _build_quote_context(quote_references: list[dict[str, Any]] | None) -> str: - """Format quoted chat messages as a context block for the AI prompt. - - Injects a ``[用户引用的历史消息]`` section above the current question, - giving the model visibility into what the user is referencing. - """ - if not quote_references: - return "" - - blocks: list[str] = [] - for idx, qr in enumerate(quote_references, start=1): - original_sender = str(qr.get("originalSender", "unknown")) - original_timestamp = str(qr.get("originalTimestamp", "")) - quoted_text = str(qr.get("quotedText", "")) - is_full_message = bool(qr.get("isFullMessage", False)) - - truncation_note = "" - display_text = quoted_text - if len(quoted_text) > 2000: - display_text = quoted_text[:2000] + "\n… [已截断]" - truncation_note = " (已截断)" - - msg_type = "完整消息" if is_full_message else "消息片段" - - blocks.append( - f"[引自历史消息 {idx}] 发送者: {original_sender}, " - f"时间: {original_timestamp}, 类型: {msg_type}{truncation_note}\n" - f"---\n{display_text}\n---" - ) - - return "[用户引用的历史消息]\n\n" + "\n\n".join(blocks) + return prompt_context.build_quote_context(quote_references) async def lookup_agent( @@ -2108,45 +2050,11 @@ async def build_prompt(agent_id: str, domain: str, content: str, symbolic: dict, def _estimate_token_usage(user_text: str, model_output: str) -> tuple[int, int, int]: - """Estimate token counts with CJK-aware heuristics. + return prompt_context.estimate_token_usage(user_text, model_output) - Pure ASCII text averages ~4 chars/token. CJK characters (Chinese, - Japanese, Korean) are denser — roughly 1.5 chars/token — because each - logogram is a distinct token unit. Mixing the two ratios gives a much - better estimate than the naive ``len // 4`` for bilingual content. - """ - def _count_tokens(text: str) -> int: - cjk = sum(1 for c in text if '一' <= c <= '鿿' or '㐀' <= c <= '䶿') - non_cjk = len(text) - cjk - # CJK: ~1.5 chars/token, non-CJK: ~4 chars/token - return max(1, int(cjk / 1.5 + non_cjk / 4)) - - prompt_tokens = _count_tokens(user_text) - completion_tokens = _count_tokens(model_output) - total_tokens = prompt_tokens + completion_tokens - return prompt_tokens, completion_tokens, total_tokens def _format_conversation(conversation: list[dict]) -> str: - """Format a multi-turn conversation (including tool calls/results) for prompt injection.""" - parts: list[str] = [] - for turn in conversation: - role = turn.get("role", "") - if role == "user": - parts.append(f"【用户消息】\n{turn.get('content', '')}") - elif role == "assistant" and "tool_calls" in turn: - tcs = turn["tool_calls"] - for tc in tcs: - parts.append( - f"【工具调用】\n" - f"调用工具: {tc.get('name', 'unknown')}\n" - f"参数: {json.dumps(tc.get('arguments', {}), ensure_ascii=False)}" - ) - elif role == "tool": - from app.services.tool_executor import tool_executor - parts.append(tool_executor.build_tool_result_context(turn.get("results", []))) - elif role == "assistant": - parts.append(f"【助手回复】\n{turn.get('content', '')}") - return "\n\n".join(parts) + return prompt_context.format_conversation_for_prompt(conversation) async def _log_tool_call(session_id: str, agent_id: str, tool_name: str, arguments: dict, result: dict) -> None: diff --git a/app/services/test_agent_prompt_context.py b/app/services/test_agent_prompt_context.py new file mode 100644 index 0000000..0517d7e --- /dev/null +++ b/app/services/test_agent_prompt_context.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from app.services.agent_prompt_context import ( + build_attachment_context, + build_quote_context, + estimate_token_usage, + format_conversation_for_prompt, +) + + +def test_build_attachment_context_truncates_text_and_returns_metadata() -> None: + content = "x" * 12_500 + text, meta = build_attachment_context([ + {"name": "notes.md", "type": "text/markdown", "size": 12_500, "content": content}, + ]) + + assert "notes.md" in text + assert len(text) < len(content) + assert meta == [{"name": "notes.md", "type": "text/markdown", "size": 12500}] + + +def test_build_quote_context_truncates_long_messages() -> None: + text = build_quote_context([ + { + "originalSender": "alice", + "originalTimestamp": "2026-07-18T10:00:00Z", + "quotedText": "y" * 2100, + "isFullMessage": True, + } + ]) + + assert "[用户引用的历史消息]" in text + assert "alice" in text + assert "已截断" in text + + +def test_estimate_token_usage_prefers_cjk_density() -> None: + ascii_prompt, ascii_completion, ascii_total = estimate_token_usage("abcd", "efgh") + cjk_prompt, cjk_completion, cjk_total = estimate_token_usage("你好世界", "再见世界") + + assert ascii_total > 0 + assert cjk_prompt >= ascii_prompt + assert cjk_completion >= ascii_completion + assert cjk_total >= ascii_total + + +def test_format_conversation_for_prompt_supports_basic_turns() -> None: + text = format_conversation_for_prompt([ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "world"}, + ]) + + assert "hello" in text + assert "world" in text From c9b04bd7d404dab0b10addca59970fec80ef711a Mon Sep 17 00:00:00 2001 From: Nat-xu <19707027371@163.com> Date: Sat, 18 Jul 2026 22:33:41 +0800 Subject: [PATCH 02/22] refactor auto name prompt generation --- app/api/chat.py | 9 +- app/services/auto_name_prompt.py | 113 ++++++++++++++++++++++++++ app/services/test_auto_name_prompt.py | 19 +++++ 3 files changed, 137 insertions(+), 4 deletions(-) create mode 100644 app/services/auto_name_prompt.py create mode 100644 app/services/test_auto_name_prompt.py diff --git a/app/api/chat.py b/app/api/chat.py index 83b3538..809414a 100644 --- a/app/api/chat.py +++ b/app/api/chat.py @@ -16,6 +16,7 @@ check_session_access, ) from app.services.agent_service import list_messages +from app.services.auto_name_prompt import build_auto_name_prompt, extract_local_title from app.services.session_preferences import ( PINNED_SESSIONS_SETTING_KEY, apply_session_pin_state, @@ -729,7 +730,7 @@ async def auto_name_session(session_id: str, user: dict = Depends(get_current_us if not msgs or len(msgs) < 2: return {"status": "skipped", "reason": "Not enough messages", "sessionId": session_id} - prompt = _build_auto_name_prompt(msgs) + prompt = build_auto_name_prompt(msgs) if not prompt: return {"status": "skipped", "reason": "No message content", "sessionId": session_id} @@ -739,7 +740,7 @@ async def auto_name_session(session_id: str, user: dict = Depends(get_current_us user_msgs = [m for m in msgs if m.get("sender") not in ("system", "agent", "orchestrator")] first_msg = (user_msgs[0].get("content") or "").strip() if user_msgs else "" if first_msg: - name = _extract_local_title(first_msg) + name = extract_local_title(first_msg) if not name: return {"status": "skipped", "reason": "LLM call failed", "sessionId": session_id} @@ -764,13 +765,13 @@ async def try_auto_name_session(session_id: str) -> str | None: if not msgs or len(msgs) < 1: return None - prompt = _build_auto_name_prompt(msgs) + prompt = build_auto_name_prompt(msgs) if not prompt: # No prompt could be built — try local extraction directly user_msgs = [m for m in msgs if m.get("sender") not in ("system", "agent", "orchestrator")] first_msg = (user_msgs[0].get("content") or "").strip() if user_msgs else "" if first_msg: - name = _extract_local_title(first_msg) + name = extract_local_title(first_msg) if name: await aexecute("UPDATE sessions SET name=$1 WHERE id=$2", name, session_id) return name diff --git a/app/services/auto_name_prompt.py b/app/services/auto_name_prompt.py new file mode 100644 index 0000000..c4a1249 --- /dev/null +++ b/app/services/auto_name_prompt.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import re +from typing import Any + + +def build_auto_name_prompt(messages: list[dict[str, Any]]) -> str: + """Build a compact prompt for session auto-naming.""" + user_msgs = [m for m in messages if m.get("sender") not in ("system", "agent", "orchestrator")] + agent_msgs = [m for m in messages if m.get("sender") in ("agent", "orchestrator", "system")] + + first_user = (user_msgs[0].get("content") or "").strip() if user_msgs else "" + if not first_user: + return "" + if len(first_user) > 180: + first_user = first_user[:180] + "..." + + first_reply = "" + if agent_msgs: + reply_content = (agent_msgs[0].get("content") or "").strip() + if reply_content: + if len(reply_content) > 120: + reply_content = reply_content[:120] + "..." + first_reply = f"\n助手回复:{reply_content}" + + return ( + "请根据首轮对话生成一个中文会话标题。\n" + "要求:3-15字,概括核心意图,避免空泛词如“新建会话”“聊天”“对话”,不要加引号或编号。\n" + f"用户首条消息:{first_user}" + f"{first_reply}\n" + "标题:" + ) + + +def extract_local_title(first_message: str) -> str: + """Fallback title extraction without an LLM.""" + text = first_message.strip() + text = re.sub(r'@\w+\s*', '', text).strip() + if not text: + return "" + + patterns = [ + r'(?:生成|创建|编写|实现|开发|搭建)\s*(?:一个\s*)?(.{2,30}(?:文件|代码|页面|模块|功能|路由|接口|API|组件)?)$', + r'(?:帮我|请帮我|麻烦)\s*(.{2,30}?)(?:谢谢|一下)?$', + r'(?:如何|怎么|怎样)\s*(.{2,30}?)(?:\?|?)?$', + r'(?:修复|修改|优化|调整|更新)\s*(.{2,30}?)$', + r'(?:分析|审查|检查|review|analyze)\s*(.{2,30}?)$', + ] + for pattern in patterns: + m = re.search(pattern, text, re.IGNORECASE) + if m: + keyword = m.group(1).strip().rstrip('。!?!?,,') + if 2 <= len(keyword) <= 20: + return keyword + + eng_patterns = [ + (r'[Gg]enerate\s+(?:a\s+)?(.{2,40}?)(?:\s+(?:file|route|code|page|module))?$', '生成'), + (r'[Cc]reate\s+(?:a\s+)?(.{2,40}?)$', '创建'), + (r'[Ff]ix\s+(?:the\s+)?(.{2,40}?)$', '修复'), + (r'[Ii]mplement\s+(?:a\s+)?(.{2,40}?)$', '实现'), + (r'[Cc]ode\s+(?:review|check)\s+(?:of\s+)?(.{2,40}?)$', '审查'), + ] + text_lower = text.lower() + if "health route" in text_lower: + return "生成健康检查路由" + if "health check" in text_lower: + return "生成健康检查" + if "rest api" in text_lower: + return "生成REST接口" + translations = { + 'health route': '健康检查路由', + 'health check': '健康检查', + 'rest api': 'REST接口', + 'login': '登录功能', + 'auth': '认证功能', + 'database': '数据库', + 'config': '配置管理', + 'test': '测试用例', + 'component': '组件开发', + 'middleware': '中间件', + 'docker': 'Docker部署', + 'frontend': '前端页面', + 'backend': '后端服务', + 'pipeline': 'CI/CD流水线', + 'deploy': '部署流程', + 'health route': '健康检查路由', + 'api': 'API接口', + } + for pattern, prefix in eng_patterns: + m = re.search(pattern, text) + if m: + keyword = m.group(1).strip().rstrip('.!?') + keyword_lower = keyword.lower() + if "health route" in keyword_lower: + return f"{prefix}健康检查路由" + if "health check" in keyword_lower: + return f"{prefix}健康检查" + if "rest api" in keyword_lower: + return f"{prefix}REST接口" + for eng, chn in translations.items(): + if eng in keyword_lower: + return f"{prefix}{chn}" + title = f"{prefix}{keyword[:10]}" + return title[:15] + + parts = re.split(r'[,,。!?\n!?]', text) + for part in parts: + part = part.strip() + clean = re.sub(r'[^\w\u4e00-\u9fff]', '', part) + if len(clean) >= 3: + return part[:15] if len(part) > 15 else part + + return text[:15] if len(text) >= 3 else "" diff --git a/app/services/test_auto_name_prompt.py b/app/services/test_auto_name_prompt.py new file mode 100644 index 0000000..d11dc1c --- /dev/null +++ b/app/services/test_auto_name_prompt.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from app.services.auto_name_prompt import build_auto_name_prompt, extract_local_title + + +def test_build_auto_name_prompt_is_compact() -> None: + prompt = build_auto_name_prompt([ + {"sender": "user", "content": "请帮我实现一个飞书消息同步功能,支持多租户和增量更新。"}, + {"sender": "agent", "content": "可以,下面我先拆一下模块和数据流。"}, + ]) + + assert "标题:" in prompt + assert "飞书消息同步功能" in prompt + assert len(prompt) < 260 + + +def test_extract_local_title_handles_common_intents() -> None: + assert extract_local_title("请帮我实现一个订单导出页面") == "订单导出页面" + assert extract_local_title("Generate a FastAPI health route") == "生成健康检查路由" From 448bceaf64cd0caede03b6334ccebf50dc27b11b Mon Sep 17 00:00:00 2001 From: Nat-xu <19707027371@163.com> Date: Sat, 18 Jul 2026 22:41:49 +0800 Subject: [PATCH 03/22] compress conversation history prompts --- app/services/agent_service.py | 19 +++---------- app/services/conversation_history.py | 32 ++++++++++++++++++++++ app/services/test_conversation_history.py | 33 +++++++++++++++++++++++ 3 files changed, 68 insertions(+), 16 deletions(-) create mode 100644 app/services/conversation_history.py create mode 100644 app/services/test_conversation_history.py diff --git a/app/services/agent_service.py b/app/services/agent_service.py index 3265687..84685c7 100644 --- a/app/services/agent_service.py +++ b/app/services/agent_service.py @@ -15,6 +15,7 @@ from app.services.adapter_manager import adapter_manager from app.services.auth.service import AuthService from app.services.codegen_service import write_generated_files +from app.services.conversation_history import build_conversation_history_transcript from app.services import agent_prompt_context as prompt_context from app.services.prompt_cache import prompt_cache from app.services.secret_service import decrypt_secret @@ -1348,7 +1349,7 @@ async def _run_loop(): return stream() -async def _build_conversation_history(session_id: str, max_chars: int = 8000) -> str: +async def _build_conversation_history(session_id: str, max_chars: int = 5000) -> str: """Fetch recent messages from this session and format as a transcript. Gives every agent called in the session full awareness of what was @@ -1377,21 +1378,7 @@ async def _build_conversation_history(session_id: str, max_chars: int = 8000) -> prompt_cache.set_history(session_id, "") return "" - # Keep DESC order (newest first), build lines until we hit max_chars, - # then reverse to chronological for the prompt. - lines: list[str] = [] - total = 0 - for r in rows: - content = _strip_think_tags(r["content"]) - # Truncate individual messages — larger cap to preserve more meaning - line = f"{r['sender']}:{content[:1200]}" - total += len(line) - lines.append(line) - if total > max_chars: - break - - lines.reverse() # chronological order for readability - result = "【会话历史记录 — 以下是本会话中之前的对话内容,请基于此上下文理解用户的后续问题】\n" + "\n".join(lines) + result = build_conversation_history_transcript(rows, max_chars=max_chars) prompt_cache.set_history(session_id, result) return result diff --git a/app/services/conversation_history.py b/app/services/conversation_history.py new file mode 100644 index 0000000..41bbeaf --- /dev/null +++ b/app/services/conversation_history.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from typing import Any + +from app.services.text_processing import strip_think_tags + + +def build_conversation_history_transcript( + rows: list[dict[str, Any]], + *, + max_chars: int = 5000, + max_messages: int = 16, + max_message_chars: int = 600, +) -> str: + if not rows: + return "" + + lines: list[str] = [] + total = 0 + for row in rows[:max_messages]: + sender = str(row.get("sender", "unknown")) + content = strip_think_tags(str(row.get("content", ""))) + if len(content) > max_message_chars: + content = content[:max_message_chars] + "..." + line = f"{sender}: {content}" + total += len(line) + lines.append(line) + if total > max_chars: + break + + lines.reverse() + return "【会话历史】\n" + "\n".join(lines) diff --git a/app/services/test_conversation_history.py b/app/services/test_conversation_history.py new file mode 100644 index 0000000..592720a --- /dev/null +++ b/app/services/test_conversation_history.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from app.services.conversation_history import build_conversation_history_transcript + + +def test_build_conversation_history_transcript_keeps_chronological_order() -> None: + transcript = build_conversation_history_transcript( + [ + {"sender": "assistant", "content": "second"}, + {"sender": "user", "content": "first"}, + ], + max_chars=10_000, + max_messages=10, + ) + + assert transcript.startswith("【会话历史】") + assert transcript.index("first") < transcript.index("second") + + +def test_build_conversation_history_transcript_truncates_long_messages() -> None: + transcript = build_conversation_history_transcript( + [{"sender": "user", "content": "x" * 700}], + max_chars=10_000, + max_messages=10, + max_message_chars=100, + ) + + assert len(transcript) < 200 + assert transcript.endswith("...") + + +def test_build_conversation_history_transcript_handles_empty_input() -> None: + assert build_conversation_history_transcript([]) == "" From 68cc345d50f81b9aa3135a582eb009865d6b3161 Mon Sep 17 00:00:00 2001 From: Nat-xu <19707027371@163.com> Date: Sat, 18 Jul 2026 22:50:51 +0800 Subject: [PATCH 04/22] extract compact prompt sections --- app/services/agent_service.py | 22 +++-------- app/services/prompt_sections.py | 56 ++++++++++++++++++++++++++++ app/services/test_prompt_sections.py | 42 +++++++++++++++++++++ 3 files changed, 103 insertions(+), 17 deletions(-) create mode 100644 app/services/prompt_sections.py create mode 100644 app/services/test_prompt_sections.py diff --git a/app/services/agent_service.py b/app/services/agent_service.py index 84685c7..b3c4985 100644 --- a/app/services/agent_service.py +++ b/app/services/agent_service.py @@ -17,6 +17,7 @@ from app.services.codegen_service import write_generated_files from app.services.conversation_history import build_conversation_history_transcript from app.services import agent_prompt_context as prompt_context +from app.services import prompt_sections from app.services.prompt_cache import prompt_cache from app.services.secret_service import decrypt_secret from app.services.text_processing import ( @@ -1633,27 +1634,13 @@ async def build_prompt(agent_id: str, domain: str, content: str, symbolic: dict, # This is the "main context window" — every agent reads it before # its role-specific instructions, ensuring a unified understanding # of what the conversation is about regardless of domain. - shared_context = "" - if history: - shared_context = ( - "【共享会话上下文 — 所有Agent的对话记忆窗口】\n" - "以下是你与用户及其他Agent之间的完整对话记录。请首先通读此上下文," - "理解当前话题和讨论脉络,再结合你的专业角色给出回复。\n" - "即使话题与你的专业领域不完全匹配,也请基于上下文给出合理回答," - "不要以\"我是XX专家\"为由拒绝回复。\n\n" - f"{history}\n" - "─── 以上为共享记忆,以下是你的角色指令 ───\n" - ) - - collab_section = f"\n\n{collab_ctx}" if collab_ctx else "" + shared_context = prompt_sections.build_shared_context(history) + collab_section = prompt_sections.build_collab_section(collab_ctx) # ── Current date (so the model knows what "today" is) ──────────── # The model's training cutoff may be months ago. Without this, the # model hallucinates dates or uses stale ones in search queries. - from datetime import datetime as _dt - today_str = _dt.now().strftime("%Y年%m月%d日") - weekday_str = ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"][_dt.now().weekday()] - date_context = f"【当前日期】{today_str} {weekday_str}。涉及\"今天\"、\"最新\"、\"最近\"等内容时,请基于此日期。\n" + date_context = prompt_sections.build_date_context() # ── Workspace filesystem context ───────────────────────────────── # Informs the agent that it has a real filesystem to work with. @@ -1698,6 +1685,7 @@ async def build_prompt(agent_id: str, domain: str, content: str, symbolic: dict, "多人协作时,系统自动检测文件冲突并发出警告。\n" f"{_ws_files_summary}\n" ) if _ws_root.exists() else "" + workspace_context_block = prompt_sections.build_workspace_context() # ── Load settings for reply language, reasoning, thinking ─────── settings = await _load_settings() diff --git a/app/services/prompt_sections.py b/app/services/prompt_sections.py new file mode 100644 index 0000000..610c31c --- /dev/null +++ b/app/services/prompt_sections.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from datetime import datetime +from pathlib import Path + + +def build_shared_context(history: str) -> str: + if not history: + return "" + return ( + "【共享会话上下文】\n" + "先参考以下历史,再按当前角色回答;不要因角色不完全匹配而拒绝。\n\n" + f"{history}\n" + "─── 以上为共享记忆,以下是角色指令 ───\n" + ) + + +def build_collab_section(collab_ctx: str) -> str: + return f"\n\n{collab_ctx}" if collab_ctx else "" + + +def build_date_context(now: datetime | None = None) -> str: + current = now or datetime.now() + today_str = current.strftime("%Y年%m月%d日") + weekday_str = ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"][current.weekday()] + return f"【当前日期】{today_str} {weekday_str}。涉及今天、最新、最近时以此为准。\n" + + +def build_workspace_context(root: Path | None = None, *, max_items: int = 20) -> str: + if root is None: + from app.services.workspace_context import get_workspace_root + + root = get_workspace_root() + + if not root.exists(): + return "" + + lines = [f"工作区: {root}", "可用文件工具: file_read/file_write/file_write_batch/file_edit/file_patch/file_search/file_glob/mkdir/code_execute"] + try: + items = sorted(root.iterdir(), key=lambda p: (p.is_dir(), p.name.lower()))[:max_items] + for path in items: + kind = "dir" if path.is_dir() else "file" + size = "" + if path.is_file(): + try: + bytes_size = path.stat().st_size + size = f" ({bytes_size:,} bytes)" if bytes_size < 1024 else f" ({bytes_size / 1024:.0f} KB)" + except OSError: + pass + lines.append(f"- {kind}: {path.name}{size}") + if len(items) >= max_items: + lines.append("- ... 使用 file_glob 或 file_read 查看更多") + except OSError: + pass + + return "【工作区文件系统】\n" + "\n".join(lines) + "\n" diff --git a/app/services/test_prompt_sections.py b/app/services/test_prompt_sections.py new file mode 100644 index 0000000..23e74f6 --- /dev/null +++ b/app/services/test_prompt_sections.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from datetime import datetime + +from app.services.prompt_sections import ( + build_collab_section, + build_date_context, + build_shared_context, + build_workspace_context, +) + + +def test_build_shared_context_is_compact() -> None: + section = build_shared_context("user: hello") + + assert "共享会话上下文" in section + assert "user: hello" in section + assert len(section) < 120 + + +def test_build_collab_section_preserves_empty_state() -> None: + assert build_collab_section("") == "" + assert build_collab_section("team notes") == "\n\nteam notes" + + +def test_build_date_context_is_deterministic_with_now() -> None: + section = build_date_context(datetime(2026, 7, 18)) + + assert "2026年07月18日" in section + assert "星期六" in section + + +def test_build_workspace_context_lists_files(tmp_path) -> None: + (tmp_path / "a.txt").write_text("hello", encoding="utf-8") + (tmp_path / "src").mkdir() + + section = build_workspace_context(tmp_path, max_items=5) + + assert "工作区文件系统" in section + assert "file_write_batch" in section + assert "a.txt" in section + assert "src" in section From 7e20e38788fb8b8875710ee6f78e915d2f31f83f Mon Sep 17 00:00:00 2001 From: Nat-xu <19707027371@163.com> Date: Sat, 18 Jul 2026 22:56:20 +0800 Subject: [PATCH 05/22] remove legacy workspace prompt block --- app/services/agent_service.py | 45 ++--------------------------------- 1 file changed, 2 insertions(+), 43 deletions(-) diff --git a/app/services/agent_service.py b/app/services/agent_service.py index b3c4985..c70062c 100644 --- a/app/services/agent_service.py +++ b/app/services/agent_service.py @@ -1642,49 +1642,8 @@ async def build_prompt(agent_id: str, domain: str, content: str, symbolic: dict, # model hallucinates dates or uses stale ones in search queries. date_context = prompt_sections.build_date_context() - # ── Workspace filesystem context ───────────────────────────────── - # Informs the agent that it has a real filesystem to work with. - # The workspace is session-scoped and git-versioned — every file - # write is automatically committed so the agent should feel - # confident persisting code and data. - from app.services.workspace_context import get_workspace_root as _ws_root_fn - _ws_root = _ws_root_fn() - _ws_files_summary = "" - try: - if _ws_root.exists(): - _items = sorted(_ws_root.iterdir(), key=lambda p: (p.is_dir(), p.name.lower()))[:30] - _lines = [f"工作区路径: {_ws_root}"] - for p in _items: - _kind = "📁" if p.is_dir() else "📄" - _size = "" - if p.is_file(): - try: - sz = p.stat().st_size - _size = f" ({sz:,} bytes)" if sz < 1024 else f" ({sz/1024:.0f} KB)" - except OSError: - pass - _lines.append(f" {_kind} {p.name}{_size}") - if len(_items) >= 30: - _lines.append(" ... (已截断,使用 file_read 查看完整目录)") - _ws_files_summary = "\n".join(_lines) + "\n" - except OSError: - pass - workspace_context_block = ( - "【工作区文件系统 — 真实落盘能力】\n" - "你拥有真实的工作区文件系统,可以使用以下工具操作文件:\n" - "- file_read — 读取文件内容或列出目录\n" - "- file_write — 创建/覆写/追加文件到工作区(自动创建父目录)\n" - "- file_write_batch — 批量写入多个文件(推荐用于一次生成多文件代码)\n" - "- file_edit — 精确字符串替换编辑文件(新文件自动创建,无需先用 file_write)\n" - "- file_patch — 应用 unified diff 补丁(适合增量修改)\n" - "- file_search — 在文件中搜索匹配内容(支持正则)\n" - "- file_glob — 按通配符模式查找文件(如 **/*.py)\n" - "- mkdir — 创建目录(类似 mkdir -p,用于搭建项目结构)\n" - "- code_execute — 在工作区中执行 Python/Bash 代码\n\n" - "所有文件操作自动纳入 Git 版本控制,可追溯变更历史。\n" - "多人协作时,系统自动检测文件冲突并发出警告。\n" - f"{_ws_files_summary}\n" - ) if _ws_root.exists() else "" + # Workspace filesystem context is intentionally compact. Tool details live in + # prompt_sections so build_prompt stays focused on orchestration. workspace_context_block = prompt_sections.build_workspace_context() # ── Load settings for reply language, reasoning, thinking ─────── From 0f450c95f66e4de8e74bef56d02c540f79fd7cf5 Mon Sep 17 00:00:00 2001 From: Nat-xu <19707027371@163.com> Date: Sat, 18 Jul 2026 23:02:05 +0800 Subject: [PATCH 06/22] extract general prompt template --- app/services/agent_prompt_templates.py | 43 +++++++++++++++++++++ app/services/agent_service.py | 38 +++++++++--------- app/services/test_agent_prompt_templates.py | 31 +++++++++++++++ 3 files changed, 94 insertions(+), 18 deletions(-) create mode 100644 app/services/agent_prompt_templates.py create mode 100644 app/services/test_agent_prompt_templates.py diff --git a/app/services/agent_prompt_templates.py b/app/services/agent_prompt_templates.py new file mode 100644 index 0000000..ec003a9 --- /dev/null +++ b/app/services/agent_prompt_templates.py @@ -0,0 +1,43 @@ +from __future__ import annotations + + +def build_general_prompt( + *, + agent_id: str, + role_desc: str, + content: str, + symbolic_text: str, + memory_context: str, + shared_context: str, + date_context: str, + workspace_context: str, + role_prompt: str, + actual_model_line: str, + reply_lang_instruction: str, + reasoning_instruction: str, + thinking_rule: str, + code_format_rules: str, + mermaid_rules: str, + output_rules: str, + tool_section: str, + collab_section: str, +) -> str: + custom_role = role_prompt.strip() if role_prompt else "" + return ( + f"{memory_context}" + f"{shared_context}" + f"{date_context}" + f"{workspace_context}" + f"你是 AgentHub 平台中的 {agent_id}({role_desc})。\n" + + (f"\n{custom_role}\n\n" if custom_role else "\n") + + f"{actual_model_line}" + + f"{reply_lang_instruction}" + f"{reasoning_instruction}" + f"{thinking_rule}" + f"{code_format_rules}\n" + f"{mermaid_rules}\n" + f"{output_rules}\n" + f"{tool_section}" + f"{collab_section}" + f"符号消息: {symbolic_text}\n用户需求: {content}" + ) diff --git a/app/services/agent_service.py b/app/services/agent_service.py index c70062c..44a401e 100644 --- a/app/services/agent_service.py +++ b/app/services/agent_service.py @@ -17,6 +17,7 @@ from app.services.codegen_service import write_generated_files from app.services.conversation_history import build_conversation_history_transcript from app.services import agent_prompt_context as prompt_context +from app.services.agent_prompt_templates import build_general_prompt from app.services import prompt_sections from app.services.prompt_cache import prompt_cache from app.services.secret_service import decrypt_secret @@ -1919,24 +1920,25 @@ async def build_prompt(agent_id: str, domain: str, content: str, symbolic: dict, ) # ── General agent prompt ──────────────────────────────────────── - custom_role = role_prompt.strip() if role_prompt else "" - prompt = ( - f"{memory_context}" - f"{shared_context}" - f"{date_context}" - f"{workspace_context_block}" - f"你是 AgentHub 平台中的 {agent_id}({role_desc})。\n" - + (f"\n{custom_role}\n\n" if custom_role else "\n") - + f"{actual_model_line}" - + f"{reply_lang_instr}" - f"{reasoning_instr}" - f"{thinking_rule}" - f"{code_format_rules}\n" - f"{mermaid_rules}\n" - f"{output_rules}\n" - + (_build_tool_section(agent_id, available_tools, permission_mode) if tools_enabled else "") - + f"{collab_section}" - f"符号消息: {json.dumps(public_symbolic(symbolic), ensure_ascii=False)}\n用户需求: {content}" + prompt = build_general_prompt( + agent_id=agent_id, + role_desc=role_desc, + content=content, + symbolic_text=json.dumps(public_symbolic(symbolic), ensure_ascii=False), + memory_context=memory_context, + shared_context=shared_context, + date_context=date_context, + workspace_context=workspace_context_block, + role_prompt=role_prompt, + actual_model_line=actual_model_line, + reply_lang_instruction=reply_lang_instr, + reasoning_instruction=reasoning_instr, + thinking_rule=thinking_rule, + code_format_rules=code_format_rules, + mermaid_rules=mermaid_rules, + output_rules=output_rules, + tool_section=_build_tool_section(agent_id, available_tools, permission_mode) if tools_enabled else "", + collab_section=collab_section, ) # ── Prompt size guard ────────────────────────────────────────── diff --git a/app/services/test_agent_prompt_templates.py b/app/services/test_agent_prompt_templates.py new file mode 100644 index 0000000..2c6a007 --- /dev/null +++ b/app/services/test_agent_prompt_templates.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from app.services.agent_prompt_templates import build_general_prompt + + +def test_build_general_prompt_preserves_anchor_and_sections() -> None: + prompt = build_general_prompt( + agent_id="Helper", + role_desc="generalist", + content="summarize this", + symbolic_text='{"intent":"general"}', + memory_context="[memory]\n", + shared_context="[shared]\n", + date_context="[date]\n", + workspace_context="[workspace]\n", + role_prompt="Be concise.", + actual_model_line="[model]\n", + reply_lang_instruction="[language]\n", + reasoning_instruction="[reasoning]\n", + thinking_rule="[thinking]\n", + code_format_rules="[code]\n", + mermaid_rules="[mermaid]\n", + output_rules="[output]\n", + tool_section="[tools]\n", + collab_section="[collab]\n", + ) + + assert prompt.startswith("[memory]\n[shared]\n[date]\n[workspace]\n") + assert "Be concise." in prompt + assert "符号消息: {\"intent\":\"general\"}" in prompt + assert prompt.endswith("用户需求: summarize this") From e978dbcfcfc36898e39e620d867421bf7a7d7e72 Mon Sep 17 00:00:00 2001 From: Nat-xu <19707027371@163.com> Date: Sat, 18 Jul 2026 23:06:31 +0800 Subject: [PATCH 07/22] extract codegen prompt template --- app/services/agent_prompt_templates.py | 44 ++++++++++++++++++++ app/services/agent_service.py | 41 ++++++++---------- app/services/test_codegen_prompt_template.py | 29 +++++++++++++ 3 files changed, 91 insertions(+), 23 deletions(-) create mode 100644 app/services/test_codegen_prompt_template.py diff --git a/app/services/agent_prompt_templates.py b/app/services/agent_prompt_templates.py index ec003a9..7420f34 100644 --- a/app/services/agent_prompt_templates.py +++ b/app/services/agent_prompt_templates.py @@ -41,3 +41,47 @@ def build_general_prompt( f"{collab_section}" f"符号消息: {symbolic_text}\n用户需求: {content}" ) + + +def build_codegen_prompt( + *, + agent_id: str, + content: str, + symbolic_text: str, + memory_context: str, + shared_context: str, + date_context: str, + workspace_context: str, + actual_model_line: str, + reply_lang_instruction: str, + reasoning_instruction: str, + thinking_rule: str, + code_format_rules: str, + mermaid_rules: str, + output_rules: str, + tool_section: str, + collab_section: str, +) -> str: + return ( + f"{memory_context}" + f"{shared_context}" + f"{date_context}" + f"{workspace_context}" + f"你是 {agent_id},AgentHub 多智能体平台中的代码生成专家。\n\n" + f"{actual_model_line}" + f"{reply_lang_instruction}" + f"{reasoning_instruction}" + f"{thinking_rule}" + f"{code_format_rules}\n" + f"{mermaid_rules}\n" + f"{output_rules}\n" + "# 代码生成规则\n" + "当且仅当用户明确请求生成代码、创建文件、修改代码、实现具体功能时,回复使用 JSON 格式:\n" + "{\"files\":[{\"path\":\"相对路径\",\"content\":\"文件完整内容\"}]}\n" + "- 路径只能是相对路径,代码必须完整可运行\n" + "- JSON 不要包裹在 Markdown 代码块中\n\n" + "# 非代码请求:直接以纯文本回复,严禁输出 JSON 格式。\n" + f"{tool_section}" + f"{collab_section}" + f"符号消息: {symbolic_text}\n用户需求: {content}" + ) diff --git a/app/services/agent_service.py b/app/services/agent_service.py index 44a401e..6ae482c 100644 --- a/app/services/agent_service.py +++ b/app/services/agent_service.py @@ -17,7 +17,7 @@ from app.services.codegen_service import write_generated_files from app.services.conversation_history import build_conversation_history_transcript from app.services import agent_prompt_context as prompt_context -from app.services.agent_prompt_templates import build_general_prompt +from app.services.agent_prompt_templates import build_codegen_prompt, build_general_prompt from app.services import prompt_sections from app.services.prompt_cache import prompt_cache from app.services.secret_service import decrypt_secret @@ -1720,28 +1720,23 @@ async def build_prompt(agent_id: str, domain: str, content: str, symbolic: dict, ) if agent_id == "CodeGen": - return ( - f"{memory_context}" - f"{shared_context}" - f"{date_context}" - f"{workspace_context_block}" - f"你是 CodeGenAgent,AgentHub 多智能体平台中的代码生成专家。\n\n" - f"{actual_model_line}" - f"{reply_lang_instr}" - f"{reasoning_instr}" - f"{thinking_rule}" - f"{code_format_rules}\n" - f"{mermaid_rules}\n" - f"{output_rules}\n" - "# 代码生成规则\n" - "当且仅当用户明确请求生成代码、创建文件、修改代码、实现具体功能时,回复使用 JSON 格式:\n" - "{\"files\":[{\"path\":\"相对路径\",\"content\":\"文件完整内容\"}]}\n" - "- 路径只能是相对路径,代码必须完整可运行\n" - "- JSON 不要包裹在 Markdown 代码块中\n\n" - "# 非代码请求:直接以纯文本回复,严禁输出 JSON 格式。\n" - + (_build_tool_section(agent_id, available_tools, permission_mode) if tools_enabled else "") - + f"{collab_section}" - f"符号消息: {json.dumps(public_symbolic(symbolic), ensure_ascii=False)}\n用户需求: {content}" + return build_codegen_prompt( + agent_id=agent_id, + content=content, + symbolic_text=json.dumps(public_symbolic(symbolic), ensure_ascii=False), + memory_context=memory_context, + shared_context=shared_context, + date_context=date_context, + workspace_context=workspace_context_block, + actual_model_line=actual_model_line, + reply_lang_instruction=reply_lang_instr, + reasoning_instruction=reasoning_instr, + thinking_rule=thinking_rule, + code_format_rules=code_format_rules, + mermaid_rules=mermaid_rules, + output_rules=output_rules, + tool_section=_build_tool_section(agent_id, available_tools, permission_mode) if tools_enabled else "", + collab_section=collab_section, ) if agent_id == "Orchestrator": diff --git a/app/services/test_codegen_prompt_template.py b/app/services/test_codegen_prompt_template.py new file mode 100644 index 0000000..7709f7e --- /dev/null +++ b/app/services/test_codegen_prompt_template.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from app.services.agent_prompt_templates import build_codegen_prompt + + +def test_build_codegen_prompt_keeps_json_contract() -> None: + prompt = build_codegen_prompt( + agent_id="CodeGen", + content="implement feature", + symbolic_text='{"intent":"code_generation"}', + memory_context="[memory]\n", + shared_context="[shared]\n", + date_context="[date]\n", + workspace_context="[workspace]\n", + actual_model_line="[model]\n", + reply_lang_instruction="[language]\n", + reasoning_instruction="[reasoning]\n", + thinking_rule="[thinking]\n", + code_format_rules="[code]\n", + mermaid_rules="[mermaid]\n", + output_rules="[output]\n", + tool_section="[tools]\n", + collab_section="[collab]\n", + ) + + assert prompt.startswith("[memory]\n[shared]\n[date]\n[workspace]\n") + assert "代码生成规则" in prompt + assert "{\"files\":[{\"path\":\"相对路径\"" in prompt + assert prompt.endswith("用户需求: implement feature") From 69c85e04e98f55c68d1abfc42b5f3aa1da50200e Mon Sep 17 00:00:00 2001 From: Nat-xu <19707027371@163.com> Date: Sat, 18 Jul 2026 23:14:41 +0800 Subject: [PATCH 08/22] extract orchestrator prompt template --- app/services/agent_prompt_templates.py | 97 ++++++++++++++++ app/services/agent_service.py | 104 +++--------------- .../test_orchestrator_prompt_template.py | 50 +++++++++ 3 files changed, 163 insertions(+), 88 deletions(-) create mode 100644 app/services/test_orchestrator_prompt_template.py diff --git a/app/services/agent_prompt_templates.py b/app/services/agent_prompt_templates.py index 7420f34..d30af63 100644 --- a/app/services/agent_prompt_templates.py +++ b/app/services/agent_prompt_templates.py @@ -85,3 +85,100 @@ def build_codegen_prompt( f"{collab_section}" f"符号消息: {symbolic_text}\n用户需求: {content}" ) + + +def build_orchestrator_prompt( + *, + content: str, + symbolic_text: str, + memory_context: str, + shared_context: str, + date_context: str, + workspace_context: str, + actual_model_line: str, + reply_lang_instruction: str, + reasoning_instruction: str, + thinking_rule: str, + mermaid_rules: str, + tool_section: str, + collab_section: str, + preprocess_context: str = "", + tools_enabled: bool = True, +) -> str: + if not tools_enabled: + return ( + f"{date_context}" + "你是 AgentHub 平台中的 AI 助手。\n\n" + f"{actual_model_line}" + f"{reply_lang_instruction}" + "【输出规则】请直接友好回复用户。如果是简单问候或闲聊,回复简洁明了(20字以内)。" + "不要调用任何工具,不要拆解任务,不要输出任何任务计划。\n\n" + f"{shared_context}" + f"符号消息: {symbolic_text}\n用户需求: {content}" + ) + + if preprocess_context: + preprocess_block = ( + "# 系统预处理分析\n\n" + "以下是对用户问题的预处理分析,由系统的需求分析模块生成。" + "请基于此分析直接执行任务,无需重复拆解,无需等待用户确认:\n\n" + f"{preprocess_context}\n\n" + "---\n\n" + ) + workflow_section = ( + "# 工作流程(基于预处理分析 — 直接执行,无需确认)\n\n" + "## 第一步:直接委派执行\n" + "1. 根据预处理分析中的子任务拆解和 Agent 调用顺序,立即使用 invoke_agent 工具调用专业 Agent。\n" + "2. 不要先输出计划再等确认,直接调用工具开始执行。\n" + "3. 有依赖关系的 Agent 串行调用;无依赖的用 invoke_agents_parallel 并行调用。\n\n" + "## 第二步:汇总与仲裁\n" + "4. 收集所有 Agent 的输出,检查是否存在冲突或矛盾。\n" + "5. 如果 Review 提出了修改建议而 CodeGen 未处理,重新调用 CodeGen 修复。\n" + "6. 如果 Test 发现了 Bug,将测试结果反馈给 CodeGen 修复。\n" + "7. 综合所有 Agent 的输出,生成最终的用户回复。\n" + "8. 标注每个结论来自哪个 Agent。\n\n" + ) + else: + preprocess_block = "" + workflow_section = ( + "# 工作流程\n" + "1. 判断: 简单问题(问候/闲聊/知识问答)直接回复,不调工具。\n" + "2. 委派: 复杂任务立即调用 invoke_agent 委派给专业 Agent:\n" + " Architect(架构) | CodeGen(代码) | Review(审查) | Test(测试) | Deploy(部署)\n" + " 无依赖->invoke_agents_parallel 并行;有依赖->串行。\n" + "3. 汇总: 收集结果综合回复,冲突时仲裁,失败时重试->替代Agent->手动建议。\n" + " 标注结论来源(如“根据 Architect 分析...”)。\n\n" + ) + + orchestrator_identity = ( + "你是 AgentHub 调度中心,通过 invoke_agent 工具实际调用 " + "Architect/CodeGen/Review/Test/Deploy 等专业 Agent 执行任务。\n\n" + "原则: 简单直接回 | 复杂直接调 Agent(不等确认) | 冲突仲裁 | 失败降级(重试->替代->手建) | 标注来源\n\n" + "【批处理写入 — 减少 tool_call 次数】\n" + "当 CodeGen/Architect 产出多个文件时(如一个功能包含前端+后端+配置)," + "请使用 file_write_batch 一次性写入所有文件,而不是多次调用 file_write。" + "这大幅减少工具调用轮次,提升响应速度。\n" + "示例: file_write_batch(paths_contents=[{\"path\":\"src/app.py\",\"content\":\"...\"}, {\"path\":\"README.md\",\"content\":\"...\"}])\n\n" + ) + + return ( + f"{memory_context}" + f"{shared_context}" + f"{date_context}" + f"{workspace_context}" + f"{orchestrator_identity}" + f"{actual_model_line}" + f"{reply_lang_instruction}" + f"{reasoning_instruction}" + f"{thinking_rule}" + f"{preprocess_block}" + f"{workflow_section}" + f"{mermaid_rules}\n" + "# 约束\n" + "- 简单问候/闲聊直接回复(≤20字),严禁调工具。\n" + "- 不先展示计划等确认,直接行动。\n" + "- 每轮最多 3 个工具调用,超过 3 轮必须给出最终回复。\n" + f"{tool_section}" + f"{collab_section}" + f"符号消息: {symbolic_text}\n用户需求: {content}" + ) diff --git a/app/services/agent_service.py b/app/services/agent_service.py index 6ae482c..500639b 100644 --- a/app/services/agent_service.py +++ b/app/services/agent_service.py @@ -1740,94 +1740,22 @@ async def build_prompt(agent_id: str, domain: str, content: str, symbolic: dict, ) if agent_id == "Orchestrator": - # ── Simple mode: tools disabled by preprocessor ──────────────── - # When the preprocessor determines the message is a simple greeting - # or short non-technical message, use a minimal prompt — no tools, - # no workflow, no task decomposition. This prevents the LLM from - # getting confused by aggressive tool-calling instructions and making - # chaotic tool calls for trivial messages like "你好". - if not tools_enabled: - return ( - f"{date_context}" - f"你是 AgentHub 平台中的 AI 助手。\n\n" - f"{actual_model_line}" - f"{reply_lang_instr}" - f"【输出规则】请直接友好回复用户。如果是简单问候或闲聊,回复简洁明了(20字以内)。" - f"不要调用任何工具,不要拆解任务,不要输出任何任务计划。\n\n" - f"{shared_context}" - f"符号消息: {json.dumps(public_symbolic(symbolic), ensure_ascii=False)}\n用户需求: {content}" - ) - - # ── Preprocess block: system already analyzed the question ── - preprocess_block = "" - workflow_section = "" - if preprocess_context: - preprocess_block = ( - "# 系统预处理分析\n\n" - "以下是对用户问题的预处理分析,由系统的需求分析模块生成。" - "请基于此分析**直接执行任务**,无需重复拆解,无需等待用户确认:\n\n" - f"{preprocess_context}\n\n" - "---\n\n" - ) - workflow_section = ( - "# 工作流程(基于预处理分析 — 直接执行,无需确认)\n\n" - "## 第一步:直接委派执行\n" - "1. 根据预处理分析中的子任务拆解和 Agent 调用顺序,**立即使用 invoke_agent 工具**调用专业 Agent。\n" - "2. 不要先输出计划再等确认——直接调用工具开始执行。\n" - "3. 有依赖关系的 Agent 串行调用;无依赖的用 invoke_agents_parallel 并行调用。\n\n" - "## 第二步:汇总与仲裁\n" - "4. 收集所有 Agent 的输出,检查是否存在冲突或矛盾。\n" - "5. 如果 Review 提出了修改建议而 CodeGen 未处理 → 重新调用 CodeGen 修复。\n" - "6. 如果 Test 发现了 Bug → 将测试结果反馈给 CodeGen 修复。\n" - "7. 综合所有 Agent 的输出,生成最终的用户回复。\n" - "8. 标注每个结论来自哪个 Agent。\n\n" - ) - else: - # ── No preprocess: concise 3-step workflow ──────────────── - # The LLM needs to judge complexity itself, so we give it a - # compact decision tree rather than verbose step-by-step. - workflow_section = ( - "# 工作流程\n" - "1. **判断**: 简单问题(问候/闲聊/知识问答)直接回复,不调工具。\n" - "2. **委派**: 复杂任务**立即**调用 invoke_agent 委派给专业 Agent:\n" - " Architect(架构) | CodeGen(代码) | Review(审查) | Test(测试) | Deploy(部署)\n" - " 无依赖→invoke_agents_parallel 并行;有依赖→串行。\n" - "3. **汇总**: 收集结果综合回复,冲突时仲裁,失败时重试→替代Agent→手动建议。\n" - " 标注结论来源(如\"根据 Architect 分析...\")。\n\n" - ) - - # ── Slim identity + principles (merged from two sections) ───── - orchestrator_identity = ( - "你是 AgentHub 调度中心,通过 invoke_agent 工具实际调用 " - "Architect/CodeGen/Review/Test/Deploy 等专业 Agent 执行任务。\n\n" - "原则: 简单直接回 | 复杂直接调 Agent(不等确认) | 冲突仲裁 | 失败降级(重试→替代→手建) | 标注来源\n\n" - "【批处理写入 — 减少 tool_call 次数】\n" - "当 CodeGen/Architect 产出多个文件时(如一个功能包含前端+后端+配置)," - "请使用 file_write_batch 一次性写入所有文件,而不是多次调用 file_write。" - "这大幅减少工具调用轮次,提升响应速度。\n" - "示例: file_write_batch(paths_contents=[{\"path\":\"src/app.py\",\"content\":\"...\"}, {\"path\":\"README.md\",\"content\":\"...\"}])\n\n" - ) - - return ( - f"{memory_context}" - f"{shared_context}" - f"{date_context}" - f"{workspace_context_block}" - f"{orchestrator_identity}" - f"{actual_model_line}" - f"{reply_lang_instr}" - f"{reasoning_instr}" - f"{thinking_rule}" - f"{preprocess_block}" - f"{workflow_section}" - f"{mermaid_rules}\n" - "# 约束\n" - "- 简单问候/闲聊直接回复(≤20字),**严禁调工具**。\n" - "- 不先展示计划等确认,直接行动。\n" - "- 每轮最多 3 个工具调用,超过 3 轮必须给出最终回复。\n" - + (_build_tool_section(agent_id, available_tools, permission_mode) if tools_enabled else "") - + f"{collab_section}" - f"符号消息: {json.dumps(public_symbolic(symbolic), ensure_ascii=False)}\n用户需求: {content}" + return build_orchestrator_prompt( + content=content, + symbolic_text=json.dumps(public_symbolic(symbolic), ensure_ascii=False), + memory_context=memory_context, + shared_context=shared_context, + date_context=date_context, + workspace_context=workspace_context_block, + actual_model_line=actual_model_line, + reply_lang_instruction=reply_lang_instr, + reasoning_instruction=reasoning_instr, + thinking_rule=thinking_rule, + mermaid_rules=mermaid_rules, + tool_section=_build_tool_section(agent_id, available_tools, permission_mode) if tools_enabled else "", + collab_section=collab_section, + preprocess_context=preprocess_context, + tools_enabled=tools_enabled, ) if agent_id == "Architect": diff --git a/app/services/test_orchestrator_prompt_template.py b/app/services/test_orchestrator_prompt_template.py new file mode 100644 index 0000000..2b4508d --- /dev/null +++ b/app/services/test_orchestrator_prompt_template.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from app.services.agent_prompt_templates import build_orchestrator_prompt + + +def test_build_orchestrator_prompt_simple_mode_is_minimal() -> None: + prompt = build_orchestrator_prompt( + content="hello", + symbolic_text='{"intent":"general"}', + memory_context="", + shared_context="[shared]\n", + date_context="[date]\n", + workspace_context="[workspace]\n", + actual_model_line="[model]\n", + reply_lang_instruction="[language]\n", + reasoning_instruction="[reasoning]\n", + thinking_rule="[thinking]\n", + mermaid_rules="[mermaid]\n", + tool_section="[tools]\n", + collab_section="[collab]\n", + tools_enabled=False, + ) + + assert "AI 助手" in prompt + assert "不要调用任何工具" in prompt + assert prompt.endswith("用户需求: hello") + + +def test_build_orchestrator_prompt_with_preprocess_includes_workflow() -> None: + prompt = build_orchestrator_prompt( + content="do work", + symbolic_text='{"intent":"orchestration"}', + memory_context="[memory]\n", + shared_context="[shared]\n", + date_context="[date]\n", + workspace_context="[workspace]\n", + actual_model_line="[model]\n", + reply_lang_instruction="[language]\n", + reasoning_instruction="[reasoning]\n", + thinking_rule="[thinking]\n", + mermaid_rules="[mermaid]\n", + tool_section="[tools]\n", + collab_section="[collab]\n", + preprocess_context="subtask A -> B", + tools_enabled=True, + ) + + assert "系统预处理分析" in prompt + assert "invoke_agent" in prompt + assert "[tools]" in prompt From f71b91ebe0ef7f9d06348ce7681c9537e1343a1f Mon Sep 17 00:00:00 2001 From: Nat-xu <19707027371@163.com> Date: Sat, 18 Jul 2026 23:17:15 +0800 Subject: [PATCH 09/22] extract architect prompt template --- app/services/agent_prompt_templates.py | 44 +++++++++++++++ app/services/agent_service.py | 56 +++++-------------- .../test_architect_prompt_template.py | 27 +++++++++ 3 files changed, 86 insertions(+), 41 deletions(-) create mode 100644 app/services/test_architect_prompt_template.py diff --git a/app/services/agent_prompt_templates.py b/app/services/agent_prompt_templates.py index d30af63..15182dc 100644 --- a/app/services/agent_prompt_templates.py +++ b/app/services/agent_prompt_templates.py @@ -182,3 +182,47 @@ def build_orchestrator_prompt( f"{collab_section}" f"符号消息: {symbolic_text}\n用户需求: {content}" ) + + +def build_architect_prompt( + *, + agent_id: str, + content: str, + symbolic_text: str, + memory_context: str, + shared_context: str, + date_context: str, + workspace_context: str, + actual_model_line: str, + reply_lang_instruction: str, + reasoning_instruction: str, + thinking_rule: str, + mermaid_rules: str, + tool_section: str, + collab_section: str, +) -> str: + return ( + f"{memory_context}" + f"{shared_context}" + f"{date_context}" + f"{workspace_context}" + f"你是 AgentHub 平台中的 {agent_id}(架构设计师)。你负责分析用户意图、项目结构和技术边界,输出可执行的技术方案。\n\n" + f"{actual_model_line}" + f"{reply_lang_instruction}" + f"{reasoning_instruction}" + f"{thinking_rule}" + f"{mermaid_rules}\n" + "# Architect 工作原则\n\n" + "1. 先理解需求和项目现状,再输出方案。\n" + "2. 方案必须包含架构设计、技术选型、文件影响范围和风险边界。\n" + "3. 为 CodeGen 提供足够详细的规格说明,便于直接编码。\n\n" + "## 汇报机制\n" + "完成完整方案、代码生成/修改、代码审查或测试报告后,需在回复开头用 '@主人' 或 '@用户' 主动汇报。\n\n" + "## 约束\n" + "- 不直接写代码。\n" + "- 不确定时先查现有代码和项目结构。\n" + "- 简单问候/闲聊直接简短回复(20字以内)。\n" + f"{tool_section}" + f"{collab_section}" + f"符号消息: {symbolic_text}\n用户需求: {content}" + ) diff --git a/app/services/agent_service.py b/app/services/agent_service.py index 500639b..8378cb8 100644 --- a/app/services/agent_service.py +++ b/app/services/agent_service.py @@ -1759,47 +1759,21 @@ async def build_prompt(agent_id: str, domain: str, content: str, symbolic: dict, ) if agent_id == "Architect": - return ( - f"{memory_context}" - f"{shared_context}" - f"{date_context}" - f"{workspace_context_block}" - f"你是 AgentHub 平台中的 Architect(架构设计师),负责分析用户意图与项目结构,输出技术方案与文件影响范围。\n\n" - f"{actual_model_line}" - f"{reply_lang_instr}" - f"{reasoning_instr}" - f"{thinking_rule}" - f"{mermaid_rules}\n" - "# Architect 工作原则\n\n" - "## 你的职责\n" - "1. 分析用户需求,理解技术上下文和项目现状。\n" - "2. 输出清晰的技术方案,包括架构设计、技术选型、文件影响范围。\n" - "3. 为 CodeGen 等下游 Agent 提供足够详细的规格说明,使其可以直接开始编码。\n\n" - "## 汇报机制 — 每完成一项大任务主动 @ 主人\n" - "当你完成以下任务之一时,必须在回复开头用 '@主人' 或 '@用户' 主动汇报:\n" - "- 完成了一个完整的技术方案或架构设计\n" - "- 完成了多个文件的代码生成或修改\n" - "- 完成了一轮代码审查\n" - "- 完成了测试并给出了报告\n\n" - "汇报格式:\n" - " @主人 👋 早上/下午/晚上好!刚刚完成了 [任务名称]。\n" - " 📋 **完成内容**: [简要列举关键产出]\n" - " 📁 **涉及文件**: [列出修改/创建的文件路径]\n" - " ⚠️ **风险/注意事项**: [如有]\n" - " 💡 **下一步建议**: [可选]\n\n" - "示例: \"@主人 下午好!Architect 刚刚完成了博客系统的架构设计。\n" - "📋 完成: 技术栈选型(React+FastAPI+PostgreSQL)、模块划分(6个核心模块)、数据流设计\n" - "📁 规格说明已输出,可供 CodeGen 直接编码\n" - "💡 建议先实现核心 API 模块,再搭建前端页面\"\n\n" - "## 约束\n" - "- 不直接写代码 — 你的产出是设计文档和规格说明,不是可运行的代码。\n" - "- 不确定技术细节时,使用工具查看现有代码和项目结构,而不是猜测。\n" - "- 方案中要明确标注风险和边界条件。\n" - "- 对于简单问候或闲聊,直接简短回复即可(20 字以内)。\n" - "- 使用工具前确保所有必填参数齐全,缺失参数时先向用户询问。\n" - + (_build_tool_section(agent_id, available_tools, permission_mode) if tools_enabled else "") - + f"{collab_section}" - f"符号消息: {json.dumps(public_symbolic(symbolic), ensure_ascii=False)}\n用户需求: {content}" + return build_architect_prompt( + agent_id=agent_id, + content=content, + symbolic_text=json.dumps(public_symbolic(symbolic), ensure_ascii=False), + memory_context=memory_context, + shared_context=shared_context, + date_context=date_context, + workspace_context=workspace_context_block, + actual_model_line=actual_model_line, + reply_lang_instruction=reply_lang_instr, + reasoning_instruction=reasoning_instr, + thinking_rule=thinking_rule, + mermaid_rules=mermaid_rules, + tool_section=_build_tool_section(agent_id, available_tools, permission_mode) if tools_enabled else "", + collab_section=collab_section, ) if agent_id == "Deploy": diff --git a/app/services/test_architect_prompt_template.py b/app/services/test_architect_prompt_template.py new file mode 100644 index 0000000..40d36bc --- /dev/null +++ b/app/services/test_architect_prompt_template.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from app.services.agent_prompt_templates import build_architect_prompt + + +def test_build_architect_prompt_keeps_design_contract() -> None: + prompt = build_architect_prompt( + agent_id="Architect", + content="design the workflow", + symbolic_text='{"intent":"architecture"}', + memory_context="[memory]\n", + shared_context="[shared]\n", + date_context="[date]\n", + workspace_context="[workspace]\n", + actual_model_line="[model]\n", + reply_lang_instruction="[language]\n", + reasoning_instruction="[reasoning]\n", + thinking_rule="[thinking]\n", + mermaid_rules="[mermaid]\n", + tool_section="[tools]\n", + collab_section="[collab]\n", + ) + + assert "架构设计师" in prompt + assert "@主人" in prompt + assert "不直接写代码" in prompt + assert prompt.endswith("用户需求: design the workflow") From 90b0ee1798c57ebce5faccc4e905abe1ac8c6d8d Mon Sep 17 00:00:00 2001 From: Nat-xu <19707027371@163.com> Date: Sat, 18 Jul 2026 23:25:51 +0800 Subject: [PATCH 10/22] refactor deploy prompt into template --- app/services/agent_prompt_templates.py | 50 +++++++++++++++++ app/services/agent_service.py | 62 ++++++++------------- app/services/test_deploy_prompt_template.py | 29 ++++++++++ 3 files changed, 103 insertions(+), 38 deletions(-) create mode 100644 app/services/test_deploy_prompt_template.py diff --git a/app/services/agent_prompt_templates.py b/app/services/agent_prompt_templates.py index 15182dc..7e020b2 100644 --- a/app/services/agent_prompt_templates.py +++ b/app/services/agent_prompt_templates.py @@ -226,3 +226,53 @@ def build_architect_prompt( f"{collab_section}" f"符号消息: {symbolic_text}\n用户需求: {content}" ) + + +def build_deploy_prompt( + *, + agent_id: str, + content: str, + symbolic_text: str, + memory_context: str, + shared_context: str, + date_context: str, + workspace_context: str, + actual_model_line: str, + reply_lang_instruction: str, + reasoning_instruction: str, + thinking_rule: str, + code_format_rules: str, + mermaid_rules: str, + output_rules: str, + tool_section: str, + collab_section: str, +) -> str: + return ( + f"{memory_context}" + f"{shared_context}" + f"{date_context}" + f"{workspace_context}" + f"你是 AgentHub 平台中的 {agent_id}(部署工程师),负责执行文件部署、Git 版本记录和生成部署状态报告。\n\n" + f"{actual_model_line}" + f"{reply_lang_instruction}" + f"{reasoning_instruction}" + f"{thinking_rule}" + f"{code_format_rules}\n" + f"{mermaid_rules}\n" + f"{output_rules}\n" + "# Deploy 工作原则\n\n" + "1. 直接使用工具完成文件部署相关操作,不要先写计划。\n" + "2. 完成后确认目标文件存在,再输出部署报告。\n" + "3. 不需要检查 Review/Test 状态;用户直接 @Deploy 即表示授权。\n\n" + "## 部署卡片\n" + "完成后在回复中输出 deploy-card 标记,包含 version、completed-at、description、files。\n" + "随后附上 50 字以内的部署汇报。\n\n" + "## 约束\n" + "- 每轮最多 3 个工具调用,最多 3 轮,第三轮必须产出最终回复。\n" + "- 不要搜索记忆/博客/项目背景,也不要跑 code_execute。\n" + "- 简单问候/闲聊直接短回复(20字以内),严禁调用任何工具。\n" + "- 不确定文件内容时先向用户确认,不要自行编造。\n" + f"{tool_section}" + f"{collab_section}" + f"符号消息: {symbolic_text}\n用户需求: {content}" + ) diff --git a/app/services/agent_service.py b/app/services/agent_service.py index 8378cb8..c470b2b 100644 --- a/app/services/agent_service.py +++ b/app/services/agent_service.py @@ -17,7 +17,13 @@ from app.services.codegen_service import write_generated_files from app.services.conversation_history import build_conversation_history_transcript from app.services import agent_prompt_context as prompt_context -from app.services.agent_prompt_templates import build_codegen_prompt, build_general_prompt +from app.services.agent_prompt_templates import ( + build_architect_prompt, + build_codegen_prompt, + build_deploy_prompt, + build_general_prompt, + build_orchestrator_prompt, +) from app.services import prompt_sections from app.services.prompt_cache import prompt_cache from app.services.secret_service import decrypt_secret @@ -1777,43 +1783,23 @@ async def build_prompt(agent_id: str, domain: str, content: str, symbolic: dict, ) if agent_id == "Deploy": - return ( - f"{memory_context}" - f"{shared_context}" - f"{date_context}" - f"{workspace_context_block}" - f"你是 AgentHub 平台中的 Deploy(部署工程师),负责执行文件部署、Git 版本记录和生成部署状态报告。\n\n" - f"{actual_model_line}" - f"{reply_lang_instr}" - f"{reasoning_instr}" - f"{thinking_rule}" - f"{code_format_rules}\n" - f"{mermaid_rules}\n" - f"{output_rules}\n" - "# Deploy 工作原则\n\n" - "## 你的职责(聚焦版)\n" - "1. 根据用户需求,直接使用工具完成文件操作(创建/修改/删除)。\n" - "2. 完成后用 file_glob 确认目标文件存在,然后输出部署卡片。\n" - "3. 不需要检查 Review/Test 状态——用户直接 @Deploy 即表示授权你执行部署。\n\n" - "## 部署卡片 — 每完成文件操作必须发送部署卡片\n" - "当你完成部署任务后,必须在回复中输出以下格式的部署卡片标记,系统会自动将其渲染为可视化卡片:\n" - "```deploy-card\n" - "version: \n" - "completed-at: <当前时间>\n" - "description: <简短描述本次部署内容>\n" - "files:\n" - " - <涉及的文件路径>\n" - "```\n" - "请在卡片标记后紧接着写一段简短的部署汇报(≤50字)。\n\n" - "## 严格约束\n" - "- **工具调用上限**: 每轮最多 3 个工具调用,最多 3 轮,第 3 轮必须产出最终回复(含部署卡片)。\n" - "- **禁止无关探索**: 不要搜索记忆、不要查博客/项目背景、不要跑 code_execute。你的任务就是文件操作+部署卡片。\n" - "- **简单场景直接执行**: 用户指定了具体文件名时,直接创建/确认文件,输出部署卡片即可,不需要检查环境、端口、依赖。\n" - "- 简单问候或闲聊直接简短回复(≤20字),**严禁调用任何工具**。\n" - "- 不确定文件内容时向用户确认,不要自己编造内容。\n" - + (_build_tool_section(agent_id, available_tools, permission_mode) if tools_enabled else "") - + f"{collab_section}" - f"符号消息: {json.dumps(public_symbolic(symbolic), ensure_ascii=False)}\n用户需求: {content}" + return build_deploy_prompt( + agent_id=agent_id, + content=content, + symbolic_text=json.dumps(public_symbolic(symbolic), ensure_ascii=False), + memory_context=memory_context, + shared_context=shared_context, + date_context=date_context, + workspace_context=workspace_context_block, + actual_model_line=actual_model_line, + reply_lang_instruction=reply_lang_instr, + reasoning_instruction=reasoning_instr, + thinking_rule=thinking_rule, + code_format_rules=code_format_rules, + mermaid_rules=mermaid_rules, + output_rules=output_rules, + tool_section=_build_tool_section(agent_id, available_tools, permission_mode) if tools_enabled else "", + collab_section=collab_section, ) # ── General agent prompt ──────────────────────────────────────── diff --git a/app/services/test_deploy_prompt_template.py b/app/services/test_deploy_prompt_template.py new file mode 100644 index 0000000..5630025 --- /dev/null +++ b/app/services/test_deploy_prompt_template.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from app.services.agent_prompt_templates import build_deploy_prompt + + +def test_build_deploy_prompt_keeps_deployment_contract() -> None: + prompt = build_deploy_prompt( + agent_id="Deploy", + content="deploy feature", + symbolic_text='{"intent":"deployment"}', + memory_context="[memory]\n", + shared_context="[shared]\n", + date_context="[date]\n", + workspace_context="[workspace]\n", + actual_model_line="[model]\n", + reply_lang_instruction="[language]\n", + reasoning_instruction="[reasoning]\n", + thinking_rule="[thinking]\n", + code_format_rules="[code]\n", + mermaid_rules="[mermaid]\n", + output_rules="[output]\n", + tool_section="[tools]\n", + collab_section="[collab]\n", + ) + + assert prompt.startswith("[memory]\n[shared]\n[date]\n[workspace]\n") + assert "deploy-card" in prompt + assert "code_execute" in prompt + assert prompt.endswith("deploy feature") From e68849f8ab9688d6b57205555f52e7e834947fe9 Mon Sep 17 00:00:00 2001 From: Nat-xu <19707027371@163.com> Date: Sat, 18 Jul 2026 23:39:44 +0800 Subject: [PATCH 11/22] split websocket session state helpers --- app/api/test_websocket_state.py | 71 ++++++++++++++++ app/api/websocket.py | 75 +++++++++-------- app/api/websocket_state.py | 145 ++++++++++++++++++++++++++++++++ 3 files changed, 254 insertions(+), 37 deletions(-) create mode 100644 app/api/test_websocket_state.py create mode 100644 app/api/websocket_state.py diff --git a/app/api/test_websocket_state.py b/app/api/test_websocket_state.py new file mode 100644 index 0000000..d20ae31 --- /dev/null +++ b/app/api/test_websocket_state.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import asyncio + +from app.api import websocket_state as ws_state + + +def test_session_exec_permission_roundtrip() -> None: + session_id = "session-permission-test" + assert ws_state.get_session_exec_permission(session_id) == 1 + + ws_state.set_session_exec_permission(session_id, 2) + assert ws_state.get_session_exec_permission(session_id) == 2 + + ws_state.set_session_exec_permission(session_id, 9) + assert ws_state.get_session_exec_permission(session_id) == 2 + + +def test_auto_name_and_memory_throttle_are_session_scoped(monkeypatch) -> None: + current = [100.0] + monkeypatch.setattr(ws_state.time, "monotonic", lambda: current[0]) + + session_id = "session-throttle-test" + assert ws_state._should_auto_name(session_id) is True + assert ws_state._should_auto_name(session_id) is False + + current[0] += 16 + assert ws_state._should_auto_name(session_id) is True + + assert ws_state.should_run_memory_tasks(session_id) is True + assert ws_state.should_run_memory_tasks(session_id) is False + + current[0] += 31 + assert ws_state.should_run_memory_tasks(session_id) is True + + +def test_permission_response_wakes_waiter() -> None: + session_id = "session-permission-response" + request_id = "req-1" + event = asyncio.Event() + ws_state._permission_state[session_id] = { + request_id: {"event": event, "decision": "deny"}, + } + + try: + assert ws_state.handle_permission_response(session_id, request_id, "allow") is True + assert event.is_set() + assert ws_state._permission_state[session_id][request_id]["decision"] == "allow" + finally: + ws_state._permission_state.pop(session_id, None) + + +def test_task_preview_resolution_and_wait() -> None: + async def _run() -> tuple[str, str]: + session_id = "session-preview-test" + preview_id = "preview-1" + waiter = asyncio.create_task( + ws_state.wait_for_task_confirmation(session_id, preview_id, token=None) + ) + await asyncio.sleep(0) + + ws_state._pending_task_previews[session_id][preview_id] + assert ws_state.resolve_pending_task_preview(session_id, preview_id, "modify", "update plan") + return await waiter + + try: + decision, modifications = asyncio.run(_run()) + assert decision == "modify" + assert modifications == "update plan" + finally: + ws_state._pending_task_previews.pop("session-preview-test", None) diff --git a/app/api/websocket.py b/app/api/websocket.py index d7281f0..2fa25cb 100644 --- a/app/api/websocket.py +++ b/app/api/websocket.py @@ -11,6 +11,7 @@ from app.db.init_db import now from app.db.session import afetch_all, afetch_one +from app.api import websocket_state as ws_state from app.services.agent_service import extract_mentions, get_direct_chat_agent, lookup_agent, save_message from app.services.auth_service import websocket_user from app.services.auth.session_guard import check_session_access, SessionRole @@ -105,7 +106,7 @@ async def _request_tool_permission( request_id = str(uuid.uuid4()) evt = asyncio.Event() entry = {"event": evt, "decision": "deny"} - _permission_state.setdefault(session_id, {})[request_id] = entry + ws_state._permission_state.setdefault(session_id, {})[request_id] = entry try: await manager.broadcast( @@ -130,9 +131,9 @@ async def _request_tool_permission( ) finally: decision = entry.get("decision", "deny") - _permission_state.get(session_id, {}).pop(request_id, None) - if session_id in _permission_state and not _permission_state[session_id]: - del _permission_state[session_id] + ws_state._permission_state.get(session_id, {}).pop(request_id, None) + if session_id in ws_state._permission_state and not ws_state._permission_state[session_id]: + del ws_state._permission_state[session_id] return decision @@ -141,7 +142,7 @@ def _handle_permission_response(session_id: str, request_id: str, decision: str) Returns True if the request was found and signaled. """ - session_entries = _permission_state.get(session_id, {}) + session_entries = ws_state._permission_state.get(session_id, {}) entry = session_entries.get(request_id) if entry: entry["decision"] = decision @@ -347,16 +348,16 @@ async def _handle_agent_question_response(session_id: str, data: dict, user_id: sender_id = user_id or "" # ── First-wins: check if already resolved ──────────────────────── - if not _mark_interaction_resolved(session_id, question_msg_id, sender_id, sender_name): + if not ws_state.mark_interaction_resolved(session_id, question_msg_id, sender_id, sender_name): # Already resolved — notify the late responder - resolver = _get_resolved_by(session_id, question_msg_id) + resolver = ws_state.get_resolved_by(session_id, question_msg_id) await manager.broadcast_interaction_already_resolved( session_id, question_msg_id, resolver or {}, ) return # Wake up the waiting agent - session_qs = _pm_pending_questions.get(session_id, {}) + session_qs = ws_state._pm_pending_questions.get(session_id, {}) entry = session_qs.get(question_msg_id) if entry: entry["response"] = {"selectedOptionId": selected, "customAnswer": custom} @@ -391,15 +392,15 @@ async def _handle_risk_warning_response(session_id: str, data: dict, user_id: st sender_id = user_id or "" # ── First-wins: check if already resolved ──────────────────────── - if not _mark_interaction_resolved(session_id, warning_msg_id, sender_id, sender_name): - resolver = _get_resolved_by(session_id, warning_msg_id) + if not ws_state.mark_interaction_resolved(session_id, warning_msg_id, sender_id, sender_name): + resolver = ws_state.get_resolved_by(session_id, warning_msg_id) await manager.broadcast_interaction_already_resolved( session_id, warning_msg_id, resolver or {}, ) return # Wake up the waiting agent - session_ws = _pm_pending_warnings.get(session_id, {}) + session_ws = ws_state._pm_pending_warnings.get(session_id, {}) entry = session_ws.get(warning_msg_id) if entry: entry["response"] = {"selectedActionId": selected} @@ -434,15 +435,15 @@ async def _handle_agent_todo_response(session_id: str, data: dict, user_id: str sender_id = user_id or "" # ── First-wins: check if already resolved ──────────────────────── - if not _mark_interaction_resolved(session_id, todo_msg_id, sender_id, sender_name): - resolver = _get_resolved_by(session_id, todo_msg_id) + if not ws_state.mark_interaction_resolved(session_id, todo_msg_id, sender_id, sender_name): + resolver = ws_state.get_resolved_by(session_id, todo_msg_id) await manager.broadcast_interaction_already_resolved( session_id, todo_msg_id, resolver or {}, ) return # Wake up the waiting agent - session_tds = _pm_pending_todos.get(session_id, {}) + session_tds = ws_state._pm_pending_todos.get(session_id, {}) entry = session_tds.get(todo_msg_id) if entry: entry["response"] = {"selectedActionId": selected, "comment": comment} @@ -486,8 +487,8 @@ async def _handle_task_preview_response( user_role = await _get_user_session_role(session_id, user_id) # ── First-wins: check if already resolved ──────────────────────── - if not _mark_interaction_resolved(session_id, preview_msg_id, user_id, user_name): - resolver = _get_resolved_by(session_id, preview_msg_id) + if not ws_state.mark_interaction_resolved(session_id, preview_msg_id, user_id, user_name): + resolver = ws_state.get_resolved_by(session_id, preview_msg_id) await manager.broadcast_interaction_already_resolved( session_id, preview_msg_id, resolver or {}, ) @@ -495,7 +496,7 @@ async def _handle_task_preview_response( # ── Non-owner member: record as advisory vote ───────────────────── if user_role != "owner": - entry = _pending_task_previews.get(session_id, {}).get(preview_msg_id) + entry = ws_state._pending_task_previews.get(session_id, {}).get(preview_msg_id) if entry and entry.get("member_votes") is not None: entry["member_votes"][user_id] = decision action_label = {"confirm": "赞同执行", "cancel": "建议取消", "modify": "建议修改"}.get(decision, decision) @@ -530,7 +531,7 @@ async def _handle_task_preview_response( # ── If a _process_and_stream call is waiting on this preview, # signal it so it can proceed / cancel / modify in-line ────── - if _resolve_pending_task_preview(session_id, preview_msg_id, decision, modifications): + if ws_state.resolve_pending_task_preview(session_id, preview_msg_id, decision, modifications): action_label = {"confirm": "确认执行", "cancel": "取消了任务执行", "modify": "修改计划"}.get(decision, decision) await save_message(session_id, user_name, f"[{action_label}]: {modifications or ''}", "text", user_id=user_id) return @@ -565,12 +566,12 @@ async def _handle_solution_selection( solution_id = data.get("solutionId", "") auto_selected = data.get("autoSelected", False) - if session_id in _solution_selection_events: - _solution_selection_results[session_id] = { + if session_id in ws_state._solution_selection_events: + ws_state._solution_selection_results[session_id] = { "solutionId": solution_id, "autoSelected": auto_selected, } - _solution_selection_events[session_id].set() + ws_state._solution_selection_events[session_id].set() logger.info( "solution_selection: session=%s solution=%s auto=%s user=%s", session_id, solution_id, auto_selected, user_id, @@ -635,7 +636,7 @@ async def _auto_name_and_broadcast(session_id: str) -> None: new_name = await try_auto_name_session(session_id) if new_name: # Reset attempts: we found a good name, no need to keep trying - _auto_name_state.pop(session_id, None) + ws_state._auto_name_state.pop(session_id, None) await manager.broadcast( session_id, @@ -727,9 +728,9 @@ def _should_run_memory_tasks(session_id: str) -> bool: """Return True if enough time has passed since last memory task run.""" import time now_ts = time.monotonic() - last = _throttle_state.get(session_id, 0.0) - if now_ts - last >= _THROTTLE_SECONDS: - _throttle_state[session_id] = now_ts + last = ws_state._throttle_state.get(session_id, 0.0) + if now_ts - last >= ws_state._THROTTLE_SECONDS: + ws_state._throttle_state[session_id] = now_ts return True return False @@ -842,7 +843,7 @@ async def websocket_endpoint(websocket: WebSocket, session_id: str, token: str | request_id = data.get("requestId", "") decision = data.get("decision", "deny") if request_id: - _handle_permission_response(session_id, request_id, decision) + ws_state.handle_permission_response(session_id, request_id, decision) continue # ── PM/PMO interaction responses ────────────────────────── @@ -874,7 +875,7 @@ async def websocket_endpoint(websocket: WebSocket, session_id: str, token: str | if data.get("event") == "set_exec_permission": exec_perm = data.get("mode") if isinstance(exec_perm, int) and exec_perm in (1, 2, 3): - set_session_exec_permission(session_id, exec_perm) + ws_state.set_session_exec_permission(session_id, exec_perm) from app.services.tools.permission import set_exec_permission set_exec_permission(session_id, exec_perm) await manager.broadcast_permission_mode_changed( @@ -899,7 +900,7 @@ async def websocket_endpoint(websocket: WebSocket, session_id: str, token: str | # ── Store exec permission mode for this session ──────── exec_perm = data.get("exec_permission") if isinstance(exec_perm, int) and exec_perm in (1, 2, 3): - set_session_exec_permission(session_id, exec_perm) + ws_state.set_session_exec_permission(session_id, exec_perm) # Also sync to the shared permission module store from app.services.tools.permission import set_exec_permission set_exec_permission(session_id, exec_perm) @@ -1276,7 +1277,7 @@ async def _broadcast_to_agent(agent_row: dict): # ── Wait for user confirmation before executing DAG ── if not token.cancelled: - multi_decision, multi_modifications = await _wait_for_task_confirmation( + multi_decision, multi_modifications = await ws_state.wait_for_task_confirmation( session_id, task_preview_msg_id, token, ) if token.cancelled: @@ -1500,7 +1501,7 @@ async def _synthesize_invoke(prompt): # Both extraction and summarization fire in background after a message. # Throttled: only run once every _THROTTLE_SECONDS per session to avoid # firing expensive LLM calls on every single message. - if _should_run_memory_tasks(session_id): + if ws_state.should_run_memory_tasks(session_id): try: from app.config import AUTO_MEMORY_ENABLED if AUTO_MEMORY_ENABLED: @@ -1518,7 +1519,7 @@ async def _synthesize_invoke(prompt): # ── Auto session naming (background, non-blocking) ──────── # Fire after every message for sessions with generic names. # Runs on its own throttle separate from memory tasks. - if _should_auto_name(session_id): + if ws_state._should_auto_name(session_id): asyncio.create_task(_auto_name_and_broadcast(session_id)) @@ -1623,8 +1624,8 @@ async def _invoke_agent( # ── Set up async wait for user selection ────────────────── _sel_event = asyncio.Event() - _solution_selection_events[session_id] = _sel_event - _solution_selection_results.pop(session_id, None) + ws_state._solution_selection_events[session_id] = _sel_event + ws_state._solution_selection_results.pop(session_id, None) await manager.broadcast_solution_proposal( session_id=session_id, @@ -1642,7 +1643,7 @@ async def _invoke_agent( # Wait for user selection or auto-confirm timeout try: await asyncio.wait_for(_sel_event.wait(), timeout=_auto_confirm_sec) - _selection = _solution_selection_results.get(session_id, {}) + _selection = ws_state._solution_selection_results.get(session_id, {}) logger.info( "ws orchestrator: solution selected session=%s solution=%s", session_id, _selection.get("solutionId", "?"), @@ -1655,8 +1656,8 @@ async def _invoke_agent( session_id, recommended_id, ) finally: - _solution_selection_events.pop(session_id, None) - _solution_selection_results.pop(session_id, None) + ws_state._solution_selection_events.pop(session_id, None) + ws_state._solution_selection_results.pop(session_id, None) # Resolve the selected solution to its full context selected_id = _selection.get("solutionId", recommended_id) @@ -2123,7 +2124,7 @@ async def _on_tool_event(status: str, tool_calls: list[dict], tool_results: list if token.cancelled: return text = str(response.get("content", "")) - for piece in _chunk_text_for_streaming(text): + for piece in ws_state.chunk_text_for_streaming(text): if token.cancelled: return await manager.stream_broadcast(session_id, message_id, piece, is_final=False) diff --git a/app/api/websocket_state.py b/app/api/websocket_state.py new file mode 100644 index 0000000..ccaeb5c --- /dev/null +++ b/app/api/websocket_state.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +import asyncio +import logging +import time + +from app.db.init_db import now + +logger = logging.getLogger("agenthub.websocket") + +# Session-scoped websocket state +_throttle_state: dict[str, float] = {} +_THROTTLE_SECONDS = 30 + +_permission_state: dict[str, dict[str, dict]] = {} +_session_exec_permission: dict[str, int] = {} + +_solution_selection_events: dict[str, asyncio.Event] = {} +_solution_selection_results: dict[str, dict] = {} + +_auto_name_state: dict[str, tuple[float, int]] = {} +_AUTO_NAME_INITIAL_SECONDS = 15 +_AUTO_NAME_BACKOFF_SECONDS = 120 +_AUTO_NAME_MAX_ATTEMPTS = 5 + +_pm_pending_questions: dict[str, dict[str, dict]] = {} +_pm_pending_warnings: dict[str, dict[str, dict]] = {} +_pm_pending_todos: dict[str, dict[str, dict]] = {} + +_pending_task_previews: dict[str, dict[str, dict]] = {} +_resolved_interactions: dict[str, dict[str, dict]] = {} + + +def get_session_exec_permission(session_id: str) -> int: + return _session_exec_permission.get(session_id, 1) + + +def set_session_exec_permission(session_id: str, mode: int) -> None: + if mode in (1, 2, 3): + _session_exec_permission[session_id] = mode + + +def _should_auto_name(session_id: str) -> bool: + now_ts = time.monotonic() + last_ts, attempts = _auto_name_state.get(session_id, (0.0, 0)) + if attempts >= _AUTO_NAME_MAX_ATTEMPTS: + return False + if attempts == 0: + _auto_name_state[session_id] = (now_ts, 1) + return True + interval = _AUTO_NAME_INITIAL_SECONDS if attempts < 2 else _AUTO_NAME_BACKOFF_SECONDS + if now_ts - last_ts >= interval: + _auto_name_state[session_id] = (now_ts, attempts + 1) + return True + return False + + +def handle_permission_response(session_id: str, request_id: str, decision: str) -> bool: + session_entries = _permission_state.get(session_id, {}) + entry = session_entries.get(request_id) + if entry: + entry["decision"] = decision + entry["event"].set() + return True + return False + + +async def wait_for_task_confirmation( + session_id: str, preview_msg_id: str, token, user_id: str = "", +) -> tuple[str, str]: + event = asyncio.Event() + _pending_task_previews.setdefault(session_id, {})[preview_msg_id] = { + "event": event, + "decision": "confirm", + "modifications": "", + "owner_id": user_id, + "member_votes": {} if user_id else None, + } + + try: + await asyncio.wait_for(event.wait(), timeout=300) + except asyncio.TimeoutError: + _pending_task_previews.get(session_id, {}).pop(preview_msg_id, None) + logger.info( + "task_preview_wait timeout session=%s preview=%s - proceeding with execution", + session_id, preview_msg_id, + ) + return "confirm", "" + + if token and token.cancelled: + return "cancel", "" + + entry = _pending_task_previews.get(session_id, {}).pop(preview_msg_id, None) + if entry: + return entry["decision"], entry.get("modifications", "") + return "confirm", "" + + +def resolve_pending_task_preview( + session_id: str, preview_msg_id: str, decision: str, modifications: str = "", +) -> bool: + entry = _pending_task_previews.get(session_id, {}).get(preview_msg_id) + if entry: + entry["decision"] = decision + entry["modifications"] = modifications + entry["event"].set() + return True + return False + + +def mark_interaction_resolved(session_id: str, message_id: str, user_id: str, user_name: str) -> bool: + session_map = _resolved_interactions.setdefault(session_id, {}) + if message_id in session_map: + return False + session_map[message_id] = {"resolvedBy": user_id, "userName": user_name, "timestamp": now()} + return True + + +def get_resolved_by(session_id: str, message_id: str) -> dict | None: + return _resolved_interactions.get(session_id, {}).get(message_id) + + +def should_run_memory_tasks(session_id: str) -> bool: + now_ts = time.monotonic() + last = _throttle_state.get(session_id, 0.0) + if now_ts - last >= _THROTTLE_SECONDS: + _throttle_state[session_id] = now_ts + return True + return False + + +def chunk_text_for_streaming(text: str, chunk_size: int = 60) -> list[str]: + if not text: + return [] + chunks: list[str] = [] + buf = "" + separators = ",。!?;:!?\n" + for ch in text: + buf += ch + if len(buf) >= chunk_size or ch in separators: + chunks.append(buf) + buf = "" + if buf: + chunks.append(buf) + return chunks From adcab97055f3330d6f14af62d172ba59ddc57f03 Mon Sep 17 00:00:00 2001 From: Nat-xu <19707027371@163.com> Date: Sat, 18 Jul 2026 23:58:02 +0800 Subject: [PATCH 12/22] split websocket dispatch layers --- app/api/test_websocket_dispatch.py | 116 +++++++++++++++++++ app/api/websocket.py | 66 +++++++++++ app/api/websocket_dispatch.py | 174 +++++++++++++++++++++++++++++ app/api/websocket_lifecycle.py | 72 ++++++++++++ 4 files changed, 428 insertions(+) create mode 100644 app/api/test_websocket_dispatch.py create mode 100644 app/api/websocket_dispatch.py create mode 100644 app/api/websocket_lifecycle.py diff --git a/app/api/test_websocket_dispatch.py b/app/api/test_websocket_dispatch.py new file mode 100644 index 0000000..65be6fa --- /dev/null +++ b/app/api/test_websocket_dispatch.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import asyncio + +import pytest + +from app.api import websocket_dispatch as dispatch + + +def test_dispatch_control_event_routes_presence(monkeypatch: pytest.MonkeyPatch) -> None: + seen: dict[str, object] = {} + + class FakeManager: + def set_user_presence(self, session_id: str, user_id: str, status: str) -> None: + seen["presence"] = (session_id, user_id, status) + + async def broadcast_user_event(self, session_id: str, payload: dict, exclude_user: str | None = None) -> None: + seen["broadcast"] = (session_id, payload, exclude_user) + + monkeypatch.setattr(dispatch, "_manager", lambda: FakeManager()) + + handled = asyncio.run( + dispatch.dispatch_control_event( + session_id="session-1", + data={"event": "set_presence", "status": "away"}, + websocket=object(), + user_id="user-1", + user_name="Alice", + conn_id="conn-1", + on_agent_question_response=lambda *args: asyncio.sleep(0), + on_risk_warning_response=lambda *args: asyncio.sleep(0), + on_agent_todo_response=lambda *args: asyncio.sleep(0), + on_task_preview_response=lambda *args: asyncio.sleep(0), + on_solution_selection=lambda *args: asyncio.sleep(0), + on_diff_decision=lambda *args: asyncio.sleep(0), + ) + ) + + assert handled is True + assert seen["presence"] == ("session-1", "user-1", "away") + assert seen["broadcast"][1]["event"] == "presence_update" + + +def test_dispatch_message_flow_blocks_non_writers(monkeypatch: pytest.MonkeyPatch) -> None: + seen: list[dict] = [] + + class FakeManager: + async def _send_safe(self, websocket, payload: dict) -> bool: + seen.append(payload) + return True + + monkeypatch.setattr(dispatch, "_manager", lambda: FakeManager()) + + handled = asyncio.run( + dispatch.dispatch_message_flow( + session_id="session-2", + content="hello", + sender="Alice", + user_id="user-2", + access_can_write=False, + websocket=object(), + data={}, + attachments=[], + quote_references=[], + auto_reply=True, + process_and_stream=lambda *args: asyncio.sleep(0), + log_task_error=lambda *args: None, + ) + ) + + assert handled is True + assert seen[0]["event"] == "system" + assert "permission" in seen[0]["content"] + + +def test_dispatch_message_flow_schedules_processing(monkeypatch: pytest.MonkeyPatch) -> None: + seen: list[str] = [] + + async def fake_process_and_stream(*args) -> None: + seen.append(args[1]) + + def fake_create_task(coro): + coro.close() + + class _Task: + def add_done_callback(self, callback): + seen.append("scheduled") + + return _Task() + + class FakeManager: + def has_active_stream(self, session_id: str) -> bool: + return False + + monkeypatch.setattr(dispatch, "_manager", lambda: FakeManager()) + monkeypatch.setattr(dispatch.asyncio, "create_task", fake_create_task) + + handled = asyncio.run( + dispatch.dispatch_message_flow( + session_id="session-3", + content="do work", + sender="Alice", + user_id="user-3", + access_can_write=True, + websocket=object(), + data={"auto_reply": True}, + attachments=[{"path": "a.txt"}], + quote_references=[], + auto_reply=True, + process_and_stream=fake_process_and_stream, + log_task_error=lambda *args: None, + ) + ) + + assert handled is True + assert seen == ["scheduled"] diff --git a/app/api/websocket.py b/app/api/websocket.py index 2fa25cb..0cb8594 100644 --- a/app/api/websocket.py +++ b/app/api/websocket.py @@ -12,6 +12,8 @@ from app.db.init_db import now from app.db.session import afetch_all, afetch_one from app.api import websocket_state as ws_state +from app.api.websocket_dispatch import dispatch_control_event, dispatch_message_flow +from app.api.websocket_lifecycle import close_websocket_session, open_websocket_session from app.services.agent_service import extract_mentions, get_direct_chat_agent, lookup_agent, save_message from app.services.auth_service import websocket_user from app.services.auth.session_guard import check_session_access, SessionRole @@ -767,6 +769,7 @@ async def websocket_endpoint(websocket: WebSocket, session_id: str, token: str | # ── Session access control ───────────────────────────────────── access = await check_session_access(session_id, user) + return await websocket_endpoint_v2(websocket, session_id, user, access) conn_id = await manager.connect(session_id, websocket, user_id, access.role.value, user_name) @@ -969,6 +972,69 @@ def _log_task_error(session_id: str, task: asyncio.Task) -> None: +async def websocket_endpoint_v2(websocket: WebSocket, session_id: str, user: dict, access) -> None: + user_id = user["id"] + user_name = user["name"] + + conn_id, heartbeat_task = await open_websocket_session( + session_id=session_id, + websocket=websocket, + user_id=user_id, + user_name=user_name, + role=access.role.value, + ) + + try: + while True: + try: + data = await websocket.receive_json() + except WebSocketDisconnect: + break + + if await dispatch_control_event( + session_id=session_id, + data=data, + websocket=websocket, + user_id=user_id, + user_name=user_name, + conn_id=conn_id, + on_agent_question_response=_handle_agent_question_response, + on_risk_warning_response=_handle_risk_warning_response, + on_agent_todo_response=_handle_agent_todo_response, + on_task_preview_response=_handle_task_preview_response, + on_solution_selection=_handle_solution_selection, + on_diff_decision=_handle_diff_decision, + ): + continue + + content = str(data.get("content", "")).strip() + if await dispatch_message_flow( + session_id=session_id, + content=content, + sender=user_name, + user_id=user_id, + access_can_write=access.can_write, + websocket=websocket, + data=data, + attachments=data.get("attachments", []), + quote_references=data.get("quoteReferences", []), + auto_reply=data.get("auto_reply", True), + process_and_stream=_process_and_stream, + log_task_error=_log_task_error, + ): + continue + except WebSocketDisconnect: + pass + finally: + await close_websocket_session( + session_id=session_id, + websocket=websocket, + user_id=user_id, + user_name=user_name, + heartbeat_task=heartbeat_task, + ) + + def _build_safety_block_message(result) -> str: """Build a human-readable safety block message from guardrail flags.""" lines = [ diff --git a/app/api/websocket_dispatch.py b/app/api/websocket_dispatch.py new file mode 100644 index 0000000..433c14d --- /dev/null +++ b/app/api/websocket_dispatch.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +from typing import Any + +from app.api import websocket_state as ws_state +from app.db.init_db import now + +ControlCallback = Callable[[str, dict, str, str], Awaitable[None]] +DiffCallback = Callable[[str, dict], Awaitable[None]] +ProcessCallback = Callable[[str, str, str, str, list[dict], list[dict], bool], Awaitable[None]] + + +def _manager(): + from app.services.websocket_manager import manager as _manager_instance + + return _manager_instance + + +async def dispatch_control_event( + *, + session_id: str, + data: dict, + websocket: Any, + user_id: str, + user_name: str, + conn_id: str, + on_agent_question_response: ControlCallback, + on_risk_warning_response: ControlCallback, + on_agent_todo_response: ControlCallback, + on_task_preview_response: ControlCallback, + on_solution_selection: ControlCallback, + on_diff_decision: DiffCallback, +) -> bool: + event_name = data.get("event") + ws_manager = _manager() + + if event_name == "pong": + ws_manager.record_pong(session_id, conn_id) + return True + + if event_name == "set_presence": + status = data.get("status", "online") + ws_manager.set_user_presence(session_id, user_id, status) + await ws_manager.broadcast_user_event(session_id, { + "event": "presence_update", + "sessionId": session_id, + "users": [{"userId": user_id, "status": status}], + }, exclude_user=user_id) + return True + + if event_name == "typing": + is_typing = data.get("isTyping", False) + await ws_manager.broadcast_user_event(session_id, { + "event": "typing_indicator", + "sessionId": session_id, + "userId": user_id, + "userName": user_name, + "isTyping": is_typing, + }, exclude_user=user_id) + return True + + if event_name == "sync_request": + last_id = data.get("lastMessageId") + count = await ws_manager.replay_missed_messages(session_id, websocket, last_id) + await ws_manager._send_safe(websocket, { + "event": "sync_complete", + "sessionId": session_id, + "replayed": count, + }) + return True + + if event_name == "permission_response": + request_id = data.get("requestId", "") + decision = data.get("decision", "deny") + if request_id: + ws_state.handle_permission_response(session_id, request_id, decision) + return True + + if event_name == "agent_question_response": + await on_agent_question_response(session_id, data, user_id, user_name) + return True + + if event_name == "risk_warning_response": + await on_risk_warning_response(session_id, data, user_id, user_name) + return True + + if event_name == "agent_todo_response": + await on_agent_todo_response(session_id, data, user_id, user_name) + return True + + if event_name == "task_preview_response": + await on_task_preview_response(session_id, data, user_id, user_name) + return True + + if event_name == "solution_selection": + await on_solution_selection(session_id, data, user_id, user_name) + return True + + if event_name == "diff_decision": + await on_diff_decision(session_id, data) + return True + + if event_name == "set_exec_permission": + exec_perm = data.get("mode") + if isinstance(exec_perm, int) and exec_perm in (1, 2, 3): + ws_state.set_session_exec_permission(session_id, exec_perm) + from app.services.tools.permission import set_exec_permission + set_exec_permission(session_id, exec_perm) + await ws_manager.broadcast_permission_mode_changed( + session_id, exec_perm, user_id, user_name, + ) + return True + + return False + + +async def dispatch_message_flow( + *, + session_id: str, + content: str, + sender: str, + user_id: str, + access_can_write: bool, + websocket: Any, + data: dict, + attachments: list[dict], + quote_references: list[dict], + auto_reply: bool, + process_and_stream: ProcessCallback, + log_task_error: Callable[[str, object], None], +) -> bool: + ws_manager = _manager() + + if not content: + return False + + if not access_can_write: + await ws_manager._send_safe(websocket, { + "event": "system", + "sessionId": session_id, + "content": "You do not have permission to send messages in this session.", + "timestamp": now(), + }) + return True + + exec_perm = data.get("exec_permission") + if isinstance(exec_perm, int) and exec_perm in (1, 2, 3): + ws_state.set_session_exec_permission(session_id, exec_perm) + from app.services.tools.permission import set_exec_permission + set_exec_permission(session_id, exec_perm) + + if ws_manager.has_active_stream(session_id): + ws_manager.cancel_token(session_id) + await ws_manager.send_stream_interrupted(session_id, "New message received, interrupting current stream") + lock = ws_manager.get_session_lock(session_id) + try: + await asyncio.wait_for(lock.acquire(), timeout=2.0) + lock.release() + except asyncio.TimeoutError: + pass + await asyncio.sleep(0.02) + + task = asyncio.create_task( + process_and_stream( + session_id, content, sender, user_id, + attachments, quote_references, auto_reply, + ) + ) + task.add_done_callback( + lambda t: log_task_error(session_id, t) if t.exception() else None + ) + return True diff --git a/app/api/websocket_lifecycle.py b/app/api/websocket_lifecycle.py new file mode 100644 index 0000000..679f0ec --- /dev/null +++ b/app/api/websocket_lifecycle.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import asyncio +from typing import Any + +from app.db.init_db import now + + +def _manager(): + from app.services.websocket_manager import manager as _manager_instance + + return _manager_instance + + +async def open_websocket_session( + *, + session_id: str, + websocket: Any, + user_id: str, + user_name: str, + role: str, +) -> tuple[str, asyncio.Task]: + ws_manager = _manager() + + conn_id = await ws_manager.connect(session_id, websocket, user_id, role, user_name) + await ws_manager.broadcast_user_event(session_id, { + "event": "user_joined", + "sessionId": session_id, + "userId": user_id, + "userName": user_name, + "role": role, + "timestamp": now(), + }, exclude_user=user_id) + await ws_manager._send_safe(websocket, { + "event": "user_roster", + "sessionId": session_id, + "users": ws_manager.get_online_users(session_id), + }) + heartbeat_task = asyncio.create_task( + ws_manager.heartbeat_loop(session_id, conn_id, websocket), + ) + return conn_id, heartbeat_task + + +async def close_websocket_session( + *, + session_id: str, + websocket: Any, + user_id: str, + user_name: str, + heartbeat_task: asyncio.Task, +) -> None: + heartbeat_task.cancel() + try: + await heartbeat_task + except (asyncio.CancelledError, Exception): + pass + + try: + await ws_manager.broadcast_user_event(session_id, { + "event": "user_left", + "sessionId": session_id, + "userId": user_id, + "userName": user_name, + "timestamp": now(), + }, exclude_user=user_id) + except Exception: + pass + + ws_manager.disconnect(session_id, websocket) + if ws_manager._connection_count(session_id) == 0: + ws_manager.teardown_session(session_id) From d8b6095fe7884ffff4dea164171d79d1dc70a9f2 Mon Sep 17 00:00:00 2001 From: Nat-xu <19707027371@163.com> Date: Sun, 19 Jul 2026 00:35:13 +0800 Subject: [PATCH 13/22] split websocket message flow --- app/api/test_websocket_message_flow.py | 106 +++++ app/api/websocket.py | 18 + app/api/websocket_message_flow.py | 574 +++++++++++++++++++++++++ 3 files changed, 698 insertions(+) create mode 100644 app/api/test_websocket_message_flow.py create mode 100644 app/api/websocket_message_flow.py diff --git a/app/api/test_websocket_message_flow.py b/app/api/test_websocket_message_flow.py new file mode 100644 index 0000000..93088a0 --- /dev/null +++ b/app/api/test_websocket_message_flow.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import asyncio +from types import SimpleNamespace + +import pytest + +from app.api import websocket_message_flow as flow + + +def test_detect_multi_mention_greeting_strips_mentions() -> None: + is_greeting, cleaned = flow.detect_multi_mention_greeting( + "@A @B 大家好", + ["A", "B"], + route_selected=False, + ) + + assert is_greeting is True + assert cleaned == "大家好" + + +def test_detect_multi_mention_greeting_rejects_technical_content() -> None: + is_greeting, cleaned = flow.detect_multi_mention_greeting( + "@A @B 请帮我改代码", + ["A", "B"], + route_selected=False, + ) + + assert is_greeting is False + assert cleaned == "@A @B 请帮我改代码" + + +def test_build_dag_task_items_supports_objects_and_dicts() -> None: + items = flow.build_dag_task_items( + [ + SimpleNamespace( + id="n1", + agent="Architect", + domain="architect", + description="梳理方案", + dependencies=["n0"], + estimated_effort="high", + ), + { + "id": "n2", + "agent": "CodeGen", + "domain": "codegen", + "description": "实现代码", + "dependencies": [], + "estimated_effort": "low", + }, + ] + ) + + assert items[0]["description"].startswith("Architect") + assert items[0]["estimatedSeconds"] == 90 + assert items[1]["estimatedSeconds"] == 20 + + +def test_build_followup_todos_includes_domain_actions() -> None: + todos = flow.build_followup_todos( + [ + {"agent_id": "CodeGen", "domain": "codegen"}, + {"agent_id": "Review", "domain": "review"}, + {"agent_id": "Deploy", "domain": "deploy"}, + ] + ) + + ids = [item["id"] for item in todos] + assert ids == ["todo_test", "todo_fix", "todo_verify_deploy", "todo_feedback"] + + +def test_run_message_flow_uses_greeting_broadcast(monkeypatch: pytest.MonkeyPatch) -> None: + seen: dict[str, object] = {} + + async def fake_broadcast_greeting(**kwargs) -> None: + seen["greeting"] = kwargs + + async def fail_collab(**kwargs) -> None: # pragma: no cover - guard rail + raise AssertionError("collaborative flow should not run") + + async def fail_single(**kwargs) -> None: # pragma: no cover - guard rail + raise AssertionError("single flow should not run") + + monkeypatch.setattr(flow, "_broadcast_greeting", fake_broadcast_greeting) + monkeypatch.setattr(flow, "_run_collaborative_flow", fail_collab) + monkeypatch.setattr(flow, "_run_single_message_flow", fail_single) + + asyncio.run( + flow.run_message_flow( + session_id="s1", + content="@A @B 大家好", + sender="Alice", + user_id="u1", + token=SimpleNamespace(cancelled=False), + attachments=[], + quote_references=[], + auto_reply=True, + target_agents=[{"agent_id": "A"}, {"agent_id": "B"}], + route_dag=None, + mentioned=["A", "B"], + invoke_agent=lambda *args, **kwargs: None, + ) + ) + + assert seen["greeting"]["content"] == "大家好" diff --git a/app/api/websocket.py b/app/api/websocket.py index 0cb8594..7e986f3 100644 --- a/app/api/websocket.py +++ b/app/api/websocket.py @@ -12,6 +12,7 @@ from app.db.init_db import now from app.db.session import afetch_all, afetch_one from app.api import websocket_state as ws_state +from app.api import websocket_message_flow as message_flow from app.api.websocket_dispatch import dispatch_control_event, dispatch_message_flow from app.api.websocket_lifecycle import close_websocket_session, open_websocket_session from app.services.agent_service import extract_mentions, get_direct_chat_agent, lookup_agent, save_message @@ -1176,6 +1177,7 @@ async def _process_and_stream( # nodes define the workflow. @mentions in the same message # are treated as conversational references, not routing # directives. + mentioned: list[str] = [] if route_dag is not None: # Build target_agents from route nodes target_agents: list[dict] = [] @@ -1221,6 +1223,22 @@ async def _process_and_stream( # When the user explicitly selects a route via #route:name, # the greeting guard is skipped — they clearly intended the # workflow to run. + await message_flow.run_message_flow( + session_id=session_id, + content=content, + sender=sender, + user_id=user_id, + token=token, + attachments=attachments or [], + quote_references=quote_references, + auto_reply=auto_reply, + target_agents=target_agents, + route_dag=route_dag, + mentioned=mentioned, + invoke_agent=_invoke_agent, + ) + return + is_greeting_broadcast = False if route_dag is None and len(target_agents) >= 2: # Strip @mentions to inspect the real message content diff --git a/app/api/websocket_message_flow.py b/app/api/websocket_message_flow.py new file mode 100644 index 0000000..4636cf6 --- /dev/null +++ b/app/api/websocket_message_flow.py @@ -0,0 +1,574 @@ +from __future__ import annotations + +import asyncio +import logging +import re +import uuid +from collections.abc import Awaitable, Callable +from typing import Any + +from app.api import websocket_state as ws_state +from app.db.init_db import now +from app.schemas.dag import DAGConfig + +logger = logging.getLogger("agenthub.websocket") + +_DOMAIN_LABELS = { + "orchestrator": "协调调度", + "architect": "架构设计", + "codegen": "代码生成", + "review": "代码审查", + "test": "测试验证", + "deploy": "部署发布", +} + +_ESTIMATED_SECONDS = {"low": 20, "medium": 45, "high": 90} + +_MULTI_MENTION_GREETING_PATTERNS = [ + r"^(大家|各位|朋友们|伙伴们|同学们|hello\s*all|hi\s*all|hey\s*all|hello\s*everyone|hi\s*everyone|hey\s*everyone)", + r"^(你好|hi|hello|hey|再见|谢谢|thanks?|thank\s*you|3q|ok|好的|知道了|明白)", + r"^(今天|最近|how\s*are\s*you|what'?s?\s*up|干嘛呢|在吗|我来了|我回来了)", + r"^(你是谁|你的名字|你能做什么|介绍一下你自己)", +] + +_MULTI_MENTION_TECH_KEYWORDS = [ + "开发", + "实现", + "修改", + "代码", + "生成", + "创建", + "设计", + "架构", + "部署", + "发布", + "上线", + "测试", + "审查", + "修复", + "bug", + "错误", + "优化", + "重构", + "配置", + "安装", + "集成", + "迁移", + "升级", + "api", + "接口", + "页面", + "组件", + "模块", + "功能", + "系统", + "数据库", + "前端", + "后端", + "全栈", + "react", + "vue", + "angular", + "node", + "python", + "java", + "go", + "rust", + "docker", + "k8s", + "ci/cd", + "develop", + "implement", + "create", + "build", + "design", + "deploy", + "code", + "function", + "feature", + "component", + "module", + "crud", + "rest", + "graphql", + "sql", + "nosql", + "redis", + "分析", + "检查", + "排查", + "修复", + "重构", +] + + +def _manager(): + from app.services.websocket_manager import manager as _manager_instance + + return _manager_instance + + +def _node_value(node: Any, key: str, default: Any = None) -> Any: + if isinstance(node, dict): + return node.get(key, default) + return getattr(node, key, default) + + +def _strip_mentions(content: str, mentioned: list[str]) -> str: + cleaned = content + for name in mentioned: + cleaned = re.sub(rf"@{re.escape(name)}", "", cleaned) + return cleaned.strip() + + +def is_multi_mention_greeting(text: str) -> bool: + stripped = text.strip() + if not stripped: + return True + + stripped_lower = stripped.lower() + for kw in _MULTI_MENTION_TECH_KEYWORDS: + if kw in stripped_lower: + return False + + for pattern in _MULTI_MENTION_GREETING_PATTERNS: + if re.match(pattern, stripped, re.IGNORECASE): + return True + + return len(stripped) <= 15 + + +def detect_multi_mention_greeting( + content: str, + mentioned: list[str], + *, + route_selected: bool, +) -> tuple[bool, str]: + if route_selected or len(mentioned) < 2: + return False, content + + cleaned = _strip_mentions(content, mentioned) + if is_multi_mention_greeting(cleaned): + return True, cleaned or content + return False, content + + +def build_dag_task_items(dag_nodes: list[Any]) -> list[dict[str, Any]]: + items: list[dict[str, Any]] = [] + for node in dag_nodes: + domain = str(_node_value(node, "domain", "")) + dependencies = _node_value(node, "dependencies", []) + if not isinstance(dependencies, list): + dependencies = list(dependencies) if dependencies else [] + items.append( + { + "id": str(_node_value(node, "id", "")), + "description": ( + f"{_node_value(node, 'agent', '')} " + f"({_DOMAIN_LABELS.get(domain, domain)}): " + f"{_node_value(node, 'description', '')}" + ), + "agent": str(_node_value(node, "agent", "")), + "dependencies": dependencies, + "estimatedSeconds": _ESTIMATED_SECONDS.get( + str(_node_value(node, "estimated_effort", "medium")), + 45, + ), + } + ) + return items + + +def build_followup_todos(target_agents: list[dict[str, Any]]) -> list[dict[str, Any]]: + todo_items: list[dict[str, Any]] = [] + agent_domains = {str(a.get("domain", "")).lower() for a in target_agents} + agent_ids = {str(a.get("agent_id", "")).lower() for a in target_agents} + + if "codegen" in agent_domains or "codegen" in agent_ids: + todo_items.append( + { + "id": "todo_test", + "label": "运行测试验证代码", + "intent": "approve", + "description": "建议先执行单元测试和集成测试,确认代码修改没有引入回归。", + } + ) + if "review" in agent_domains or "review" in agent_ids: + todo_items.append( + { + "id": "todo_fix", + "label": "按审查意见修正代码", + "intent": "approve", + "description": "Review Agent 已给出反馈,请核对并继续修正。", + } + ) + if "deploy" in agent_domains or "deploy" in agent_ids: + todo_items.append( + { + "id": "todo_verify_deploy", + "label": "验证部署结果", + "intent": "approve", + "description": "检查部署环境是否正常,重点关注日志和核心指标。", + } + ) + + todo_items.append( + { + "id": "todo_feedback", + "label": "提供反馈或继续迭代", + "intent": "approve", + "description": "如果结果还不够理想,可以补充反馈或开启下一轮协作。", + } + ) + return todo_items + + +def _build_fallback_dag(target_agents: list[dict[str, Any]]) -> DAGConfig: + return DAGConfig( + total=len(target_agents), + completed=0, + nodes=[ + { + "id": f"n{i}", + "domain": a.get("domain", "general"), + "agent": a["agent_id"], + "description": f"执行 {a['agent_id']} 的任务", + "dependencies": [f"n{j}" for j in range(i)] if i > 0 else [], + } + for i, a in enumerate(target_agents) + ], + execution_strategy="sequential", + analysis=f"自动拆解为 {len(target_agents)} 个节点", + ) + + +async def _broadcast_greeting( + *, + session_id: str, + content: str, + sender: str, + user_id: str, + token, + attachments: list[dict], + quote_references: list[dict] | None, + target_agents: list[dict[str, Any]], + invoke_agent: Callable[..., Awaitable[str]], +) -> None: + async def _broadcast_to_agent(agent_row: dict[str, Any]) -> None: + try: + await invoke_agent( + session_id, + content, + agent_row, + user_id, + token, + attachments or [], + sender_override=sender, + quote_references=quote_references, + ) + except Exception: + logger.exception( + "ws greeting broadcast agent failed session=%s agent=%s", + session_id, + agent_row.get("agent_id", "?"), + ) + + await asyncio.gather( + *(_broadcast_to_agent(agent_row) for agent_row in target_agents), + return_exceptions=True, + ) + + +async def _run_collaborative_flow( + *, + session_id: str, + content: str, + cleaned_content: str, + sender: str, + user_id: str, + token, + attachments: list[dict], + quote_references: list[dict] | None, + target_agents: list[dict[str, Any]], + route_dag: DAGConfig | None, + invoke_agent: Callable[..., Awaitable[str]], +) -> None: + from app.services.agent_service import CollaborationContext, lookup_agent, save_message + from app.services.dag_executor import DAGExecutor + from app.services.result_synthesizer import result_synthesizer + from app.services.task_decomposer import task_decomposer + + ws_manager = _manager() + + collab = CollaborationContext(cleaned_content) + for agent_row in target_agents: + collab.register(agent_row) + + if route_dag is not None: + dag_config = route_dag + logger.info( + "ws using predefined route DAG session=%s nodes=%d", + session_id, + len(dag_config.nodes), + ) + else: + try: + dag_config = await task_decomposer.decompose( + content=cleaned_content, + session_id=session_id, + agents=target_agents, + ) + except Exception: + logger.exception("ws task decomposition failed session=%s", session_id) + dag_config = _build_fallback_dag(target_agents) + + task_preview_msg_id = str(uuid.uuid4()) + task_items = build_dag_task_items(list(dag_config.nodes)) + await ws_manager.broadcast_task_preview( + session_id, + task_preview_msg_id, + task_items, + eta_seconds=sum(item.get("estimatedSeconds", 45) for item in task_items), + ) + + if token.cancelled: + return + + decision, modifications = await ws_state.wait_for_task_confirmation( + session_id, + task_preview_msg_id, + token, + ) + if token.cancelled: + return + if decision == "cancel": + await ws_manager.broadcast( + session_id, + { + "event": "message", + "sessionId": session_id, + "content": "协作任务已取消。", + "sender": "system", + "timestamp": now(), + "type": "system", + }, + ) + return + if decision == "modify": + await run_message_flow( + session_id=session_id, + content=f"[用户修改后的任务计划]\n{modifications}", + sender=sender, + user_id=user_id, + token=token, + attachments=attachments, + quote_references=quote_references, + auto_reply=True, + target_agents=target_agents, + route_dag=route_dag, + mentioned=[], + invoke_agent=invoke_agent, + ) + return + + async def _dag_invoke( + sid: str, + agent_id: str, + task_content: str, + extra_context: str = "", + ) -> str: + agent_row = await lookup_agent(agent_id, user_id, columns="*") + if not agent_row: + return f"[错误] Agent '{agent_id}' 未在注册表中找到" + + full = task_content + if extra_context: + full = f"{extra_context}\n\n{full}" + + result = await invoke_agent( + sid, + full, + agent_row, + user_id, + token, + attachments or [], + collab_ctx="", + quote_references=quote_references, + ) + if result: + collab.record(agent_id, agent_row.get("domain", ""), result) + return result or "" + + executor = DAGExecutor( + session_id=session_id, + manager=ws_manager, + invoke_fn=_dag_invoke, + on_node_update=None, + ) + + try: + node_results = await executor.execute(dag_config, collab) + except Exception as exc: + logger.warning("DAG execution error: %s", exc) + node_results = executor.node_results + + if not token.cancelled: + async def _synthesize_invoke(prompt: str) -> str | None: + architect_row = await lookup_agent("Architect", user_id, columns="*") + if not architect_row: + return None + return await invoke_agent( + session_id, + prompt, + architect_row, + user_id, + token, + attachments or [], + collab_ctx="", + quote_references=None, + ) + + final_response = await result_synthesizer.synthesize( + dag=dag_config, + node_results=node_results, + original_request=cleaned_content, + invoke_fn=_synthesize_invoke, + ) + if final_response and not token.cancelled: + await save_message( + session_id, + final_response, + "Architect", + "text", + None, + None, + user_id=user_id or "", + ) + await ws_manager.broadcast( + session_id, + { + "event": "message", + "sessionId": session_id, + "content": final_response, + "sender": "Architect", + "timestamp": now(), + "type": "text", + "userId": user_id or "", + }, + ) + + if not token.cancelled: + await ws_manager.broadcast_agent_todo( + session_id, + str(uuid.uuid4()), + "PM", + "协作完成 - 建议后续步骤", + f"以下 Agent 已完成本轮协作:{', '.join(a['agent_id'] for a in target_agents)}。", + build_followup_todos(target_agents), + priority="medium", + ) + + +async def _run_single_message_flow( + *, + session_id: str, + content: str, + sender: str, + user_id: str, + token, + attachments: list[dict], + quote_references: list[dict] | None, + auto_reply: bool, + target_agents: list[dict[str, Any]], + invoke_agent: Callable[..., Awaitable[str]], +) -> None: + from app.services.agent_service import get_direct_chat_agent + + agent = target_agents[0] if target_agents else None + if agent is None: + if auto_reply: + logger.info("ws auto_reply mode session=%s user=%s", session_id, user_id) + agent = await get_direct_chat_agent(user_id) + else: + logger.info( + "ws no_agent mode session=%s user=%s (message saved, no agent invoked)", + session_id, + user_id, + ) + return + + await invoke_agent( + session_id, + content, + agent, + user_id, + token, + attachments or [], + sender_override=sender, + quote_references=quote_references, + ) + + +async def run_message_flow( + *, + session_id: str, + content: str, + sender: str, + user_id: str, + token, + attachments: list[dict], + quote_references: list[dict] | None, + auto_reply: bool, + target_agents: list[dict[str, Any]], + route_dag: DAGConfig | None, + mentioned: list[str], + invoke_agent: Callable[..., Awaitable[str]], +) -> None: + is_greeting_broadcast, cleaned_content = detect_multi_mention_greeting( + content, + mentioned, + route_selected=route_dag is not None, + ) + + if is_greeting_broadcast and len(target_agents) >= 2: + await _broadcast_greeting( + session_id=session_id, + content=cleaned_content, + sender=sender, + user_id=user_id, + token=token, + attachments=attachments, + quote_references=quote_references, + target_agents=target_agents, + invoke_agent=invoke_agent, + ) + return + + if len(target_agents) >= 2: + await _run_collaborative_flow( + session_id=session_id, + content=content, + cleaned_content=cleaned_content, + sender=sender, + user_id=user_id, + token=token, + attachments=attachments, + quote_references=quote_references, + target_agents=target_agents, + route_dag=route_dag, + invoke_agent=invoke_agent, + ) + return + + await _run_single_message_flow( + session_id=session_id, + content=cleaned_content, + sender=sender, + user_id=user_id, + token=token, + attachments=attachments, + quote_references=quote_references, + auto_reply=auto_reply, + target_agents=target_agents, + invoke_agent=invoke_agent, + ) From db75594da133578762c12b33cbeb1dcb742c5e07 Mon Sep 17 00:00:00 2001 From: Nat-xu <19707027371@163.com> Date: Sun, 19 Jul 2026 00:52:03 +0800 Subject: [PATCH 14/22] trim websocket entry and split message flow --- app/api/websocket.py | 198 +----------- app/api/websocket_message_flow.py | 509 +++++++++++++++++------------- 2 files changed, 299 insertions(+), 408 deletions(-) diff --git a/app/api/websocket.py b/app/api/websocket.py index 7e986f3..f042ad5 100644 --- a/app/api/websocket.py +++ b/app/api/websocket.py @@ -1,4 +1,4 @@ -from __future__ import annotations +from __future__ import annotations import asyncio import copy @@ -771,201 +771,6 @@ async def websocket_endpoint(websocket: WebSocket, session_id: str, token: str | # ── Session access control ───────────────────────────────────── access = await check_session_access(session_id, user) return await websocket_endpoint_v2(websocket, session_id, user, access) - - conn_id = await manager.connect(session_id, websocket, user_id, access.role.value, user_name) - - # ── Broadcast user_joined to other connected users ───────────── - await manager.broadcast_user_event(session_id, { - "event": "user_joined", - "sessionId": session_id, - "userId": user_id, - "userName": user_name, - "role": access.role.value, - "timestamp": now(), - }, exclude_user=user_id) - - # ── Send current user roster to the newly connected client ───── - online_users = manager.get_online_users(session_id) - await manager._send_safe(websocket, { - "event": "user_roster", - "sessionId": session_id, - "users": online_users, - }) - - # Start per-connection heartbeat - heartbeat_task = asyncio.create_task( - manager.heartbeat_loop(session_id, conn_id, websocket) - ) - - try: - while True: - try: - data = await websocket.receive_json() - except WebSocketDisconnect: - break - - # Handle client pong responses - if data.get("event") == "pong": - manager.record_pong(session_id, conn_id) - continue - - # ── Presence & typing indicators ──────────────────────────── - if data.get("event") == "set_presence": - status = data.get("status", "online") - manager.set_user_presence(session_id, user_id, status) - await manager.broadcast_user_event(session_id, { - "event": "presence_update", - "sessionId": session_id, - "users": [{"userId": user_id, "status": status}], - }, exclude_user=user_id) - continue - - if data.get("event") == "typing": - is_typing = data.get("isTyping", False) - await manager.broadcast_user_event(session_id, { - "event": "typing_indicator", - "sessionId": session_id, - "userId": user_id, - "userName": user_name, - "isTyping": is_typing, - }, exclude_user=user_id) - continue - - # Handle reconnection sync request - if data.get("event") == "sync_request": - last_id = data.get("lastMessageId") - count = await manager.replay_missed_messages(session_id, websocket, last_id) - await manager._send_safe(websocket, { - "event": "sync_complete", - "sessionId": session_id, - "replayed": count, - }) - continue - - # Handle permission response from frontend - if data.get("event") == "permission_response": - request_id = data.get("requestId", "") - decision = data.get("decision", "deny") - if request_id: - ws_state.handle_permission_response(session_id, request_id, decision) - continue - - # ── PM/PMO interaction responses ────────────────────────── - if data.get("event") == "agent_question_response": - await _handle_agent_question_response(session_id, data, user_id, user["name"]) - continue - - if data.get("event") == "risk_warning_response": - await _handle_risk_warning_response(session_id, data, user_id, user["name"]) - continue - - if data.get("event") == "agent_todo_response": - await _handle_agent_todo_response(session_id, data, user_id, user["name"]) - continue - - if data.get("event") == "task_preview_response": - await _handle_task_preview_response(session_id, data, user_id, user["name"]) - continue - - if data.get("event") == "solution_selection": - await _handle_solution_selection(session_id, data, user_id, user["name"]) - continue - - if data.get("event") == "diff_decision": - await _handle_diff_decision(session_id, data) - continue - - # ── Execution permission mode change (real-time sync) ────── - if data.get("event") == "set_exec_permission": - exec_perm = data.get("mode") - if isinstance(exec_perm, int) and exec_perm in (1, 2, 3): - ws_state.set_session_exec_permission(session_id, exec_perm) - from app.services.tools.permission import set_exec_permission - set_exec_permission(session_id, exec_perm) - await manager.broadcast_permission_mode_changed( - session_id, exec_perm, user_id, user_name, - ) - continue - - content = str(data.get("content", "")).strip() - if not content: - continue - - # ── Write access check: viewers cannot send messages ─── - if not access.can_write: - await manager._send_safe(websocket, { - "event": "system", - "sessionId": session_id, - "content": "You do not have permission to send messages in this session.", - "timestamp": now(), - }) - continue - - # ── Store exec permission mode for this session ──────── - exec_perm = data.get("exec_permission") - if isinstance(exec_perm, int) and exec_perm in (1, 2, 3): - ws_state.set_session_exec_permission(session_id, exec_perm) - # Also sync to the shared permission module store - from app.services.tools.permission import set_exec_permission - set_exec_permission(session_id, exec_perm) - - # Cancel any in-flight stream — but ONLY if there is one. - # 无条件 cancel 会让上一轮尚未走完模型循环的 invocation 看到 token.cancelled=True - # 并返回 "流式响应已被中断"。 先用 has_active_stream 守卫,避免误中断。 - if manager.has_active_stream(session_id): - manager.cancel_token(session_id) - await manager.send_stream_interrupted(session_id, "New message received, interrupting current stream") - # 等旧任务释放 session 锁;锁释放后新任务才能进入 _process_and_stream。 - lock = manager.get_session_lock(session_id) - # 最多等 2s;拿不到就直接放行(让新消息处理,新任务内部 create_token 会再取消一次)。 - try: - await asyncio.wait_for(lock.acquire(), timeout=2.0) - lock.release() - except asyncio.TimeoutError: - logger.debug("ws interrupt wait timeout session=%s", session_id) - await asyncio.sleep(0.02) - - task = asyncio.create_task( - _process_and_stream( - session_id, content, - user["name"], - user_id, - data.get("attachments", []), - quote_references=data.get("quoteReferences", []), - auto_reply=data.get("auto_reply", True), - ) - ) - task.add_done_callback( - lambda t: _log_task_error(session_id, t) if t.exception() else None - ) - - except WebSocketDisconnect: - pass - finally: - heartbeat_task.cancel() - try: - await heartbeat_task - except (asyncio.CancelledError, Exception): - pass - - # ── Broadcast user_left before disconnecting ──────────────── - try: - await manager.broadcast_user_event(session_id, { - "event": "user_left", - "sessionId": session_id, - "userId": user_id, - "userName": user_name, - "timestamp": now(), - }, exclude_user=user_id) - except Exception: - pass - - manager.disconnect(session_id, websocket) - # Only teardown if this was the last connection - if manager._connection_count(session_id) == 0: - manager.teardown_session(session_id) - - def _log_task_error(session_id: str, task: asyncio.Task) -> None: exc = task.exception() if exc: @@ -2512,3 +2317,4 @@ async def _broadcast_final_db_message(session_id: str, message_id: str) -> None: content = final.get("content", "") if sender == "Deploy" and content: await _maybe_broadcast_deploy_card(session_id, message_id, content, sender) + diff --git a/app/api/websocket_message_flow.py b/app/api/websocket_message_flow.py index 4636cf6..c0c7f2a 100644 --- a/app/api/websocket_message_flow.py +++ b/app/api/websocket_message_flow.py @@ -14,58 +14,58 @@ logger = logging.getLogger("agenthub.websocket") _DOMAIN_LABELS = { - "orchestrator": "协调调度", - "architect": "架构设计", - "codegen": "代码生成", - "review": "代码审查", - "test": "测试验证", - "deploy": "部署发布", + "orchestrator": "orchestrator", + "architect": "architect", + "codegen": "codegen", + "review": "review", + "test": "test", + "deploy": "deploy", } _ESTIMATED_SECONDS = {"low": 20, "medium": 45, "high": 90} -_MULTI_MENTION_GREETING_PATTERNS = [ - r"^(大家|各位|朋友们|伙伴们|同学们|hello\s*all|hi\s*all|hey\s*all|hello\s*everyone|hi\s*everyone|hey\s*everyone)", - r"^(你好|hi|hello|hey|再见|谢谢|thanks?|thank\s*you|3q|ok|好的|知道了|明白)", - r"^(今天|最近|how\s*are\s*you|what'?s?\s*up|干嘛呢|在吗|我来了|我回来了)", - r"^(你是谁|你的名字|你能做什么|介绍一下你自己)", +_GREETING_PATTERNS = [ + "^(\u5927\u5bb6|\u5404\u4f4d|\u670b\u53cb\u4eec|\u4f19\u4f34\u4eec|\u540c\u5b66\u4eec|hello\\s*all|hi\\s*all|hey\\s*all|hello\\s*everyone|hi\\s*everyone|hey\\s*everyone)", + "^(\u4f60\u597d|hi|hello|hey|\u518d\u89c1|\u8c22\u8c22|thanks?|thank\\s*you|3q|ok|\u597d\u7684|\u77e5\u9053\u4e86|\u660e\u767d)", + "^(\u4eca\u5929|\u6700\u8fd1|how\\s*are\\s*you|what'?s?\\s*up|\u5e72\u5417\u5462|\u5728\u5417|\u6211\u6765\u4e86|\u6211\u56de\u6765\u4e86)", + "^(\u4f60\u662f\u8c01|\u4f60\u7684\u540d\u5b57|\u4f60\u80fd\u505a\u4ec0\u4e48|\u4ecb\u7ecd\u4e00\u4e0b\u4f60\u81ea\u5df1)", ] -_MULTI_MENTION_TECH_KEYWORDS = [ - "开发", - "实现", - "修改", - "代码", - "生成", - "创建", - "设计", - "架构", - "部署", - "发布", - "上线", - "测试", - "审查", - "修复", +_TECH_KEYWORDS = [ + "\u5f00\u53d1", + "\u5b9e\u73b0", + "\u4fee\u6539", + "\u4ee3\u7801", + "\u751f\u6210", + "\u521b\u5efa", + "\u8bbe\u8ba1", + "\u67b6\u6784", + "\u90e8\u7f72", + "\u53d1\u5e03", + "\u4e0a\u7ebf", + "\u6d4b\u8bd5", + "\u5ba1\u67e5", + "\u4fee\u590d", "bug", - "错误", - "优化", - "重构", - "配置", - "安装", - "集成", - "迁移", - "升级", + "\u9519\u8bef", + "\u4f18\u5316", + "\u91cd\u6784", + "\u914d\u7f6e", + "\u5b89\u88c5", + "\u96c6\u6210", + "\u8fc1\u79fb", + "\u5347\u7ea7", "api", - "接口", - "页面", - "组件", - "模块", - "功能", - "系统", - "数据库", - "前端", - "后端", - "全栈", + "\u63a5\u53e3", + "\u9875\u9762", + "\u7ec4\u4ef6", + "\u6a21\u5757", + "\u529f\u80fd", + "\u7cfb\u7edf", + "\u6570\u636e\u5e93", + "\u524d\u7aef", + "\u540e\u7aef", + "\u5168\u6808", "react", "vue", "angular", @@ -94,11 +94,10 @@ "sql", "nosql", "redis", - "分析", - "检查", - "排查", - "修复", - "重构", + "\u5206\u6790", + "\u68c0\u67e5", + "\u6392\u67e5", + "\u91cd\u6784", ] @@ -126,12 +125,12 @@ def is_multi_mention_greeting(text: str) -> bool: if not stripped: return True - stripped_lower = stripped.lower() - for kw in _MULTI_MENTION_TECH_KEYWORDS: - if kw in stripped_lower: + lowered = stripped.lower() + for keyword in _TECH_KEYWORDS: + if keyword in lowered: return False - for pattern in _MULTI_MENTION_GREETING_PATTERNS: + for pattern in _GREETING_PATTERNS: if re.match(pattern, stripped, re.IGNORECASE): return True @@ -188,36 +187,36 @@ def build_followup_todos(target_agents: list[dict[str, Any]]) -> list[dict[str, todo_items.append( { "id": "todo_test", - "label": "运行测试验证代码", + "label": "run tests", "intent": "approve", - "description": "建议先执行单元测试和集成测试,确认代码修改没有引入回归。", + "description": "Run unit tests and integration tests before the next step.", } ) if "review" in agent_domains or "review" in agent_ids: todo_items.append( { "id": "todo_fix", - "label": "按审查意见修正代码", + "label": "apply review notes", "intent": "approve", - "description": "Review Agent 已给出反馈,请核对并继续修正。", + "description": "Review feedback exists; verify and continue refining the change.", } ) if "deploy" in agent_domains or "deploy" in agent_ids: todo_items.append( { "id": "todo_verify_deploy", - "label": "验证部署结果", + "label": "verify deployment", "intent": "approve", - "description": "检查部署环境是否正常,重点关注日志和核心指标。", + "description": "Check deployment health, logs, and key metrics.", } ) todo_items.append( { "id": "todo_feedback", - "label": "提供反馈或继续迭代", + "label": "continue iterating", "intent": "approve", - "description": "如果结果还不够理想,可以补充反馈或开启下一轮协作。", + "description": "Provide feedback or start the next collaboration round.", } ) return todo_items @@ -232,144 +231,90 @@ def _build_fallback_dag(target_agents: list[dict[str, Any]]) -> DAGConfig: "id": f"n{i}", "domain": a.get("domain", "general"), "agent": a["agent_id"], - "description": f"执行 {a['agent_id']} 的任务", + "description": f"Execute task for {a['agent_id']}", "dependencies": [f"n{j}" for j in range(i)] if i > 0 else [], } for i, a in enumerate(target_agents) ], execution_strategy="sequential", - analysis=f"自动拆解为 {len(target_agents)} 个节点", + analysis=f"Auto split into {len(target_agents)} nodes", ) -async def _broadcast_greeting( +async def _resolve_collaborative_dag( *, session_id: str, - content: str, - sender: str, - user_id: str, - token, - attachments: list[dict], - quote_references: list[dict] | None, - target_agents: list[dict[str, Any]], - invoke_agent: Callable[..., Awaitable[str]], -) -> None: - async def _broadcast_to_agent(agent_row: dict[str, Any]) -> None: - try: - await invoke_agent( - session_id, - content, - agent_row, - user_id, - token, - attachments or [], - sender_override=sender, - quote_references=quote_references, - ) - except Exception: - logger.exception( - "ws greeting broadcast agent failed session=%s agent=%s", - session_id, - agent_row.get("agent_id", "?"), - ) - - await asyncio.gather( - *(_broadcast_to_agent(agent_row) for agent_row in target_agents), - return_exceptions=True, - ) - - -async def _run_collaborative_flow( - *, - session_id: str, - content: str, cleaned_content: str, - sender: str, - user_id: str, - token, - attachments: list[dict], - quote_references: list[dict] | None, target_agents: list[dict[str, Any]], route_dag: DAGConfig | None, - invoke_agent: Callable[..., Awaitable[str]], -) -> None: - from app.services.agent_service import CollaborationContext, lookup_agent, save_message - from app.services.dag_executor import DAGExecutor - from app.services.result_synthesizer import result_synthesizer +) -> DAGConfig: from app.services.task_decomposer import task_decomposer - ws_manager = _manager() - - collab = CollaborationContext(cleaned_content) - for agent_row in target_agents: - collab.register(agent_row) - if route_dag is not None: - dag_config = route_dag logger.info( "ws using predefined route DAG session=%s nodes=%d", session_id, - len(dag_config.nodes), + len(route_dag.nodes), ) - else: - try: - dag_config = await task_decomposer.decompose( - content=cleaned_content, - session_id=session_id, - agents=target_agents, - ) - except Exception: - logger.exception("ws task decomposition failed session=%s", session_id) - dag_config = _build_fallback_dag(target_agents) + return route_dag - task_preview_msg_id = str(uuid.uuid4()) + try: + return await task_decomposer.decompose( + content=cleaned_content, + session_id=session_id, + agents=target_agents, + ) + except Exception: + logger.exception("ws task decomposition failed session=%s", session_id) + return _build_fallback_dag(target_agents) + + +async def _prepare_task_preview( + *, + session_id: str, + dag_config: DAGConfig, + token, +) -> tuple[str, str, str]: + ws_manager = _manager() + preview_msg_id = str(uuid.uuid4()) task_items = build_dag_task_items(list(dag_config.nodes)) await ws_manager.broadcast_task_preview( session_id, - task_preview_msg_id, + preview_msg_id, task_items, eta_seconds=sum(item.get("estimatedSeconds", 45) for item in task_items), ) if token.cancelled: - return + return preview_msg_id, "cancel", "" decision, modifications = await ws_state.wait_for_task_confirmation( session_id, - task_preview_msg_id, + preview_msg_id, token, ) - if token.cancelled: - return - if decision == "cancel": - await ws_manager.broadcast( - session_id, - { - "event": "message", - "sessionId": session_id, - "content": "协作任务已取消。", - "sender": "system", - "timestamp": now(), - "type": "system", - }, - ) - return - if decision == "modify": - await run_message_flow( - session_id=session_id, - content=f"[用户修改后的任务计划]\n{modifications}", - sender=sender, - user_id=user_id, - token=token, - attachments=attachments, - quote_references=quote_references, - auto_reply=True, - target_agents=target_agents, - route_dag=route_dag, - mentioned=[], - invoke_agent=invoke_agent, - ) - return + return preview_msg_id, decision, modifications + + +async def _run_dag_execution( + *, + session_id: str, + cleaned_content: str, + target_agents: list[dict[str, Any]], + dag_config: DAGConfig, + user_id: str, + token, + attachments: list[dict], + quote_references: list[dict] | None, + invoke_agent: Callable[..., Awaitable[str]], +) -> dict[str, str]: + from app.services.agent_service import CollaborationContext, lookup_agent + from app.services.dag_executor import DAGExecutor + + ws_manager = _manager() + collab = CollaborationContext(cleaned_content) + for agent_row in target_agents: + collab.register(agent_row) async def _dag_invoke( sid: str, @@ -379,7 +324,7 @@ async def _dag_invoke( ) -> str: agent_row = await lookup_agent(agent_id, user_id, columns="*") if not agent_row: - return f"[错误] Agent '{agent_id}' 未在注册表中找到" + return f"[error] Agent '{agent_id}' not found" full = task_content if extra_context: @@ -407,68 +352,126 @@ async def _dag_invoke( ) try: - node_results = await executor.execute(dag_config, collab) + await executor.execute(dag_config, collab) except Exception as exc: logger.warning("DAG execution error: %s", exc) - node_results = executor.node_results - if not token.cancelled: - async def _synthesize_invoke(prompt: str) -> str | None: - architect_row = await lookup_agent("Architect", user_id, columns="*") - if not architect_row: - return None - return await invoke_agent( - session_id, - prompt, - architect_row, - user_id, - token, - attachments or [], - collab_ctx="", - quote_references=None, - ) + return executor.node_results + + +async def _run_result_synthesis( + *, + session_id: str, + dag_config: DAGConfig, + node_results: dict[str, str], + cleaned_content: str, + target_agents: list[dict[str, Any]], + user_id: str, + token, + attachments: list[dict], + invoke_agent: Callable[..., Awaitable[str]], +) -> None: + from app.services.agent_service import lookup_agent, save_message + from app.services.result_synthesizer import result_synthesizer + + if token.cancelled: + return - final_response = await result_synthesizer.synthesize( - dag=dag_config, - node_results=node_results, - original_request=cleaned_content, - invoke_fn=_synthesize_invoke, + async def _synthesize_invoke(prompt: str) -> str | None: + architect_row = await lookup_agent("Architect", user_id, columns="*") + if not architect_row: + return None + return await invoke_agent( + session_id, + prompt, + architect_row, + user_id, + token, + attachments or [], + collab_ctx="", + quote_references=None, + ) + + final_response = await result_synthesizer.synthesize( + dag=dag_config, + node_results=node_results, + original_request=cleaned_content, + invoke_fn=_synthesize_invoke, + ) + if final_response and not token.cancelled: + ws_manager = _manager() + await save_message( + session_id, + final_response, + "Architect", + "text", + None, + None, + user_id=user_id or "", + ) + await ws_manager.broadcast( + session_id, + { + "event": "message", + "sessionId": session_id, + "content": final_response, + "sender": "Architect", + "timestamp": now(), + "type": "text", + "userId": user_id or "", + }, ) - if final_response and not token.cancelled: - await save_message( - session_id, - final_response, - "Architect", - "text", - None, - None, - user_id=user_id or "", - ) - await ws_manager.broadcast( - session_id, - { - "event": "message", - "sessionId": session_id, - "content": final_response, - "sender": "Architect", - "timestamp": now(), - "type": "text", - "userId": user_id or "", - }, - ) if not token.cancelled: + ws_manager = _manager() await ws_manager.broadcast_agent_todo( session_id, str(uuid.uuid4()), "PM", - "协作完成 - 建议后续步骤", - f"以下 Agent 已完成本轮协作:{', '.join(a['agent_id'] for a in target_agents)}。", + "collaboration complete - next steps", + f"Agents finished this round: {', '.join(a['agent_id'] for a in target_agents)}.", build_followup_todos(target_agents), priority="medium", ) +async def _broadcast_greeting( + *, + session_id: str, + content: str, + sender: str, + user_id: str, + token, + attachments: list[dict], + quote_references: list[dict] | None, + target_agents: list[dict[str, Any]], + invoke_agent: Callable[..., Awaitable[str]], +) -> None: + async def _broadcast_to_agent(agent_row: dict[str, Any]) -> None: + try: + await invoke_agent( + session_id, + content, + agent_row, + user_id, + token, + attachments or [], + sender_override=sender, + quote_references=quote_references, + ) + except Exception: + logger.exception( + "ws greeting broadcast agent failed session=%s agent=%s", + session_id, + agent_row.get("agent_id", "?"), + ) + + await asyncio.gather( + *(_broadcast_to_agent(agent_row) for agent_row in target_agents), + return_exceptions=True, + ) + + async def _run_single_message_flow( *, session_id: str, @@ -509,6 +512,88 @@ async def _run_single_message_flow( ) +async def _run_collaborative_flow( + *, + session_id: str, + content: str, + cleaned_content: str, + sender: str, + user_id: str, + token, + attachments: list[dict], + quote_references: list[dict] | None, + target_agents: list[dict[str, Any]], + route_dag: DAGConfig | None, + invoke_agent: Callable[..., Awaitable[str]], +) -> None: + dag_config = await _resolve_collaborative_dag( + session_id=session_id, + cleaned_content=cleaned_content, + target_agents=target_agents, + route_dag=route_dag, + ) + + _preview_msg_id, decision, modifications = await _prepare_task_preview( + session_id=session_id, + dag_config=dag_config, + token=token, + ) + if decision == "cancel": + ws_manager = _manager() + await ws_manager.broadcast( + session_id, + { + "event": "message", + "sessionId": session_id, + "content": "collaboration task cancelled.", + "sender": "system", + "timestamp": now(), + "type": "system", + }, + ) + return + if decision == "modify": + await run_message_flow( + session_id=session_id, + content=f"[user modified the task plan]\n{modifications}", + sender=sender, + user_id=user_id, + token=token, + attachments=attachments, + quote_references=quote_references, + auto_reply=True, + target_agents=target_agents, + route_dag=route_dag, + mentioned=[], + invoke_agent=invoke_agent, + ) + return + + node_results = await _run_dag_execution( + session_id=session_id, + cleaned_content=cleaned_content, + target_agents=target_agents, + dag_config=dag_config, + user_id=user_id, + token=token, + attachments=attachments, + quote_references=quote_references, + invoke_agent=invoke_agent, + ) + + await _run_result_synthesis( + session_id=session_id, + dag_config=dag_config, + node_results=node_results, + cleaned_content=cleaned_content, + target_agents=target_agents, + user_id=user_id, + token=token, + attachments=attachments, + invoke_agent=invoke_agent, + ) + + async def run_message_flow( *, session_id: str, From 79ff7e080fde3e72643161f174765fd78b704c00 Mon Sep 17 00:00:00 2001 From: Nat-xu <19707027371@163.com> Date: Sun, 19 Jul 2026 01:15:01 +0800 Subject: [PATCH 15/22] stabilize dag recovery and websocket url --- frontend/__tests__/lib/dagStore.test.ts | 97 +++++++++++ frontend/__tests__/lib/websocketUrl.test.ts | 21 +++ frontend/app/page.tsx | 66 +++++--- frontend/lib/dagStore.ts | 172 ++++++++++++++++++++ frontend/lib/websocketUrl.ts | 21 +++ 5 files changed, 357 insertions(+), 20 deletions(-) create mode 100644 frontend/__tests__/lib/dagStore.test.ts create mode 100644 frontend/__tests__/lib/websocketUrl.test.ts create mode 100644 frontend/lib/dagStore.ts create mode 100644 frontend/lib/websocketUrl.ts diff --git a/frontend/__tests__/lib/dagStore.test.ts b/frontend/__tests__/lib/dagStore.test.ts new file mode 100644 index 0000000..79f7cd4 --- /dev/null +++ b/frontend/__tests__/lib/dagStore.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; +import { + buildDagStateFromTaskPreview, + deriveDagStateFromMessages, + mergeDagTaskUpdate, +} from '../../lib/dagStore'; +import type { Message, TaskPreviewEvent } from '../../types'; + +describe('dagStore helpers', () => { + it('builds a pending dag from task preview payload', () => { + const payload: TaskPreviewEvent = { + event: 'task_preview', + sessionId: 'session-1', + messageId: 'msg-1', + timestamp: '2026-07-19T00:00:00.000Z', + tasks: [ + { + id: 'node-1', + description: 'Inspect repo', + agent: 'Orchestrator', + dependencies: ['node-0'], + estimatedSeconds: 90, + }, + ], + }; + + const dag = buildDagStateFromTaskPreview(payload); + + expect(dag.total).toBe(1); + expect(dag.completed).toBe(0); + expect(dag.nodes[0]).toMatchObject({ + id: 'node-1', + agent: 'Orchestrator', + description: 'Inspect repo', + dependencies: ['node-0'], + status: 'PENDING', + estimated_effort: '90s', + }); + }); + + it('merges task updates with progress metadata', () => { + const prev = { + total: 2, + completed: 0, + nodes: [ + { id: 'node-1', agent: 'Orchestrator', description: 'Plan', dependencies: [], status: 'PENDING' }, + { id: 'node-2', agent: 'CodeGen', description: 'Build', dependencies: ['node-1'], status: 'PENDING' }, + ], + }; + + const next = mergeDagTaskUpdate(prev, { + nodeId: 'node-1', + status: 'SUCCESS', + detail: { error: 'ignored' }, + progress: { completed: 1, total: 2, percent: 50 }, + }); + + expect(next.total).toBe(2); + expect(next.completed).toBe(1); + expect(next.nodes[0]).toMatchObject({ status: 'SUCCESS' }); + }); + + it('derives a dag from the latest task preview message', () => { + const messages: Message[] = [ + { + event: 'message', + sessionId: 'session-1', + sender: 'system', + content: 'older', + type: 'text', + timestamp: '2026-07-19T00:00:00.000Z', + }, + { + event: 'message', + sessionId: 'session-1', + sender: 'system', + content: '任务预览', + type: 'task_preview', + timestamp: '2026-07-19T00:00:01.000Z', + taskPreviewData: { + event: 'task_preview', + sessionId: 'session-1', + messageId: 'msg-2', + timestamp: '2026-07-19T00:00:01.000Z', + tasks: [ + { id: 'node-3', description: 'Review', agent: 'Review', dependencies: [] }, + ], + }, + }, + ]; + + const dag = deriveDagStateFromMessages(messages); + + expect(dag.total).toBe(1); + expect(dag.nodes[0].id).toBe('node-3'); + }); +}); diff --git a/frontend/__tests__/lib/websocketUrl.test.ts b/frontend/__tests__/lib/websocketUrl.test.ts new file mode 100644 index 0000000..e2bc3b5 --- /dev/null +++ b/frontend/__tests__/lib/websocketUrl.test.ts @@ -0,0 +1,21 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { buildChatWebSocketUrl } from '../../lib/websocketUrl'; + +describe('buildChatWebSocketUrl', () => { + afterEach(() => { + delete process.env.NEXT_PUBLIC_WS_URL; + }); + + it('falls back to the local backend by default', () => { + const url = buildChatWebSocketUrl('session-1', 'token-1'); + expect(url).toContain('ws://127.0.0.1:8000/ws/session-1?token=token-1'); + }); + + it('normalizes http and https websocket bases', () => { + process.env.NEXT_PUBLIC_WS_URL = 'https://example.com:8443'; + expect(buildChatWebSocketUrl('abc', 'tok')).toContain('wss://example.com:8443/ws/abc?token=tok'); + + process.env.NEXT_PUBLIC_WS_URL = 'http://example.com:8001/'; + expect(buildChatWebSocketUrl('abc', 'tok')).toContain('ws://example.com:8001/ws/abc?token=tok'); + }); +}); diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index a2b88d8..bb02a6f 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -26,6 +26,14 @@ import ResizableDivider from '../components/common/ResizableDivider'; import { useResizableSize } from '../hooks/useResizableSize'; import { useFileUpload } from '../hooks/useFileUpload'; import { normalizeReferences } from '../lib/references'; +import { buildChatWebSocketUrl } from '../lib/websocketUrl'; +import { + clearDagSession, + setDagState, + syncDagFromMessages, + updateDagState, + useDagState, +} from '../lib/dagStore'; import { useSessionMessages, useSessionStreaming, @@ -39,7 +47,7 @@ import { unpinSession, type StreamBuffer, } from '../lib/sessionStore'; -import type { Agent, AttachedFile, AttachmentMeta, ChatSession, DagState, FileReference, GeneratedData, Message, PendingMessage, QuoteReference, SkillMeta, StreamChunk, ToolCallEvent, ToolResultEvent, User, WorkflowSummary, WorkspacePreviewTab } from '../types'; +import type { Agent, AttachedFile, AttachmentMeta, ChatSession, FileReference, GeneratedData, Message, PendingMessage, QuoteReference, SkillMeta, StreamChunk, ToolCallEvent, ToolResultEvent, User, WorkflowSummary, WorkspacePreviewTab } from '../types'; import type { FilePreviewTarget } from '../components/chat/FilePreviewModal'; import { ToastProvider, useAddToast } from '../components/ui/Toast'; @@ -95,10 +103,10 @@ export default function AgentHubIM(): JSX.Element { const [sessionId, setSessionId] = useState(''); const messages = useSessionMessages(sessionId); const isStreaming = useSessionStreaming(sessionId); + const dag = useDagState(sessionId); const { addToast } = useAddToast(); const [sessionQuery, setSessionQuery] = useState(''); const [input, setInput] = useState('@CodeGen Generate a FastAPI health route file, save as health_router.py'); - const [dag, setDag] = useState({ total: 0, completed: 0, nodes: [] }); const [taskOpen, setTaskOpen] = useState(false); const [previewOpen, setPreviewOpen] = useState(false); const [previewUrl, setPreviewUrl] = useState(''); @@ -252,11 +260,13 @@ export default function AgentHubIM(): JSX.Element { // Prevent duplicate logout cascades if (!localStorage.getItem('agenthub_token')) return; + const sessionIds = Array.from(wsRef.current.keys()); localStorage.removeItem('agenthub_token'); localStorage.removeItem('agenthub_user'); // Close every open WebSocket — the token is dead - Array.from(wsRef.current.keys()).forEach((sid) => closeWs(sid)); - Array.from(wsRef.current.keys()).forEach((sid) => clearSession(sid)); + sessionIds.forEach((sid) => closeWs(sid)); + sessionIds.forEach((sid) => clearSession(sid)); + sessionIds.forEach((sid) => clearDagSession(sid)); if (reconnectRef.current) { clearTimeout(reconnectRef.current); reconnectRef.current = null; @@ -340,6 +350,7 @@ export default function AgentHubIM(): JSX.Element { [...data].sort((a, b) => a.timestamp.localeCompare(b.timestamp)), ); } + syncDagFromMessages(sid, data); } catch { /* ignore */ } } @@ -394,6 +405,11 @@ export default function AgentHubIM(): JSX.Element { }; }, [token, sessionId]); + useEffect(() => { + if (!sessionId) return; + syncDagFromMessages(sessionId, messages); + }, [sessionId, messages]); + // ── Scroll-to-bottom helper refs ────────────────────────── const scrollRafRef = useRef(0); const lastScrollTimeRef = useRef(0); @@ -588,7 +604,7 @@ export default function AgentHubIM(): JSX.Element { reconnectRef.current = null; } - const ws = new WebSocket(`ws://127.0.0.1:8000/ws/${sid}?token=${encodeURIComponent(token)}`); + const ws = new WebSocket(buildChatWebSocketUrl(sid, token)); wsRef.current.set(sid, ws); ws.onopen = () => { @@ -731,18 +747,24 @@ export default function AgentHubIM(): JSX.Element { } if (evt === 'task_update') { - // Per-node incremental update — merge into existing dag state - const tu = raw as { nodeId: string; status: string; detail?: { error?: string; retries?: number }; sessionId: string }; + // Per-node incremental update — merge into session-scoped DAG state + const tu = raw as { + nodeId: string; + status: string; + detail?: { error?: string; retries?: number }; + progress?: { completed?: number; total?: number; failed?: number; running?: number; percent?: number }; + durationMs?: number; + retries?: number; + sessionId: string; + }; if (tu.nodeId && tu.status) { - setDag(prev => { - const nodes = prev.nodes.map(n => - n.id === tu.nodeId ? { ...n, status: tu.status, error: tu.detail?.error } : n - ); - return { - ...prev, - nodes, - completed: nodes.filter(n => n.status === 'SUCCESS' || n.status === 'FAILED').length, - }; + updateDagState(chunkSessionId, { + nodeId: tu.nodeId, + status: tu.status, + detail: tu.detail, + progress: tu.progress, + durationMs: tu.durationMs, + retries: tu.retries, }); } } @@ -1236,15 +1258,16 @@ export default function AgentHubIM(): JSX.Element { if (evt === 'task_preview') { const payload = raw as unknown as import('../types').TaskPreviewEvent; // Initialize DAG state for real-time node status tracking - setDag({ + setDagState(chunkSessionId, { total: payload.tasks.length, completed: 0, - nodes: payload.tasks.map(t => ({ + nodes: payload.tasks.map((t) => ({ id: t.id, agent: t.agent, description: t.description, dependencies: t.dependencies, status: 'PENDING', + estimated_effort: t.estimatedSeconds != null ? `${t.estimatedSeconds}s` : undefined, })), }); updateSessionMessages(chunkSessionId, (prev) => [ @@ -1569,12 +1592,14 @@ export default function AgentHubIM(): JSX.Element { }, [authMode, authForm]); const handleLogout = useCallback(() => { + const sessionIds = Array.from(wsRef.current.keys()); localStorage.removeItem('agenthub_token'); localStorage.removeItem('agenthub_user'); // 关闭所有 session 的 WebSocket - Array.from(wsRef.current.keys()).forEach((sid) => closeWs(sid)); + sessionIds.forEach((sid) => closeWs(sid)); // 关闭 store 里所有的 session - Array.from(wsRef.current.keys()).forEach((sid) => clearSession(sid)); + sessionIds.forEach((sid) => clearSession(sid)); + sessionIds.forEach((sid) => clearDagSession(sid)); if (reconnectRef.current) { clearTimeout(reconnectRef.current); reconnectRef.current = null; @@ -1657,6 +1682,7 @@ export default function AgentHubIM(): JSX.Element { streamFlushRafRef.current.delete(id); } clearSession(id); + clearDagSession(id); setSessions((prev) => prev.filter((s) => s.id !== id)); if (sessionId === id) { const next = sessions.find((s) => s.id !== id); diff --git a/frontend/lib/dagStore.ts b/frontend/lib/dagStore.ts new file mode 100644 index 0000000..adcdf2c --- /dev/null +++ b/frontend/lib/dagStore.ts @@ -0,0 +1,172 @@ +import { useCallback, useSyncExternalStore } from 'react'; +import type { DagState, Message, TaskPreviewEvent } from '../types'; + +export interface DagTaskUpdateEvent { + nodeId: string; + status: string; + detail?: { error?: string }; + progress?: { + completed?: number; + total?: number; + failed?: number; + running?: number; + percent?: number; + }; + durationMs?: number; + retries?: number; +} + +type Listener = () => void; + +const EMPTY_DAG_STATE: DagState = Object.freeze({ + total: 0, + completed: 0, + nodes: [], +}) as DagState; + +function createEmptyDagState(): DagState { + return EMPTY_DAG_STATE; +} + +export function buildDagStateFromTaskPreview(payload: TaskPreviewEvent): DagState { + return { + total: payload.tasks.length, + completed: 0, + nodes: payload.tasks.map((task) => ({ + id: task.id, + agent: task.agent, + description: task.description, + dependencies: task.dependencies, + status: 'PENDING', + estimated_effort: task.estimatedSeconds != null ? `${task.estimatedSeconds}s` : undefined, + })), + }; +} + +export function mergeDagTaskUpdate(prev: DagState, update: DagTaskUpdateEvent): DagState { + if (!update.nodeId || !update.status) return prev; + + let matched = false; + const nodes = prev.nodes.map((node) => { + if (node.id !== update.nodeId) return node; + matched = true; + return { + ...node, + status: update.status, + error: update.detail?.error ?? node.error, + duration_ms: update.durationMs ?? node.duration_ms, + }; + }); + + const completed = update.progress?.completed ?? nodes.filter((node) => node.status === 'SUCCESS').length; + const total = update.progress?.total ?? prev.total; + + if (!matched && !update.progress) { + return prev; + } + + return { + ...prev, + total, + completed, + nodes, + }; +} + +export function deriveDagStateFromMessages(messages: Message[]): DagState { + for (let i = messages.length - 1; i >= 0; i -= 1) { + const msg = messages[i]; + if (msg.type === 'task_preview' && msg.taskPreviewData) { + return buildDagStateFromTaskPreview(msg.taskPreviewData); + } + } + return createEmptyDagState(); +} + +class DagStore { + private sessions = new Map(); + private listenersBySession = new Map>(); + + private notify(sessionId: string): void { + const listeners = this.listenersBySession.get(sessionId); + if (!listeners) return; + for (const listener of listeners) listener(); + } + + private subscribe(sessionId: string, listener: Listener): () => void { + let listeners = this.listenersBySession.get(sessionId); + if (!listeners) { + listeners = new Set(); + this.listenersBySession.set(sessionId, listeners); + } + listeners.add(listener); + return () => { + listeners?.delete(listener); + if (listeners && listeners.size === 0) { + this.listenersBySession.delete(sessionId); + } + }; + } + + getState(sessionId: string): DagState { + return this.sessions.get(sessionId) ?? createEmptyDagState(); + } + + setState(sessionId: string, next: DagState): void { + this.sessions.set(sessionId, next); + this.notify(sessionId); + } + + updateTask(sessionId: string, update: DagTaskUpdateEvent): void { + const prev = this.sessions.get(sessionId) ?? createEmptyDagState(); + const next = mergeDagTaskUpdate(prev, update); + if (next === prev) return; + this.sessions.set(sessionId, next); + this.notify(sessionId); + } + + syncFromMessages(sessionId: string, messages: Message[]): void { + if (this.sessions.has(sessionId)) return; + const derived = deriveDagStateFromMessages(messages); + if (derived.total === 0 && derived.nodes.length === 0) return; + this.sessions.set(sessionId, derived); + this.notify(sessionId); + } + + clearSession(sessionId: string): void { + if (!this.sessions.has(sessionId)) return; + this.sessions.delete(sessionId); + this.notify(sessionId); + } + + useDagState(sessionId: string): DagState { + // eslint-disable-next-line react-hooks/rules-of-hooks + const subscribe = useCallback((cb: Listener) => this.subscribe(sessionId, cb), [sessionId]); + // eslint-disable-next-line react-hooks/rules-of-hooks + const getSnapshot = useCallback(() => this.getState(sessionId), [sessionId]); + // eslint-disable-next-line react-hooks/rules-of-hooks + return useSyncExternalStore(subscribe, getSnapshot, createEmptyDagState); + } +} + +const dagStore = new DagStore(); + +export function useDagState(sessionId: string): DagState { + return dagStore.useDagState(sessionId); +} + +export function setDagState(sessionId: string, dag: DagState): void { + dagStore.setState(sessionId, dag); +} + +export function updateDagState(sessionId: string, update: DagTaskUpdateEvent): void { + dagStore.updateTask(sessionId, update); +} + +export function syncDagFromMessages(sessionId: string, messages: Message[]): void { + dagStore.syncFromMessages(sessionId, messages); +} + +export function clearDagSession(sessionId: string): void { + dagStore.clearSession(sessionId); +} diff --git a/frontend/lib/websocketUrl.ts b/frontend/lib/websocketUrl.ts new file mode 100644 index 0000000..e03b3e0 --- /dev/null +++ b/frontend/lib/websocketUrl.ts @@ -0,0 +1,21 @@ +const DEFAULT_WS_BASE_URL = 'ws://127.0.0.1:8000'; + +function normalizeWebSocketBaseUrl(raw: string): string { + const trimmed = raw.trim().replace(/\/$/, ''); + if (trimmed.startsWith('ws://') || trimmed.startsWith('wss://')) { + return trimmed; + } + if (trimmed.startsWith('http://')) { + return `ws://${trimmed.slice('http://'.length)}`; + } + if (trimmed.startsWith('https://')) { + return `wss://${trimmed.slice('https://'.length)}`; + } + return trimmed; +} + +export function buildChatWebSocketUrl(sessionId: string, token: string): string { + const configuredBase = process.env.NEXT_PUBLIC_WS_URL?.trim(); + const baseUrl = configuredBase ? normalizeWebSocketBaseUrl(configuredBase) : DEFAULT_WS_BASE_URL; + return `${baseUrl}/ws/${encodeURIComponent(sessionId)}?token=${encodeURIComponent(token)}`; +} From 0fcf14340fed97cc4c191c971a23b832fb469f4e Mon Sep 17 00:00:00 2001 From: Nat-xu <19707027371@163.com> Date: Sun, 19 Jul 2026 01:31:09 +0800 Subject: [PATCH 16/22] extract message recovery helpers --- .../__tests__/lib/messageRecovery.test.ts | 91 ++++++++++++++ .../lib/outgoingMessageDraft.test.ts | 44 +++++++ frontend/app/page.tsx | 112 +++--------------- frontend/lib/messageRecovery.ts | 60 ++++++++++ frontend/lib/outgoingMessageDraft.ts | 68 +++++++++++ 5 files changed, 277 insertions(+), 98 deletions(-) create mode 100644 frontend/__tests__/lib/messageRecovery.test.ts create mode 100644 frontend/__tests__/lib/outgoingMessageDraft.test.ts create mode 100644 frontend/lib/messageRecovery.ts create mode 100644 frontend/lib/outgoingMessageDraft.ts diff --git a/frontend/__tests__/lib/messageRecovery.test.ts b/frontend/__tests__/lib/messageRecovery.test.ts new file mode 100644 index 0000000..75972ff --- /dev/null +++ b/frontend/__tests__/lib/messageRecovery.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; +import { + mergeFinalMessage, + mergeReloadedMessages, + registerReplayMessageId, +} from '../../lib/messageRecovery'; +import type { Message } from '../../types'; + +describe('messageRecovery helpers', () => { + it('merges reloaded messages without duplicating finalized ids', () => { + const prev: Message[] = [ + { + event: 'message', + sessionId: 's1', + sender: 'agent', + content: 'streaming', + type: 'text', + timestamp: '2026-07-19T00:00:00.000Z', + isStreaming: true, + }, + { + event: 'message', + sessionId: 's1', + sender: 'user', + content: 'done', + type: 'text', + timestamp: '2026-07-19T00:00:01.000Z', + id: 'm1', + }, + ]; + const merged = mergeReloadedMessages(prev, [ + { + event: 'message', + sessionId: 's1', + sender: 'user', + content: 'done', + type: 'text', + timestamp: '2026-07-19T00:00:01.000Z', + id: 'm1', + }, + { + event: 'message', + sessionId: 's1', + sender: 'agent', + content: 'fresh', + type: 'text', + timestamp: '2026-07-19T00:00:02.000Z', + id: 'm2', + }, + ]); + + expect(merged.some((m) => m.content === 'streaming')).toBe(false); + expect(merged.map((m) => m.id)).toContain('m2'); + }); + + it('replaces streaming placeholder with the final message payload', () => { + const merged = mergeFinalMessage([ + { + event: 'message', + sessionId: 's1', + sender: 'agent', + content: 'tools done', + type: 'text', + timestamp: '2026-07-19T00:00:00.000Z', + messageId: 'mid-1', + isStreaming: true, + }, + ], { + event: 'message', + sessionId: 's1', + sender: 'agent', + content: 'final answer', + type: 'text', + timestamp: '2026-07-19T00:00:01.000Z', + messageId: 'mid-1', + id: 'server-1', + }); + + expect(merged).toHaveLength(1); + expect(merged[0].content).toBe('final answer'); + expect(merged[0].isStreaming).toBe(false); + }); + + it('keeps replay dedup ids bounded', () => { + let seen = new Set(); + for (let i = 0; i < 520; i += 1) { + seen = registerReplayMessageId(seen, `m-${i}`); + } + expect(seen.size).toBeLessThanOrEqual(500); + }); +}); diff --git a/frontend/__tests__/lib/outgoingMessageDraft.test.ts b/frontend/__tests__/lib/outgoingMessageDraft.test.ts new file mode 100644 index 0000000..ad2c76d --- /dev/null +++ b/frontend/__tests__/lib/outgoingMessageDraft.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { buildOutgoingMessageDraft } from '../../lib/outgoingMessageDraft'; + +describe('buildOutgoingMessageDraft', () => { + it('builds ai content and attachment metadata from files and references', () => { + const draft = buildOutgoingMessageDraft({ + text: 'Please review', + files: [ + { + name: 'hello.py', + size: 120, + type: 'text/x-python', + category: 'code', + content: 'print("hi")', + }, + ] as any, + references: [ + { + id: 'ref-1', + path: 'src/app.py', + quote: 'def main(): pass', + originalSender: 'user', + originalTimestamp: '2026-07-19T00:00:00.000Z', + isFullMessage: false, + lineStart: 10, + lineEnd: 12, + } as any, + ], + }); + + expect(draft.displayContent).toBe('Please review'); + expect(draft.attachments).toEqual([ + { + name: 'hello.py', + size: 120, + type: 'text/x-python', + category: 'code', + fileId: undefined, + }, + ]); + expect(draft.aiContent).toContain('[Attached File: hello.py]'); + expect(draft.aiContent).toContain('[Referenced File: src/app.py]'); + }); +}); diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index bb02a6f..e20dbae 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -25,7 +25,8 @@ import ConfirmDialog from '../components/ui/ConfirmDialog'; import ResizableDivider from '../components/common/ResizableDivider'; import { useResizableSize } from '../hooks/useResizableSize'; import { useFileUpload } from '../hooks/useFileUpload'; -import { normalizeReferences } from '../lib/references'; +import { buildOutgoingMessageDraft } from '../lib/outgoingMessageDraft'; +import { mergeFinalMessage, mergeReloadedMessages, registerReplayMessageId } from '../lib/messageRecovery'; import { buildChatWebSocketUrl } from '../lib/websocketUrl'; import { clearDagSession, @@ -47,7 +48,7 @@ import { unpinSession, type StreamBuffer, } from '../lib/sessionStore'; -import type { Agent, AttachedFile, AttachmentMeta, ChatSession, FileReference, GeneratedData, Message, PendingMessage, QuoteReference, SkillMeta, StreamChunk, ToolCallEvent, ToolResultEvent, User, WorkflowSummary, WorkspacePreviewTab } from '../types'; +import type { Agent, AttachedFile, ChatSession, FileReference, GeneratedData, Message, PendingMessage, QuoteReference, SkillMeta, StreamChunk, ToolCallEvent, ToolResultEvent, User, WorkflowSummary, WorkspacePreviewTab } from '../types'; import type { FilePreviewTarget } from '../components/chat/FilePreviewModal'; import { ToastProvider, useAddToast } from '../components/ui/Toast'; @@ -333,15 +334,7 @@ export default function AgentHubIM(): JSX.Element { } const data: Message[] = (await res.json()) as Message[]; if (merge) { - updateSessionMessages(sid, (prev) => { - const existingIds = new Set(prev.filter((m) => m.id).map((m) => m.id)); - const newMessages = data.filter((m) => !m.id || !existingIds.has(m.id)); - if (newMessages.length === 0) return prev; - // Only remove actively-streaming temp messages; keep finalized ones - // that haven't been replaced by the DB message event yet. - const clean = prev.filter((m) => !m.isStreaming); - return [...clean, ...newMessages].sort((a, b) => a.timestamp.localeCompare(b.timestamp)); - }); + updateSessionMessages(sid, (prev) => mergeReloadedMessages(prev, data)); } else { // Always replace messages on a full (non-merge) reload. // 写到 SessionStore(per-session),切走再切回来时这条记录还在 Map 里。 @@ -703,12 +696,7 @@ export default function AgentHubIM(): JSX.Element { return; // already processed } if (msgId) { - dedupIdsRef.current.add(msgId); - // Keep dedup set from growing unbounded (cap at ~500) - if (dedupIdsRef.current.size > 500) { - const arr = [...dedupIdsRef.current]; - dedupIdsRef.current = new Set(arr.slice(arr.length - 250)); - } + dedupIdsRef.current = registerReplayMessageId(dedupIdsRef.current, msgId); } // Track last known message ID for reconnection sync @@ -1450,41 +1438,7 @@ export default function AgentHubIM(): JSX.Element { setSessionStreaming(cSessionId, false); const msg = raw as unknown as Message; const isSystemMsg = msg.type === 'system' || msg.sender === 'system'; - updateSessionMessages(cSessionId, (prev) => { - // Clean up ALL streaming thinking placeholders (from agent_thinking) - // before adding/replacing the final message. The prior narrow filter - // (empty or "正在"-prefixed) missed the "synthesizing" phase content - // like "工具执行完成,正在综合结果生成回复..." - let cleaned = prev; - const hasStaleStreamers = prev.some( - (m) => m.isStreaming && m.sender !== 'user' && !m.diffFilePath && m.type === 'text' - ); - if (hasStaleStreamers) { - cleaned = prev.filter( - (m) => !(m.isStreaming && m.sender !== 'user' && !m.diffFilePath && m.type === 'text') - ); - } - - const targetMessageId = (raw.messageId || msg.messageId || '') as string; - const streamingIdx = targetMessageId - ? cleaned.findIndex((m) => m.messageId === targetMessageId) - : -1; - if (streamingIdx >= 0 && !isSystemMsg) { - const updated = [...cleaned]; - updated[streamingIdx] = { ...msg, messageId: undefined, isStreaming: false }; - if (msg.id) { - return updated.filter((m, i) => i === streamingIdx || m.id !== msg.id); - } - return updated; - } - if (isSystemMsg && msg.content && msg.content.includes('已连接')) { - return cleaned; - } - if (msg.id && cleaned.some((m) => m.id === msg.id)) { - return cleaned; - } - return [...cleaned, { ...msg, messageId: undefined, isStreaming: false }]; - }); + updateSessionMessages(cSessionId, (prev) => mergeFinalMessage(prev, msg)); if (!isSystemMsg) { setSessions((prev) => sortSessions(prev.map((s) => (s.id === (msg.sessionId || cSessionId) ? { ...s, lastMessageAt: msg.timestamp || new Date().toISOString() } : s)))); } @@ -2063,49 +2017,11 @@ export default function AgentHubIM(): JSX.Element { : ''; const text = rawText.trim(); if (!text && currentFiles.length === 0) return; - - const fileMetas: AttachmentMeta[] = currentFiles.map((f) => ({ - name: f.name, - size: f.size, - type: f.type, - category: f.category, - fileId: f.fileId, - })); - - let aiContent = text; - if (currentFiles.length > 0) { - const fileBlocks = currentFiles.map((f) => { - const ext = f.name.split('.').pop()?.toLowerCase() || ''; - if (f.fileId && !f.content) { - return `[Attached File: ${f.name} (fileId: ${f.fileId})]`; - } - return `[Attached File: ${f.name}]\n\`\`\`${ext}\n${f.content || ''}\n\`\`\``; - }).join('\n\n'); - aiContent = text ? `${text}\n\n---\n${fileBlocks}` : fileBlocks; - } - - // Include file references (quoted text from preview panel) in the message - if (fileReferences.length > 0) { - // 规范化:截断超长 quote、补全 lineEnd - const normalized = normalizeReferences(fileReferences); - const refBlocks = normalized.map((ref) => { - const parts: string[] = [`[Referenced File: ${ref.path}]`]; - if (ref.lineStart) { - const lineRange = ref.lineEnd && ref.lineEnd !== ref.lineStart - ? `Lines ${ref.lineStart}-${ref.lineEnd}` - : `Line ${ref.lineStart}`; - parts.push(`(${lineRange})`); - } - if (ref.quote) { - parts.push(`\n\`\`\`\n${ref.quote}\n\`\`\``); - } - return parts.join(''); - }); - const refContent = refBlocks.join('\n\n'); - aiContent = aiContent ? `${aiContent}\n\n---\n${refContent}` : refContent; - } - - const displayContent = text || (currentFiles.length > 0 ? `发送了 ${currentFiles.length} 个文件` : ''); + const draft = buildOutgoingMessageDraft({ + text, + files: currentFiles, + references: fileReferences, + }); if (process.env.NODE_ENV === 'development') { console.log('[agenthub] handleSend', { sessionId: activeSessionId, text }); @@ -2116,17 +2032,17 @@ export default function AgentHubIM(): JSX.Element { id: clientId, event: 'message', sessionId: activeSessionId, - content: displayContent, + content: draft.displayContent, sender: user?.name || 'user', userId: user?.id || '', timestamp: new Date().toISOString(), type: 'text', - attachments: fileMetas.length > 0 ? fileMetas : undefined, + attachments: draft.attachments.length > 0 ? draft.attachments : undefined, }; const wsMsg: PendingMessage = { sessionId: activeSessionId, - content: aiContent, + content: draft.aiContent, sender: user?.name || 'user', timestamp: new Date().toISOString(), type: 'text', diff --git a/frontend/lib/messageRecovery.ts b/frontend/lib/messageRecovery.ts new file mode 100644 index 0000000..1e59b46 --- /dev/null +++ b/frontend/lib/messageRecovery.ts @@ -0,0 +1,60 @@ +import type { Message } from '../types'; + +const DEFAULT_REPLAY_CAP = 500; +const DEFAULT_REPLAY_TRIM_TO = 250; + +function isStreamingThinkingMessage(message: Message): boolean { + return !!(message.isStreaming && message.sender !== 'user' && !message.diffFilePath && message.type === 'text'); +} + +function isSystemConnectedMessage(message: Message): boolean { + return (message.type === 'system' || message.sender === 'system') && message.content.includes('已连接'); +} + +export function registerReplayMessageId(seenIds: Set, messageId: string, cap = DEFAULT_REPLAY_CAP): Set { + if (!messageId) return seenIds; + if (seenIds.has(messageId)) return seenIds; + const next = new Set(seenIds); + next.add(messageId); + if (next.size <= cap) return next; + const arr = [...next]; + return new Set(arr.slice(arr.length - DEFAULT_REPLAY_TRIM_TO)); +} + +export function mergeReloadedMessages(prev: Message[], incoming: Message[]): Message[] { + const existingIds = new Set(prev.filter((m) => m.id).map((m) => m.id as string)); + const newMessages = incoming.filter((m) => !m.id || !existingIds.has(m.id)); + if (newMessages.length === 0) { + return prev; + } + const clean = prev.filter((m) => !isStreamingThinkingMessage(m)); + return [...clean, ...newMessages].sort((a, b) => a.timestamp.localeCompare(b.timestamp)); +} + +export function mergeFinalMessage(prev: Message[], incoming: Message): Message[] { + const clean = prev.filter((message) => !isStreamingThinkingMessage(message)); + const isSystemMsg = incoming.type === 'system' || incoming.sender === 'system'; + const targetMessageId = incoming.messageId || incoming.id || ''; + const streamingIdx = targetMessageId + ? clean.findIndex((message) => message.messageId === targetMessageId) + : -1; + + if (streamingIdx >= 0 && !isSystemMsg) { + const updated = [...clean]; + updated[streamingIdx] = { ...incoming, messageId: undefined, isStreaming: false }; + if (incoming.id) { + return updated.filter((message, index) => index === streamingIdx || message.id !== incoming.id); + } + return updated; + } + + if (isSystemConnectedMessage(incoming)) { + return clean; + } + + if (incoming.id && clean.some((message) => message.id === incoming.id)) { + return clean; + } + + return [...clean, { ...incoming, messageId: undefined, isStreaming: false }]; +} diff --git a/frontend/lib/outgoingMessageDraft.ts b/frontend/lib/outgoingMessageDraft.ts new file mode 100644 index 0000000..ab5c7ed --- /dev/null +++ b/frontend/lib/outgoingMessageDraft.ts @@ -0,0 +1,68 @@ +import { normalizeReferences } from './references'; +import type { AttachedFile, AttachmentMeta, FileReference } from '../types'; + +export interface OutgoingMessageDraft { + aiContent: string; + displayContent: string; + attachments: AttachmentMeta[]; +} + +interface BuildOutgoingMessageDraftInput { + text: string; + files: AttachedFile[]; + references: FileReference[]; +} + +export function buildOutgoingMessageDraft({ + text, + files, + references, +}: BuildOutgoingMessageDraftInput): OutgoingMessageDraft { + const trimmedText = text.trim(); + const attachments: AttachmentMeta[] = files.map((file) => ({ + name: file.name, + size: file.size, + type: file.type, + category: file.category, + fileId: file.fileId, + })); + + let aiContent = trimmedText; + if (files.length > 0) { + const fileBlocks = files.map((file) => { + const ext = file.name.split('.').pop()?.toLowerCase() || ''; + if (file.fileId && !file.content) { + return `[Attached File: ${file.name} (fileId: ${file.fileId})]`; + } + return `[Attached File: ${file.name}]\n\`\`\`${ext}\n${file.content || ''}\n\`\`\``; + }).join('\n\n'); + aiContent = trimmedText ? `${trimmedText}\n\n---\n${fileBlocks}` : fileBlocks; + } + + if (references.length > 0) { + const normalized = normalizeReferences(references); + const refBlocks = normalized.map((ref) => { + const parts: string[] = [`[Referenced File: ${ref.path}]`]; + if (ref.lineStart) { + const lineRange = ref.lineEnd && ref.lineEnd !== ref.lineStart + ? `Lines ${ref.lineStart}-${ref.lineEnd}` + : `Line ${ref.lineStart}`; + parts.push(`(${lineRange})`); + } + if (ref.quote) { + parts.push(`\n\`\`\`\n${ref.quote}\n\`\`\``); + } + return parts.join(''); + }); + const refContent = refBlocks.join('\n\n'); + aiContent = aiContent ? `${aiContent}\n\n---\n${refContent}` : refContent; + } + + const displayContent = trimmedText || (files.length > 0 ? `发送了 ${files.length} 个文件` : ''); + + return { + aiContent, + displayContent, + attachments, + }; +} From 12105dd62ac309e88e27bfd2d3b0b9c28c17ca01 Mon Sep 17 00:00:00 2001 From: Nat-xu <19707027371@163.com> Date: Sun, 19 Jul 2026 02:34:01 +0800 Subject: [PATCH 17/22] refactor session recovery and prompt compaction --- app/api/test_websocket_message_flow.py | 2 +- app/api/websocket.py | 34 +- app/api/websocket_message_flow.py | 36 +-- app/services/agent_service.py | 6 +- app/services/context_compaction.py | 191 ++++++++++++ app/services/conversation_history.py | 21 +- app/services/orchestrator_preprocessor.py | 115 +------ app/services/result_synthesizer.py | 7 +- app/services/task_decomposer.py | 79 +++-- app/services/test_context_compaction.py | 59 ++++ app/services/tools/agent_tools.py | 4 +- docs/optimization-roadmap.md | 293 ++++++++++++++---- .../hooks/useSessionRecovery.test.ts | 31 ++ .../__tests__/lib/chatEventMessages.test.ts | 73 +++++ frontend/app/page.tsx | 118 ++----- frontend/hooks/useSessionRecovery.ts | 55 ++++ frontend/lib/chatEventMessages.ts | 68 ++++ 17 files changed, 819 insertions(+), 373 deletions(-) create mode 100644 app/services/context_compaction.py create mode 100644 app/services/test_context_compaction.py create mode 100644 frontend/__tests__/hooks/useSessionRecovery.test.ts create mode 100644 frontend/__tests__/lib/chatEventMessages.test.ts create mode 100644 frontend/hooks/useSessionRecovery.ts create mode 100644 frontend/lib/chatEventMessages.ts diff --git a/app/api/test_websocket_message_flow.py b/app/api/test_websocket_message_flow.py index 93088a0..be4954a 100644 --- a/app/api/test_websocket_message_flow.py +++ b/app/api/test_websocket_message_flow.py @@ -52,7 +52,7 @@ def test_build_dag_task_items_supports_objects_and_dicts() -> None: ] ) - assert items[0]["description"].startswith("Architect") + assert items[0]["description"] == "梳理方案" assert items[0]["estimatedSeconds"] == 90 assert items[1]["estimatedSeconds"] == 20 diff --git a/app/api/websocket.py b/app/api/websocket.py index f042ad5..94dfdc5 100644 --- a/app/api/websocket.py +++ b/app/api/websocket.py @@ -1143,22 +1143,7 @@ async def _broadcast_to_agent(agent_row: dict): # ── 🟡 Trigger 1: Task Preview with real DAG ──────── task_preview_msg_id = str(uuid.uuid4()) - task_items = [] - for node in dag_config.nodes: - domain_label = { - "orchestrator": "协调调度", "architect": "架构设计", - "codegen": "代码生成", "review": "代码审查", - "test": "测试验证", "deploy": "部署发布", - }.get(node.domain, node.domain) - task_items.append({ - "id": node.id, - "description": f"{node.agent} ({domain_label}): {node.description}", - "agent": node.agent, - "dependencies": node.dependencies, - "estimatedSeconds": {"low": 20, "medium": 45, "high": 90}.get( - node.estimated_effort, 45 - ), - }) + task_items = message_flow.build_dag_task_items(list(dag_config.nodes)) await manager.broadcast_task_preview( session_id, task_preview_msg_id, task_items, eta_seconds=sum(t.get("estimatedSeconds", 45) for t in task_items), @@ -1610,22 +1595,7 @@ async def _invoke_agent( # ── Broadcast task preview ────────────────────────── _dag_preview_id = str(uuid.uuid4()) - _dag_task_items = [] - for node in dag_config.nodes: - domain_label = { - "orchestrator": "协调调度", "architect": "架构设计", - "codegen": "代码生成", "review": "代码审查", - "test": "测试验证", "deploy": "部署发布", - }.get(node.domain, node.domain) - _dag_task_items.append({ - "id": node.id, - "description": f"{node.agent} ({domain_label}): {node.description}", - "agent": node.agent, - "dependencies": node.dependencies, - "estimatedSeconds": {"low": 20, "medium": 45, "high": 90}.get( - node.estimated_effort, 45 - ), - }) + _dag_task_items = message_flow.build_dag_task_items(list(dag_config.nodes)) await manager.broadcast_task_preview( session_id, _dag_preview_id, _dag_task_items, eta_seconds=sum(t.get("estimatedSeconds", 45) for t in _dag_task_items), diff --git a/app/api/websocket_message_flow.py b/app/api/websocket_message_flow.py index c0c7f2a..1c14a4c 100644 --- a/app/api/websocket_message_flow.py +++ b/app/api/websocket_message_flow.py @@ -10,20 +10,10 @@ from app.api import websocket_state as ws_state from app.db.init_db import now from app.schemas.dag import DAGConfig +from app.services.context_compaction import build_task_preview_item logger = logging.getLogger("agenthub.websocket") -_DOMAIN_LABELS = { - "orchestrator": "orchestrator", - "architect": "architect", - "codegen": "codegen", - "review": "review", - "test": "test", - "deploy": "deploy", -} - -_ESTIMATED_SECONDS = {"low": 20, "medium": 45, "high": 90} - _GREETING_PATTERNS = [ "^(\u5927\u5bb6|\u5404\u4f4d|\u670b\u53cb\u4eec|\u4f19\u4f34\u4eec|\u540c\u5b66\u4eec|hello\\s*all|hi\\s*all|hey\\s*all|hello\\s*everyone|hi\\s*everyone|hey\\s*everyone)", "^(\u4f60\u597d|hi|hello|hey|\u518d\u89c1|\u8c22\u8c22|thanks?|thank\\s*you|3q|ok|\u597d\u7684|\u77e5\u9053\u4e86|\u660e\u767d)", @@ -153,29 +143,7 @@ def detect_multi_mention_greeting( def build_dag_task_items(dag_nodes: list[Any]) -> list[dict[str, Any]]: - items: list[dict[str, Any]] = [] - for node in dag_nodes: - domain = str(_node_value(node, "domain", "")) - dependencies = _node_value(node, "dependencies", []) - if not isinstance(dependencies, list): - dependencies = list(dependencies) if dependencies else [] - items.append( - { - "id": str(_node_value(node, "id", "")), - "description": ( - f"{_node_value(node, 'agent', '')} " - f"({_DOMAIN_LABELS.get(domain, domain)}): " - f"{_node_value(node, 'description', '')}" - ), - "agent": str(_node_value(node, "agent", "")), - "dependencies": dependencies, - "estimatedSeconds": _ESTIMATED_SECONDS.get( - str(_node_value(node, "estimated_effort", "medium")), - 45, - ), - } - ) - return items + return [build_task_preview_item(node) for node in dag_nodes] def build_followup_todos(target_agents: list[dict[str, Any]]) -> list[dict[str, Any]]: diff --git a/app/services/agent_service.py b/app/services/agent_service.py index c470b2b..eb4c8c3 100644 --- a/app/services/agent_service.py +++ b/app/services/agent_service.py @@ -1357,7 +1357,7 @@ async def _run_loop(): return stream() -async def _build_conversation_history(session_id: str, max_chars: int = 5000) -> str: +async def _build_conversation_history(session_id: str, max_chars: int = 3600) -> str: """Fetch recent messages from this session and format as a transcript. Gives every agent called in the session full awareness of what was @@ -1376,7 +1376,7 @@ async def _build_conversation_history(session_id: str, max_chars: int = 5000) -> try: rows = await afetch_all( - "SELECT sender,content FROM messages WHERE session_id=$1 AND type!='system' ORDER BY created_at DESC LIMIT 25", + "SELECT sender,content FROM messages WHERE session_id=$1 AND type!='system' ORDER BY created_at DESC LIMIT 18", session_id, ) except Exception: @@ -1391,7 +1391,7 @@ async def _build_conversation_history(session_id: str, max_chars: int = 5000) -> return result -async def _build_memory_context(user_id: str = "", session_id: str = "", max_chars: int = 3000, force: bool = False) -> str: +async def _build_memory_context(user_id: str = "", session_id: str = "", max_chars: int = 2200, force: bool = False) -> str: """Load current-session conversation memory and global summary. Only loads what is strictly needed for conversational continuity: diff --git a/app/services/context_compaction.py b/app/services/context_compaction.py new file mode 100644 index 0000000..b2bed27 --- /dev/null +++ b/app/services/context_compaction.py @@ -0,0 +1,191 @@ +from __future__ import annotations + +from typing import Any + + +def compact_text(text: str, *, max_chars: int = 240, ellipsis: str = "...") -> str: + """Collapse whitespace and trim a string to a compact prompt-friendly form.""" + normalized = " ".join(str(text or "").split()).strip() + if not normalized: + return "" + if len(normalized) <= max_chars: + return normalized + if max_chars <= len(ellipsis): + return normalized[:max_chars] + return normalized[: max_chars - len(ellipsis)].rstrip() + ellipsis + + +def _compact_tags(tags_raw: Any, *, max_tags: int = 4) -> str: + if isinstance(tags_raw, str): + import json + + try: + tags = json.loads(tags_raw) + except Exception: + tags = [] + elif isinstance(tags_raw, list): + tags = tags_raw + else: + tags = [] + return ",".join(str(tag) for tag in tags[:max_tags] if str(tag).strip()) + + +def build_agent_roster_summary( + agents: list[dict[str, Any]], + *, + max_agents: int = 8, + max_tags: int = 4, + max_duty_chars: int = 72, +) -> str: + """Build a compact agent capability block for LLM prompts.""" + if not agents: + return "" + + lines: list[str] = ["【Agent能力摘要】"] + for agent in agents[:max_agents]: + agent_id = compact_text(str(agent.get("agent_id", "unknown")), max_chars=28) + domain = compact_text(str(agent.get("domain", "unknown")), max_chars=18) + risk = compact_text(str(agent.get("risk_level", "L1")), max_chars=8) + status = compact_text(str(agent.get("status", "sleeping")), max_chars=12) + duty = compact_text(str(agent.get("duty_note", "")), max_chars=max_duty_chars) + tags = _compact_tags(agent.get("capability_tags", []), max_tags=max_tags) + tag_part = f" tags={tags}" if tags else "" + duty_part = f" duty={duty}" if duty else "" + lines.append(f"- {agent_id}|{domain}|{status}|{risk}{tag_part}{duty_part}") + + if len(agents) > max_agents: + lines.append(f"- ... 其余 {len(agents) - max_agents} 个 Agent 已省略") + + return "\n".join(lines) + "\n" + + +def build_preprocess_context(preprocessed: dict[str, Any]) -> str: + """Compress orchestrator preprocessor output into a short prompt block.""" + if not preprocessed or preprocessed.get("is_simple"): + return "" + + parts: list[str] = ["【预处理摘要】"] + parts.append(f"intent={preprocessed.get('intent_type', 'technical_development')}") + + clarified = compact_text(preprocessed.get("clarified_question", ""), max_chars=160) + if clarified: + parts.append(f"question={clarified}") + + requirements = [ + compact_text(req, max_chars=70) + for req in preprocessed.get("requirements", [])[:5] + if compact_text(req, max_chars=70) + ] + if requirements: + parts.append("requirements=" + ";".join(requirements)) + + nf_requirements = [ + compact_text(req, max_chars=70) + for req in preprocessed.get("non_functional_requirements", [])[:4] + if compact_text(req, max_chars=70) + ] + if nf_requirements: + parts.append("nonfunctional=" + ";".join(nf_requirements)) + + selected_solution = preprocessed.get("_selected_solution") + if isinstance(selected_solution, dict): + sol_name = compact_text(selected_solution.get("name", ""), max_chars=60) + sol_stack = ", ".join(str(item) for item in selected_solution.get("tech_stack", [])[:4] if str(item).strip()) + sol_arch = compact_text(selected_solution.get("architecture", ""), max_chars=120) + sol_bits = [bit for bit in [sol_name, sol_stack, sol_arch] if bit] + if sol_bits: + parts.append("selected=" + " | ".join(sol_bits)) + + solutions = preprocessed.get("solutions", [])[:2] + if solutions: + solution_bits: list[str] = [] + for sol in solutions: + if not isinstance(sol, dict): + continue + sol_id = compact_text(sol.get("id", ""), max_chars=20) + sol_name = compact_text(sol.get("name", ""), max_chars=48) + stack = ", ".join(str(item) for item in sol.get("tech_stack", [])[:4] if str(item).strip()) + score = sol.get("score") + risk = compact_text(sol.get("risk_level", ""), max_chars=10) + bit = f"{sol_id}:{sol_name}" + extras: list[str] = [] + if stack: + extras.append(stack) + if score is not None: + extras.append(f"score={score}") + if risk: + extras.append(f"risk={risk}") + if extras: + bit += f" ({', '.join(extras)})" + solution_bits.append(bit) + if solution_bits: + parts.append("solutions=" + " | ".join(solution_bits)) + + sub_tasks = preprocessed.get("sub_tasks", [])[:5] + if sub_tasks: + task_bits: list[str] = [] + for task in sub_tasks: + if not isinstance(task, dict): + continue + task_id = compact_text(str(task.get("id", "?")), max_chars=12) + domain = compact_text(str(task.get("domain", "general")), max_chars=12) + title = compact_text(task.get("title", ""), max_chars=36) + deps = ",".join(f"n{dep}" if str(dep).isdigit() else compact_text(str(dep), max_chars=12) for dep in task.get("depends_on", [])[:4]) or "-" + task_bits.append(f"{task_id}:{domain}:{title}<-{deps}") + if task_bits: + parts.append("subtasks=" + " | ".join(task_bits)) + + constraints = [ + compact_text(item, max_chars=64) + for item in preprocessed.get("constraints", [])[:5] + if compact_text(item, max_chars=64) + ] + if constraints: + parts.append("constraints=" + ";".join(constraints)) + + routing = preprocessed.get("routing") + if isinstance(routing, dict): + order = [compact_text(item, max_chars=16) for item in routing.get("execution_order", [])[:5] if compact_text(item, max_chars=16)] + parallel = [compact_text(item, max_chars=24) for item in routing.get("parallel_opportunities", [])[:3] if compact_text(item, max_chars=24)] + conflicts = [compact_text(item, max_chars=24) for item in routing.get("potential_conflicts", [])[:3] if compact_text(item, max_chars=24)] + if order: + parts.append("route=" + "->".join(order)) + if parallel: + parts.append("parallel=" + ";".join(parallel)) + if conflicts: + parts.append("conflicts=" + ";".join(conflicts)) + + approach = compact_text(preprocessed.get("suggested_approach", ""), max_chars=120) + if approach: + parts.append(f"approach={approach}") + + return "\n".join(parts) + "\n" + + +def build_task_preview_item(node: Any) -> dict[str, Any]: + """Build a compact preview item for websocket task confirmations.""" + def _node_value(obj: Any, key: str, default: Any = "") -> Any: + if isinstance(obj, dict): + return obj.get(key, default) + return getattr(obj, key, default) + + dependencies = _node_value(node, "dependencies", []) + if not isinstance(dependencies, list): + dependencies = list(dependencies) if dependencies else [] + + return { + "id": str(_node_value(node, "id", "")), + "description": compact_text(_node_value(node, "description", ""), max_chars=84), + "agent": str(_node_value(node, "agent", "")), + "dependencies": dependencies, + "estimatedSeconds": { + "low": 20, + "medium": 45, + "high": 90, + }.get(str(_node_value(node, "estimated_effort", "medium")), 45), + } + + +def build_result_preview(text: str, *, max_chars: int = 220) -> str: + """Compress a result text into a short preview for synthesis metadata.""" + return compact_text(text, max_chars=max_chars) diff --git a/app/services/conversation_history.py b/app/services/conversation_history.py index 41bbeaf..7252286 100644 --- a/app/services/conversation_history.py +++ b/app/services/conversation_history.py @@ -2,31 +2,38 @@ from typing import Any +from app.services.context_compaction import compact_text from app.services.text_processing import strip_think_tags def build_conversation_history_transcript( rows: list[dict[str, Any]], *, - max_chars: int = 5000, - max_messages: int = 16, - max_message_chars: int = 600, + max_chars: int = 4200, + max_messages: int = 12, + max_message_chars: int = 420, ) -> str: if not rows: return "" lines: list[str] = [] total = 0 + last_line = "" for row in rows[:max_messages]: - sender = str(row.get("sender", "unknown")) - content = strip_think_tags(str(row.get("content", ""))) - if len(content) > max_message_chars: - content = content[:max_message_chars] + "..." + sender = compact_text(str(row.get("sender", "unknown")), max_chars=32) + content = compact_text(strip_think_tags(str(row.get("content", ""))), max_chars=max_message_chars) + if not sender or not content: + continue line = f"{sender}: {content}" + if line == last_line: + continue total += len(line) lines.append(line) + last_line = line if total > max_chars: break lines.reverse() + if not lines: + return "" return "【会话历史】\n" + "\n".join(lines) diff --git a/app/services/orchestrator_preprocessor.py b/app/services/orchestrator_preprocessor.py index 7674050..8b5653b 100644 --- a/app/services/orchestrator_preprocessor.py +++ b/app/services/orchestrator_preprocessor.py @@ -12,6 +12,7 @@ ORCHESTRATOR_PREPROCESS_MIN_LENGTH, ) from app.services.adapter_manager import adapter_manager +from app.services.context_compaction import build_preprocess_context from app.services.secret_service import decrypt_secret logger = logging.getLogger("agenthub.orchestrator_preprocessor") @@ -468,118 +469,8 @@ def _parse_response(self, raw: str) -> dict[str, Any] | None: # ── Prompt formatting ────────────────────────────────────────────── def format_for_prompt(self, preprocessed: dict[str, Any]) -> str: - """Format a pre-processing result as a Markdown block for the main prompt. - - This is injected before the user's original question in build_prompt(). - Includes requirements, selected solution, agent routing suggestions, - potential conflicts, and fallback strategies. - """ - if not preprocessed or preprocessed.get("is_simple"): - return "" - - lines: list[str] = [] - - # Clarified question - clarified = preprocessed.get("clarified_question", "").strip() - if clarified: - lines.append(f"**问题重述**: {clarified}") - - # ── Requirements ────────────────────────────────────────────── - requirements = preprocessed.get("requirements", []) - if requirements: - lines.append("\n**功能需求**:") - for req in requirements: - lines.append(f" - {req}") - - nf_reqs = preprocessed.get("non_functional_requirements", []) - if nf_reqs: - lines.append("\n**非功能需求**:") - for nf in nf_reqs: - lines.append(f" - {nf}") - - # ── Selected solution context ────────────────────────────────── - selected_solution = preprocessed.get("_selected_solution") - if selected_solution and isinstance(selected_solution, dict): - sol_name = selected_solution.get("name", "") - sol_stack = selected_solution.get("tech_stack", []) - sol_arch = selected_solution.get("architecture", "") - if sol_name: - lines.append(f"\n**选定技术方案**: {sol_name}") - if sol_stack: - lines.append(f"**技术栈**: {', '.join(sol_stack)}") - if sol_arch: - lines.append(f"**架构**: {sol_arch}") - - # Sub-tasks with dependency info - sub_tasks = preprocessed.get("sub_tasks", []) - if sub_tasks: - lines.append("\n**子任务拆解**:") - for st in sub_tasks: - sid = st.get("id", "?") - title = st.get("title", "") - desc = st.get("description", "") - domain = st.get("domain", "general") - deps = st.get("depends_on", []) - domain_labels = { - "architect": "Architect(架构设计)", - "codegen": "CodeGen(代码生成)", - "review": "Review(代码审查)", - "test": "Test(测试验证)", - "deploy": "Deploy(部署发布)", - "general": "通用", - } - domain_label = domain_labels.get(domain, domain) - dep_str = f" (依赖: 任务{', '.join(map(str, deps))})" if deps else "" - lines.append(f" {sid}. {domain_label}: {title}{dep_str}") - if desc: - lines.append(f" → {desc}") - - # Constraints - constraints = preprocessed.get("constraints", []) - if constraints: - lines.append(f"\n**技术约束**: {', '.join(constraints)}") - - # Suggested approach - approach = preprocessed.get("suggested_approach", "").strip() - if approach: - lines.append(f"\n**建议路径**: {approach}") - - # ── Agent routing guidance ────────────────────────────────────── - routing = preprocessed.get("routing") - if routing and isinstance(routing, dict): - # Execution order - exec_order = routing.get("execution_order", []) - if exec_order: - arrows = " → ".join(exec_order) - lines.append(f"\n**Agent 调用顺序**: {arrows}") - - # Parallel opportunities - parallel = routing.get("parallel_opportunities", []) - if parallel: - if isinstance(parallel, list): - for p in parallel: - lines.append(f"**并行机会**: {p}") - elif isinstance(parallel, str): - lines.append(f"**并行机会**: {parallel}") - - # Potential conflicts - conflicts = routing.get("potential_conflicts", []) - if conflicts: - if isinstance(conflicts, list): - lines.append(f"\n**⚠️ 潜在冲突**:") - for c in conflicts: - lines.append(f" - {c}") - elif isinstance(conflicts, str): - lines.append(f"\n**⚠️ 潜在冲突**: {conflicts}") - - # Fallback agents - fallbacks = routing.get("fallback_agents", {}) - if fallbacks and isinstance(fallbacks, dict): - lines.append(f"\n**🔄 失败降级方案**:") - for agent_name, fallback_plan in fallbacks.items(): - lines.append(f" - {agent_name} 失败时: {fallback_plan}") - - return "\n".join(lines) if lines else "" + """Format a pre-processing result as a compact block for the main prompt.""" + return build_preprocess_context(preprocessed) # ── DAG construction from preprocess result ───────────────────────── diff --git a/app/services/result_synthesizer.py b/app/services/result_synthesizer.py index b234306..0104b09 100644 --- a/app/services/result_synthesizer.py +++ b/app/services/result_synthesizer.py @@ -7,6 +7,7 @@ from typing import Any from app.schemas.dag import DAGConfig +from app.services.context_compaction import compact_text logger = logging.getLogger("agenthub.result_synthesizer") @@ -116,10 +117,10 @@ def _build_results_section( if node.id in node_results: output = node_results[node.id] # Truncate very long outputs - if len(output) > 3000: - output = output[:3000] + "\n\n... (输出过长,已截断)" + if len(output) > 1800: + output = output[:1800] + "\n\n... (输出过长,已截断)" sections.append( - f"### [{node.agent}] {node.description}\n" + f"### [{node.agent}] {compact_text(node.description, max_chars=72)}\n" f"{output}\n" ) return "\n\n".join(sections) diff --git a/app/services/task_decomposer.py b/app/services/task_decomposer.py index 6d67f0f..4075356 100644 --- a/app/services/task_decomposer.py +++ b/app/services/task_decomposer.py @@ -2,12 +2,14 @@ import json import logging +import hashlib import re import time from typing import Any from app.db.session import afetch_all from app.schemas.dag import DAGConfig, DAGNode +from app.services.context_compaction import build_agent_roster_summary, compact_text from app.services.template_engine import template_engine logger = logging.getLogger("agenthub.task_decomposer") @@ -70,8 +72,8 @@ class ArchitectTaskDecomposer: DECOMPOSE_TIMEOUT = 30.0 def __init__(self) -> None: - self._agent_capability_cache: str | None = None - self._cache_ts: float = 0.0 + self._agent_capability_cache: dict[str, tuple[float, str]] = {} + self._historical_context_cache: dict[str, tuple[float, str | None]] = {} self._cache_ttl: float = 300.0 # 5 min # ── Public API ───────────────────────────────────────────────────── @@ -158,32 +160,26 @@ async def _llm_decompose( async def _build_capability_summary(self, agents: list[dict[str, Any]]) -> str: """Build a concise agent capability description for the prompt.""" - lines: list[str] = [] - for a in agents: - agent_id = a.get("agent_id", "unknown") - domain = a.get("domain", "unknown") - duty = a.get("duty_note", "") - risk = a.get("risk_level", "L1") - status = a.get("status", "sleeping") - tags_raw = a.get("capability_tags", "[]") - try: - tags = json.loads(tags_raw) if isinstance(tags_raw, str) else tags_raw - except (json.JSONDecodeError, TypeError): - tags = [] - tag_str = ", ".join(tags[:6]) if tags else "无" - - status_icon = {"online": "✓", "sleeping": "~", "offline": "✗"}.get(status, "?") - lines.append( - f"- **{agent_id}** [{status_icon}] domain={domain}, risk={risk}\n" - f" 职责: {duty}\n 能力标签: {tag_str}" - ) - return "\n".join(lines) + fingerprint = self._fingerprint_agents(agents) + cached = self._agent_capability_cache.get(fingerprint) + now_ts = time.monotonic() + if cached and (now_ts - cached[0]) < self._cache_ttl: + return cached[1] + + summary = build_agent_roster_summary(agents, max_agents=8, max_tags=4, max_duty_chars=64) + self._agent_capability_cache[fingerprint] = (now_ts, summary) + return summary async def _build_historical_context(self, content: str) -> str | None: """Query task_execution_history for relevant past performance.""" try: # Simple keyword extraction for task type matching task_type = self._classify_task_type(content) + cached = self._historical_context_cache.get(task_type) + now_ts = time.monotonic() + if cached and (now_ts - cached[0]) < self._cache_ttl: + return cached[1] + rows = await afetch_all( """SELECT assigned_agent, SUM(CASE WHEN success THEN 1 ELSE 0 END) * 1.0 / COUNT(*) as success_rate, @@ -196,17 +192,42 @@ async def _build_historical_context(self, content: str) -> str | None: task_type, ) if not rows: + self._historical_context_cache[task_type] = (now_ts, None) return None - lines = [f"任务类型 '{task_type}' 的历史执行数据:"] - for r in rows[:10]: - lines.append( - f" {r['assigned_agent']}: 成功率={r['success_rate']:.0%}, " - f"平均耗时={r['avg_duration_ms']}ms, 总次数={r['total_runs']}" - ) - return "\n".join(lines) + + lines = [f"【历史执行摘要】task={task_type}"] + for r in rows[:5]: + agent_name = compact_text(str(r["assigned_agent"]), max_chars=24) + success_rate = int(round(float(r["success_rate"]) * 100)) + avg_duration = int(r["avg_duration_ms"] or 0) + total_runs = int(r["total_runs"] or 0) + lines.append(f"- {agent_name}: {success_rate}% / {avg_duration}ms / {total_runs}次") + summary = "\n".join(lines) + self._historical_context_cache[task_type] = (now_ts, summary) + return summary except Exception: return None + def _fingerprint_agents(self, agents: list[dict[str, Any]]) -> str: + parts: list[str] = [] + for agent in agents: + tags_raw = agent.get("capability_tags", "[]") + if isinstance(tags_raw, list): + tags_raw = json.dumps(tags_raw, ensure_ascii=False) + parts.append( + "|".join([ + str(agent.get("agent_id", "")), + str(agent.get("domain", "")), + str(agent.get("risk_level", "")), + str(agent.get("status", "")), + str(agent.get("duty_note", ""))[:80], + str(tags_raw)[:120], + ]) + ) + if not parts: + return "empty" + return hashlib.sha256("\n".join(parts).encode("utf-8")).hexdigest()[:24] + def _find_architect(self, agents: list[dict[str, Any]]) -> dict[str, Any] | None: """Find the Architect agent from the agent list.""" for a in agents: diff --git a/app/services/test_context_compaction.py b/app/services/test_context_compaction.py new file mode 100644 index 0000000..1485988 --- /dev/null +++ b/app/services/test_context_compaction.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from app.services.context_compaction import ( + build_preprocess_context, + build_result_preview, + build_task_preview_item, +) + + +def test_build_preprocess_context_is_compact() -> None: + text = build_preprocess_context( + { + "intent_type": "technical_development", + "clarified_question": "请帮我设计一个可扩展的企业级多智能体平台,并支持任务编排。", + "requirements": ["多智能体协作", "企业自托管"], + "non_functional_requirements": ["低耦合", "低 token 消耗"], + "solutions": [ + { + "id": "A", + "name": "Go + Python", + "tech_stack": ["Go", "Python", "NATS"], + "score": 91, + "risk_level": "low", + } + ], + "sub_tasks": [ + {"id": 1, "domain": "architect", "title": "拆分架构", "depends_on": []}, + ], + "constraints": ["自托管"], + "routing": {"execution_order": ["Architect", "CodeGen"]}, + } + ) + + assert "intent=technical_development" in text + assert "route=Architect->CodeGen" in text + assert "requirements=" in text + assert "【预处理摘要】" in text + + +def test_build_task_preview_item_is_short() -> None: + item = build_task_preview_item( + { + "id": "n1", + "agent": "Architect", + "description": "梳理企业级多智能体平台的整体架构、模块边界和协作方式", + "dependencies": ["n0"], + "estimated_effort": "high", + } + ) + + assert item["description"].startswith("梳理企业级多智能体平台") + assert item["estimatedSeconds"] == 90 + + +def test_build_result_preview_trims_long_text() -> None: + preview = build_result_preview("x" * 500, max_chars=80) + + assert len(preview) <= 80 + assert preview.endswith("...") diff --git a/app/services/tools/agent_tools.py b/app/services/tools/agent_tools.py index e28cea9..32530cf 100644 --- a/app/services/tools/agent_tools.py +++ b/app/services/tools/agent_tools.py @@ -13,6 +13,8 @@ import time from typing import Any +from app.services.context_compaction import build_result_preview + logger = logging.getLogger("agenthub.tools.agent") # ── Context variables (set by _run_tool_call_loop before each iteration) ─ @@ -371,7 +373,7 @@ async def _run_one(call: dict[str, Any]) -> dict[str, Any]: } if success: result_text = r.get("result", "") - summary["result_preview"] = result_text[:500] + summary["result_preview"] = build_result_preview(result_text, max_chars=220) summary["result_length"] = len(result_text) else: summary["error"] = r.get("error", "未知错误") diff --git a/docs/optimization-roadmap.md b/docs/optimization-roadmap.md index 2119c99..af1c394 100644 --- a/docs/optimization-roadmap.md +++ b/docs/optimization-roadmap.md @@ -1,74 +1,261 @@ # AgentHub Optimization Roadmap -## Principles +## 0. Current Position -- Keep orchestration, transport, state, and storage separate. -- Prefer pure helpers for transforms and formatting. -- Make per-session and per-user state explicit. -- Reduce prompt length by compressing context before the model sees it. +AgentHub is already past the "chat wrapper" stage. The current codebase has a usable multi-agent runtime, DAG tasking, WebSocket collaboration, memory/session persistence, and a multi-language service backbone. -## Phase 1: Stabilize +What is now true in the repository: -Goal: fix user-visible correctness and security issues. +- `frontend/app/page.tsx` is already thinner than before and now delegates message recovery, WebSocket URL building, and DAG state to helpers. +- Prompt/token control has started through `app/services/context_compaction.py`, `app/services/conversation_history.py`, `app/services/orchestrator_preprocessor.py`, `app/services/task_decomposer.py`, and `app/services/result_synthesizer.py`. +- `app/api/websocket_message_flow.py` and `app/api/websocket.py` now share compact task preview construction. +- The repo already carries the architecture needed for enterprise expansion, but it still has a few oversized hot modules. -- Remove hardcoded login hints from the UI. -- Move session pinning to per-user preferences. -- Keep legacy fields only as compatibility fallbacks. -- Reduce broad `except Exception` usage in hot paths. +The next step is not to add more features first. The next step is to make the core flow smaller, more explicit, and cheaper to run. -Deliverables: -- Clean login form defaults. -- User-scoped session pin state. -- Regression tests for pin parsing and sorting. +## 1. Scorecard + +| Dimension | Score | Why it lost points | +|---|---:|---| +| Architecture | 82 | Strong platform shape, but a few modules still hold transport, orchestration, persistence, and formatting together. | +| Code quality | 72 | Hot-path functions are still too large and a lot of logic is repeated in slightly different forms. | +| Performance / concurrency | 78 | Caching exists, but prompt assembly, history replay, and summary synthesis still do too much repeated work. | +| Business completeness | 84 | Multi-agent, DAG, memory, IAM, observability, and deploy are present; editor, marketplace, SDK, and token management are still missing. | +| Operations / deployment | 79 | Compose and monitoring are present, but service startup, retries, and runtime health are not uniform enough. | +| Security | 71 | IAM and tool gating exist, but broad exception handling and in-process state remain weak points. | +| Extensibility | 76 | The stack is right, but the extension points are not yet shaped as clean platform APIs. | + +Overall: **77/100**. + +## 2. Problem List + +### 2.1 Architecture coupling + +| Problem | Scope | Risk | Root cause | +|---|---|---:|---| +| `app/services/agent_service.py` does too much | Prompt, history, memory, tool loop, persistence, synthesis | High | The runtime was expanded without a hard boundary between orchestration and formatting. | +| `app/api/websocket.py` still carries lifecycle + control + task preview + collaboration | All IM traffic and replay flows | High | The WebSocket layer still owns more than transport concerns. | +| `frontend/app/page.tsx` is still a large orchestration shell | Main IM UI, reconnect, preview, recovery, draft state | High | UI state and transport state were not separated early enough. | +| DAG preview generation is duplicated | `websocket.py`, `websocket_message_flow.py` | Medium | Item shaping lived in two places before a shared helper existed. | + +### 2.2 Code quality + +| Problem | Scope | Risk | Root cause | +|---|---|---:|---| +| Repeated string assembly for prompt payloads | Every LLM call | High | No shared context-compaction layer existed. | +| Broad `except Exception` blocks | Hot paths and fallback paths | High | Fail-open behavior was used too often to keep the UX moving. | +| Overloaded helpers | History, preprocessor, synthesis, preview | Medium | Helpers were kept as convenience wrappers instead of becoming true domain units. | + +### 2.3 Performance and token use + +| Problem | Scope | Risk | Root cause | +|---|---|---:|---| +| Conversation history was too long | Frequent turns and tool loops | High | History replay was treated as raw transcript replay. | +| Memory context was too large | Long sessions | High | Current-session memory and global summary were both injected too freely. | +| Preprocess output was too verbose | Orchestrator prompt | High | Structured analysis was formatted as markdown instead of compact prompt data. | +| Result synthesis repeated long node outputs | Multi-agent DAG runs | Medium | Each result was copied into the final synthesis with too much text. | + +### 2.4 Business flow gaps + +| Problem | Scope | Risk | Root cause | +|---|---|---:|---| +| Message recovery and DAG replay still need hardening | Refresh, reconnect, cross-tab continuity | High | Recovery is partly client-side and partly session-scoped, not a single source of truth. | +| Task preview payload is still larger than needed | PM confirmation flow | Medium | Preview text includes extra labels that do not improve decision quality. | +| Route / agent planning is not cached as a reusable artifact | DAG generation and synthesis | Medium | Pre-summaries are not yet first-class data. | + +### 2.5 Operations and security + +| Problem | Scope | Risk | Root cause | +|---|---|---:|---| +| Hidden runtime failures are still possible | All service layers | High | Fallbacks absorb too many errors without enough structured telemetry. | +| Session state lives too much in-process | WebSocket and task state | High | Restart safety and multi-instance coordination are not fully externalized yet. | +| Audit and trace correlation are inconsistent | Python / Go / Rust | Medium | Logging and tracing conventions are not yet standardized end-to-end. | + +## 3. Detailed Fix Plan + +### 3.1 Fast fixes + +| Issue | Fast fix | File paths | Validation | +|---|---|---|---| +| Prompt bloat | Keep compact prompt helpers and use them everywhere | `app/services/context_compaction.py`, `app/services/conversation_history.py`, `app/services/orchestrator_preprocessor.py`, `app/services/task_decomposer.py`, `app/services/result_synthesizer.py` | Compare prompt length before/after and keep routine turns materially shorter. | +| WebSocket preview duplication | Use one compact task preview builder | `app/api/websocket_message_flow.py`, `app/api/websocket.py` | Task preview output should be identical across both paths and smaller. | +| History replay noise | Truncate and dedupe history more aggressively | `app/services/conversation_history.py` | History should stay readable while dropping repeated lines and extra whitespace. | +| Result preview inflation | Shrink partial summaries | `app/services/tools/agent_tools.py` | Partial summaries must stay short and still preserve enough context for synthesis. | -## Phase 2: Decouple +### 3.2 Structural refactor -Goal: shrink high-coupling modules. +| Issue | Refactor | File paths | Validation | +|---|---|---|---| +| `agent_service.py` overload | Split into prompt builder, memory context, tool loop, persistence adapter | `app/services/agent_service.py` plus new helpers under `app/services/` | `build_prompt` remains orchestration-only and helper boundaries become testable. | +| WebSocket overload | Split into lifecycle, control events, preview events, collaboration stream | `app/api/websocket.py`, `app/api/websocket_dispatch.py`, `app/api/websocket_lifecycle.py`, `app/api/websocket_message_flow.py` | Each file should own one responsibility and tests should target each lane independently. | +| Frontend page overload | Move IM orchestration into feature hooks and stores | `frontend/app/page.tsx`, `frontend/lib/*`, `frontend/components/chat/*` | Page file should only compose state and pass callbacks. | +| Recovery / replay complexity | Centralize recovery state and replay rules | `frontend/lib/messageRecovery.ts`, `frontend/lib/dagStore.ts`, `frontend/lib/sessionStore.ts` | Reload, refresh, and reconnect should preserve messages and DAG state. | -- Split `agent_service.py` into: - - agent runtime - - prompt/context builder - - memory manager - - tool execution adapter - - streaming/event emitter -- Split `websocket.py` into: - - connection lifecycle - - collaboration events - - task preview events - - message streaming -- Move shared session preference logic to a small service module. +### 3.3 Long-term refactor -## Phase 3: Token Economy +| Issue | Long-term direction | File / area | +|---|---|---| +| Session state | Move to Redis-backed and event-backed coordination | `app/services/websocket_manager.py`, `app/api/websocket_state.py`, Go session services | +| Prompt economy | Introduce layered ContextOS | `app/services/context_compaction.py` and memory stack | +| Replay / audit | Make server-side event log the source of truth | WebSocket events, task state machine, storage layer | +| Product extensibility | Add DAG editor, template market, SDKs, and token management | `frontend/components/admin/*`, `app/api/tasks.py`, `app/api/agent.py`, docs | -Goal: cut model input size without losing task quality. +## 4. Business Closure Plan -- Add layered context compression: - - L0 recent turn cache - - L1 short-term conversation summary - - L2 retrieval snippets - - L3 knowledge graph references -- Truncate auto-name and preview prompts aggressively. -- Cache static system prompts by role and provider. -- Reuse structured summaries instead of replaying raw histories. +### 4.1 End-to-end flow -Target outcomes: -- 30-60% less prompt payload in routine chat turns. -- Lower repeated token spend on long sessions. +The target closed loop should be: -## Phase 4: Platform Hardening +1. User message enters IM. +2. Preprocessor classifies intent and compresses route / solution / task hints. +3. Router decides whether to use direct response, collaborative DAG, or orchestration. +4. WebSocket sends a compact task preview. +5. User confirms or modifies the plan. +6. DAG execution runs with per-node updates and compact result previews. +7. Synthesizer combines node results into a final answer. +8. Message, DAG, audit, and memory state are persisted. +9. Frontend can replay the full state after refresh or reconnect. +10. Observability and audit logs show the full chain. -Goal: make the architecture enterprise-grade. +### 4.2 Short term -- Replace in-process session state with Redis-backed coordination. -- Make task execution idempotent and restart-safe. -- Add explicit retry, timeout, and cancellation semantics. -- Standardize telemetry for Go, Rust, and Python services. +Focus: -## Phase 5: Productization +- Continue thinning `frontend/app/page.tsx`. +- Finish message recovery and DAG replay hardening. +- Keep prompt payloads small by default. -Goal: improve adoption and extensibility. +Success criteria: + +- Refresh does not lose messages. +- DAG preview and replay remain stable. +- Common prompts are shorter and less repetitive. + +### 4.3 Mid term + +Focus: - DAG visual editor. -- Agent template and tool marketplace. -- SDKs for TypeScript, Python, and Go. -- Better docs for enterprise deployment and self-hosting. +- Agent template market. +- Tool market. +- TypeScript / Python / Go SDKs. +- Token and API key management. + +Success criteria: + +- Users can create, save, reuse, and share workflows. +- Teams can manage tokens and permissions from the platform. +- External developers can integrate without reading internal code first. + +### 4.4 Long term + +Focus: + +- CRDT multi-user editing. +- K8s elasticity with canary and chaos testing. +- SOC2 / compliance work. +- AgentNet decentralized communication. + +Success criteria: + +- Platform can support many tenants and many teams without collapsing into one-process assumptions. + +## 5. Execution Phases + +### Phase A: Stabilize + +Priority: + +1. Keep transport and state recovery correct. +2. Remove duplicated preview and recovery logic. +3. Keep user-visible flows from breaking. + +Deliverables: + +- Message recovery helpers in `frontend/lib/messageRecovery.ts`. +- DAG session state helpers in `frontend/lib/dagStore.ts`. +- Compact WebSocket URL builder in `frontend/lib/websocketUrl.ts`. +- Confirmed task preview and solution proposal flows. + +### Phase B: Decouple + +Priority: + +1. Thin `agent_service.py`. +2. Thin `websocket.py`. +3. Keep `page.tsx` as a composition shell. + +Deliverables: + +- Shared context compaction helpers. +- Short prompt templates. +- Smaller transport/event handlers. + +### Phase C: Token Economy + +Priority: + +1. Cache route / agent pre-summaries. +2. Shorten preview payloads. +3. Reduce repeated context assembly. + +Deliverables: + +- `app/services/context_compaction.py`. +- Shorter history and memory context. +- Shorter synthesis inputs. + +### Phase D: Platform Hardening + +Priority: + +1. Externalize session state. +2. Make execution restart-safe. +3. Standardize telemetry. + +Deliverables: + +- Redis/event-backed coordination. +- Structured logs and trace IDs. +- Retry / timeout / cancellation semantics. + +### Phase E: Productization + +Priority: + +1. DAG editor. +2. Template market. +3. SDKs and token management. + +Deliverables: + +- Workflow authoring UI. +- Reusable templates. +- Public integration surface. + +## 6. Already Landed + +These are the concrete foundation pieces now in the repo: + +- `app/services/context_compaction.py` +- `app/services/conversation_history.py` +- `app/services/orchestrator_preprocessor.py` +- `app/services/task_decomposer.py` +- `app/services/result_synthesizer.py` +- `app/services/tools/agent_tools.py` +- `app/api/websocket_message_flow.py` +- `app/api/websocket.py` +- `frontend/lib/dagStore.ts` +- `frontend/lib/messageRecovery.ts` +- `frontend/lib/websocketUrl.ts` +- `frontend/lib/outgoingMessageDraft.ts` + +## 7. Rule of Thumb + +Do not expand feature surface until the core loop stays: + +- smaller, +- replayable, +- observable, +- and cheap enough to run repeatedly. + diff --git a/frontend/__tests__/hooks/useSessionRecovery.test.ts b/frontend/__tests__/hooks/useSessionRecovery.test.ts new file mode 100644 index 0000000..a7c93f0 --- /dev/null +++ b/frontend/__tests__/hooks/useSessionRecovery.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { sortMessagesByTimestamp } from '../../hooks/useSessionRecovery'; +import type { Message } from '../../types'; + +describe('useSessionRecovery helpers', () => { + it('sorts reloaded messages by timestamp without mutating input', () => { + const messages: Message[] = [ + { + event: 'message', + sessionId: 's1', + sender: 'agent', + content: 'later', + type: 'text', + timestamp: '2026-07-19T00:00:02.000Z', + }, + { + event: 'message', + sessionId: 's1', + sender: 'agent', + content: 'earlier', + type: 'text', + timestamp: '2026-07-19T00:00:01.000Z', + }, + ]; + + const sorted = sortMessagesByTimestamp(messages); + + expect(sorted.map((m) => m.content)).toEqual(['earlier', 'later']); + expect(messages.map((m) => m.content)).toEqual(['later', 'earlier']); + }); +}); diff --git a/frontend/__tests__/lib/chatEventMessages.test.ts b/frontend/__tests__/lib/chatEventMessages.test.ts new file mode 100644 index 0000000..f39e2e8 --- /dev/null +++ b/frontend/__tests__/lib/chatEventMessages.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import { + buildAgentTodoMessage, + buildDeployCardMessage, + buildSolutionProposalMessage, + buildTaskPreviewMessage, +} from '../../lib/chatEventMessages'; + +describe('chatEventMessages helpers', () => { + it('builds a task preview message', () => { + const msg = buildTaskPreviewMessage('s1', { + event: 'task_preview', + sessionId: 's1', + messageId: 'm1', + timestamp: '2026-07-19T00:00:00.000Z', + tasks: [], + }); + + expect(msg.type).toBe('task_preview'); + expect(msg.taskPreviewData?.messageId).toBe('m1'); + expect(msg.content).toBe('任务预览'); + }); + + it('builds a solution proposal message', () => { + const msg = buildSolutionProposalMessage('s1', { + event: 'solution_proposal', + sessionId: 's1', + messageId: 'm2', + timestamp: '2026-07-19T00:00:00.000Z', + intentType: 'technical_development', + requirements: [], + nonFunctionalRequirements: [], + constraints: [], + solutions: [{ id: 'a', name: 'A', techStack: [], architecture: '', pros: [], cons: [], estimatedEffort: 'low', riskLevel: 'low', score: 1 }], + recommendedSolutionId: 'a', + recommendationReason: '', + autoConfirmSeconds: 15, + }); + + expect(msg.type).toBe('solution_proposal'); + expect(msg.solutionProposalData?.messageId).toBe('m2'); + }); + + it('builds agent todo and deploy messages', () => { + const todo = buildAgentTodoMessage('s1', { + event: 'agent_todo', + sessionId: 's1', + messageId: 'm3', + timestamp: '2026-07-19T00:00:00.000Z', + agentId: 'PM', + title: 'Check', + description: 'Do it', + actions: [], + priority: 'medium', + }); + const deploy = buildDeployCardMessage('s1', { + event: 'deploy_card', + sessionId: 's1', + messageId: 'm4', + timestamp: '2026-07-19T00:00:00.000Z', + version: '1.0.0', + completedAt: '2026-07-19T00:00:00.000Z', + description: 'Deployed', + affectedFiles: [], + agentId: 'Deploy', + }); + + expect(todo.type).toBe('agent_todo'); + expect(todo.content).toContain('Check'); + expect(deploy.type).toBe('deploy_card'); + expect(deploy.deployCardData?.version).toBe('1.0.0'); + }); +}); diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index e20dbae..03a77f4 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -25,16 +25,23 @@ import ConfirmDialog from '../components/ui/ConfirmDialog'; import ResizableDivider from '../components/common/ResizableDivider'; import { useResizableSize } from '../hooks/useResizableSize'; import { useFileUpload } from '../hooks/useFileUpload'; +import { useSessionRecovery } from '../hooks/useSessionRecovery'; import { buildOutgoingMessageDraft } from '../lib/outgoingMessageDraft'; -import { mergeFinalMessage, mergeReloadedMessages, registerReplayMessageId } from '../lib/messageRecovery'; +import { mergeFinalMessage, registerReplayMessageId } from '../lib/messageRecovery'; import { buildChatWebSocketUrl } from '../lib/websocketUrl'; import { + buildDagStateFromTaskPreview, clearDagSession, setDagState, - syncDagFromMessages, updateDagState, useDagState, } from '../lib/dagStore'; +import { + buildAgentTodoMessage, + buildDeployCardMessage, + buildSolutionProposalMessage, + buildTaskPreviewMessage, +} from '../lib/chatEventMessages'; import { useSessionMessages, useSessionStreaming, @@ -105,6 +112,12 @@ export default function AgentHubIM(): JSX.Element { const messages = useSessionMessages(sessionId); const isStreaming = useSessionStreaming(sessionId); const dag = useDagState(sessionId); + const { reloadMessages } = useSessionRecovery({ + sessionId, + token, + messages, + onTokenExpired: handleTokenExpired, + }); const { addToast } = useAddToast(); const [sessionQuery, setSessionQuery] = useState(''); const [input, setInput] = useState('@CodeGen Generate a FastAPI health route file, save as health_router.py'); @@ -321,32 +334,6 @@ export default function AgentHubIM(): JSX.Element { .catch(() => {}); }, [token]); - async function reloadMessages(merge = false): Promise { - const sid = currentSessionRef.current; - // Guard: never issue requests with an empty session id — the - // resulting double-slash URL (…/sessions//messages) would 404. - if (!sid) return; - try { - const res = await fetch(`/api/chat/sessions/${encodeURIComponent(sid)}/messages`, { headers: authHeaders() }); - if (!res.ok) { - if (res.status === 401) handleTokenExpired(); - return; - } - const data: Message[] = (await res.json()) as Message[]; - if (merge) { - updateSessionMessages(sid, (prev) => mergeReloadedMessages(prev, data)); - } else { - // Always replace messages on a full (non-merge) reload. - // 写到 SessionStore(per-session),切走再切回来时这条记录还在 Map 里。 - replaceSessionMessages( - sid, - [...data].sort((a, b) => a.timestamp.localeCompare(b.timestamp)), - ); - } - syncDagFromMessages(sid, data); - } catch { /* ignore */ } - } - function authHeaders(extra: Record = {}): Record { const localToken = typeof window !== 'undefined' ? localStorage.getItem('agenthub_token') : ''; return localToken ? { ...extra, Authorization: `Bearer ${localToken}` } : extra; @@ -398,11 +385,6 @@ export default function AgentHubIM(): JSX.Element { }; }, [token, sessionId]); - useEffect(() => { - if (!sessionId) return; - syncDagFromMessages(sessionId, messages); - }, [sessionId, messages]); - // ── Scroll-to-bottom helper refs ────────────────────────── const scrollRafRef = useRef(0); const lastScrollTimeRef = useRef(0); @@ -1228,85 +1210,25 @@ export default function AgentHubIM(): JSX.Element { if (evt === 'agent_todo') { const payload = raw as unknown as import('../types').AgentTodoEvent; - updateSessionMessages(chunkSessionId, (prev) => [ - ...prev, - { - event: 'message', - sessionId: chunkSessionId, - sender: payload.agentId || 'PM', - content: payload.title + '\n' + payload.description, - type: 'agent_todo' as const, - timestamp: payload.timestamp, - messageId: payload.messageId, - todoData: payload, - }, - ]); + updateSessionMessages(chunkSessionId, (prev) => [...prev, buildAgentTodoMessage(chunkSessionId, payload)]); } if (evt === 'task_preview') { const payload = raw as unknown as import('../types').TaskPreviewEvent; - // Initialize DAG state for real-time node status tracking - setDagState(chunkSessionId, { - total: payload.tasks.length, - completed: 0, - nodes: payload.tasks.map((t) => ({ - id: t.id, - agent: t.agent, - description: t.description, - dependencies: t.dependencies, - status: 'PENDING', - estimated_effort: t.estimatedSeconds != null ? `${t.estimatedSeconds}s` : undefined, - })), - }); - updateSessionMessages(chunkSessionId, (prev) => [ - ...prev, - { - event: 'message', - sessionId: chunkSessionId, - sender: 'system', - content: '任务预览', - type: 'task_preview' as const, - timestamp: payload.timestamp, - messageId: payload.messageId, - taskPreviewData: payload, - }, - ]); + setDagState(chunkSessionId, buildDagStateFromTaskPreview(payload)); + updateSessionMessages(chunkSessionId, (prev) => [...prev, buildTaskPreviewMessage(chunkSessionId, payload)]); } // ── Solution proposal event ────────────────────────────────────── if (evt === 'solution_proposal') { const payload = raw as unknown as import('../types').SolutionProposalEvent; - updateSessionMessages(chunkSessionId, (prev) => [ - ...prev, - { - event: 'message', - sessionId: chunkSessionId, - sender: 'Orchestrator', - content: `方案分析 — ${payload.solutions.length} 个方案`, - type: 'solution_proposal' as const, - timestamp: payload.timestamp, - messageId: payload.messageId, - solutionProposalData: payload, - }, - ]); + updateSessionMessages(chunkSessionId, (prev) => [...prev, buildSolutionProposalMessage(chunkSessionId, payload)]); } // ── Deploy card event ────────────────────────────────────────── if (evt === 'deploy_card') { const payload = raw as unknown as import('../types').DeployCardEvent; - updateSessionMessages(chunkSessionId, (prev) => [ - ...prev, - { - event: 'message', - sessionId: chunkSessionId, - sender: payload.agentId || 'Deploy', - content: payload.description || '部署完成', - type: 'deploy_card' as const, - timestamp: payload.timestamp, - messageId: payload.messageId, - deployCardData: payload, - }, - ]); + updateSessionMessages(chunkSessionId, (prev) => [...prev, buildDeployCardMessage(chunkSessionId, payload)]); } // ── CloudCode: terminal output (streaming) ──────────────────── diff --git a/frontend/hooks/useSessionRecovery.ts b/frontend/hooks/useSessionRecovery.ts new file mode 100644 index 0000000..27af875 --- /dev/null +++ b/frontend/hooks/useSessionRecovery.ts @@ -0,0 +1,55 @@ +import { useCallback, useEffect } from 'react'; +import { mergeReloadedMessages } from '../lib/messageRecovery'; +import { replaceSessionMessages, updateSessionMessages } from '../lib/sessionStore'; +import { syncDagFromMessages } from '../lib/dagStore'; +import type { Message } from '../types'; + +export function sortMessagesByTimestamp(messages: Message[]): Message[] { + return [...messages].sort((a, b) => a.timestamp.localeCompare(b.timestamp)); +} + +interface UseSessionRecoveryOptions { + sessionId: string; + token: string; + messages: Message[]; + onTokenExpired: () => void; +} + +export function useSessionRecovery({ + sessionId, + token, + messages, + onTokenExpired, +}: UseSessionRecoveryOptions) { + useEffect(() => { + if (!sessionId) return; + syncDagFromMessages(sessionId, messages); + }, [sessionId, messages]); + + const reloadMessages = useCallback(async (merge = false): Promise => { + if (!sessionId) return; + + try { + const headers: Record = {}; + if (token) { + headers.Authorization = `Bearer ${token}`; + } + const res = await fetch(`/api/chat/sessions/${encodeURIComponent(sessionId)}/messages`, { headers }); + if (!res.ok) { + if (res.status === 401) onTokenExpired(); + return; + } + const data: Message[] = (await res.json()) as Message[]; + if (merge) { + updateSessionMessages(sessionId, (prev) => mergeReloadedMessages(prev, data)); + } else { + replaceSessionMessages(sessionId, sortMessagesByTimestamp(data)); + } + syncDagFromMessages(sessionId, data); + } catch { + /* ignore */ + } + }, [sessionId, token, onTokenExpired]); + + return { reloadMessages }; +} diff --git a/frontend/lib/chatEventMessages.ts b/frontend/lib/chatEventMessages.ts new file mode 100644 index 0000000..410d167 --- /dev/null +++ b/frontend/lib/chatEventMessages.ts @@ -0,0 +1,68 @@ +import type { AgentTodoEvent, DeployCardEvent, Message, SolutionProposalEvent, TaskPreviewEvent } from '../types'; + +function baseMessage( + sessionId: string, + payload: { timestamp: string; messageId: string; sender: string; content: string; type: Message['type'] }, +): Message { + return { + event: 'message', + sessionId, + sender: payload.sender, + content: payload.content, + type: payload.type, + timestamp: payload.timestamp, + messageId: payload.messageId, + }; +} + +export function buildAgentTodoMessage(sessionId: string, payload: AgentTodoEvent): Message { + return { + ...baseMessage(sessionId, { + timestamp: payload.timestamp, + messageId: payload.messageId, + sender: payload.agentId || 'PM', + content: `${payload.title}\n${payload.description}`, + type: 'agent_todo', + }), + todoData: payload, + }; +} + +export function buildTaskPreviewMessage(sessionId: string, payload: TaskPreviewEvent): Message { + return { + ...baseMessage(sessionId, { + timestamp: payload.timestamp, + messageId: payload.messageId, + sender: 'system', + content: '任务预览', + type: 'task_preview', + }), + taskPreviewData: payload, + }; +} + +export function buildSolutionProposalMessage(sessionId: string, payload: SolutionProposalEvent): Message { + return { + ...baseMessage(sessionId, { + timestamp: payload.timestamp, + messageId: payload.messageId, + sender: 'Orchestrator', + content: `方案分析 — ${payload.solutions.length} 个方案`, + type: 'solution_proposal', + }), + solutionProposalData: payload, + }; +} + +export function buildDeployCardMessage(sessionId: string, payload: DeployCardEvent): Message { + return { + ...baseMessage(sessionId, { + timestamp: payload.timestamp, + messageId: payload.messageId, + sender: payload.agentId || 'Deploy', + content: payload.description || '部署完成', + type: 'deploy_card', + }), + deployCardData: payload, + }; +} From 6ea5bb4a54d5860c3fa24f67fe3b1b935b0ab6ba Mon Sep 17 00:00:00 2001 From: Nat-xu <19707027371@163.com> Date: Sun, 19 Jul 2026 02:47:16 +0800 Subject: [PATCH 18/22] harden message recovery replay --- .../__tests__/lib/messageRecovery.test.ts | 29 +++++++++++++++++++ frontend/hooks/useSessionRecovery.ts | 7 +++-- frontend/lib/messageRecovery.ts | 18 ++++++++---- frontend/vitest.config.ts | 4 +-- 4 files changed, 48 insertions(+), 10 deletions(-) diff --git a/frontend/__tests__/lib/messageRecovery.test.ts b/frontend/__tests__/lib/messageRecovery.test.ts index 75972ff..7b6c5f0 100644 --- a/frontend/__tests__/lib/messageRecovery.test.ts +++ b/frontend/__tests__/lib/messageRecovery.test.ts @@ -53,6 +53,35 @@ describe('messageRecovery helpers', () => { expect(merged.map((m) => m.id)).toContain('m2'); }); + it('deduplicates reloaded messages by messageId when server id is missing', () => { + const prev: Message[] = [ + { + event: 'message', + sessionId: 's1', + sender: 'agent', + content: 'cached', + type: 'text', + timestamp: '2026-07-19T00:00:00.000Z', + messageId: 'mid-1', + }, + ]; + + const merged = mergeReloadedMessages(prev, [ + { + event: 'message', + sessionId: 's1', + sender: 'agent', + content: 'cached', + type: 'text', + timestamp: '2026-07-19T00:00:00.000Z', + messageId: 'mid-1', + }, + ]); + + expect(merged).toHaveLength(1); + expect(merged[0].messageId).toBe('mid-1'); + }); + it('replaces streaming placeholder with the final message payload', () => { const merged = mergeFinalMessage([ { diff --git a/frontend/hooks/useSessionRecovery.ts b/frontend/hooks/useSessionRecovery.ts index 27af875..64cdf03 100644 --- a/frontend/hooks/useSessionRecovery.ts +++ b/frontend/hooks/useSessionRecovery.ts @@ -40,12 +40,13 @@ export function useSessionRecovery({ return; } const data: Message[] = (await res.json()) as Message[]; + const ordered = sortMessagesByTimestamp(data); if (merge) { - updateSessionMessages(sessionId, (prev) => mergeReloadedMessages(prev, data)); + updateSessionMessages(sessionId, (prev) => mergeReloadedMessages(prev, ordered)); } else { - replaceSessionMessages(sessionId, sortMessagesByTimestamp(data)); + replaceSessionMessages(sessionId, ordered); } - syncDagFromMessages(sessionId, data); + syncDagFromMessages(sessionId, ordered); } catch { /* ignore */ } diff --git a/frontend/lib/messageRecovery.ts b/frontend/lib/messageRecovery.ts index 1e59b46..ffa08dc 100644 --- a/frontend/lib/messageRecovery.ts +++ b/frontend/lib/messageRecovery.ts @@ -11,6 +11,10 @@ function isSystemConnectedMessage(message: Message): boolean { return (message.type === 'system' || message.sender === 'system') && message.content.includes('已连接'); } +function getMessageIdentity(message: Message): string { + return message.id || message.messageId || ''; +} + export function registerReplayMessageId(seenIds: Set, messageId: string, cap = DEFAULT_REPLAY_CAP): Set { if (!messageId) return seenIds; if (seenIds.has(messageId)) return seenIds; @@ -22,8 +26,11 @@ export function registerReplayMessageId(seenIds: Set, messageId: string, } export function mergeReloadedMessages(prev: Message[], incoming: Message[]): Message[] { - const existingIds = new Set(prev.filter((m) => m.id).map((m) => m.id as string)); - const newMessages = incoming.filter((m) => !m.id || !existingIds.has(m.id)); + const existingIds = new Set(prev.map(getMessageIdentity).filter(Boolean)); + const newMessages = incoming.filter((message) => { + const identity = getMessageIdentity(message); + return !identity || !existingIds.has(identity); + }); if (newMessages.length === 0) { return prev; } @@ -34,6 +41,7 @@ export function mergeReloadedMessages(prev: Message[], incoming: Message[]): Mes export function mergeFinalMessage(prev: Message[], incoming: Message): Message[] { const clean = prev.filter((message) => !isStreamingThinkingMessage(message)); const isSystemMsg = incoming.type === 'system' || incoming.sender === 'system'; + const incomingIdentity = getMessageIdentity(incoming); const targetMessageId = incoming.messageId || incoming.id || ''; const streamingIdx = targetMessageId ? clean.findIndex((message) => message.messageId === targetMessageId) @@ -42,8 +50,8 @@ export function mergeFinalMessage(prev: Message[], incoming: Message): Message[] if (streamingIdx >= 0 && !isSystemMsg) { const updated = [...clean]; updated[streamingIdx] = { ...incoming, messageId: undefined, isStreaming: false }; - if (incoming.id) { - return updated.filter((message, index) => index === streamingIdx || message.id !== incoming.id); + if (incomingIdentity) { + return updated.filter((message, index) => index === streamingIdx || getMessageIdentity(message) !== incomingIdentity); } return updated; } @@ -52,7 +60,7 @@ export function mergeFinalMessage(prev: Message[], incoming: Message): Message[] return clean; } - if (incoming.id && clean.some((message) => message.id === incoming.id)) { + if (incomingIdentity && clean.some((message) => getMessageIdentity(message) === incomingIdentity)) { return clean; } diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 3c00f45..c50cb5f 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -7,10 +7,10 @@ export default defineConfig({ test: { environment: 'jsdom', globals: true, - setupFiles: ['./__tests__/vitest.setup.ts'], + setupFiles: [path.resolve(__dirname, '__tests__/vitest.setup.ts')], include: ['__tests__/**/*.{test,spec}.{ts,tsx}'], // Ensure relative imports from the frontend root resolve - root: '.', + root: path.resolve(__dirname, '.'), // Allow CSS imports without crashes css: false, }, From e0cdc2407822e8cd42725e0814c68693e44b2389 Mon Sep 17 00:00:00 2001 From: Nat-xu <19707027371@163.com> Date: Sun, 19 Jul 2026 03:06:28 +0800 Subject: [PATCH 19/22] extract websocket shared event routing --- .../lib/websocketSharedEvents.test.ts | 106 +++++ frontend/app/page.tsx | 337 +--------------- frontend/lib/websocketSharedEvents.ts | 367 ++++++++++++++++++ frontend/types/index.ts | 1 + 4 files changed, 489 insertions(+), 322 deletions(-) create mode 100644 frontend/__tests__/lib/websocketSharedEvents.test.ts create mode 100644 frontend/lib/websocketSharedEvents.ts diff --git a/frontend/__tests__/lib/websocketSharedEvents.test.ts b/frontend/__tests__/lib/websocketSharedEvents.test.ts new file mode 100644 index 0000000..fe2344f --- /dev/null +++ b/frontend/__tests__/lib/websocketSharedEvents.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { handleSharedWebSocketEvent } from '../../lib/websocketSharedEvents'; +import { clearSession, getSessionStore } from '../../lib/sessionStore'; +import type { ChatSession, WorkspacePreviewTab } from '../../types'; + +describe('websocketSharedEvents', () => { + beforeEach(() => { + clearSession('session-1'); + }); + + it('refreshes workspace previews on workspace change', () => { + let version = 0; + let tabs: WorkspacePreviewTab[] = [ + { id: 'tab-1', path: '/tmp/a.ts', kind: 'file' }, + ]; + + const handled = handleSharedWebSocketEvent( + { event: 'workspace_change', path: '/tmp/a.ts' }, + 'workspace_change', + 'session-1', + { + wsRef: { current: new Map() }, + setWorkspaceVersion: (updater) => { + version = typeof updater === 'function' ? updater(version) : updater; + }, + setPreviewTabs: (updater) => { + tabs = typeof updater === 'function' ? updater(tabs) : updater; + }, + setNotice: vi.fn(), + setPmState: vi.fn(), + setDegradationStatus: vi.fn(), + setSessions: vi.fn(), + setIsAutoNaming: vi.fn(), + setExecPermission: vi.fn(), + sortSessions: (items: ChatSession[]) => items, + }, + ); + + expect(handled).toBe(true); + expect(version).toBe(1); + expect(tabs[0]._version).toBe(1); + }); + + it('stores task preview messages in the session store', () => { + handleSharedWebSocketEvent( + { + event: 'task_preview', + sessionId: 'session-1', + messageId: 'msg-1', + timestamp: '2026-07-19T00:00:00.000Z', + tasks: [ + { + id: 'node-1', + description: 'Inspect repo', + agent: 'Orchestrator', + dependencies: [], + }, + ], + }, + 'task_preview', + 'session-1', + { + wsRef: { current: new Map() }, + setWorkspaceVersion: vi.fn(), + setPreviewTabs: vi.fn(), + setNotice: vi.fn(), + setPmState: vi.fn(), + setDegradationStatus: vi.fn(), + setSessions: vi.fn(), + setIsAutoNaming: vi.fn(), + setExecPermission: vi.fn(), + sortSessions: (items: ChatSession[]) => items, + }, + ); + + const messages = getSessionStore().getState('session-1').messages; + expect(messages).toHaveLength(1); + expect(messages[0].type).toBe('task_preview'); + }); + + it('updates session names on rename', () => { + let sessions: ChatSession[] = [{ id: 'session-1', name: 'Old' } as ChatSession]; + + handleSharedWebSocketEvent( + { event: 'session_renamed', sessionId: 'session-1', name: 'New' }, + 'session_renamed', + 'session-1', + { + wsRef: { current: new Map() }, + setWorkspaceVersion: vi.fn(), + setPreviewTabs: vi.fn(), + setNotice: vi.fn(), + setPmState: vi.fn(), + setDegradationStatus: vi.fn(), + setSessions: (updater) => { + sessions = typeof updater === 'function' ? updater(sessions) : updater; + }, + setIsAutoNaming: vi.fn(), + setExecPermission: vi.fn(), + sortSessions: (items: ChatSession[]) => items, + }, + ); + + expect(sessions[0].name).toBe('New'); + }); +}); diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 03a77f4..0e763ed 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -8,8 +8,6 @@ import ChatInput from '../components/chat/ChatInput'; import UserRoster from '../components/collaboration/UserRoster'; import TypingIndicator from '../components/collaboration/TypingIndicator'; -import { getPresenceStore } from '../lib/presenceStore'; -import { getCollaborationStore } from '../lib/collaborationStore'; import ShareDialog from '../components/collaboration/ShareDialog'; import OneClickDeployModal from '../components/chat/OneClickDeployModal'; // FIX: MessageList SSR causes hydration mismatch — disable SSR @@ -29,19 +27,8 @@ import { useSessionRecovery } from '../hooks/useSessionRecovery'; import { buildOutgoingMessageDraft } from '../lib/outgoingMessageDraft'; import { mergeFinalMessage, registerReplayMessageId } from '../lib/messageRecovery'; import { buildChatWebSocketUrl } from '../lib/websocketUrl'; -import { - buildDagStateFromTaskPreview, - clearDagSession, - setDagState, - updateDagState, - useDagState, -} from '../lib/dagStore'; -import { - buildAgentTodoMessage, - buildDeployCardMessage, - buildSolutionProposalMessage, - buildTaskPreviewMessage, -} from '../lib/chatEventMessages'; +import { clearDagSession, useDagState } from '../lib/dagStore'; +import { handleSharedWebSocketEvent } from '../lib/websocketSharedEvents'; import { useSessionMessages, useSessionStreaming, @@ -687,56 +674,19 @@ export default function AgentHubIM(): JSX.Element { } // ── Workspace file change events ───────────────────────────── - if (evt === 'workspace_change') { - setWorkspaceVersion(v => v + 1); - // Preview tabs: if the changed file is open in a tab, refresh its content - const changePath = raw.path as string; - if (changePath) { - setPreviewTabs(prev => - prev.map(t => (t.path === changePath ? { ...t, _version: (t as any)._version + 1 || 1 } : t)) - ); - } - } - - if (evt === 'file_conflict') { - const conflictPath = raw.path as string; - const backupPath = raw.backupPath as string; - if (conflictPath) { - setNotice( - `⚠️ 文件冲突: ${conflictPath} 被其他用户修改过` + - (backupPath ? ` (原文件已备份为 ${backupPath})` : '') - ); - } - // Still refresh the file tree - setWorkspaceVersion(v => v + 1); - } - - if (evt === 'file_lock_change') { - // Increment version so lock indicators update in the file tree - setWorkspaceVersion(v => v + 1); - } - - if (evt === 'task_update') { - // Per-node incremental update — merge into session-scoped DAG state - const tu = raw as { - nodeId: string; - status: string; - detail?: { error?: string; retries?: number }; - progress?: { completed?: number; total?: number; failed?: number; running?: number; percent?: number }; - durationMs?: number; - retries?: number; - sessionId: string; - }; - if (tu.nodeId && tu.status) { - updateDagState(chunkSessionId, { - nodeId: tu.nodeId, - status: tu.status, - detail: tu.detail, - progress: tu.progress, - durationMs: tu.durationMs, - retries: tu.retries, - }); - } + if (handleSharedWebSocketEvent(raw, evt, chunkSessionId, { + wsRef, + setWorkspaceVersion, + setPreviewTabs, + setNotice, + setPmState, + setDegradationStatus, + setSessions, + setIsAutoNaming, + setExecPermission, + sortSessions, + })) { + return; } if (evt === 'message_chunk') { @@ -1060,263 +1010,6 @@ export default function AgentHubIM(): JSX.Element { } } - // ── PM state & degradation events ──────────────────────────── - - if (evt === 'pm_state_change') { - const payload = raw as unknown as import('../types').PMStateChangeEvent; - setPmState(payload.state); - } - - if (evt === 'degradation_change') { - const payload = raw as unknown as import('../types').DegradationEvent; - setDegradationStatus(payload.status); - } - - // ── Multi-user collaboration events ─────────────────────────── - - if (evt === 'user_roster') { - // Initial roster of online users when connecting - const roster = raw as unknown as import('../types').UserRosterEvent; - getPresenceStore().setRoster(chunkSessionId, roster.users); - } - - if (evt === 'user_joined') { - const joined = raw as unknown as import('../types').UserJoinedEvent; - getPresenceStore().addUser(chunkSessionId, { - userId: joined.userId, - name: joined.userName, - role: joined.role, - status: 'online', - }); - } - - if (evt === 'user_left') { - const left = raw as unknown as import('../types').UserLeftEvent; - getPresenceStore().removeUser(chunkSessionId, left.userId); - getCollaborationStore().setTyping(chunkSessionId, left.userId, left.userName, false); - } - - if (evt === 'presence_update') { - const pu = raw as unknown as import('../types').PresenceUpdateEvent; - getPresenceStore().bulkUpdateStatus(chunkSessionId, pu.users); - } - - if (evt === 'typing_indicator') { - const ti = raw as unknown as import('../types').TypingIndicatorEvent; - getCollaborationStore().setTyping(chunkSessionId, ti.userId, ti.userName, ti.isTyping); - } - - // ── PM interaction state sync ───────────────────────────────── - - if (evt === 'interaction_already_resolved') { - const iar = raw as unknown as import('../types').InteractionAlreadyResolvedEvent; - updateSessionMessages(chunkSessionId, (prev) => { - const updated = [...prev]; - for (let i = updated.length - 1; i >= 0; i--) { - const m = updated[i]; - if ((m.messageId || m.id) === iar.messageId) { - // Update all PM interaction data types with resolvedBy - const resolver = { resolvedBy: iar.resolvedBy, resolvedByName: iar.userName }; - if (m.questionData) { - updated[i] = { ...m, questionData: { ...m.questionData, ...resolver } }; - } else if (m.riskWarningData) { - updated[i] = { ...m, riskWarningData: { ...m.riskWarningData, ...resolver } }; - } else if (m.todoData) { - updated[i] = { ...m, todoData: { ...m.todoData, ...resolver } }; - } else if (m.taskPreviewData) { - updated[i] = { ...m, taskPreviewData: { ...m.taskPreviewData, ...resolver } }; - } - break; - } - } - return updated; - }); - } - - if (evt === 'permission_mode_changed') { - const pmc = raw as unknown as import('../types').PermissionModeChangedEvent; - if (pmc.mode === 1 || pmc.mode === 2 || pmc.mode === 3) { - setExecPermission(pmc.mode as ExecPermission); - } - } - - // ── PM/PMO agent interaction events ────────────────────────── - - if (evt === 'agent_question') { - const payload = raw as unknown as import('../types').AgentQuestionEvent; - updateSessionMessages(chunkSessionId, (prev) => [ - ...prev, - { - event: 'message', - sessionId: chunkSessionId, - sender: payload.agentId || 'PM', - content: payload.question, - type: 'agent_question' as const, - timestamp: payload.timestamp, - messageId: payload.messageId, - questionData: payload, - }, - ]); - } - - if (evt === 'progress_update') { - const payload = raw as unknown as import('../types').ProgressUpdateEvent; - // Replace existing progress message with same messageId, or append new - updateSessionMessages(chunkSessionId, (prev) => { - const existingIdx = prev.findIndex( - (m) => m.messageId === payload.messageId && m.type === 'progress_update' - ); - if (existingIdx >= 0) { - const updated = [...prev]; - updated[existingIdx] = { - ...updated[existingIdx], - content: payload.currentStep, - progressData: payload, - }; - return updated; - } - return [ - ...prev, - { - event: 'message', - sessionId: chunkSessionId, - sender: payload.agentId || 'PM', - content: payload.currentStep, - type: 'progress_update' as const, - timestamp: payload.timestamp, - messageId: payload.messageId, - progressData: payload, - }, - ]; - }); - } - - if (evt === 'risk_warning') { - const payload = raw as unknown as import('../types').RiskWarningEvent; - updateSessionMessages(chunkSessionId, (prev) => [ - ...prev, - { - event: 'message', - sessionId: chunkSessionId, - sender: payload.agentId || 'PM', - content: payload.title + '\n' + payload.description, - type: 'risk_warning' as const, - timestamp: payload.timestamp, - messageId: payload.messageId, - riskWarningData: payload, - }, - ]); - } - - if (evt === 'agent_todo') { - const payload = raw as unknown as import('../types').AgentTodoEvent; - updateSessionMessages(chunkSessionId, (prev) => [...prev, buildAgentTodoMessage(chunkSessionId, payload)]); - } - - if (evt === 'task_preview') { - const payload = raw as unknown as import('../types').TaskPreviewEvent; - setDagState(chunkSessionId, buildDagStateFromTaskPreview(payload)); - updateSessionMessages(chunkSessionId, (prev) => [...prev, buildTaskPreviewMessage(chunkSessionId, payload)]); - } - - // ── Solution proposal event ────────────────────────────────────── - if (evt === 'solution_proposal') { - const payload = raw as unknown as import('../types').SolutionProposalEvent; - updateSessionMessages(chunkSessionId, (prev) => [...prev, buildSolutionProposalMessage(chunkSessionId, payload)]); - } - - // ── Deploy card event ────────────────────────────────────────── - if (evt === 'deploy_card') { - const payload = raw as unknown as import('../types').DeployCardEvent; - updateSessionMessages(chunkSessionId, (prev) => [...prev, buildDeployCardMessage(chunkSessionId, payload)]); - } - - // ── CloudCode: terminal output (streaming) ──────────────────── - if (evt === 'terminal_output') { - const payload = raw as unknown as import('../types').TerminalOutputEvent; - updateSessionMessages(chunkSessionId, (prev) => { - const existingIdx = prev.findIndex( - (m) => m.messageId === payload.messageId && m.type === 'terminal' - ); - if (existingIdx >= 0) { - const updated = [...prev]; - updated[existingIdx] = { - ...updated[existingIdx], - content: updated[existingIdx].content + payload.content, - isStreaming: true, - }; - return updated; - } - return [ - ...prev, - { - event: 'message', - sessionId: chunkSessionId, - sender: payload.sender || 'system', - content: payload.content, - type: 'terminal' as const, - timestamp: payload.timestamp, - messageId: payload.messageId, - isStreaming: true, - }, - ]; - }); - } - - // ── CloudCode: diff update ──────────────────────────────────── - if (evt === 'diff_update') { - const payload = raw as unknown as import('../types').DiffUpdateEvent; - updateSessionMessages(chunkSessionId, (prev) => [ - ...prev, - { - event: 'message', - sessionId: chunkSessionId, - sender: 'system', - content: payload.diff, - type: 'diff' as const, - timestamp: payload.timestamp, - messageId: payload.messageId, - diffFilePath: payload.path, - diffDecisionState: 'pending' as const, - }, - ]); - } - - // ── Diff decision (user clicked Accept/Reject on a diff bubble) ── - if (evt === 'diff_decision') { - const payload = raw as unknown as import('../types').DiffDecisionEvent; - updateSessionMessages(chunkSessionId, (prev) => { - const updated = [...prev]; - for (let i = updated.length - 1; i >= 0; i--) { - if (updated[i].messageId === payload.messageId && updated[i].type === 'diff') { - updated[i] = { - ...updated[i], - diffDecisionState: payload.decision === 'accept' ? 'accepted' : 'rejected', - }; - // Forward decision to server - const targetWs = wsRef.current.get(chunkSessionId); - if (targetWs && targetWs.readyState === WebSocket.OPEN) { - targetWs.send(JSON.stringify({ - event: 'diff_decision', - sessionId: payload.sessionId, - messageId: payload.messageId, - decision: payload.decision, - path: payload.path, - })); - } - break; - } - } - return updated; - }); - } - - if (evt === 'session_renamed') { - const payload = raw as { sessionId: string; name: string }; - setSessions((prev) => prev.map((s) => (s.id === payload.sessionId ? { ...s, name: payload.name } : s))); - setIsAutoNaming(false); - } - if (evt === 'message') { // ★ 方案2: 消息完成,标记阶段为 done,2秒后恢复 idle setStreamPhase('done'); diff --git a/frontend/lib/websocketSharedEvents.ts b/frontend/lib/websocketSharedEvents.ts new file mode 100644 index 0000000..87914a3 --- /dev/null +++ b/frontend/lib/websocketSharedEvents.ts @@ -0,0 +1,367 @@ +import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; +import type { + ChatSession, + DegradationStatus, + DiffDecisionEvent, + DiffUpdateEvent, + AgentQuestionEvent, + AgentTodoEvent, + DeployCardEvent, + Message, + PMState, + PermissionModeChangedEvent, + PresenceUpdateEvent, + ProgressUpdateEvent, + RiskWarningEvent, + SolutionProposalEvent, + TaskPreviewEvent, + TerminalOutputEvent, + TypingIndicatorEvent, + UserJoinedEvent, + UserLeftEvent, + UserRosterEvent, + WorkspacePreviewTab, +} from '../types'; +import { getCollaborationStore } from './collaborationStore'; +import { getPresenceStore } from './presenceStore'; +import { updateSessionMessages } from './sessionStore'; +import { setDagState, buildDagStateFromTaskPreview, updateDagState } from './dagStore'; +import { + buildAgentTodoMessage, + buildDeployCardMessage, + buildSolutionProposalMessage, + buildTaskPreviewMessage, +} from './chatEventMessages'; + +type SessionSetState = Dispatch>; + +export interface SharedWebSocketEventDeps { + wsRef: MutableRefObject>; + setWorkspaceVersion: SessionSetState; + setPreviewTabs: SessionSetState; + setNotice: (msg: string) => void; + setPmState: (state: PMState) => void; + setDegradationStatus: (status: DegradationStatus | null) => void; + setSessions: SessionSetState; + setIsAutoNaming: (value: boolean) => void; + setExecPermission: (mode: 1 | 2 | 3) => void; + sortSessions: (sessions: ChatSession[]) => ChatSession[]; +} + +function isPreviewChangePath(tab: WorkspacePreviewTab, changePath: string): boolean { + return tab.path === changePath; +} + +function appendMessage(sessionId: string, message: Message): void { + updateSessionMessages(sessionId, (prev) => [...prev, message]); +} + +export function handleSharedWebSocketEvent( + raw: Record, + evt: string | undefined, + chunkSessionId: string, + deps: SharedWebSocketEventDeps, +): boolean { + if (!evt) return false; + + if (evt === 'workspace_change') { + deps.setWorkspaceVersion((v) => v + 1); + const changePath = raw.path as string; + if (changePath) { + deps.setPreviewTabs((prev) => prev.map((tab) => ( + isPreviewChangePath(tab, changePath) + ? { ...tab, _version: (tab._version || 0) + 1 } + : tab + ))); + } + return true; + } + + if (evt === 'file_conflict') { + const conflictPath = raw.path as string; + const backupPath = raw.backupPath as string; + if (conflictPath) { + deps.setNotice( + `⚠️ 文件冲突: ${conflictPath} 被其他用户修改过` + + (backupPath ? ` (原文件已备份为 ${backupPath})` : ''), + ); + } + deps.setWorkspaceVersion((v) => v + 1); + return true; + } + + if (evt === 'file_lock_change') { + deps.setWorkspaceVersion((v) => v + 1); + return true; + } + + if (evt === 'task_update') { + const tu = raw as { + nodeId?: string; + status?: string; + detail?: { error?: string; retries?: number }; + progress?: { completed?: number; total?: number; failed?: number; running?: number; percent?: number }; + durationMs?: number; + retries?: number; + }; + if (tu.nodeId && tu.status) { + updateDagState(chunkSessionId, { + nodeId: tu.nodeId, + status: tu.status, + detail: tu.detail, + progress: tu.progress, + durationMs: tu.durationMs, + retries: tu.retries, + }); + return true; + } + return true; + } + + if (evt === 'pm_state_change') { + const payload = raw as { state?: PMState }; + if (payload.state) deps.setPmState(payload.state); + return true; + } + + if (evt === 'degradation_change') { + const payload = raw as { status?: DegradationStatus | null }; + deps.setDegradationStatus(payload.status ?? null); + return true; + } + + if (evt === 'user_roster') { + getPresenceStore().setRoster(chunkSessionId, (raw as unknown as UserRosterEvent).users); + return true; + } + + if (evt === 'user_joined') { + const joined = raw as unknown as UserJoinedEvent; + getPresenceStore().addUser(chunkSessionId, { + userId: joined.userId, + name: joined.userName, + role: joined.role, + status: 'online', + }); + return true; + } + + if (evt === 'user_left') { + const left = raw as unknown as UserLeftEvent; + getPresenceStore().removeUser(chunkSessionId, left.userId); + getCollaborationStore().setTyping(chunkSessionId, left.userId, left.userName, false); + return true; + } + + if (evt === 'presence_update') { + const pu = raw as unknown as PresenceUpdateEvent; + getPresenceStore().bulkUpdateStatus(chunkSessionId, pu.users); + return true; + } + + if (evt === 'typing_indicator') { + const ti = raw as unknown as TypingIndicatorEvent; + getCollaborationStore().setTyping(chunkSessionId, ti.userId, ti.userName, ti.isTyping); + return true; + } + + if (evt === 'interaction_already_resolved') { + const iar = raw as { messageId?: string; resolvedBy?: string; userName?: string }; + updateSessionMessages(chunkSessionId, (prev) => { + const updated = [...prev]; + for (let i = updated.length - 1; i >= 0; i -= 1) { + const m = updated[i]; + if ((m.messageId || m.id) === iar.messageId) { + const resolver = { resolvedBy: iar.resolvedBy, resolvedByName: iar.userName }; + if (m.questionData) { + updated[i] = { ...m, questionData: { ...m.questionData, ...resolver } }; + } else if (m.riskWarningData) { + updated[i] = { ...m, riskWarningData: { ...m.riskWarningData, ...resolver } }; + } else if (m.todoData) { + updated[i] = { ...m, todoData: { ...m.todoData, ...resolver } }; + } else if (m.taskPreviewData) { + updated[i] = { ...m, taskPreviewData: { ...m.taskPreviewData, ...resolver } }; + } + break; + } + } + return updated; + }); + return true; + } + + if (evt === 'permission_mode_changed') { + const pmc = raw as unknown as PermissionModeChangedEvent; + if (pmc.mode === 1 || pmc.mode === 2 || pmc.mode === 3) { + deps.setExecPermission(pmc.mode as 1 | 2 | 3); + } + return true; + } + + if (evt === 'agent_question') { + const payload = raw as unknown as AgentQuestionEvent; + appendMessage(chunkSessionId, { + event: 'message', + sessionId: chunkSessionId, + sender: payload.agentId || 'PM', + content: payload.question, + type: 'agent_question', + timestamp: payload.timestamp, + messageId: payload.messageId, + questionData: payload, + }); + return true; + } + + if (evt === 'progress_update') { + const payload = raw as unknown as ProgressUpdateEvent; + updateSessionMessages(chunkSessionId, (prev) => { + const existingIdx = prev.findIndex( + (m) => m.messageId === payload.messageId && m.type === 'progress_update', + ); + if (existingIdx >= 0) { + const updated = [...prev]; + updated[existingIdx] = { + ...updated[existingIdx], + content: payload.currentStep, + progressData: payload, + }; + return updated; + } + return [ + ...prev, + { + event: 'message', + sessionId: chunkSessionId, + sender: payload.agentId || 'PM', + content: payload.currentStep, + type: 'progress_update', + timestamp: payload.timestamp, + messageId: payload.messageId, + progressData: payload, + }, + ]; + }); + return true; + } + + if (evt === 'risk_warning') { + const payload = raw as unknown as RiskWarningEvent; + appendMessage(chunkSessionId, { + event: 'message', + sessionId: chunkSessionId, + sender: payload.agentId || 'PM', + content: `${payload.title}\n${payload.description}`, + type: 'risk_warning', + timestamp: payload.timestamp, + messageId: payload.messageId, + riskWarningData: payload, + }); + return true; + } + + if (evt === 'agent_todo') { + appendMessage(chunkSessionId, buildAgentTodoMessage(chunkSessionId, raw as unknown as AgentTodoEvent)); + return true; + } + + if (evt === 'task_preview') { + const payload = raw as unknown as TaskPreviewEvent; + setDagState(chunkSessionId, buildDagStateFromTaskPreview(payload)); + appendMessage(chunkSessionId, buildTaskPreviewMessage(chunkSessionId, payload)); + return true; + } + + if (evt === 'solution_proposal') { + appendMessage(chunkSessionId, buildSolutionProposalMessage(chunkSessionId, raw as unknown as SolutionProposalEvent)); + return true; + } + + if (evt === 'deploy_card') { + appendMessage(chunkSessionId, buildDeployCardMessage(chunkSessionId, raw as unknown as DeployCardEvent)); + return true; + } + + if (evt === 'terminal_output') { + const payload = raw as unknown as TerminalOutputEvent; + updateSessionMessages(chunkSessionId, (prev) => { + const existingIdx = prev.findIndex((m) => m.messageId === payload.messageId && m.type === 'terminal'); + if (existingIdx >= 0) { + const updated = [...prev]; + updated[existingIdx] = { + ...updated[existingIdx], + content: updated[existingIdx].content + payload.content, + isStreaming: true, + }; + return updated; + } + return [ + ...prev, + { + event: 'message', + sessionId: chunkSessionId, + sender: payload.sender || 'system', + content: payload.content, + type: 'terminal', + timestamp: payload.timestamp, + messageId: payload.messageId, + isStreaming: true, + }, + ]; + }); + return true; + } + + if (evt === 'diff_update') { + const payload = raw as unknown as DiffUpdateEvent; + appendMessage(chunkSessionId, { + event: 'message', + sessionId: chunkSessionId, + sender: 'system', + content: payload.diff, + type: 'diff', + timestamp: payload.timestamp, + messageId: payload.messageId, + diffFilePath: payload.path, + diffDecisionState: 'pending', + }); + return true; + } + + if (evt === 'diff_decision') { + const payload = raw as unknown as DiffDecisionEvent; + updateSessionMessages(chunkSessionId, (prev) => { + const updated = [...prev]; + for (let i = updated.length - 1; i >= 0; i -= 1) { + if (updated[i].messageId === payload.messageId && updated[i].type === 'diff') { + updated[i] = { + ...updated[i], + diffDecisionState: payload.decision === 'accept' ? 'accepted' : 'rejected', + }; + const targetWs = deps.wsRef.current.get(chunkSessionId); + if (targetWs && targetWs.readyState === WebSocket.OPEN) { + targetWs.send(JSON.stringify({ + event: 'diff_decision', + sessionId: payload.sessionId, + messageId: payload.messageId, + decision: payload.decision, + path: payload.path, + })); + } + break; + } + } + return updated; + }); + return true; + } + + if (evt === 'session_renamed') { + const payload = raw as { sessionId: string; name: string }; + deps.setSessions((prev) => deps.sortSessions(prev.map((s) => (s.id === payload.sessionId ? { ...s, name: payload.name } : s)))); + deps.setIsAutoNaming(false); + return true; + } + + return false; +} diff --git a/frontend/types/index.ts b/frontend/types/index.ts index fe33959..84ff530 100644 --- a/frontend/types/index.ts +++ b/frontend/types/index.ts @@ -794,6 +794,7 @@ export interface WorkspacePreviewTab { textLength?: number; totalChars?: number; truncated?: boolean; + _version?: number; } // ── Agent 真落盘 — workspace real-time event types ────────────────────── From fc2490abba6faa01f825b115a77b4252580efd7a Mon Sep 17 00:00:00 2001 From: Nat-xu <19707027371@163.com> Date: Sun, 19 Jul 2026 03:28:29 +0800 Subject: [PATCH 20/22] extract websocket stream event handling --- .../lib/websocketStreamEvents.test.ts | 130 +++++ frontend/app/page.tsx | 503 +----------------- frontend/lib/websocketStreamEvents.ts | 469 ++++++++++++++++ 3 files changed, 621 insertions(+), 481 deletions(-) create mode 100644 frontend/__tests__/lib/websocketStreamEvents.test.ts create mode 100644 frontend/lib/websocketStreamEvents.ts diff --git a/frontend/__tests__/lib/websocketStreamEvents.test.ts b/frontend/__tests__/lib/websocketStreamEvents.test.ts new file mode 100644 index 0000000..a11088d --- /dev/null +++ b/frontend/__tests__/lib/websocketStreamEvents.test.ts @@ -0,0 +1,130 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Dispatch, SetStateAction } from 'react'; +import { handleStreamWebSocketEvent, type StreamPhase } from '../../lib/websocketStreamEvents'; +import { clearSession, getSessionStore } from '../../lib/sessionStore'; + +describe('websocketStreamEvents', () => { + beforeEach(() => { + clearSession('session-1'); + vi.restoreAllMocks(); + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb: FrameRequestCallback) => { + cb(0); + return 1; + }); + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {}); + }); + + it('flushes a streamed chunk into session messages', () => { + let phase: StreamPhase = 'idle'; + const setPhase: Dispatch> = (next) => { + phase = typeof next === 'function' ? next(phase) : next; + }; + const handled = handleStreamWebSocketEvent( + { + event: 'message_chunk', + sessionId: 'session-1', + messageId: 'msg-1', + content: 'hello', + isFinal: true, + }, + 'message_chunk', + 'session-1', + { + streamFlushRafRef: { current: new Map() }, + progressiveFlushTimersRef: { current: new Map() }, + streamInterruptedAtRef: { current: new Map() }, + setStreamPhase: setPhase, + setActiveTools: vi.fn(), + setCurrentAgentName: vi.fn(), + setSessions: vi.fn(), + sortSessions: (sessions) => sessions, + addToast: vi.fn(), + setGenerated: vi.fn(), + handleOpenFilePreview: vi.fn(), + handleOpenDiffPreview: vi.fn(), + }, + ); + + const state = getSessionStore().getState('session-1'); + + expect(handled).toBe(true); + expect(phase).toBe('generating'); + expect(state.buffer).toBeNull(); + expect(state.messages).toHaveLength(1); + expect(state.messages[0].content).toBe('hello'); + expect(state.messages[0].isStreaming).toBe(false); + }); + + it('turns tool calls into tool results and opens previews', () => { + let activeTools = ['read_file']; + const setActiveTools: Dispatch> = (next) => { + activeTools = typeof next === 'function' ? next(activeTools) : next; + }; + const openPreview = vi.fn(); + const handledCall = handleStreamWebSocketEvent( + { + event: 'tool_call', + sessionId: 'session-1', + messageId: 'tool-1', + timestamp: '2026-07-19T00:00:00.000Z', + toolCalls: [{ name: 'read_file', arguments: {}, status: 'calling' }], + }, + 'tool_call', + 'session-1', + { + streamFlushRafRef: { current: new Map() }, + progressiveFlushTimersRef: { current: new Map() }, + streamInterruptedAtRef: { current: new Map() }, + setStreamPhase: vi.fn(), + setActiveTools, + setCurrentAgentName: vi.fn(), + setSessions: vi.fn(), + sortSessions: (sessions) => sessions, + addToast: vi.fn(), + setGenerated: vi.fn(), + handleOpenFilePreview: openPreview, + handleOpenDiffPreview: vi.fn(), + }, + ); + + const handledResult = handleStreamWebSocketEvent( + { + event: 'tool_result', + sessionId: 'session-1', + messageId: 'tool-1', + timestamp: '2026-07-19T00:00:01.000Z', + results: [ + { + tool_name: 'read_file', + success: true, + result: { path: 'src/app.py', content: 'print("ok")' }, + }, + ], + }, + 'tool_result', + 'session-1', + { + streamFlushRafRef: { current: new Map() }, + progressiveFlushTimersRef: { current: new Map() }, + streamInterruptedAtRef: { current: new Map() }, + setStreamPhase: vi.fn(), + setActiveTools, + setCurrentAgentName: vi.fn(), + setSessions: vi.fn(), + sortSessions: (sessions) => sessions, + addToast: vi.fn(), + setGenerated: vi.fn(), + handleOpenFilePreview: openPreview, + handleOpenDiffPreview: vi.fn(), + }, + ); + + const state = getSessionStore().getState('session-1'); + + expect(handledCall).toBe(true); + expect(handledResult).toBe(true); + expect(state.messages.some((message) => message.type === 'tool_result')).toBe(true); + expect(activeTools).toEqual([]); + expect(openPreview).toHaveBeenCalledWith('src/app.py', 'print("ok")', undefined, undefined); + }); +}); diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 0e763ed..7e2e4cf 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -25,24 +25,23 @@ import { useResizableSize } from '../hooks/useResizableSize'; import { useFileUpload } from '../hooks/useFileUpload'; import { useSessionRecovery } from '../hooks/useSessionRecovery'; import { buildOutgoingMessageDraft } from '../lib/outgoingMessageDraft'; -import { mergeFinalMessage, registerReplayMessageId } from '../lib/messageRecovery'; +import { registerReplayMessageId } from '../lib/messageRecovery'; import { buildChatWebSocketUrl } from '../lib/websocketUrl'; import { clearDagSession, useDagState } from '../lib/dagStore'; import { handleSharedWebSocketEvent } from '../lib/websocketSharedEvents'; +import { flushAllPendingStreamBuffer, handleStreamWebSocketEvent } from '../lib/websocketStreamEvents'; import { useSessionMessages, useSessionStreaming, updateSessionMessages, setSessionStreaming, getSessionBuffer, - setSessionBuffer, replaceSessionMessages, clearSession, pinSession, unpinSession, - type StreamBuffer, } from '../lib/sessionStore'; -import type { Agent, AttachedFile, ChatSession, FileReference, GeneratedData, Message, PendingMessage, QuoteReference, SkillMeta, StreamChunk, ToolCallEvent, ToolResultEvent, User, WorkflowSummary, WorkspacePreviewTab } from '../types'; +import type { Agent, AttachedFile, ChatSession, FileReference, GeneratedData, Message, PendingMessage, QuoteReference, SkillMeta, User, WorkflowSummary, WorkspacePreviewTab } from '../types'; import type { FilePreviewTarget } from '../components/chat/FilePreviewModal'; import { ToastProvider, useAddToast } from '../components/ui/Toast'; @@ -420,97 +419,6 @@ export default function AgentHubIM(): JSX.Element { } }, [messages, sessionId]); - // ── Streaming ──────────────────────────────────────────── - - /** - * Progressive chunk release: release ONE chunk at a time, then - * schedule the next release via setTimeout. This ensures text - * appears progressively even when chunks arrive faster than the - * animation frame rate (e.g., when the LLM adapter returns the - * entire response in one big chunk, or when chunks arrive over - * a very fast network connection within a single RAF window). - * - * Each chunk is ~1-5 characters (individual SSE tokens), so - * releasing them at ~8ms intervals gives ~125-625 chars/sec — - * a natural "streaming typewriter" feel. - */ - const PROGRESSIVE_FLUSH_INTERVAL_MS = 8; - - function flushStreamBuffer(sessionId: string): void { - const buf = getSessionBuffer(sessionId); - if (!buf) return; - - // Handle final signal even when no content chunks are pending. - // The backend sends an empty message_chunk with isFinal=true to - // signal end-of-stream. Without this branch the early return - // below would drop the final flag, leaving isStreaming stuck. - if (buf.chunks.length === 0) { - if (buf.isFinal) { - setSessionStreaming(buf.sessionId, false); - setSessionBuffer(buf.sessionId, null); - } - return; - } - - // ── Release only the FIRST chunk per flush ── - // Remaining chunks stay in the buffer and will be flushed by - // subsequent timer/RAF calls, creating a visible typing effect. - const chunk = buf.chunks[0]; - const remaining = buf.chunks.slice(1); - const isLastChunk = remaining.length === 0 && buf.isFinal; - - const nextBuf: StreamBuffer = { - messageId: buf.messageId, - sessionId: buf.sessionId, - chunks: remaining, - isFinal: isLastChunk ? false : buf.isFinal, - }; - setSessionBuffer(buf.sessionId, nextBuf); - - const bufMessageId = buf.messageId; - - updateSessionMessages(buf.sessionId, (prev) => { - const idx = prev.findIndex((m) => m.messageId === bufMessageId); - if (idx >= 0) { - const updated = [...prev]; - updated[idx] = { - ...updated[idx], - content: updated[idx].content + chunk, - isStreaming: !isLastChunk, - }; - return updated; - } - const newMsg: Message = { - event: 'message', - sessionId: buf.sessionId, - sender: 'agent', - content: chunk, - type: 'text', - timestamp: new Date().toISOString(), - messageId: bufMessageId, - isStreaming: !isLastChunk, - }; - return [...prev, newMsg]; - }); - - if (isLastChunk) { - setSessionStreaming(buf.sessionId, false); - setSessionBuffer(buf.sessionId, null); - return; - } - - // ── Schedule next chunk release ── - // If there are more chunks in the buffer, release the next one - // after a short delay to maintain the progressive typing effect. - if (remaining.length > 0) { - const timerId = window.setTimeout(() => { - progressiveFlushTimersRef.current.delete(sessionId); - flushStreamBuffer(sessionId); - }, PROGRESSIVE_FLUSH_INTERVAL_MS); - progressiveFlushTimersRef.current.set(sessionId, timerId); - } - } - // ── WebSocket ──────────────────────────────────────────── function _reconnectDelay(): number { @@ -607,9 +515,10 @@ export default function AgentHubIM(): JSX.Element { window.cancelAnimationFrame(raf); streamFlushRafRef.current.delete(sid); } - flushStreamBuffer(sid); - setSessionBuffer(sid, null); - setSessionStreaming(sid, false); + flushAllPendingStreamBuffer(sid, { + streamFlushRafRef, + progressiveFlushTimersRef, + }); // 清理该 session 的中断标记 streamInterruptedAtRef.current.delete(sid); // ★ 方案4: 重连通知 @@ -689,389 +598,21 @@ export default function AgentHubIM(): JSX.Element { return; } - if (evt === 'message_chunk') { - const chunk = raw as unknown as StreamChunk; - const cSessionId = chunk.sessionId || chunkSessionId; - // ★ 中断守卫:stream_interrupted 后 800ms 内的迟到的 chunk 直接丢弃, - // 避免旧 buffer 的残余内容被写入新会话的渲染区。 - const interruptedAt = streamInterruptedAtRef.current.get(cSessionId); - if (interruptedAt && Date.now() - interruptedAt < 800) { - return; - } - setSessionStreaming(cSessionId, !chunk.isFinal); - // ★ 方案2: 第一个文本chunk到达 → 进入"生成回复"阶段 - setStreamPhase('generating'); - - const existingBuf = getSessionBuffer(cSessionId); - if (!existingBuf || existingBuf.messageId !== chunk.messageId) { - // ★ 方案3: 保留最后一个 thinking 占位,更新为 "工具完成,生成回复中" - // 而非全部删除。这样用户始终看到阶段上下文。 - updateSessionMessages(cSessionId, (prev) => { - const lastThinkingIdx = (() => { - for (let i = prev.length - 1; i >= 0; i--) { - if (prev[i].isStreaming && prev[i].sender !== 'user' && !prev[i].diffFilePath && prev[i].type === 'text') { - return i; - } - } - return -1; - })(); - if (lastThinkingIdx >= 0) { - const updated = [...prev]; - if (!updated[lastThinkingIdx].content || updated[lastThinkingIdx].content.startsWith('🔧')) { - updated[lastThinkingIdx] = { - ...updated[lastThinkingIdx], - content: '工具执行完成,正在综合结果生成回复...', - }; - } - // Delete OLDER thinking placeholders but keep the latest one - return updated.filter((m, i) => { - if (i === lastThinkingIdx) return true; - if (m.isStreaming && m.sender !== 'user' && !m.diffFilePath && m.type === 'text') { - return false; - } - return true; - }); - } - // Fallback: clean up all (no thinking buffer found) - const cleaned = prev.filter( - (m) => !(m.isStreaming && m.sender !== 'user' && !m.diffFilePath && m.type === 'text') - ); - return cleaned.length !== prev.length ? cleaned : prev; - }); - - setSessionBuffer(cSessionId, { - messageId: chunk.messageId, - sessionId: cSessionId, - chunks: [], - isFinal: false, - }); - // ★ 全新流开始了,清除该 session 的打断标记 - streamInterruptedAtRef.current.delete(cSessionId); - } - - // 把 chunk 追加到对应 session 的 buffer - const buf = getSessionBuffer(cSessionId); - if (buf) { - const nextChunks = chunk.content ? [...buf.chunks, chunk.content] : buf.chunks; - setSessionBuffer(cSessionId, { - messageId: buf.messageId, - sessionId: buf.sessionId, - chunks: nextChunks, - isFinal: buf.isFinal || !!chunk.isFinal, - }); - } - - // 调度该 session 自己的 RAF flush — only if no progressive - // timer is already running (which would release chunks one at a time). - if ( - !streamFlushRafRef.current.has(cSessionId) && - !progressiveFlushTimersRef.current.has(cSessionId) - ) { - const raf = window.requestAnimationFrame(() => { - streamFlushRafRef.current.delete(cSessionId); - flushStreamBuffer(cSessionId); - }); - streamFlushRafRef.current.set(cSessionId, raf); - } - } - - if (evt === 'stream_interrupted') { - const iSessionId = chunkSessionId; - // ★ 方案2+3: 重置 UX 状态 - setStreamPhase('idle'); - setActiveTools([]); - setCurrentAgentName(''); - // ★ 强制定位:清除该 session 的所有 streaming 状态、buffer、待调度 RAF - // 以及流式 filter 状态。即便后续 stream_chunk 因为竞态到达,buffer - // 已经被清空,flush 时不会把老内容写入新消息。 - setSessionStreaming(iSessionId, false); - setSessionBuffer(iSessionId, null); - const pendingRaf = streamFlushRafRef.current.get(iSessionId); - if (pendingRaf) { - window.cancelAnimationFrame(pendingRaf); - streamFlushRafRef.current.delete(iSessionId); - } - // Clear any pending progressive flush timer - const pendingTimer = progressiveFlushTimersRef.current.get(iSessionId); - if (pendingTimer) { - window.clearTimeout(pendingTimer); - progressiveFlushTimersRef.current.delete(iSessionId); - } - // 记录打断时刻;800ms 内的 agent_thinking / stream_chunk 会被忽略 - streamInterruptedAtRef.current.set(iSessionId, Date.now()); - updateSessionMessages(iSessionId, (prev) => { - const updated = [...prev]; - let changed = false; - for (let i = updated.length - 1; i >= 0; i--) { - if (updated[i].isStreaming) { - // thinking placeholder(空或"正在..."进度文案)整体删除 - if (!updated[i].content || updated[i].content.startsWith('正在')) { - updated.splice(i, 1); - } else { - updated[i] = { - ...updated[i], - isStreaming: false, - content: updated[i].content + '\n\n[Interrupted, processing new message...]', - }; - } - changed = true; - } - } - return changed ? updated : prev; - }); - } - - // ── Agent thinking (shows streaming indicator during tool phase) ── - if (evt === 'agent_thinking') { - const payload = raw as { - messageId: string; - agentId: string; - phase?: string; - details?: string; - }; - // ★ 中断守卫:如果该 session 在 800ms 内被 stream_interrupted, - // 迟到的 agent_thinking 事件不应该重新激活流式状态,否则标题 - // 栏"AI streaming..."会卡死。 直接 return,等下一次新会话。 - const interruptedAt = streamInterruptedAtRef.current.get(chunkSessionId); - if (interruptedAt && Date.now() - interruptedAt < 800) { - return; - } - // 过了窗口期就清掉标记,避免污染下一次正常流 - if (interruptedAt) { - streamInterruptedAtRef.current.delete(chunkSessionId); - } - setSessionStreaming(chunkSessionId, true); - // ★ 方案2: 更新阶段进度 - const phase = payload.phase || ''; - if (phase === 'analyzing' || phase === 'planning') { - setStreamPhase('thinking'); - } else if (phase === 'executing') { - setStreamPhase('executing'); - } else if (phase === 'synthesizing') { - setStreamPhase('generating'); - } - if (payload.agentId) setCurrentAgentName(payload.agentId); - - // Insert or update the thinking placeholder with phase details. - // Subsequent agent_thinking events (e.g. "executing", "synthesizing") - // update the same placeholder in-place so the user sees live progress. - updateSessionMessages(chunkSessionId, (prev) => { - // ★ 方案1: 如果有乐观占位,替换它而非新增 - const optimisticIdx = prev.findIndex( - (m) => (m as any)._optimistic && m.isStreaming - ); - const existingIdx = prev.findIndex( - (m) => m.messageId === payload.messageId && m.isStreaming - ); - if (optimisticIdx >= 0) { - // Replace optimistic placeholder with real agent_thinking - const updated = [...prev]; - updated[optimisticIdx] = { - ...updated[optimisticIdx], - messageId: payload.messageId, - sender: payload.agentId || updated[optimisticIdx].sender, - content: payload.details || '模型正在思考中...', - _optimistic: undefined, - }; - return updated; - } - if (existingIdx >= 0) { - // Update existing placeholder with new phase info - const updated = [...prev]; - updated[existingIdx] = { - ...updated[existingIdx], - content: payload.details || updated[existingIdx].content, - }; - return updated; - } - return [ - ...prev, - { - event: 'message', - sessionId: chunkSessionId, - sender: payload.agentId || 'agent', - content: payload.details || '', - type: 'text' as const, - timestamp: new Date().toISOString(), - messageId: payload.messageId, - isStreaming: true, - }, - ]; - }); - } - - // ── Tool call events ────────────────────────────────────────── - if (evt === 'tool_call') { - const payload = raw as unknown as ToolCallEvent; - // ★ 方案2+3: 更新阶段和活跃工具列表 - setStreamPhase('executing'); - if (payload.toolCalls && payload.toolCalls.length > 0) { - setActiveTools(payload.toolCalls.map((c: any) => c.name || '')); - } - updateSessionMessages(chunkSessionId, (prev) => { - // ★ 方案3: 保留最后一个 thinking 占位,更新为 "工具执行中" - // 而不是全部删除。这样用户始终看到最近的上下文。 - const lastThinkingIdx = (() => { - for (let i = prev.length - 1; i >= 0; i--) { - if (prev[i].isStreaming && prev[i].sender !== 'user' && !prev[i].diffFilePath && prev[i].type === 'text') { - return i; - } - } - return -1; - })(); - let cleaned = prev; - if (lastThinkingIdx >= 0) { - cleaned = prev.map((m, i) => { - if (i === lastThinkingIdx) { - return { ...m, content: '🔧 正在执行工具...' }; - } - if (m.isStreaming && m.sender !== 'user' && !m.diffFilePath && m.type === 'text') { - return null; // Remove older thinking placeholders - } - return m; - }).filter(Boolean) as Message[]; - } - return [ - ...cleaned, - { - event: 'message', - sessionId: chunkSessionId, - sender: 'system', - content: '', - type: 'tool_call' as const, - timestamp: payload.timestamp, - messageId: payload.messageId, - toolCallData: { calls: payload.toolCalls }, - }, - ]; - }); - } - - if (evt === 'tool_result') { - const payload = raw as unknown as ToolResultEvent; - // ★ 方案3: 从活跃工具列表中移除完成的工具 - if (payload.results) { - setActiveTools(prev => prev.filter( - name => !payload.results.some((r: any) => r.tool_name === name) - )); - // ★ 方案4: 工具失败时弹出 toast 通知 - for (const result of payload.results) { - if (!result.success) { - addToast({ - type: 'error', - title: `工具执行失败: ${result.tool_name}`, - message: result.error || '未知错误', - duration: 8000, - }); - } - } - } - updateSessionMessages(chunkSessionId, (prev) => { - // Find the matching tool_call message and update it - const updated = [...prev]; - for (let i = updated.length - 1; i >= 0; i--) { - if (updated[i].type === 'tool_call' && updated[i].messageId === payload.messageId) { - updated[i] = { - ...updated[i], - type: 'tool_result' as const, - toolResultData: { results: payload.results }, - }; - break; - } - } - return updated; - }); - - // ── Auto-detect file/diff content from tool results ───── - if (payload.results) { - for (const result of payload.results) { - if (!result.success || !result.result) continue; - const r = result.result as Record; - const filePath = r.path as string | undefined; - if (!filePath) continue; - - // File creation/write → open file preview - const content = r.content as string | undefined; - if (content && typeof content === 'string') { - const ext = filePath.split('.').pop()?.toLowerCase() || ''; - // Only auto-open for code/config/doc files, skip binary - const isPreviewable = /^(py|js|ts|jsx|tsx|java|go|rs|c|cpp|h|hpp|swift|kt|rb|php|sql|sh|bash|vue|svelte|astro|html|css|scss|less|json|yaml|yml|xml|toml|ini|md|txt|cfg|conf|env|dockerfile|makefile|graphql|proto)$/i.test(ext); - if (isPreviewable && content.length < 500000) { - handleOpenFilePreview(filePath, content, undefined, r.status as string | undefined); - } - } - - // Diff result → open diff preview - const diff = r.diff as string | undefined; - if (diff && typeof diff === 'string' && diff.length > 0) { - handleOpenDiffPreview(filePath, diff); - } - } - } - } - - if (evt === 'message') { - // ★ 方案2: 消息完成,标记阶段为 done,2秒后恢复 idle - setStreamPhase('done'); - setActiveTools([]); - setTimeout(() => setStreamPhase('idle'), 2000); - // Flush any pending stream buffer for THIS session before searching - // for the placeholder — the RAF callback may not have fired yet. - const cSessionId = chunkSessionId; - const buf = getSessionBuffer(cSessionId); - if (buf) { - const pendingRaf = streamFlushRafRef.current.get(cSessionId); - if (pendingRaf != null) { - window.cancelAnimationFrame(pendingRaf); - streamFlushRafRef.current.delete(cSessionId); - } - const pendingTimer = progressiveFlushTimersRef.current.get(cSessionId); - if (pendingTimer != null) { - window.clearTimeout(pendingTimer); - progressiveFlushTimersRef.current.delete(cSessionId); - } - // Flush ALL remaining chunks at once before the final message - if (buf.chunks.length > 0) { - const allContent = buf.chunks.join(''); - const bufMsgId = buf.messageId; - updateSessionMessages(cSessionId, (prev) => { - const idx = prev.findIndex((m) => m.messageId === bufMsgId); - if (idx >= 0) { - const updated = [...prev]; - updated[idx] = { - ...updated[idx], - content: updated[idx].content + allContent, - isStreaming: false, - }; - return updated; - } - return prev; - }); - } - setSessionBuffer(cSessionId, null); - } - setSessionStreaming(cSessionId, false); - const msg = raw as unknown as Message; - const isSystemMsg = msg.type === 'system' || msg.sender === 'system'; - updateSessionMessages(cSessionId, (prev) => mergeFinalMessage(prev, msg)); - if (!isSystemMsg) { - setSessions((prev) => sortSessions(prev.map((s) => (s.id === (msg.sessionId || cSessionId) ? { ...s, lastMessageAt: msg.timestamp || new Date().toISOString() } : s)))); - } - if (msg.symbolic?.generated) { - setGenerated(msg.symbolic.generated as GeneratedData); - // Auto-open generated files in preview panel - const gen = msg.symbolic.generated as GeneratedData; - if (gen.fileDetails && gen.fileDetails.length > 0) { - for (const fd of gen.fileDetails) { - if (fd.path && fd.content && fd.content.length < 500000) { - handleOpenFilePreview(fd.path, fd.content); - } - } - } - if (gen.diff) { - handleOpenDiffPreview('changes.diff', gen.diff); - } - } + if (handleStreamWebSocketEvent(raw, evt, chunkSessionId, { + streamFlushRafRef, + progressiveFlushTimersRef, + streamInterruptedAtRef, + setStreamPhase, + setActiveTools, + setCurrentAgentName, + setSessions, + sortSessions, + addToast, + setGenerated, + handleOpenFilePreview, + handleOpenDiffPreview, + })) { + return; } }; } diff --git a/frontend/lib/websocketStreamEvents.ts b/frontend/lib/websocketStreamEvents.ts new file mode 100644 index 0000000..8e794d6 --- /dev/null +++ b/frontend/lib/websocketStreamEvents.ts @@ -0,0 +1,469 @@ +import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; +import type { + ChatSession, + GeneratedData, + Message, + StreamChunk, + ToolCallEvent, + ToolResultEvent, +} from '../types'; +import { + getSessionBuffer, + setSessionBuffer, + setSessionStreaming, + updateSessionMessages, + type StreamBuffer, +} from './sessionStore'; +import { mergeFinalMessage } from './messageRecovery'; + +export type StreamPhase = 'idle' | 'thinking' | 'executing' | 'generating' | 'done'; + +interface StreamToast { + type: 'error' | 'warning' | 'success' | 'info'; + title: string; + message: string; + duration?: number; +} + +export interface StreamWebSocketEventDeps { + streamFlushRafRef: MutableRefObject>; + progressiveFlushTimersRef: MutableRefObject>; + streamInterruptedAtRef: MutableRefObject>; + setStreamPhase: Dispatch>; + setActiveTools: Dispatch>; + setCurrentAgentName: Dispatch>; + setSessions: Dispatch>; + sortSessions: (sessions: ChatSession[]) => ChatSession[]; + addToast: (toast: StreamToast) => void; + setGenerated: Dispatch>; + handleOpenFilePreview: (path: string, content: string, language?: string, status?: string) => void; + handleOpenDiffPreview: (path: string, diff: string) => void; +} + +function isThinkingPlaceholder(message: Message): boolean { + return !!(message.isStreaming && message.sender !== 'user' && !message.diffFilePath && message.type === 'text'); +} + +function preserveLatestThinkingPlaceholder(prev: Message[]): Message[] { + const lastThinkingIdx = (() => { + for (let i = prev.length - 1; i >= 0; i -= 1) { + if (isThinkingPlaceholder(prev[i])) return i; + } + return -1; + })(); + + if (lastThinkingIdx < 0) { + const cleaned = prev.filter((message) => !isThinkingPlaceholder(message)); + return cleaned.length !== prev.length ? cleaned : prev; + } + + const updated = [...prev]; + if (!updated[lastThinkingIdx].content || updated[lastThinkingIdx].content.startsWith('🔧')) { + updated[lastThinkingIdx] = { + ...updated[lastThinkingIdx], + content: '工具执行完成,正在综合结果生成回复...', + }; + } + return updated.filter((message, index) => index === lastThinkingIdx || !isThinkingPlaceholder(message)); +} + +export function flushStreamBuffer( + sessionId: string, + deps: Pick, +): void { + const buf = getSessionBuffer(sessionId); + if (!buf) return; + + if (buf.chunks.length === 0) { + if (buf.isFinal) { + setSessionStreaming(buf.sessionId, false); + setSessionBuffer(buf.sessionId, null); + } + return; + } + + const chunk = buf.chunks[0]; + const remaining = buf.chunks.slice(1); + const isLastChunk = remaining.length === 0 && buf.isFinal; + + setSessionBuffer(buf.sessionId, { + messageId: buf.messageId, + sessionId: buf.sessionId, + chunks: remaining, + isFinal: isLastChunk ? false : buf.isFinal, + }); + + const bufMessageId = buf.messageId; + updateSessionMessages(buf.sessionId, (prev) => { + const idx = prev.findIndex((message) => message.messageId === bufMessageId); + if (idx >= 0) { + const updated = [...prev]; + updated[idx] = { + ...updated[idx], + content: updated[idx].content + chunk, + isStreaming: !isLastChunk, + }; + return updated; + } + const newMsg: Message = { + event: 'message', + sessionId: buf.sessionId, + sender: 'agent', + content: chunk, + type: 'text', + timestamp: new Date().toISOString(), + messageId: bufMessageId, + isStreaming: !isLastChunk, + }; + return [...prev, newMsg]; + }); + + if (isLastChunk) { + setSessionStreaming(buf.sessionId, false); + setSessionBuffer(buf.sessionId, null); + return; + } + + if (remaining.length > 0) { + const timerId = window.setTimeout(() => { + deps.progressiveFlushTimersRef.current.delete(sessionId); + flushStreamBuffer(sessionId, deps); + }, 8); + deps.progressiveFlushTimersRef.current.set(sessionId, timerId); + } +} + +export function flushAllPendingStreamBuffer( + sessionId: string, + deps: Pick, +): void { + const buf = getSessionBuffer(sessionId); + if (!buf) return; + + const pendingRaf = deps.streamFlushRafRef.current.get(sessionId); + if (pendingRaf != null) { + window.cancelAnimationFrame(pendingRaf); + deps.streamFlushRafRef.current.delete(sessionId); + } + const pendingTimer = deps.progressiveFlushTimersRef.current.get(sessionId); + if (pendingTimer != null) { + window.clearTimeout(pendingTimer); + deps.progressiveFlushTimersRef.current.delete(sessionId); + } + if (buf.chunks.length === 0) { + setSessionStreaming(sessionId, false); + setSessionBuffer(sessionId, null); + return; + } + + const allContent = buf.chunks.join(''); + updateSessionMessages(sessionId, (prev) => { + const idx = prev.findIndex((message) => message.messageId === buf.messageId); + if (idx >= 0) { + const updated = [...prev]; + updated[idx] = { + ...updated[idx], + content: updated[idx].content + allContent, + isStreaming: false, + }; + return updated; + } + return prev; + }); + setSessionStreaming(sessionId, false); + setSessionBuffer(sessionId, null); +} + +export function handleStreamWebSocketEvent( + raw: Record, + evt: string | undefined, + chunkSessionId: string, + deps: StreamWebSocketEventDeps, +): boolean { + if (!evt) return false; + + if (evt === 'message_chunk') { + const chunk = raw as unknown as StreamChunk; + const cSessionId = chunk.sessionId || chunkSessionId; + const interruptedAt = deps.streamInterruptedAtRef.current.get(cSessionId); + if (interruptedAt && Date.now() - interruptedAt < 800) { + return true; + } + setSessionStreaming(cSessionId, !chunk.isFinal); + deps.setStreamPhase('generating'); + + const existingBuf = getSessionBuffer(cSessionId); + if (!existingBuf || existingBuf.messageId !== chunk.messageId) { + updateSessionMessages(cSessionId, preserveLatestThinkingPlaceholder); + setSessionBuffer(cSessionId, { + messageId: chunk.messageId, + sessionId: cSessionId, + chunks: [], + isFinal: false, + }); + deps.streamInterruptedAtRef.current.delete(cSessionId); + } + + const buf = getSessionBuffer(cSessionId); + if (buf) { + const nextChunks = chunk.content ? [...buf.chunks, chunk.content] : buf.chunks; + setSessionBuffer(cSessionId, { + messageId: buf.messageId, + sessionId: buf.sessionId, + chunks: nextChunks, + isFinal: buf.isFinal || !!chunk.isFinal, + }); + } + + if ( + !deps.streamFlushRafRef.current.has(cSessionId) && + !deps.progressiveFlushTimersRef.current.has(cSessionId) + ) { + const raf = window.requestAnimationFrame(() => { + deps.streamFlushRafRef.current.delete(cSessionId); + flushStreamBuffer(cSessionId, deps); + }); + deps.streamFlushRafRef.current.set(cSessionId, raf); + } + return true; + } + + if (evt === 'stream_interrupted') { + const iSessionId = chunkSessionId; + deps.setStreamPhase('idle'); + deps.setActiveTools([]); + deps.setCurrentAgentName(''); + setSessionStreaming(iSessionId, false); + setSessionBuffer(iSessionId, null); + const pendingRaf = deps.streamFlushRafRef.current.get(iSessionId); + if (pendingRaf != null) { + window.cancelAnimationFrame(pendingRaf); + deps.streamFlushRafRef.current.delete(iSessionId); + } + const pendingTimer = deps.progressiveFlushTimersRef.current.get(iSessionId); + if (pendingTimer != null) { + window.clearTimeout(pendingTimer); + deps.progressiveFlushTimersRef.current.delete(iSessionId); + } + deps.streamInterruptedAtRef.current.set(iSessionId, Date.now()); + updateSessionMessages(iSessionId, (prev) => { + const updated = [...prev]; + let changed = false; + for (let i = updated.length - 1; i >= 0; i -= 1) { + if (updated[i].isStreaming) { + if (!updated[i].content || updated[i].content.startsWith('正在')) { + updated.splice(i, 1); + } else { + updated[i] = { + ...updated[i], + isStreaming: false, + content: `${updated[i].content}\n\n[Interrupted, processing new message...]`, + }; + } + changed = true; + } + } + return changed ? updated : prev; + }); + return true; + } + + if (evt === 'agent_thinking') { + const payload = raw as unknown as { + messageId: string; + agentId: string; + phase?: string; + details?: string; + }; + const interruptedAt = deps.streamInterruptedAtRef.current.get(chunkSessionId); + if (interruptedAt && Date.now() - interruptedAt < 800) { + return true; + } + if (interruptedAt) { + deps.streamInterruptedAtRef.current.delete(chunkSessionId); + } + setSessionStreaming(chunkSessionId, true); + const phase = payload.phase || ''; + if (phase === 'analyzing' || phase === 'planning') { + deps.setStreamPhase('thinking'); + } else if (phase === 'executing') { + deps.setStreamPhase('executing'); + } else if (phase === 'synthesizing') { + deps.setStreamPhase('generating'); + } + if (payload.agentId) deps.setCurrentAgentName(payload.agentId); + + updateSessionMessages(chunkSessionId, (prev) => { + const optimisticIdx = prev.findIndex((message) => (message as Message & { _optimistic?: boolean })._optimistic && message.isStreaming); + const existingIdx = prev.findIndex((message) => message.messageId === payload.messageId && message.isStreaming); + if (optimisticIdx >= 0) { + const updated = [...prev]; + updated[optimisticIdx] = { + ...updated[optimisticIdx], + messageId: payload.messageId, + sender: payload.agentId || updated[optimisticIdx].sender, + content: payload.details || '模型正在思考中...', + _optimistic: undefined, + }; + return updated; + } + if (existingIdx >= 0) { + const updated = [...prev]; + updated[existingIdx] = { + ...updated[existingIdx], + content: payload.details || updated[existingIdx].content, + }; + return updated; + } + return [ + ...prev, + { + event: 'message', + sessionId: chunkSessionId, + sender: payload.agentId || 'agent', + content: payload.details || '', + type: 'text', + timestamp: new Date().toISOString(), + messageId: payload.messageId, + isStreaming: true, + }, + ]; + }); + return true; + } + + if (evt === 'tool_call') { + const payload = raw as unknown as ToolCallEvent; + deps.setStreamPhase('executing'); + if (payload.toolCalls && payload.toolCalls.length > 0) { + deps.setActiveTools(payload.toolCalls.map((call) => call.name || '')); + } + updateSessionMessages(chunkSessionId, (prev) => { + const lastThinkingIdx = (() => { + for (let i = prev.length - 1; i >= 0; i -= 1) { + if (isThinkingPlaceholder(prev[i])) return i; + } + return -1; + })(); + let cleaned = prev; + if (lastThinkingIdx >= 0) { + cleaned = prev.map((message, index) => { + if (index === lastThinkingIdx) { + return { ...message, content: '🔧 正在执行工具...' }; + } + if (isThinkingPlaceholder(message)) { + return null; + } + return message; + }).filter(Boolean) as Message[]; + } + return [ + ...cleaned, + { + event: 'message', + sessionId: chunkSessionId, + sender: 'system', + content: '', + type: 'tool_call', + timestamp: payload.timestamp, + messageId: payload.messageId, + toolCallData: { calls: payload.toolCalls }, + }, + ]; + }); + return true; + } + + if (evt === 'tool_result') { + const payload = raw as unknown as ToolResultEvent; + if (payload.results) { + deps.setActiveTools((prev) => prev.filter( + (name) => !payload.results.some((result) => result.tool_name === name), + )); + for (const result of payload.results) { + if (!result.success) { + deps.addToast({ + type: 'error', + title: `工具执行失败: ${result.tool_name}`, + message: result.error || '未知错误', + duration: 8000, + }); + } + } + } + updateSessionMessages(chunkSessionId, (prev) => { + const updated = [...prev]; + for (let i = updated.length - 1; i >= 0; i -= 1) { + if (updated[i].type === 'tool_call' && updated[i].messageId === payload.messageId) { + updated[i] = { + ...updated[i], + type: 'tool_result', + toolResultData: { results: payload.results }, + }; + break; + } + } + return updated; + }); + + if (payload.results) { + for (const result of payload.results) { + if (!result.success || !result.result) continue; + const r = result.result as Record; + const filePath = r.path as string | undefined; + if (!filePath) continue; + + const content = r.content as string | undefined; + if (content && typeof content === 'string') { + const ext = filePath.split('.').pop()?.toLowerCase() || ''; + const isPreviewable = /^(py|js|ts|jsx|tsx|java|go|rs|c|cpp|h|hpp|swift|kt|rb|php|sql|sh|bash|vue|svelte|astro|html|css|scss|less|json|yaml|yml|xml|toml|ini|md|txt|cfg|conf|env|dockerfile|makefile|graphql|proto)$/i.test(ext); + if (isPreviewable && content.length < 500000) { + deps.handleOpenFilePreview(filePath, content, undefined, r.status as string | undefined); + } + } + + const diff = r.diff as string | undefined; + if (diff && typeof diff === 'string' && diff.length > 0) { + deps.handleOpenDiffPreview(filePath, diff); + } + } + } + return true; + } + + if (evt === 'message') { + deps.setStreamPhase('done'); + deps.setActiveTools([]); + window.setTimeout(() => deps.setStreamPhase('idle'), 2000); + + flushAllPendingStreamBuffer(chunkSessionId, deps); + const msg = raw as unknown as Message; + const isSystemMsg = msg.type === 'system' || msg.sender === 'system'; + setSessionStreaming(chunkSessionId, false); + updateSessionMessages(chunkSessionId, (prev) => mergeFinalMessage(prev, msg)); + if (!isSystemMsg) { + deps.setSessions((prev) => deps.sortSessions( + prev.map((session) => (session.id === (msg.sessionId || chunkSessionId) + ? { ...session, lastMessageAt: msg.timestamp || new Date().toISOString() } + : session)), + )); + } + if (msg.symbolic?.generated) { + deps.setGenerated(msg.symbolic.generated as GeneratedData); + const gen = msg.symbolic.generated as GeneratedData; + if (gen.fileDetails && gen.fileDetails.length > 0) { + for (const fd of gen.fileDetails) { + if (fd.path && fd.content && fd.content.length < 500000) { + deps.handleOpenFilePreview(fd.path, fd.content); + } + } + } + if (gen.diff) { + deps.handleOpenDiffPreview('changes.diff', gen.diff); + } + } + return true; + } + + return false; +} From d76114f2c2f437c771f6dba83e4c5642d4e96a90 Mon Sep 17 00:00:00 2001 From: Nat-xu <19707027371@163.com> Date: Sun, 19 Jul 2026 16:16:14 +0800 Subject: [PATCH 21/22] refactor websocket lifecycle and compact prompts --- app/services/context_compaction.py | 81 ++-- app/services/task_decomposer.py | 2 +- app/services/test_context_compaction.py | 53 ++- .../hooks/useSessionWebSocket.test.ts | 10 + frontend/__tests__/lib/dagStore.test.ts | 50 +++ .../__tests__/lib/messageRecovery.test.ts | 38 ++ frontend/app/page.tsx | 397 +++++------------- frontend/hooks/useSessionRecovery.ts | 2 +- frontend/hooks/useSessionWebSocket.ts | 339 +++++++++++++++ frontend/lib/dagStore.ts | 14 +- frontend/lib/messageRecovery.ts | 25 +- 11 files changed, 658 insertions(+), 353 deletions(-) create mode 100644 frontend/__tests__/hooks/useSessionWebSocket.test.ts create mode 100644 frontend/hooks/useSessionWebSocket.ts diff --git a/app/services/context_compaction.py b/app/services/context_compaction.py index b2bed27..78642d6 100644 --- a/app/services/context_compaction.py +++ b/app/services/context_compaction.py @@ -33,9 +33,9 @@ def _compact_tags(tags_raw: Any, *, max_tags: int = 4) -> str: def build_agent_roster_summary( agents: list[dict[str, Any]], *, - max_agents: int = 8, - max_tags: int = 4, - max_duty_chars: int = 72, + max_agents: int = 6, + max_tags: int = 3, + max_duty_chars: int = 48, ) -> str: """Build a compact agent capability block for LLM prompts.""" if not agents: @@ -43,18 +43,18 @@ def build_agent_roster_summary( lines: list[str] = ["【Agent能力摘要】"] for agent in agents[:max_agents]: - agent_id = compact_text(str(agent.get("agent_id", "unknown")), max_chars=28) - domain = compact_text(str(agent.get("domain", "unknown")), max_chars=18) - risk = compact_text(str(agent.get("risk_level", "L1")), max_chars=8) - status = compact_text(str(agent.get("status", "sleeping")), max_chars=12) + agent_id = compact_text(str(agent.get("agent_id", "unknown")), max_chars=20) + domain = compact_text(str(agent.get("domain", "unknown")), max_chars=14) + risk = compact_text(str(agent.get("risk_level", "L1")), max_chars=6) + status = compact_text(str(agent.get("status", "sleeping")), max_chars=8) duty = compact_text(str(agent.get("duty_note", "")), max_chars=max_duty_chars) tags = _compact_tags(agent.get("capability_tags", []), max_tags=max_tags) - tag_part = f" tags={tags}" if tags else "" - duty_part = f" duty={duty}" if duty else "" + tag_part = f"|t={tags}" if tags else "" + duty_part = f"|d={duty}" if duty else "" lines.append(f"- {agent_id}|{domain}|{status}|{risk}{tag_part}{duty_part}") if len(agents) > max_agents: - lines.append(f"- ... 其余 {len(agents) - max_agents} 个 Agent 已省略") + lines.append(f"- ... +{len(agents) - max_agents} agents") return "\n".join(lines) + "\n" @@ -67,31 +67,31 @@ def build_preprocess_context(preprocessed: dict[str, Any]) -> str: parts: list[str] = ["【预处理摘要】"] parts.append(f"intent={preprocessed.get('intent_type', 'technical_development')}") - clarified = compact_text(preprocessed.get("clarified_question", ""), max_chars=160) + clarified = compact_text(preprocessed.get("clarified_question", ""), max_chars=140) if clarified: parts.append(f"question={clarified}") requirements = [ - compact_text(req, max_chars=70) - for req in preprocessed.get("requirements", [])[:5] - if compact_text(req, max_chars=70) + compact_text(req, max_chars=60) + for req in preprocessed.get("requirements", [])[:4] + if compact_text(req, max_chars=60) ] if requirements: parts.append("requirements=" + ";".join(requirements)) nf_requirements = [ - compact_text(req, max_chars=70) - for req in preprocessed.get("non_functional_requirements", [])[:4] - if compact_text(req, max_chars=70) + compact_text(req, max_chars=56) + for req in preprocessed.get("non_functional_requirements", [])[:3] + if compact_text(req, max_chars=56) ] if nf_requirements: parts.append("nonfunctional=" + ";".join(nf_requirements)) selected_solution = preprocessed.get("_selected_solution") if isinstance(selected_solution, dict): - sol_name = compact_text(selected_solution.get("name", ""), max_chars=60) - sol_stack = ", ".join(str(item) for item in selected_solution.get("tech_stack", [])[:4] if str(item).strip()) - sol_arch = compact_text(selected_solution.get("architecture", ""), max_chars=120) + sol_name = compact_text(selected_solution.get("name", ""), max_chars=48) + sol_stack = ", ".join(str(item) for item in selected_solution.get("tech_stack", [])[:3] if str(item).strip()) + sol_arch = compact_text(selected_solution.get("architecture", ""), max_chars=80) sol_bits = [bit for bit in [sol_name, sol_stack, sol_arch] if bit] if sol_bits: parts.append("selected=" + " | ".join(sol_bits)) @@ -102,9 +102,9 @@ def build_preprocess_context(preprocessed: dict[str, Any]) -> str: for sol in solutions: if not isinstance(sol, dict): continue - sol_id = compact_text(sol.get("id", ""), max_chars=20) - sol_name = compact_text(sol.get("name", ""), max_chars=48) - stack = ", ".join(str(item) for item in sol.get("tech_stack", [])[:4] if str(item).strip()) + sol_id = compact_text(sol.get("id", ""), max_chars=16) + sol_name = compact_text(sol.get("name", ""), max_chars=40) + stack = ", ".join(str(item) for item in sol.get("tech_stack", [])[:3] if str(item).strip()) score = sol.get("score") risk = compact_text(sol.get("risk_level", ""), max_chars=10) bit = f"{sol_id}:{sol_name}" @@ -121,7 +121,7 @@ def build_preprocess_context(preprocessed: dict[str, Any]) -> str: if solution_bits: parts.append("solutions=" + " | ".join(solution_bits)) - sub_tasks = preprocessed.get("sub_tasks", [])[:5] + sub_tasks = preprocessed.get("sub_tasks", [])[:4] if sub_tasks: task_bits: list[str] = [] for task in sub_tasks: @@ -129,25 +129,25 @@ def build_preprocess_context(preprocessed: dict[str, Any]) -> str: continue task_id = compact_text(str(task.get("id", "?")), max_chars=12) domain = compact_text(str(task.get("domain", "general")), max_chars=12) - title = compact_text(task.get("title", ""), max_chars=36) - deps = ",".join(f"n{dep}" if str(dep).isdigit() else compact_text(str(dep), max_chars=12) for dep in task.get("depends_on", [])[:4]) or "-" + title = compact_text(task.get("title", ""), max_chars=32) + deps = ",".join(f"n{dep}" if str(dep).isdigit() else compact_text(str(dep), max_chars=10) for dep in task.get("depends_on", [])[:3]) or "-" task_bits.append(f"{task_id}:{domain}:{title}<-{deps}") if task_bits: parts.append("subtasks=" + " | ".join(task_bits)) constraints = [ - compact_text(item, max_chars=64) - for item in preprocessed.get("constraints", [])[:5] - if compact_text(item, max_chars=64) + compact_text(item, max_chars=56) + for item in preprocessed.get("constraints", [])[:4] + if compact_text(item, max_chars=56) ] if constraints: parts.append("constraints=" + ";".join(constraints)) routing = preprocessed.get("routing") if isinstance(routing, dict): - order = [compact_text(item, max_chars=16) for item in routing.get("execution_order", [])[:5] if compact_text(item, max_chars=16)] - parallel = [compact_text(item, max_chars=24) for item in routing.get("parallel_opportunities", [])[:3] if compact_text(item, max_chars=24)] - conflicts = [compact_text(item, max_chars=24) for item in routing.get("potential_conflicts", [])[:3] if compact_text(item, max_chars=24)] + order = [compact_text(item, max_chars=14) for item in routing.get("execution_order", [])[:4] if compact_text(item, max_chars=14)] + parallel = [compact_text(item, max_chars=20) for item in routing.get("parallel_opportunities", [])[:2] if compact_text(item, max_chars=20)] + conflicts = [compact_text(item, max_chars=20) for item in routing.get("potential_conflicts", [])[:2] if compact_text(item, max_chars=20)] if order: parts.append("route=" + "->".join(order)) if parallel: @@ -155,7 +155,7 @@ def build_preprocess_context(preprocessed: dict[str, Any]) -> str: if conflicts: parts.append("conflicts=" + ";".join(conflicts)) - approach = compact_text(preprocessed.get("suggested_approach", ""), max_chars=120) + approach = compact_text(preprocessed.get("suggested_approach", ""), max_chars=96) if approach: parts.append(f"approach={approach}") @@ -170,13 +170,20 @@ def _node_value(obj: Any, key: str, default: Any = "") -> Any: return getattr(obj, key, default) dependencies = _node_value(node, "dependencies", []) - if not isinstance(dependencies, list): + if isinstance(dependencies, str): + dependencies = [dependencies] + elif not isinstance(dependencies, list): dependencies = list(dependencies) if dependencies else [] + dependencies = [ + compact_text(str(dep), max_chars=16) + for dep in dependencies[:3] + if str(dep).strip() + ] return { - "id": str(_node_value(node, "id", "")), - "description": compact_text(_node_value(node, "description", ""), max_chars=84), - "agent": str(_node_value(node, "agent", "")), + "id": compact_text(str(_node_value(node, "id", "")), max_chars=18), + "description": compact_text(_node_value(node, "description", ""), max_chars=56), + "agent": compact_text(str(_node_value(node, "agent", "")), max_chars=18), "dependencies": dependencies, "estimatedSeconds": { "low": 20, diff --git a/app/services/task_decomposer.py b/app/services/task_decomposer.py index 4075356..ef5daff 100644 --- a/app/services/task_decomposer.py +++ b/app/services/task_decomposer.py @@ -166,7 +166,7 @@ async def _build_capability_summary(self, agents: list[dict[str, Any]]) -> str: if cached and (now_ts - cached[0]) < self._cache_ttl: return cached[1] - summary = build_agent_roster_summary(agents, max_agents=8, max_tags=4, max_duty_chars=64) + summary = build_agent_roster_summary(agents, max_agents=6, max_tags=3, max_duty_chars=48) self._agent_capability_cache[fingerprint] = (now_ts, summary) return summary diff --git a/app/services/test_context_compaction.py b/app/services/test_context_compaction.py index 1485988..29cf2c6 100644 --- a/app/services/test_context_compaction.py +++ b/app/services/test_context_compaction.py @@ -1,6 +1,7 @@ from __future__ import annotations from app.services.context_compaction import ( + build_agent_roster_summary, build_preprocess_context, build_result_preview, build_task_preview_item, @@ -11,9 +12,9 @@ def test_build_preprocess_context_is_compact() -> None: text = build_preprocess_context( { "intent_type": "technical_development", - "clarified_question": "请帮我设计一个可扩展的企业级多智能体平台,并支持任务编排。", - "requirements": ["多智能体协作", "企业自托管"], - "non_functional_requirements": ["低耦合", "低 token 消耗"], + "clarified_question": "please design a scalable enterprise multi-agent platform with task orchestration", + "requirements": ["multi-agent collaboration", "enterprise self-hosting"], + "non_functional_requirements": ["low coupling", "reduce token usage"], "solutions": [ { "id": "A", @@ -24,9 +25,9 @@ def test_build_preprocess_context_is_compact() -> None: } ], "sub_tasks": [ - {"id": 1, "domain": "architect", "title": "拆分架构", "depends_on": []}, + {"id": 1, "domain": "architect", "title": "split architecture", "depends_on": []}, ], - "constraints": ["自托管"], + "constraints": ["self-hosted"], "routing": {"execution_order": ["Architect", "CodeGen"]}, } ) @@ -34,7 +35,6 @@ def test_build_preprocess_context_is_compact() -> None: assert "intent=technical_development" in text assert "route=Architect->CodeGen" in text assert "requirements=" in text - assert "【预处理摘要】" in text def test_build_task_preview_item_is_short() -> None: @@ -42,16 +42,53 @@ def test_build_task_preview_item_is_short() -> None: { "id": "n1", "agent": "Architect", - "description": "梳理企业级多智能体平台的整体架构、模块边界和协作方式", + "description": "A long task description that should be compacted for preview payloads and prompts", "dependencies": ["n0"], "estimated_effort": "high", } ) - assert item["description"].startswith("梳理企业级多智能体平台") + assert item["description"].startswith("A long task description") assert item["estimatedSeconds"] == 90 +def test_build_task_preview_item_caps_dependencies_and_text() -> None: + item = build_task_preview_item( + { + "id": "node_with_a_very_long_identifier_that_should_be_shortened", + "agent": "ArchitectAgentWithVerboseName", + "description": "x" * 160, + "dependencies": ["dep-1", "dep-2", "dep-3", "dep-4"], + "estimated_effort": "medium", + } + ) + + assert len(item["id"]) <= 18 + assert len(item["agent"]) <= 18 + assert len(item["description"]) <= 56 + assert item["dependencies"] == ["dep-1", "dep-2", "dep-3"] + + +def test_build_agent_roster_summary_defaults_are_short() -> None: + agents = [ + { + "agent_id": f"Agent{i}", + "domain": "architecture", + "risk_level": "L2", + "status": "active", + "duty_note": "x" * 120, + "capability_tags": ["plan", "code", "review", "extra"], + } + for i in range(8) + ] + + summary = build_agent_roster_summary(agents) + + assert summary.count("\n- ") == 7 + assert "+2 agents" in summary + assert "|t=plan,code,review" in summary + + def test_build_result_preview_trims_long_text() -> None: preview = build_result_preview("x" * 500, max_chars=80) diff --git a/frontend/__tests__/hooks/useSessionWebSocket.test.ts b/frontend/__tests__/hooks/useSessionWebSocket.test.ts new file mode 100644 index 0000000..499d1cf --- /dev/null +++ b/frontend/__tests__/hooks/useSessionWebSocket.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest'; +import { computeReconnectDelay } from '../../hooks/useSessionWebSocket'; + +describe('useSessionWebSocket helpers', () => { + it('computes capped reconnect backoff with deterministic jitter', () => { + expect(computeReconnectDelay(0, 0)).toBe(1000); + expect(computeReconnectDelay(3, 125)).toBe(8125); + expect(computeReconnectDelay(99, 250)).toBe(30250); + }); +}); diff --git a/frontend/__tests__/lib/dagStore.test.ts b/frontend/__tests__/lib/dagStore.test.ts index 79f7cd4..79b0f07 100644 --- a/frontend/__tests__/lib/dagStore.test.ts +++ b/frontend/__tests__/lib/dagStore.test.ts @@ -1,8 +1,11 @@ import { describe, expect, it } from 'vitest'; import { buildDagStateFromTaskPreview, + clearDagSession, deriveDagStateFromMessages, + getDagState, mergeDagTaskUpdate, + syncDagFromMessages, } from '../../lib/dagStore'; import type { Message, TaskPreviewEvent } from '../../types'; @@ -94,4 +97,51 @@ describe('dagStore helpers', () => { expect(dag.total).toBe(1); expect(dag.nodes[0].id).toBe('node-3'); }); + + it('forces dag refresh from reloaded messages', () => { + clearDagSession('session-force-refresh'); + const initial: Message[] = [ + { + event: 'message', + sessionId: 'session-force-refresh', + sender: 'system', + content: 'task preview', + type: 'task_preview', + timestamp: '2026-07-19T00:00:00.000Z', + taskPreviewData: { + event: 'task_preview', + sessionId: 'session-force-refresh', + messageId: 'msg-1', + timestamp: '2026-07-19T00:00:00.000Z', + tasks: [{ id: 'node-old', description: 'Old', agent: 'A', dependencies: [] }], + }, + }, + ]; + const refreshed: Message[] = [ + { + event: 'message', + sessionId: 'session-force-refresh', + sender: 'system', + content: 'task preview', + type: 'task_preview', + timestamp: '2026-07-19T00:00:01.000Z', + taskPreviewData: { + event: 'task_preview', + sessionId: 'session-force-refresh', + messageId: 'msg-2', + timestamp: '2026-07-19T00:00:01.000Z', + tasks: [{ id: 'node-new', description: 'New', agent: 'B', dependencies: [] }], + }, + }, + ]; + + syncDagFromMessages('session-force-refresh', initial); + expect(getDagState('session-force-refresh').nodes[0].id).toBe('node-old'); + + syncDagFromMessages('session-force-refresh', refreshed); + expect(getDagState('session-force-refresh').nodes[0].id).toBe('node-old'); + + syncDagFromMessages('session-force-refresh', refreshed, true); + expect(getDagState('session-force-refresh').nodes[0].id).toBe('node-new'); + }); }); diff --git a/frontend/__tests__/lib/messageRecovery.test.ts b/frontend/__tests__/lib/messageRecovery.test.ts index 7b6c5f0..57864d8 100644 --- a/frontend/__tests__/lib/messageRecovery.test.ts +++ b/frontend/__tests__/lib/messageRecovery.test.ts @@ -82,6 +82,44 @@ describe('messageRecovery helpers', () => { expect(merged[0].messageId).toBe('mid-1'); }); + it('deduplicates duplicate reloaded payloads and removes streaming placeholders', () => { + const merged = mergeReloadedMessages([ + { + event: 'message', + sessionId: 's1', + sender: 'agent', + content: 'in-flight', + type: 'text', + timestamp: '2026-07-19T00:00:00.000Z', + messageId: 'mid-0', + isStreaming: true, + }, + ], [ + { + event: 'message', + sessionId: 's1', + sender: 'agent', + content: 'final', + type: 'text', + timestamp: '2026-07-19T00:00:01.000Z', + messageId: 'mid-1', + }, + { + event: 'message', + sessionId: 's1', + sender: 'agent', + content: 'final', + type: 'text', + timestamp: '2026-07-19T00:00:01.000Z', + messageId: 'mid-1', + }, + ]); + + expect(merged).toHaveLength(1); + expect(merged[0].messageId).toBe('mid-1'); + expect(merged[0].isStreaming).toBeFalsy(); + }); + it('replaces streaming placeholder with the final message payload', () => { const merged = mergeFinalMessage([ { diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 7e2e4cf..a9d2f14 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -24,9 +24,8 @@ import ResizableDivider from '../components/common/ResizableDivider'; import { useResizableSize } from '../hooks/useResizableSize'; import { useFileUpload } from '../hooks/useFileUpload'; import { useSessionRecovery } from '../hooks/useSessionRecovery'; +import { useSessionWebSocket } from '../hooks/useSessionWebSocket'; import { buildOutgoingMessageDraft } from '../lib/outgoingMessageDraft'; -import { registerReplayMessageId } from '../lib/messageRecovery'; -import { buildChatWebSocketUrl } from '../lib/websocketUrl'; import { clearDagSession, useDagState } from '../lib/dagStore'; import { handleSharedWebSocketEvent } from '../lib/websocketSharedEvents'; import { flushAllPendingStreamBuffer, handleStreamWebSocketEvent } from '../lib/websocketStreamEvents'; @@ -38,8 +37,6 @@ import { getSessionBuffer, replaceSessionMessages, clearSession, - pinSession, - unpinSession, } from '../lib/sessionStore'; import type { Agent, AttachedFile, ChatSession, FileReference, GeneratedData, Message, PendingMessage, QuoteReference, SkillMeta, User, WorkflowSummary, WorkspacePreviewTab } from '../types'; import type { FilePreviewTarget } from '../components/chat/FilePreviewModal'; @@ -98,12 +95,6 @@ export default function AgentHubIM(): JSX.Element { const messages = useSessionMessages(sessionId); const isStreaming = useSessionStreaming(sessionId); const dag = useDagState(sessionId); - const { reloadMessages } = useSessionRecovery({ - sessionId, - token, - messages, - onTokenExpired: handleTokenExpired, - }); const { addToast } = useAddToast(); const [sessionQuery, setSessionQuery] = useState(''); const [input, setInput] = useState('@CodeGen Generate a FastAPI health route file, save as health_router.py'); @@ -112,7 +103,6 @@ export default function AgentHubIM(): JSX.Element { const [previewUrl, setPreviewUrl] = useState(''); const [connected, setConnected] = useState(false); const [notice, setNotice] = useState(''); - const [pending, setPending] = useState([]); const [generated, setGenerated] = useState(null); const [agents, setAgents] = useState(FALLBACK_AGENTS); const [mentionSearch, setMentionSearch] = useState(''); @@ -190,26 +180,14 @@ export default function AgentHubIM(): JSX.Element { // 整个外层 flex 容器的引用(用于响应式断点计算) const rootRef = useRef(null); - // ── per-session WebSocket 连接 ───────────────────────────────────── - // 切 session 时不关闭旧 session 的 WebSocket,让后台 session 的流照常累积。 - // closeWs(sid) 只在删除会话 / 登出 / 组件卸载时调用。 + // ── per-session stream lifecycle ────────────────────────────────── + // streamFlushRafRef 改为 per-session Map,让每个 session 独立 RAF 调度 flush。 const wsRef = useRef>(new Map()); - const wsReadyRef = useRef>(new Map()); - // ★ streamBufferRef 不再使用——buffer 走 SessionStore。 - // ★ streamFlushRafRef 改为 per-session Map,让每个 session 独立 RAF 调度 flush。 const streamFlushRafRef = useRef>(new Map()); - // ★ progressiveFlushTimersRef: per-session setTimeout IDs for progressive - // chunk release (one chunk every ~8ms for a natural typing effect). + // per-session setTimeout IDs for progressive chunk release. const progressiveFlushTimersRef = useRef>(new Map()); - // ★ streamInterruptedAtRef 记录每个 session 上次 stream_interrupted 的时间戳。 - // 用于在 800ms 窗口内阻断迟到的 agent_thinking 事件重新激活流式状态 - // (避免标题栏"AI streaming..."状态卡死)。 + // Track the last stream_interrupted timestamp per session. const streamInterruptedAtRef = useRef>(new Map()); - const retryRef = useRef([]); - const reconnectRef = useRef | null>(null); - const reconnectAttemptsRef = useRef(0); - const lastMessageIdRef = useRef(''); - const dedupIdsRef = useRef>(new Set()); const bottomRef = useRef(null); const messagesContainerRef = useRef(null); const currentSessionRef = useRef(sessionId); @@ -234,6 +212,80 @@ export default function AgentHubIM(): JSX.Element { const sessionsRef = useRef(sessions); sessionsRef.current = sessions; const blurTimerRef = useRef | null>(null); + + function handleSocketSessionClosed(sid: string): void { + const raf = streamFlushRafRef.current.get(sid); + if (raf != null) { + window.cancelAnimationFrame(raf); + streamFlushRafRef.current.delete(sid); + } + const timer = progressiveFlushTimersRef.current.get(sid); + if (timer != null) { + window.clearTimeout(timer); + progressiveFlushTimersRef.current.delete(sid); + } + flushAllPendingStreamBuffer(sid, { + streamFlushRafRef, + progressiveFlushTimersRef, + }); + streamInterruptedAtRef.current.delete(sid); + if (currentSessionRef.current === sid) { + setStreamPhase('idle'); + setActiveTools([]); + setCurrentAgentName(''); + } + } + + function handleSocketMessage(raw: Record, evt: string | undefined, chunkSessionId: string, ws: WebSocket): void { + void ws; + if (handleSharedWebSocketEvent(raw, evt, chunkSessionId, { + wsRef, + setWorkspaceVersion, + setPreviewTabs, + setNotice, + setPmState, + setDegradationStatus, + setSessions, + setIsAutoNaming, + setExecPermission, + sortSessions, + })) { + return; + } + + if (handleStreamWebSocketEvent(raw, evt, chunkSessionId, { + streamFlushRafRef, + progressiveFlushTimersRef, + streamInterruptedAtRef, + setStreamPhase, + setActiveTools, + setCurrentAgentName, + setSessions, + sortSessions, + addToast, + setGenerated, + handleOpenFilePreview, + handleOpenDiffPreview, + })) { + return; + } + } + + const { + connectSession, + closeSession, + disconnectAll, + sendOrQueue, + } = useSessionWebSocket({ + wsRef, + currentSessionRef, + tokenRef, + setConnected, + setNotice, + addToast, + onSessionClosed: handleSocketSessionClosed, + onMessage: handleSocketMessage, + }); // ── Effects ────────────────────────────────────────────── @@ -263,23 +315,21 @@ export default function AgentHubIM(): JSX.Element { const sessionIds = Array.from(wsRef.current.keys()); localStorage.removeItem('agenthub_token'); localStorage.removeItem('agenthub_user'); - // Close every open WebSocket — the token is dead - sessionIds.forEach((sid) => closeWs(sid)); + disconnectAll(); sessionIds.forEach((sid) => clearSession(sid)); sessionIds.forEach((sid) => clearDagSession(sid)); - if (reconnectRef.current) { - clearTimeout(reconnectRef.current); - reconnectRef.current = null; - } - streamFlushRafRef.current.forEach((raf) => window.cancelAnimationFrame(raf)); - streamFlushRafRef.current.clear(); - progressiveFlushTimersRef.current.forEach((tid) => window.clearTimeout(tid)); - progressiveFlushTimersRef.current.clear(); setToken(''); setUser(null); setNotice('登录已过期,请重新登录'); } + const { reloadMessages } = useSessionRecovery({ + sessionId, + token, + messages, + onTokenExpired: handleTokenExpired, + }); + useEffect(() => { if (!token) return; // ── Fetch sessions ────────────────────────────────────────────── @@ -342,34 +392,13 @@ export default function AgentHubIM(): JSX.Element { useEffect(() => { if (!token || !sessionId) return; - // Reset session-scoped refs to prevent stale data leaking across sessions - lastMessageIdRef.current = ''; - dedupIdsRef.current = new Set(); - retryRef.current = []; - setPending([]); - // ★ 关键修复:不要清 stream buffer,也不要重置 isStreaming。 - // 旧 session 正在流式接收的 chunks 应当继续累积到 SessionStore 里对应 session 的 - // buffer 中,等用户切回时能立即看到流式结果。buffer 走 store,不走 ref。 setFileReferences([]); - // Don't reset previewTabs on session switch — user may want to - // keep viewing previously generated files across sessions. - // ★ 关键修复:reloadMessages 改成写到 SessionStore 的 per-session Map。 - // 如果新 session 在 Store 里已经有缓存(用户切走又切回),命中缓存就不发 API。 const cached = getSessionBuffer(sessionId); if (!cached) { void reloadMessages(false); } - // 无论是否命中缓存,都要 connectWs —— 切回来时需要新 WebSocket 继续接收 chunks。 - connectWs(sessionId); - return () => { - // 注意:切 session 不再关闭旧 WebSocket,旧 session 仍在后台接收 chunks。 - // 旧 ws 仅在删除会话 / 登出 / 组件卸载时由 closeWs() 关闭。 - if (reconnectRef.current) { - clearTimeout(reconnectRef.current); - reconnectRef.current = null; - } - }; - }, [token, sessionId]); + connectSession(sessionId); + }, [token, sessionId, reloadMessages, connectSession]); // ── Scroll-to-bottom helper refs ────────────────────────── const scrollRafRef = useRef(0); @@ -419,204 +448,6 @@ export default function AgentHubIM(): JSX.Element { } }, [messages, sessionId]); - // ── WebSocket ──────────────────────────────────────────── - - function _reconnectDelay(): number { - // Exponential backoff: 1s → 2s → 4s → 8s → ... → 30s max - const attempt = reconnectAttemptsRef.current; - const delay = Math.min(1000 * Math.pow(2, attempt), 30000); - return delay + Math.random() * 500; // jitter to avoid thundering herd - } - - function closeWs(sid: string): void { - const existing = wsRef.current.get(sid); - if (existing) { - existing.onclose = null; - existing.close(); - wsRef.current.delete(sid); - } - wsReadyRef.current.delete(sid); - unpinSession(sid); - } - - function connectWs(targetSid?: string): void { - // 关键修复:必须用参数传入的 sessionId,而不是 currentSessionRef.current。 - // useEffect 顺序执行的不确定性会让 ref 在 connectWs 触发时还未更新, - // 导致新建/切换会话时连到了旧 sid —— 表现就是 "只能发到固定会话"。 - const sid = targetSid || currentSessionRef.current; - currentSessionRef.current = sid; - - // Guard: empty session id produces malformed ws://…/ws/ URLs that 404. - // Also refuse to connect when the stored token is missing (already - // logged out) — this prevents spurious reconnection attempts after - // handleTokenExpired() fires. Use tokenRef to avoid stale-closure - // issues when connectWs is called from setTimeout. - if (!sid || !tokenRef.current) return; - - // eslint-disable-next-line no-console - if (process.env.NODE_ENV === "development") { console.log("[agenthub] connectWs", { targetSid, finalSid: sid, hasSocket: wsRef.current.has(sid) }); } - // ★ 关键修复:如果该 session 已有 ws 但已 CLOSED/CLOSING,删掉重建。 - // 仅当 ws 是 OPEN/CONNECTING 时才跳过。 - const existing = wsRef.current.get(sid); - if (existing) { - if (existing.readyState === WebSocket.OPEN || existing.readyState === WebSocket.CONNECTING) { - return; - } - // 死连接:清理掉,准备重建 - try { existing.close(); } catch { /* ignore */ } - wsRef.current.delete(sid); - wsReadyRef.current.delete(sid); - } - pinSession(sid); - - if (reconnectRef.current) { - clearTimeout(reconnectRef.current); - reconnectRef.current = null; - } - - const ws = new WebSocket(buildChatWebSocketUrl(sid, token)); - wsRef.current.set(sid, ws); - - ws.onopen = () => { - if (wsRef.current.get(sid) !== ws) return; // 已被新连接替代 - wsReadyRef.current.set(sid, true); - setConnected(true); - reconnectAttemptsRef.current = 0; - setNotice('WebSocket connected'); - - // Replay queued messages that were pending during disconnect - const queued = [...retryRef.current]; - retryRef.current = []; - setPending([]); - queued.forEach((msg) => { - if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg)); - }); - - // Request missed messages since last known ID - if (lastMessageIdRef.current) { - ws.send(JSON.stringify({ - event: 'sync_request', - lastMessageId: lastMessageIdRef.current, - })); - } else { - void reloadMessages(true); - } - }; - - ws.onclose = () => { - wsReadyRef.current.delete(sid); - if (wsRef.current.get(sid) === ws) { - wsRef.current.delete(sid); - } - setConnected(false); - // Flush any buffered stream content for THIS session before clearing - const raf = streamFlushRafRef.current.get(sid); - if (raf != null) { - window.cancelAnimationFrame(raf); - streamFlushRafRef.current.delete(sid); - } - flushAllPendingStreamBuffer(sid, { - streamFlushRafRef, - progressiveFlushTimersRef, - }); - // 清理该 session 的中断标记 - streamInterruptedAtRef.current.delete(sid); - // ★ 方案4: 重连通知 - const attempt = reconnectAttemptsRef.current; - if (currentSessionRef.current === sid && attempt === 0) { - addToast({ type: 'warning', title: 'WebSocket 连接断开', message: '正在尝试重新连接...', duration: 5000 }); - } else if (currentSessionRef.current === sid && attempt >= 3) { - addToast({ type: 'error', title: '连接不稳定', message: `已尝试重连 ${attempt + 1} 次,请检查网络`, duration: 0 }); - } - // 仅当用户当前还在这个 session 且 token 仍有效才重连 - // tokenRef 检查防止已过期登出后仍继续无效重连 - if (currentSessionRef.current === sid && tokenRef.current) { - // Cap reconnection attempts to avoid infinite loops on - // permanent failures (e.g. expired token, deleted session). - if (reconnectAttemptsRef.current >= 10) { - setNotice('连接已断开,请刷新页面或重新登录'); - addToast({ type: 'error', title: '连接彻底断开', message: '请刷新页面或重新登录', duration: 0 }); - return; - } - reconnectAttemptsRef.current += 1; - if (reconnectRef.current) clearTimeout(reconnectRef.current); - reconnectRef.current = setTimeout(connectWs, _reconnectDelay()); - } - }; - - ws.onerror = () => { - wsReadyRef.current.delete(sid); - setConnected(false); - // Ensure cleanup even if browser doesn't fire onclose after error - if (ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) { - try { ws.close(); } catch { /* best-effort */ } - } - }; - - ws.onmessage = (event: MessageEvent) => { - // ★ 关键修复:移除 `currentSessionRef.current !== sid` 守卫。 - // 后台 session 的 chunks 也要处理(写入该 session 的 buffer),切回来时直接可见。 - const raw: Record = JSON.parse(event.data); - const evt = raw.event as string | undefined; - const chunkSessionId = (raw.sessionId || sid) as string; - - // ── Heartbeat: respond to pings ────────────────────────── - if (evt === 'ping') { - if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ event: 'pong', ts: Date.now() })); - } - return; - } - - // ── Replayed message dedup ─────────────────────────────── - const msgId = (raw.id || raw.messageId || '') as string; - if (raw._replay && msgId && dedupIdsRef.current.has(msgId)) { - return; // already processed - } - if (msgId) { - dedupIdsRef.current = registerReplayMessageId(dedupIdsRef.current, msgId); - } - - // Track last known message ID for reconnection sync - if (evt === 'message' || evt === 'message_chunk') { - if (msgId) lastMessageIdRef.current = msgId; - } - - // ── Workspace file change events ───────────────────────────── - if (handleSharedWebSocketEvent(raw, evt, chunkSessionId, { - wsRef, - setWorkspaceVersion, - setPreviewTabs, - setNotice, - setPmState, - setDegradationStatus, - setSessions, - setIsAutoNaming, - setExecPermission, - sortSessions, - })) { - return; - } - - if (handleStreamWebSocketEvent(raw, evt, chunkSessionId, { - streamFlushRafRef, - progressiveFlushTimersRef, - streamInterruptedAtRef, - setStreamPhase, - setActiveTools, - setCurrentAgentName, - setSessions, - sortSessions, - addToast, - setGenerated, - handleOpenFilePreview, - handleOpenDiffPreview, - })) { - return; - } - }; - } - // ── Mention detection ──────────────────────────────────── function detectMention(value: string, cursor: number): void { @@ -705,22 +536,12 @@ export default function AgentHubIM(): JSX.Element { const sessionIds = Array.from(wsRef.current.keys()); localStorage.removeItem('agenthub_token'); localStorage.removeItem('agenthub_user'); - // 关闭所有 session 的 WebSocket - sessionIds.forEach((sid) => closeWs(sid)); - // 关闭 store 里所有的 session + disconnectAll(); sessionIds.forEach((sid) => clearSession(sid)); sessionIds.forEach((sid) => clearDagSession(sid)); - if (reconnectRef.current) { - clearTimeout(reconnectRef.current); - reconnectRef.current = null; - } - streamFlushRafRef.current.forEach((raf) => window.cancelAnimationFrame(raf)); - streamFlushRafRef.current.clear(); - progressiveFlushTimersRef.current.forEach((tid) => window.clearTimeout(tid)); - progressiveFlushTimersRef.current.clear(); setToken(''); setUser(null); - }, []); + }, [disconnectAll]); const handleCreateSession = useCallback(async () => { const name = `Untitled Session ${sessions.length + 1}`; @@ -780,17 +601,8 @@ export default function AgentHubIM(): JSX.Element { setDeleting(false); return; } - // 关闭这个 session 的 WebSocket、清理 Store、清理重连定时器 - closeWs(id); - if (reconnectRef.current) { - clearTimeout(reconnectRef.current); - reconnectRef.current = null; - } - const raf = streamFlushRafRef.current.get(id); - if (raf != null) { - window.cancelAnimationFrame(raf); - streamFlushRafRef.current.delete(id); - } + // 关闭这个 session 的 WebSocket、清理 Store + closeSession(id); clearSession(id); clearDagSession(id); setSessions((prev) => prev.filter((s) => s.id !== id)); @@ -809,7 +621,7 @@ export default function AgentHubIM(): JSX.Element { } finally { setDeleting(false); } - }, [confirmDelete, deleting, sessionId, sessions]); + }, [closeSession, confirmDelete, deleting, sessionId, sessions]); const cancelDeleteSession = useCallback(() => { if (deleting) return; @@ -1246,17 +1058,10 @@ export default function AgentHubIM(): JSX.Element { ]); setSessions((prev) => sortSessions(prev.map((s) => (s.id === activeSessionId ? { ...s, lastMessageAt: localMsg.timestamp } : s)))); - const targetWs = wsRef.current.get(activeSessionId); - if (targetWs && targetWs.readyState === WebSocket.OPEN) { - targetWs.send(JSON.stringify(wsMsg)); + const sendResult = sendOrQueue(activeSessionId, wsMsg); + if (sendResult === 'sent') { setSendState('sent'); } else { - // ws 还没连上:保险起见触发一次连接(如果本来就在连,就是 no-op)。 - connectWs(activeSessionId); - retryRef.current.push(wsMsg); - setPending((prev) => [...prev, wsMsg]); - setNotice('Message queued for retry'); - addToast({ type: 'warning', title: '消息已排队', message: 'WebSocket 未连接,正在尝试重连后发送...', duration: 5000 }); setSendState('error'); } setInput(''); @@ -1266,15 +1071,13 @@ export default function AgentHubIM(): JSX.Element { }, [sessionId, user, isStreaming, fileReferences, quoteReferences]); const handleRetryMessage = useCallback((msg: PendingMessage) => { - const targetWs = wsRef.current.get(msg.sessionId); - if (targetWs && targetWs.readyState === WebSocket.OPEN) { - targetWs.send(JSON.stringify(msg)); - setPending((prev) => prev.filter((item) => item.timestamp !== msg.timestamp)); + const result = sendOrQueue(msg.sessionId, msg); + if (result === 'sent') { + setNotice('Message sent'); } else { - retryRef.current.push(msg); setNotice('WebSocket not connected, waiting for reconnect'); } - }, []); + }, [sendOrQueue]); const handlePreview = useCallback(async () => { const res = await fetch('/api/preview/local-task', { headers: authHeaders() }); diff --git a/frontend/hooks/useSessionRecovery.ts b/frontend/hooks/useSessionRecovery.ts index 64cdf03..9e4fbde 100644 --- a/frontend/hooks/useSessionRecovery.ts +++ b/frontend/hooks/useSessionRecovery.ts @@ -46,7 +46,7 @@ export function useSessionRecovery({ } else { replaceSessionMessages(sessionId, ordered); } - syncDagFromMessages(sessionId, ordered); + syncDagFromMessages(sessionId, ordered, true); } catch { /* ignore */ } diff --git a/frontend/hooks/useSessionWebSocket.ts b/frontend/hooks/useSessionWebSocket.ts new file mode 100644 index 0000000..2ce0f4b --- /dev/null +++ b/frontend/hooks/useSessionWebSocket.ts @@ -0,0 +1,339 @@ +import { useCallback, useEffect, useRef, type MutableRefObject } from 'react'; +import { registerReplayMessageId } from '../lib/messageRecovery'; +import { pinSession, unpinSession } from '../lib/sessionStore'; +import { buildChatWebSocketUrl } from '../lib/websocketUrl'; +import type { PendingMessage } from '../types'; + +type WebSocketToast = { + type: 'error' | 'warning' | 'success' | 'info'; + title: string; + message: string; + duration?: number; +}; + +export type SessionWebSocketMessageHandler = ( + raw: Record, + eventName: string | undefined, + sessionId: string, + ws: WebSocket, +) => void; + +interface UseSessionWebSocketOptions { + wsRef: MutableRefObject>; + currentSessionRef: MutableRefObject; + tokenRef: MutableRefObject; + setConnected: (connected: boolean) => void; + setNotice: (notice: string) => void; + addToast: (toast: WebSocketToast) => void; + onSessionClosed: (sessionId: string) => void; + onMessage: SessionWebSocketMessageHandler; +} + +export type SendQueueResult = 'sent' | 'queued'; + +export function computeReconnectDelay(attempt: number, jitterMs = Math.random() * 500): number { + return Math.min(1000 * Math.pow(2, attempt), 30000) + jitterMs; +} + +function useLatestRef(value: T): MutableRefObject { + const ref = useRef(value); + useEffect(() => { + ref.current = value; + }, [value]); + return ref; +} + +function parseSocketMessage(data: string): Record | null { + try { + return JSON.parse(data) as Record; + } catch { + return null; + } +} + +export function useSessionWebSocket({ + wsRef, + currentSessionRef, + tokenRef, + setConnected, + setNotice, + addToast, + onSessionClosed, + onMessage, +}: UseSessionWebSocketOptions) { + const wsReadyRef = useRef>(new Map()); + const pendingBySessionRef = useRef>(new Map()); + const reconnectTimerRef = useRef | null>(null); + const reconnectAttemptsRef = useRef(0); + const lastMessageIdBySessionRef = useRef>(new Map()); + const dedupIdsBySessionRef = useRef>>(new Map()); + + const setConnectedRef = useLatestRef(setConnected); + const setNoticeRef = useLatestRef(setNotice); + const addToastRef = useLatestRef(addToast); + const onSessionClosedRef = useLatestRef(onSessionClosed); + const onMessageRef = useLatestRef(onMessage); + + const cancelReconnect = useCallback((): void => { + if (!reconnectTimerRef.current) return; + clearTimeout(reconnectTimerRef.current); + reconnectTimerRef.current = null; + }, []); + + const isCurrentSession = useCallback( + (sessionId: string): boolean => currentSessionRef.current === sessionId, + [currentSessionRef], + ); + + const queuePendingMessage = useCallback((sessionId: string, message: PendingMessage): void => { + const queued = pendingBySessionRef.current.get(sessionId) ?? []; + const exists = queued.some((item) => item.timestamp === message.timestamp); + if (exists) return; + pendingBySessionRef.current.set(sessionId, [...queued, message]); + }, []); + + const flushPendingMessages = useCallback((sessionId: string, ws: WebSocket): void => { + const queued = pendingBySessionRef.current.get(sessionId); + if (!queued?.length) return; + + const remaining: PendingMessage[] = []; + for (let i = 0; i < queued.length; i += 1) { + if (ws.readyState !== WebSocket.OPEN) { + remaining.push(...queued.slice(i)); + break; + } + try { + ws.send(JSON.stringify(queued[i])); + } catch { + remaining.push(...queued.slice(i)); + break; + } + } + + if (remaining.length > 0) { + pendingBySessionRef.current.set(sessionId, remaining); + } else { + pendingBySessionRef.current.delete(sessionId); + } + }, []); + + const closeSession = useCallback((sessionId: string): void => { + const existing = wsRef.current.get(sessionId); + if (existing) { + existing.onclose = null; + existing.onerror = null; + existing.onmessage = null; + existing.onopen = null; + try { + existing.close(); + } catch { + /* best effort */ + } + wsRef.current.delete(sessionId); + } + wsReadyRef.current.delete(sessionId); + pendingBySessionRef.current.delete(sessionId); + lastMessageIdBySessionRef.current.delete(sessionId); + dedupIdsBySessionRef.current.delete(sessionId); + unpinSession(sessionId); + onSessionClosedRef.current(sessionId); + if (isCurrentSession(sessionId)) { + cancelReconnect(); + setConnectedRef.current(false); + } + }, [cancelReconnect, isCurrentSession, onSessionClosedRef, setConnectedRef]); + + const connectSession = useCallback((targetSessionId?: string): void => { + const sessionId = targetSessionId || currentSessionRef.current; + currentSessionRef.current = sessionId; + const token = tokenRef.current; + if (!sessionId || !token) return; + + const existing = wsRef.current.get(sessionId); + if (existing) { + if (existing.readyState === WebSocket.OPEN || existing.readyState === WebSocket.CONNECTING) { + return; + } + try { + existing.close(); + } catch { + /* ignore stale sockets */ + } + wsRef.current.delete(sessionId); + wsReadyRef.current.delete(sessionId); + } + + pinSession(sessionId); + cancelReconnect(); + + const ws = new WebSocket(buildChatWebSocketUrl(sessionId, token)); + wsRef.current.set(sessionId, ws); + + ws.onopen = () => { + if (wsRef.current.get(sessionId) !== ws) return; + wsReadyRef.current.set(sessionId, true); + reconnectAttemptsRef.current = 0; + if (isCurrentSession(sessionId)) { + setConnectedRef.current(true); + setNoticeRef.current('WebSocket connected'); + } + + flushPendingMessages(sessionId, ws); + + const lastMessageId = lastMessageIdBySessionRef.current.get(sessionId); + if (lastMessageId && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ + event: 'sync_request', + lastMessageId, + })); + } + }; + + ws.onclose = () => { + wsReadyRef.current.delete(sessionId); + if (wsRef.current.get(sessionId) === ws) { + wsRef.current.delete(sessionId); + } + onSessionClosedRef.current(sessionId); + + if (isCurrentSession(sessionId)) { + setConnectedRef.current(false); + const attempt = reconnectAttemptsRef.current; + if (attempt === 0) { + addToastRef.current({ + type: 'warning', + title: 'WebSocket 连接断开', + message: '正在尝试重新连接...', + duration: 5000, + }); + } else if (attempt >= 3) { + addToastRef.current({ + type: 'error', + title: '连接不稳定', + message: `已尝试重连 ${attempt + 1} 次,请检查网络`, + duration: 0, + }); + } + + if (tokenRef.current) { + if (reconnectAttemptsRef.current >= 10) { + setNoticeRef.current('连接已断开,请刷新页面或重新登录'); + addToastRef.current({ + type: 'error', + title: '连接彻底断开', + message: '请刷新页面或重新登录', + duration: 0, + }); + return; + } + const delay = computeReconnectDelay(reconnectAttemptsRef.current); + reconnectAttemptsRef.current += 1; + cancelReconnect(); + reconnectTimerRef.current = setTimeout(() => { + if (!tokenRef.current || currentSessionRef.current !== sessionId) return; + connectSession(sessionId); + }, delay); + } + } + }; + + ws.onerror = () => { + wsReadyRef.current.delete(sessionId); + if (isCurrentSession(sessionId)) { + setConnectedRef.current(false); + } + if (ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) { + try { + ws.close(); + } catch { + /* best effort */ + } + } + }; + + ws.onmessage = (event: MessageEvent) => { + const raw = parseSocketMessage(event.data); + if (!raw) return; + const evt = raw.event as string | undefined; + const chunkSessionId = (raw.sessionId || sessionId) as string; + + if (evt === 'ping') { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ event: 'pong', ts: Date.now() })); + } + return; + } + + const msgId = (raw.id || raw.messageId || '') as string; + const seenIds = dedupIdsBySessionRef.current.get(chunkSessionId) ?? new Set(); + if (raw._replay && msgId && seenIds.has(msgId)) { + return; + } + if (msgId) { + dedupIdsBySessionRef.current.set( + chunkSessionId, + registerReplayMessageId(seenIds, msgId), + ); + } + + if ((evt === 'message' || evt === 'message_chunk') && msgId) { + lastMessageIdBySessionRef.current.set(chunkSessionId, msgId); + } + + onMessageRef.current(raw, evt, chunkSessionId, ws); + }; + }, [ + cancelReconnect, + currentSessionRef, + flushPendingMessages, + isCurrentSession, + onMessageRef, + onSessionClosedRef, + setConnectedRef, + setNoticeRef, + addToastRef, + tokenRef, + ]); + + const sendOrQueue = useCallback((sessionId: string, message: PendingMessage): SendQueueResult => { + const ws = wsRef.current.get(sessionId); + if (ws?.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(message)); + return 'sent'; + } + + queuePendingMessage(sessionId, message); + connectSession(sessionId); + setNoticeRef.current('Message queued for retry'); + addToastRef.current({ + type: 'warning', + title: '消息已排队', + message: 'WebSocket 未连接,正在尝试重连后发送...', + duration: 5000, + }); + return 'queued'; + }, [addToastRef, connectSession, queuePendingMessage, setNoticeRef]); + + const disconnectAll = useCallback((): void => { + cancelReconnect(); + const sessionIds = Array.from(wsRef.current.keys()); + sessionIds.forEach((sessionId) => closeSession(sessionId)); + wsRef.current.clear(); + wsReadyRef.current.clear(); + pendingBySessionRef.current.clear(); + lastMessageIdBySessionRef.current.clear(); + dedupIdsBySessionRef.current.clear(); + reconnectAttemptsRef.current = 0; + }, [cancelReconnect, closeSession]); + + useEffect(() => disconnectAll, [disconnectAll]); + + return { + wsRef, + connectSession, + closeSession, + disconnectAll, + cancelReconnect, + sendOrQueue, + }; +} diff --git a/frontend/lib/dagStore.ts b/frontend/lib/dagStore.ts index adcdf2c..76c05e5 100644 --- a/frontend/lib/dagStore.ts +++ b/frontend/lib/dagStore.ts @@ -125,10 +125,10 @@ class DagStore { this.notify(sessionId); } - syncFromMessages(sessionId: string, messages: Message[]): void { - if (this.sessions.has(sessionId)) return; + syncFromMessages(sessionId: string, messages: Message[], force = false): void { + if (this.sessions.has(sessionId) && !force) return; const derived = deriveDagStateFromMessages(messages); - if (derived.total === 0 && derived.nodes.length === 0) return; + if (!force && derived.total === 0 && derived.nodes.length === 0) return; this.sessions.set(sessionId, derived); this.notify(sessionId); } @@ -159,12 +159,16 @@ export function setDagState(sessionId: string, dag: DagState): void { dagStore.setState(sessionId, dag); } +export function getDagState(sessionId: string): DagState { + return dagStore.getState(sessionId); +} + export function updateDagState(sessionId: string, update: DagTaskUpdateEvent): void { dagStore.updateTask(sessionId, update); } -export function syncDagFromMessages(sessionId: string, messages: Message[]): void { - dagStore.syncFromMessages(sessionId, messages); +export function syncDagFromMessages(sessionId: string, messages: Message[], force = false): void { + dagStore.syncFromMessages(sessionId, messages, force); } export function clearDagSession(sessionId: string): void { diff --git a/frontend/lib/messageRecovery.ts b/frontend/lib/messageRecovery.ts index ffa08dc..dadf1ba 100644 --- a/frontend/lib/messageRecovery.ts +++ b/frontend/lib/messageRecovery.ts @@ -15,6 +15,22 @@ function getMessageIdentity(message: Message): string { return message.id || message.messageId || ''; } +function dedupeMessagesByIdentity(messages: Message[]): Message[] { + const seen = new Map(); + const anonymous: Message[] = []; + + for (const message of messages) { + const identity = getMessageIdentity(message); + if (!identity) { + anonymous.push(message); + continue; + } + seen.set(identity, message); + } + + return [...anonymous, ...seen.values()]; +} + export function registerReplayMessageId(seenIds: Set, messageId: string, cap = DEFAULT_REPLAY_CAP): Set { if (!messageId) return seenIds; if (seenIds.has(messageId)) return seenIds; @@ -31,11 +47,12 @@ export function mergeReloadedMessages(prev: Message[], incoming: Message[]): Mes const identity = getMessageIdentity(message); return !identity || !existingIds.has(identity); }); - if (newMessages.length === 0) { + const clean = prev.filter((m) => !isStreamingThinkingMessage(m)); + const merged = dedupeMessagesByIdentity([...clean, ...newMessages]); + if (newMessages.length === 0 && clean.length === prev.length && merged.length === prev.length) { return prev; } - const clean = prev.filter((m) => !isStreamingThinkingMessage(m)); - return [...clean, ...newMessages].sort((a, b) => a.timestamp.localeCompare(b.timestamp)); + return merged.sort((a, b) => a.timestamp.localeCompare(b.timestamp)); } export function mergeFinalMessage(prev: Message[], incoming: Message): Message[] { @@ -64,5 +81,5 @@ export function mergeFinalMessage(prev: Message[], incoming: Message): Message[] return clean; } - return [...clean, { ...incoming, messageId: undefined, isStreaming: false }]; + return dedupeMessagesByIdentity([...clean, { ...incoming, messageId: undefined, isStreaming: false }]); } From 08bd5acd45997957f07c8725c843a03dbe4afbaa Mon Sep 17 00:00:00 2001 From: Nat-xu <19707027371@163.com> Date: Sun, 19 Jul 2026 16:26:30 +0800 Subject: [PATCH 22/22] harden dag and task preview replay UI --- .../components/chat/dagReplay.test.tsx | 126 ++++++++++++++++++ .../chat/taskPreviewReplay.test.tsx | 95 +++++++++++++ frontend/lib/websocketSharedEvents.ts | 22 ++- frontend/vitest.config.ts | 3 + 4 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 frontend/__tests__/components/chat/dagReplay.test.tsx create mode 100644 frontend/__tests__/components/chat/taskPreviewReplay.test.tsx diff --git a/frontend/__tests__/components/chat/dagReplay.test.tsx b/frontend/__tests__/components/chat/dagReplay.test.tsx new file mode 100644 index 0000000..ed7a7e7 --- /dev/null +++ b/frontend/__tests__/components/chat/dagReplay.test.tsx @@ -0,0 +1,126 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, render, screen } from '@testing-library/react'; +import DagModal from '../../../components/chat/DagModal'; +import { clearDagSession, useDagState } from '../../../lib/dagStore'; +import { handleSharedWebSocketEvent } from '../../../lib/websocketSharedEvents'; +import type { ChatSession } from '../../../types'; + +function buildDeps() { + return { + wsRef: { current: new Map() }, + setWorkspaceVersion: vi.fn(), + setPreviewTabs: vi.fn(), + setNotice: vi.fn(), + setPmState: vi.fn(), + setDegradationStatus: vi.fn(), + setSessions: vi.fn(), + setIsAutoNaming: vi.fn(), + setExecPermission: vi.fn(), + sortSessions: (items: ChatSession[]) => items, + }; +} + +function DagFeed({ sessionId }: { sessionId: string }) { + const dag = useDagState(sessionId); + return ; +} + +describe('dag replay UI', () => { + beforeEach(() => { + clearDagSession('dag-session-a'); + clearDagSession('dag-session-b'); + }); + + it('keeps repeated task_update events from duplicating nodes', async () => { + const deps = buildDeps(); + await act(async () => { + handleSharedWebSocketEvent({ + event: 'task_preview', + sessionId: 'dag-session-a', + messageId: 'preview-1', + timestamp: '2026-07-19T00:00:00.000Z', + tasks: [ + { id: 'node-1', description: 'Plan', agent: 'Architect', dependencies: [] }, + { id: 'node-2', description: 'Build', agent: 'CodeGen', dependencies: ['node-1'] }, + ], + }, 'task_preview', 'dag-session-a', deps); + handleSharedWebSocketEvent({ + event: 'task_update', + nodeId: 'node-1', + status: 'RUNNING', + }, 'task_update', 'dag-session-a', deps); + handleSharedWebSocketEvent({ + event: 'task_update', + nodeId: 'node-1', + status: 'RUNNING', + }, 'task_update', 'dag-session-a', deps); + }); + + render(); + + expect(screen.getAllByText('Plan')).toHaveLength(1); + expect(screen.getByText('运行中')).toBeInTheDocument(); + }); + + it('preserves dag progress after remounting the same session', async () => { + const deps = buildDeps(); + await act(async () => { + handleSharedWebSocketEvent({ + event: 'task_preview', + sessionId: 'dag-session-b', + messageId: 'preview-2', + timestamp: '2026-07-19T00:00:00.000Z', + tasks: [ + { id: 'node-1', description: 'Plan', agent: 'Architect', dependencies: [] }, + { id: 'node-2', description: 'Build', agent: 'CodeGen', dependencies: ['node-1'] }, + ], + }, 'task_preview', 'dag-session-b', deps); + handleSharedWebSocketEvent({ + event: 'task_update', + nodeId: 'node-1', + status: 'SUCCESS', + }, 'task_update', 'dag-session-b', deps); + }); + + const first = render(); + expect(screen.getByText('成功')).toBeInTheDocument(); + expect(screen.getByText('1/2 节点完成')).toBeInTheDocument(); + first.unmount(); + + render(); + expect(screen.getByText('成功')).toBeInTheDocument(); + expect(screen.getByText('1/2 节点完成')).toBeInTheDocument(); + }); + + it('does not leak dag state across sessions', async () => { + const deps = buildDeps(); + await act(async () => { + handleSharedWebSocketEvent({ + event: 'task_preview', + sessionId: 'dag-session-a', + messageId: 'preview-a', + timestamp: '2026-07-19T00:00:00.000Z', + tasks: [ + { id: 'node-a', description: 'Session A', agent: 'Architect', dependencies: [] }, + ], + }, 'task_preview', 'dag-session-a', deps); + handleSharedWebSocketEvent({ + event: 'task_preview', + sessionId: 'dag-session-b', + messageId: 'preview-b', + timestamp: '2026-07-19T00:00:01.000Z', + tasks: [ + { id: 'node-b', description: 'Session B', agent: 'CodeGen', dependencies: [] }, + ], + }, 'task_preview', 'dag-session-b', deps); + }); + + const { rerender } = render(); + expect(screen.getByText('Session A')).toBeInTheDocument(); + expect(screen.queryByText('Session B')).not.toBeInTheDocument(); + + rerender(); + expect(screen.getByText('Session B')).toBeInTheDocument(); + expect(screen.queryByText('Session A')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/__tests__/components/chat/taskPreviewReplay.test.tsx b/frontend/__tests__/components/chat/taskPreviewReplay.test.tsx new file mode 100644 index 0000000..ed2e2d0 --- /dev/null +++ b/frontend/__tests__/components/chat/taskPreviewReplay.test.tsx @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, render, screen } from '@testing-library/react'; +import { useSessionMessages, clearSession } from '../../../lib/sessionStore'; +import { handleSharedWebSocketEvent } from '../../../lib/websocketSharedEvents'; +import TaskPreviewCard from '../../../components/chat/TaskPreviewCard'; +import type { ChatSession } from '../../../types'; + +function buildDeps() { + return { + wsRef: { current: new Map() }, + setWorkspaceVersion: vi.fn(), + setPreviewTabs: vi.fn(), + setNotice: vi.fn(), + setPmState: vi.fn(), + setDegradationStatus: vi.fn(), + setSessions: vi.fn(), + setIsAutoNaming: vi.fn(), + setExecPermission: vi.fn(), + sortSessions: (items: ChatSession[]) => items, + }; +} + +function TaskPreviewFeed({ sessionId }: { sessionId: string }) { + const messages = useSessionMessages(sessionId); + const preview = messages.find((message) => message.type === 'task_preview' && message.taskPreviewData); + if (!preview?.taskPreviewData) return null; + return ( + + ); +} + +describe('task preview replay UI', () => { + beforeEach(() => { + clearSession('session-a'); + clearSession('session-b'); + }); + + it('deduplicates repeated task_preview events in the rendered preview UI', async () => { + const deps = buildDeps(); + const payload = { + event: 'task_preview', + sessionId: 'session-a', + messageId: 'preview-1', + timestamp: '2026-07-19T00:00:00.000Z', + tasks: [ + { id: 'node-1', description: 'Inspect repo', agent: 'Architect', dependencies: [] }, + ], + }; + + await act(async () => { + handleSharedWebSocketEvent(payload, 'task_preview', 'session-a', deps); + handleSharedWebSocketEvent(payload, 'task_preview', 'session-a', deps); + }); + + render(); + + expect(screen.getAllByText('Inspect repo')).toHaveLength(1); + expect(screen.getByText('共 1 个子任务')).toBeInTheDocument(); + }); + + it('keeps task preview state isolated when switching sessions', async () => { + const deps = buildDeps(); + await act(async () => { + handleSharedWebSocketEvent({ + event: 'task_preview', + sessionId: 'session-a', + messageId: 'preview-a', + timestamp: '2026-07-19T00:00:00.000Z', + tasks: [ + { id: 'node-a', description: 'Plan A', agent: 'Architect', dependencies: [] }, + ], + }, 'task_preview', 'session-a', deps); + handleSharedWebSocketEvent({ + event: 'task_preview', + sessionId: 'session-b', + messageId: 'preview-b', + timestamp: '2026-07-19T00:00:01.000Z', + tasks: [ + { id: 'node-b', description: 'Plan B', agent: 'CodeGen', dependencies: [] }, + ], + }, 'task_preview', 'session-b', deps); + }); + + const { rerender } = render(); + expect(screen.getByText('Plan A')).toBeInTheDocument(); + expect(screen.queryByText('Plan B')).not.toBeInTheDocument(); + + rerender(); + expect(screen.getByText('Plan B')).toBeInTheDocument(); + expect(screen.queryByText('Plan A')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/lib/websocketSharedEvents.ts b/frontend/lib/websocketSharedEvents.ts index 87914a3..6c3b66f 100644 --- a/frontend/lib/websocketSharedEvents.ts +++ b/frontend/lib/websocketSharedEvents.ts @@ -56,6 +56,26 @@ function appendMessage(sessionId: string, message: Message): void { updateSessionMessages(sessionId, (prev) => [...prev, message]); } +function upsertMessage(sessionId: string, message: Message): void { + const identity = message.messageId || message.id; + if (!identity) { + appendMessage(sessionId, message); + return; + } + updateSessionMessages(sessionId, (prev) => { + const existingIdx = prev.findIndex((item) => (item.messageId || item.id) === identity); + if (existingIdx < 0) { + return [...prev, message]; + } + const updated = [...prev]; + updated[existingIdx] = { + ...updated[existingIdx], + ...message, + }; + return updated; + }); +} + export function handleSharedWebSocketEvent( raw: Record, evt: string | undefined, @@ -268,7 +288,7 @@ export function handleSharedWebSocketEvent( if (evt === 'task_preview') { const payload = raw as unknown as TaskPreviewEvent; setDagState(chunkSessionId, buildDagStateFromTaskPreview(payload)); - appendMessage(chunkSessionId, buildTaskPreviewMessage(chunkSessionId, payload)); + upsertMessage(chunkSessionId, buildTaskPreviewMessage(chunkSessionId, payload)); return true; } diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index c50cb5f..33e3957 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -1,6 +1,9 @@ import { defineConfig } from 'vitest/config'; import react from '@vitejs/plugin-react'; import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); export default defineConfig({ plugins: [react()],