فهرست منبع

Merge branch 'dev_tao'

Talegorithm 4 روز پیش
والد
کامیت
ddc838118a
36فایلهای تغییر یافته به همراه1227 افزوده شده و 325 حذف شده
  1. 2 1
      .claude/settings.local.json
  2. 0 8
      .env.template
  3. 546 0
      agent/core/prompts.py
  4. 42 42
      agent/core/runner.py
  5. 1 1
      agent/docs/trace-api.md
  6. 2 2
      agent/tools/builtin/knowledge.py
  7. 23 35
      agent/tools/builtin/subagent.py
  8. 9 48
      agent/trace/compaction.py
  9. 16 1
      agent/trace/store.py
  10. 49 3
      agent/trace/websocket.py
  11. 1 1
      examples/analyze_story/run.py
  12. 1 1
      examples/deep_research/run.py
  13. 1 1
      examples/feature_extract/run.py
  14. 1 1
      examples/how/run.py
  15. 16 2
      examples/restore/run.py
  16. 1 1
      examples/restore_old/run.py
  17. 1 1
      examples/tool_research/run.py
  18. 3 3
      frontend/API.md
  19. 6 6
      frontend/htmlTemplate/templateData.py
  20. 2 2
      frontend/htmlTemplate/需求.md
  21. 3 3
      frontend/react-template/package.json
  22. 4 4
      frontend/react-template/react.md
  23. 6 2
      frontend/react-template/src/api/client.ts
  24. 68 19
      frontend/react-template/src/components/DetailPanel/DetailPanel.tsx
  25. 27 0
      frontend/react-template/src/components/ErrorFallback/ErrorFallback.tsx
  26. 63 17
      frontend/react-template/src/components/FlowChart/FlowChart.tsx
  27. 94 78
      frontend/react-template/src/components/FlowChart/hooks/useFlowChartData.ts
  28. 49 0
      frontend/react-template/src/components/ImagePreview/ImagePreviewModal.tsx
  29. 1 1
      frontend/react-template/src/components/Terminal/Terminal.tsx
  30. 30 10
      frontend/react-template/src/components/TopBar/TopBar.tsx
  31. 24 1
      frontend/react-template/src/hooks/useWebSocket.ts
  32. 1 26
      frontend/react-template/src/main.tsx
  33. 15 1
      frontend/react-template/src/types/message.ts
  34. 116 0
      frontend/react-template/src/utils/imageExtraction.ts
  35. 1 1
      frontend/react-template/vite.config.ts
  36. 2 2
      frontend/test_api.py

+ 2 - 1
.claude/settings.local.json

@@ -13,7 +13,8 @@
       "Bash(curl:*)",
       "Bash(curl:*)",
       "Bash(tree:*)",
       "Bash(tree:*)",
       "Bash(xargs grep:*)",
       "Bash(xargs grep:*)",
-      "Bash(npm run:*)"
+      "Bash(npm run:*)",
+      "Bash(sed:*)"
     ],
     ],
     "deny": [],
     "deny": [],
     "ask": []
     "ask": []

+ 0 - 8
.env.template

@@ -1,8 +0,0 @@
-# 完成配置后,将 .env.template 重命名为 .env
-
-
-# OpenRouter API Key,用于sonnet-4.6模型
-OPEN_ROUTER_API_KEY=
-
-# BrowserUse API Key
-BROWSER_USE_API_KEY=

+ 546 - 0
agent/core/prompts.py

@@ -0,0 +1,546 @@
+"""
+Agent 系统 Prompt 集中管理
+
+本文件集中管理 Agent 系统中使用的所有 prompt 模板,
+包括 runner.py、compaction.py、subagent.py、knowledge.py 等文件中的 prompt。
+
+## 📑 目录索引
+
+### 1. 系统级 Prompt (行 50+)
+- DEFAULT_SYSTEM_PREFIX                      # Agent 基础系统提示
+
+### 2. 研究流程 Prompt (行 60+)
+- RESEARCH_STAGE_PROMPT_TEMPLATE             # 调研阶段引导
+- PLANNING_STAGE_PROMPT                      # 计划阶段引导
+- RESEARCH_DECISION_GUIDE_TEMPLATE           # 调研决策阶段引导
+
+### 3. 压缩相关 Prompt (行 110+)
+- COMPRESSION_EVAL_PROMPT_TEMPLATE           # Level 2 压缩与经验评估
+- REFLECT_PROMPT                             # 经验反思提取
+- SUMMARY_HEADER_TEMPLATE                    # 压缩后的摘要头部
+
+### 4. 工具执行 Prompt (行 180+)
+- TRUNCATION_HINT                            # 响应截断提示
+- TOOL_INTERRUPTED_MESSAGE                   # 工具执行中断提示
+- AGENT_INTERRUPTED_SUMMARY                  # Agent 中断摘要
+- AGENT_CONTINUE_HINT_TEMPLATE               # Agent 继续执行提示
+
+### 5. 任务生成 Prompt (行 200+)
+- TASK_NAME_GENERATION_SYSTEM_PROMPT         # 任务名称生成
+- TASK_NAME_FALLBACK                         # 默认任务名称
+
+### 6. 经验管理 Prompt (行 210+)
+- EXPERIENCE_ENTRY_TEMPLATE                  # 经验条目格式
+- EXPERIENCE_SUMMARY_WITH_RESULTS            # 有结果的经验摘要
+- EXPERIENCE_SUMMARY_NO_RESULTS              # 无结果的经验摘要
+- EXPERIENCE_PARSE_WARNING                   # 经验解析警告
+
+### 7. 辅助函数 - 基础 Prompt 构建 (行 240+)
+- build_research_stage_prompt()              # 构建调研阶段 prompt
+- build_research_decision_guide()            # 构建调研决策引导
+- build_compression_eval_prompt()            # 构建压缩评估 prompt
+- build_summary_header()                     # 构建摘要头部
+- build_tool_interrupted_message()           # 构建工具中断消息
+- build_agent_continue_hint()                # 构建 Agent 继续提示
+- build_experience_entry()                   # 构建经验条目
+
+### 8. 子 Agent 相关 Prompt (行 320+)
+- EVALUATE_PROMPT_TEMPLATE                   # 评估任务 prompt
+- DELEGATE_RESULT_HEADER                     # 委托任务结果头部
+- DELEGATE_SAVED_KNOWLEDGE_HEADER            # 保存知识头部
+- DELEGATE_STATS_HEADER                      # 执行统计头部
+- EXPLORE_RESULT_HEADER                      # 探索结果头部
+- EXPLORE_BRANCH_TEMPLATE                    # 探索分支模板
+- EXPLORE_STATUS_SUCCESS                     # 成功状态
+- EXPLORE_STATUS_FAILED                      # 失败状态
+- EXPLORE_STATUS_ERROR                       # 错误状态
+- EXPLORE_SUMMARY_HEADER                     # 探索总结头部
+
+### 9. 辅助函数 - 子 Agent Prompt 构建 (行 380+)
+- build_evaluate_prompt()                    # 构建评估 prompt
+
+### 10. 知识管理相关 Prompt (行 400+)
+- KNOWLEDGE_SEMANTIC_ROUTE_PROMPT_TEMPLATE   # 知识语义路由
+- KNOWLEDGE_EVOLVE_PROMPT_TEMPLATE           # 知识进化重写
+- KNOWLEDGE_SLIM_PROMPT_TEMPLATE             # 知识库瘦身
+
+### 11. 辅助函数 - 知识管理 Prompt 构建 (行 450+)
+- build_knowledge_semantic_route_prompt()    # 构建知识路由 prompt
+- build_knowledge_evolve_prompt()            # 构建知识进化 prompt
+- build_knowledge_slim_prompt()              # 构建知识瘦身 prompt
+
+## 🔍 快速查找
+
+**按使用场景查找:**
+- 研究流程:第 2 节
+- 对话压缩:第 3 节
+- 工具调用:第 4 节
+- 经验管理:第 6 节
+- 子 Agent:第 8 节
+- 知识管理:第 10 节
+
+**按文件来源查找:**
+- runner.py → 第 1, 2, 4, 5 节
+- compaction.py → 第 3 节
+- subagent.py → 第 8 节
+- knowledge.py → 第 10 节
+
+## ⚠️ 重要提示
+
+1. **变量占位符**:所有 `{变量名}` 格式的占位符必须保留
+2. **特殊标记**:`[[EVALUATION]]`、`[[SUMMARY]]` 等标记不可删除
+3. **输出格式关键字**:代码解析依赖的关键字需要保持一致
+4. **使用辅助函数**:优先使用 `build_*()` 函数而非直接 `.format()`
+"""
+
+# ============================================================
+# 系统级 Prompt
+# ============================================================
+
+DEFAULT_SYSTEM_PREFIX = "你是最顶尖的AI助手,可以拆分并调用工具逐步解决复杂问题。"
+
+
+# ============================================================
+# 研究流程 Prompt - 调研阶段
+# ============================================================
+
+RESEARCH_STAGE_PROMPT_TEMPLATE = """## 📚 研究流程 - 执行调研
+
+现有信息不足,需要进行调研。
+
+{research_skill_content}
+
+**重要提示**:
+- 调研完成后,请使用 `save_knowledge` 工具保存调研结果
+- 系统会自动检测到 save_knowledge 调用,并进入下一阶段(计划)
+"""
+
+
+# ============================================================
+# 研究流程 Prompt - 计划阶段
+# ============================================================
+
+PLANNING_STAGE_PROMPT = """## 📋 研究流程 - 制定计划
+
+调研已完成(或无需调研),现在请制定执行计划。
+
+**请立即执行以下操作**:
+1. 使用 `goal` 工具创建目标树
+2. 将任务分解为可执行的子目标
+3. 为每个子目标设置合理的优先级
+
+注意:这是强制步骤,必须创建 goal tree 才能进入执行阶段。
+"""
+
+
+# ============================================================
+# 研究流程 Prompt - 调研决策阶段
+# ============================================================
+
+RESEARCH_DECISION_GUIDE_TEMPLATE = """---
+
+## 🤔 调研决策
+
+{experience_summary}
+
+### 决策指南
+
+**当前状态**:系统已自动检索知识库和经验库,相关内容已注入到上方的 GoalTree 中(查看 Current Goal 下的「📚 相关知识」部分)。
+
+**请根据已注入的知识和经验,选择下一步行动**:
+
+**选项 1: 知识充足,直接制定计划**
+- 如果上方显示的知识和经验已经足够完成任务
+- 直接使用 `goal` 工具制定执行计划
+
+**选项 2: 知识不足,需要调研** ⭐
+- 如果上方没有显示相关知识,或现有知识不足以完成任务
+- **立即调用 `agent` 工具启动调研子任务**:
+
+```python
+agent(
+    task=\"\"\"针对任务「{task_desc}」进行深入调研:
+
+1. 使用 web_search 工具搜索相关技术文档、教程、最佳实践
+2. 搜索关键词建议:
+   - 核心技术名称 + "教程"
+   - 核心技术名称 + "最佳实践"
+   - 核心技术名称 + "示例代码"
+3. 使用 read_file 工具查看项目中的相关文件
+4. 对每条有价值的信息,使用 save_knowledge 工具保存,标签类型选择:
+   - tool: 工具使用方法
+   - definition: 概念定义
+   - usercase: 使用案例
+   - strategy: 策略经验
+
+调研完成后,系统会自动进入计划阶段。
+\"\"\",
+    skills=["research"]  # 注入调研指南
+)
+```
+
+**重要提示**:
+- 如果 GoalTree 中没有显示「📚 相关知识」,说明知识库为空,必须先调研
+- 调研应该简洁高效,最多设立两个 goal
+"""
+
+
+# ============================================================
+# 压缩相关 Prompt - Level 2 压缩与经验评估
+# ============================================================
+
+COMPRESSION_EVAL_PROMPT_TEMPLATE = """请对以上对话历史进行压缩总结,并评价所引用的历史知识/经验。
+### 任务 1:评价已用知识
+本次任务参考了以下知识内容:{ex_reference_list}
+
+请对比"知识建议"与"实际执行轨迹",给出三色打分:
+[[EVALUATION]]
+ID: knowledge-xxx 或 research-xxx | Result: helpful/harmful/mixed | Reason: [优点]... [局限/修正]...
+
+### 任务 2:对话历史摘要
+要求:
+1. 保留关键决策、结论和产出(如创建的文件、修改的代码、得出的分析结论)
+2. 保留重要的上下文(如用户的要求、约束条件、之前的讨论结果)
+3. 省略中间探索过程、重复的工具调用细节
+4. 使用结构化格式(标题 + 要点 + 相关资源引用,若有)
+5. 控制在 2000 字以内
+格式要求:
+[[SUMMARY]]
+(此处填写结构化的摘要内容)
+
+当前 GoalTree 状态:
+{goal_tree_prompt}
+"""
+
+
+# ============================================================
+# 压缩相关 Prompt - 经验反思
+# ============================================================
+
+REFLECT_PROMPT = """请回顾以上整个执行过程,提取有价值的经验教训。
+你必须将经验与当前的任务意图(Intent)和环境状态(State)挂钩,以便未来精准检索。
+关注以下方面:
+1. 人工干预:用户中途的指令是否说明了原来的执行过程哪里有问题
+2. 弯路:哪些尝试是不必要的,有没有更直接的方法
+3. 好的决策:哪些判断和选择是正确的,值得记住
+4. 工具使用:哪些工具用法是高效的,哪些可以改进
+
+输出格式(严格遵守):
+- 在每条经验前加一个[]中添加自定义的标签,标签要求总结实际的内容为若干词语,包括:
+    - intent: 当前的goal
+    - state: 环境状态(如果与工具相关,可以在标签中加入工具的名称)
+- 经验标签可用自然语言描述
+- 每条经验单独成段,格式固定为:- 当 [条件] 时,应该 [动作](原因:[一句话说明])。具体案例:[案例]
+- 条目之间用一个空行分隔
+- 不输出任何标题、分类、编号、分隔线或其他结构
+- 不使用 markdown 加粗、表格、代码块等格式
+- 每条经验自包含,读者无需上下文即可理解
+- 只提取最有价值的 5-10 条,宁少勿滥
+
+示例(仅供参考格式,不要复制内容):
+- [intent:示例生成 state:用户提醒,指定样本] 当用户说"给我示例"时,应该用真实数据而不是编造(原因:编造的示例无法验证质量)。具体案例:training_samples.json 中的示例全是 LLM 自己编造的,用户明确要求"基于我指定的样本"。
+"""
+
+
+# ============================================================
+# 压缩相关 Prompt - 压缩后的摘要头部
+# ============================================================
+
+SUMMARY_HEADER_TEMPLATE = """## 对话历史摘要(自动压缩)
+
+{summary_text}
+
+---
+请基于以上摘要和当前 GoalTree 继续执行任务。"""
+
+
+# ============================================================
+# 工具执行 Prompt - 响应截断提示
+# ============================================================
+
+TRUNCATION_HINT = """你的响应因为 max_tokens 限制被截断,tool call 参数不完整,未执行。请将大内容拆分为多次小的工具调用(例如用 write_file 的 append 模式分批写入)。"""
+
+
+# ============================================================
+# 工具执行 Prompt - 工具执行中断提示
+# ============================================================
+
+TOOL_INTERRUPTED_MESSAGE = """⚠️ 工具 {tool_name} 执行被中断(进程异常退出),未获得执行结果。请根据需要重新调用。"""
+
+AGENT_INTERRUPTED_SUMMARY = "⚠️ 子Agent执行被中断(进程异常退出)"
+
+AGENT_CONTINUE_HINT_TEMPLATE = '使用 continue_from="{sub_trace_id}" 可继续执行,保留已有进度'
+
+
+# ============================================================
+# 任务生成 Prompt
+# ============================================================
+
+TASK_NAME_GENERATION_SYSTEM_PROMPT = "用中文为以下任务生成一个简短标题(10-30字),只输出标题本身:"
+
+TASK_NAME_FALLBACK = "未命名任务"
+
+
+# ============================================================
+# 经验保存 Prompt - 经验条目格式
+# ============================================================
+
+EXPERIENCE_ENTRY_TEMPLATE = """---
+id: {ex_id}
+trace_id: {trace_id}
+tags: {{intent: {intents}, state: {states}}}
+metrics: {{helpful: 1, harmful: 0}}
+created_at: {created_at}
+---
+{content}
+"""
+
+
+# ============================================================
+# 经验检索 Prompt - 经验摘要格式
+# ============================================================
+
+EXPERIENCE_SUMMARY_WITH_RESULTS = "✅ 已自动检索到 {count} 条相关经验(见上方 GoalTree 中的「📚 相关知识」)\n"
+
+EXPERIENCE_SUMMARY_NO_RESULTS = "❌ 未找到相关经验\n"
+
+
+# ============================================================
+# 经验评估 Prompt - 格式解析警告
+# ============================================================
+
+EXPERIENCE_PARSE_WARNING = "未能解析出符合格式的经验条目,请检查 REFLECT_PROMPT。"
+
+
+# ============================================================
+# 辅助函数:构建特定场景的 Prompt
+# ============================================================
+
+def build_research_stage_prompt(research_skill_content: str) -> str:
+    """构建调研阶段的引导 prompt"""
+    return RESEARCH_STAGE_PROMPT_TEMPLATE.format(
+        research_skill_content=research_skill_content
+    )
+
+
+def build_research_decision_guide(
+    experience_results: list,
+    task_desc: str
+) -> str:
+    """构建调研决策阶段的引导消息"""
+    if experience_results:
+        experience_summary = EXPERIENCE_SUMMARY_WITH_RESULTS.format(
+            count=len(experience_results)
+        )
+    else:
+        experience_summary = EXPERIENCE_SUMMARY_NO_RESULTS
+
+    # 截取任务描述前100字符
+    task_desc_short = task_desc[:100] if len(task_desc) > 100 else task_desc
+
+    return RESEARCH_DECISION_GUIDE_TEMPLATE.format(
+        experience_summary=experience_summary,
+        task_desc=task_desc_short
+    )
+
+
+def build_compression_eval_prompt(
+    goal_tree_prompt: str,
+    ex_reference_list: str
+) -> str:
+    """构建 Level 2 压缩 prompt(含经验评估)"""
+    return COMPRESSION_EVAL_PROMPT_TEMPLATE.format(
+        goal_tree_prompt=goal_tree_prompt,
+        ex_reference_list=ex_reference_list
+    )
+
+
+def build_summary_header(summary_text: str) -> str:
+    """构建压缩后的摘要头部"""
+    return SUMMARY_HEADER_TEMPLATE.format(summary_text=summary_text)
+
+
+def build_tool_interrupted_message(tool_name: str) -> str:
+    """构建工具中断消息"""
+    return TOOL_INTERRUPTED_MESSAGE.format(tool_name=tool_name)
+
+
+def build_agent_continue_hint(sub_trace_id: str) -> str:
+    """构建 Agent 继续执行提示"""
+    return AGENT_CONTINUE_HINT_TEMPLATE.format(sub_trace_id=sub_trace_id)
+
+
+def build_experience_entry(
+    ex_id: str,
+    trace_id: str,
+    intents: list,
+    states: list,
+    created_at: str,
+    content: str
+) -> str:
+    """构建经验条目"""
+    return EXPERIENCE_ENTRY_TEMPLATE.format(
+        ex_id=ex_id,
+        trace_id=trace_id,
+        intents=intents,
+        states=states,
+        created_at=created_at,
+        content=content
+    )
+
+# ============================================================
+# 子 Agent 相关 Prompt - 评估任务
+# ============================================================
+
+EVALUATE_PROMPT_TEMPLATE = """# 评估任务
+
+请评估以下任务的执行结果是否满足要求。
+
+## 目标描述
+
+{goal_description}
+
+## 执行结果
+
+{result_text}
+
+## 输出格式
+
+## 评估结论
+[通过/不通过]
+
+## 评估理由
+[详细说明通过或不通过原因]
+
+## 修改建议(如果不通过)
+1. [建议1]
+2. [建议2]
+"""
+
+
+# ============================================================
+# 子 Agent 相关 Prompt - 结果格式化
+# ============================================================
+
+DELEGATE_RESULT_HEADER = "## 委托任务完成\n"
+
+DELEGATE_SAVED_KNOWLEDGE_HEADER = "**保存的知识** ({count} 条):"
+
+DELEGATE_STATS_HEADER = "**执行统计**:"
+
+EXPLORE_RESULT_HEADER = "## 探索结果\n"
+
+EXPLORE_BRANCH_TEMPLATE = "### 方案 {branch_name}: {task}"
+
+EXPLORE_STATUS_SUCCESS = "**状态**: ✓ 完成"
+
+EXPLORE_STATUS_FAILED = "**状态**: ✗ 失败"
+
+EXPLORE_STATUS_ERROR = "**状态**: ✗ 异常"
+
+EXPLORE_SUMMARY_HEADER = "## 总结"
+
+
+# ============================================================
+# 辅助函数:构建子 Agent 相关 Prompt
+# ============================================================
+
+def build_evaluate_prompt(goal_description: str, result_text: str) -> str:
+    """构建评估 prompt"""
+    return EVALUATE_PROMPT_TEMPLATE.format(
+        goal_description=goal_description,
+        result_text=result_text or "(无执行结果)"
+    )
+
+
+# ============================================================
+# 知识管理相关 Prompt - 语义路由
+# ============================================================
+
+KNOWLEDGE_SEMANTIC_ROUTE_PROMPT_TEMPLATE = """你是一个知识检索专家。根据用户的当前任务需求,从下列原子知识元数据中挑选出最相关的最多 {routing_k} 个知识 ID。
+任务需求:"{query_text}"
+
+可选知识列表:
+{routing_data}
+
+请直接输出 ID 列表,用逗号分隔(例如: knowledge-20260302-001, research-20260302-002)。若无相关项请输出 "None"。
+"""
+
+
+# ============================================================
+# 知识管理相关 Prompt - 知识进化重写
+# ============================================================
+
+KNOWLEDGE_EVOLVE_PROMPT_TEMPLATE = """你是一个 AI Agent 知识库管理员。请根据反馈建议,对现有的知识内容进行重写进化。
+
+【原知识内容】:
+{old_content}
+
+【实战反馈建议】:
+{feedback}
+
+【重写要求】:
+1. 融合知识:将反馈中的避坑指南、新参数或修正后的选择逻辑融入原知识,使其更具通用性和准确性。
+2. 保持结构:如果原内容有特定格式(如 Markdown、代码示例等),请保持该格式。
+3. 语言:简洁直接,使用中文。
+4. 禁止:严禁输出任何开场白、解释语或额外的 Markdown 标题,直接返回重写后的正文。
+"""
+
+
+# ============================================================
+# 知识管理相关 Prompt - 知识库瘦身
+# ============================================================
+
+KNOWLEDGE_SLIM_PROMPT_TEMPLATE = """你是一个 AI Agent 知识库管理员。以下是当前知识库的全部条目,请执行瘦身操作:
+
+【任务】:
+1. 识别语义高度相似或重复的知识,将它们合并为一条更精炼、更通用的知识。
+2. 合并时保留 helpful 最高的那条的 ID 和 metrics(metrics 中 helpful/harmful 取各条之和)。
+3. 对于独立的、无重复的知识,保持原样不动。
+4. 保持原有的知识结构和格式。
+
+【当前知识库】:
+{entries_text}
+
+【输出格式要求】:
+严格按以下格式输出每条知识,条目之间用 === 分隔:
+ID: <保留的id>
+TAGS: <yaml格式的tags>
+METRICS: <yaml格式的metrics>
+SCORE: <评分>
+SCENARIO: <场景描述>
+CONTENT: <知识内容>
+===
+"""
+
+
+# ============================================================
+# 辅助函数:构建知识管理相关 Prompt
+# ============================================================
+
+def build_knowledge_semantic_route_prompt(
+    query_text: str,
+    routing_data: str,
+    routing_k: int
+) -> str:
+    """构建知识语义路由 prompt"""
+    return KNOWLEDGE_SEMANTIC_ROUTE_PROMPT_TEMPLATE.format(
+        query_text=query_text,
+        routing_data=routing_data,
+        routing_k=routing_k
+    )
+
+
+def build_knowledge_evolve_prompt(old_content: str, feedback: str) -> str:
+    """构建知识进化重写 prompt"""
+    return KNOWLEDGE_EVOLVE_PROMPT_TEMPLATE.format(
+        old_content=old_content,
+        feedback=feedback
+    )
+
+
+def build_knowledge_slim_prompt(entries_text: str) -> str:
+    """构建知识库瘦身 prompt"""
+    return KNOWLEDGE_SLIM_PROMPT_TEMPLATE.format(
+        entries_text=entries_text
+    )

+ 42 - 42
agent/core/runner.py

@@ -39,6 +39,29 @@ from agent.memory.models import Skill
 from agent.memory.protocols import MemoryStore, StateStore
 from agent.memory.protocols import MemoryStore, StateStore
 from agent.memory.skill_loader import load_skills_from_dir
 from agent.memory.skill_loader import load_skills_from_dir
 from agent.tools import ToolRegistry, get_tool_registry
 from agent.tools import ToolRegistry, get_tool_registry
+from agent.core.prompts import (
+    DEFAULT_SYSTEM_PREFIX,
+    RESEARCH_STAGE_PROMPT_TEMPLATE,
+    PLANNING_STAGE_PROMPT,
+    RESEARCH_DECISION_GUIDE_TEMPLATE,
+    TRUNCATION_HINT,
+    TOOL_INTERRUPTED_MESSAGE,
+    AGENT_INTERRUPTED_SUMMARY,
+    AGENT_CONTINUE_HINT_TEMPLATE,
+    TASK_NAME_GENERATION_SYSTEM_PROMPT,
+    TASK_NAME_FALLBACK,
+    EXPERIENCE_ENTRY_TEMPLATE,
+    EXPERIENCE_SUMMARY_WITH_RESULTS,
+    EXPERIENCE_SUMMARY_NO_RESULTS,
+    EXPERIENCE_PARSE_WARNING,
+    SUMMARY_HEADER_TEMPLATE,
+    build_research_stage_prompt,
+    build_research_decision_guide,
+    build_summary_header,
+    build_tool_interrupted_message,
+    build_agent_continue_hint,
+    build_experience_entry,
+)
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -751,18 +774,7 @@ class AgentRunner:
 """
 """
 
 
         elif stage == "planning":
         elif stage == "planning":
-            return f"""
-## 📋 研究流程 - 制定计划
-
-调研已完成(或无需调研),现在请制定执行计划。
-
-**请立即执行以下操作**:
-1. 使用 `goal` 工具创建目标树
-2. 将任务分解为可执行的子目标
-3. 为每个子目标设置合理的优先级
-
-注意:这是强制步骤,必须创建 goal tree 才能进入执行阶段。
-"""
+            return PLANNING_STAGE_PROMPT
 
 
         # research_decision 阶段的引导消息已移到 _build_research_decision_guide
         # research_decision 阶段的引导消息已移到 _build_research_decision_guide
         return ""
         return ""
@@ -1238,10 +1250,7 @@ agent(
                     "[Runner] 响应被 max_tokens 截断,跳过 %d 个不完整的 tool calls",
                     "[Runner] 响应被 max_tokens 截断,跳过 %d 个不完整的 tool calls",
                     len(tool_calls),
                     len(tool_calls),
                 )
                 )
-                truncation_hint = (
-                    "你的响应因为 max_tokens 限制被截断,tool call 参数不完整,未执行。"
-                    "请将大内容拆分为多次小的工具调用(例如用 write_file 的 append 模式分批写入)。"
-                )
+                truncation_hint = TRUNCATION_HINT
                 history.append({
                 history.append({
                     "role": "assistant",
                     "role": "assistant",
                     "content": response_content,
                     "content": response_content,
@@ -1495,15 +1504,14 @@ agent(
                     states = [s.strip() for s in state_match.group(1).split(",")] if state_match and state_match.group(1) else []
                     states = [s.strip() for s in state_match.group(1).split(",")] if state_match and state_match.group(1) else []
 
 
                     ex_id = f"ex_{datetime.now().strftime('%m%d%H%M')}_{_uuid2.uuid4().hex[:4]}"
                     ex_id = f"ex_{datetime.now().strftime('%m%d%H%M')}_{_uuid2.uuid4().hex[:4]}"
-                    entry = f"""---
-id: {ex_id}
-trace_id: {trace_id}
-tags: {{intent: {intents}, state: {states}}}
-metrics: {{helpful: 1, harmful: 0}}
-created_at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
----
-- {content}
-- 经验ID: [{ex_id}]"""
+                    entry = build_experience_entry(
+                        ex_id=ex_id,
+                        trace_id=trace_id,
+                        intents=intents,
+                        states=states,
+                        created_at=datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
+                        content=f"- {content}\n- 经验ID: [{ex_id}]"
+                    )
                     structured_entries.append(entry)
                     structured_entries.append(entry)
 
 
                 if structured_entries:
                 if structured_entries:
@@ -1556,7 +1564,7 @@ created_at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
 
 
                     logger.info(f"已提取并保存 {saved_count}/{len(structured_entries)} 条结构化经验到知识库")
                     logger.info(f"已提取并保存 {saved_count}/{len(structured_entries)} 条结构化经验到知识库")
                 else:
                 else:
-                    logger.warning("未能解析出符合格式的经验条目,请检查 REFLECT_PROMPT。")
+                    logger.warning(EXPERIENCE_PARSE_WARNING)
                     logger.debug(f"LLM Raw Output:\n{reflection_text}")
                     logger.debug(f"LLM Raw Output:\n{reflection_text}")
             else:
             else:
                 logger.warning("LLM 未生成反思内容")
                 logger.warning("LLM 未生成反思内容")
@@ -1635,10 +1643,7 @@ created_at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
             return history, head_seq, sequence
             return history, head_seq, sequence
 
 
         # --- Step 3: 存储 summary 消息 ---
         # --- Step 3: 存储 summary 消息 ---
-        summary_with_header = (
-            f"## 对话历史摘要(自动压缩)\n\n{summary_text}\n\n"
-            "---\n请基于以上摘要和当前 GoalTree 继续执行任务。"
-        )
+        summary_with_header = build_summary_header(summary_text)
 
 
         summary_msg = Message.create(
         summary_msg = Message.create(
             trace_id=trace_id,
             trace_id=trace_id,
@@ -1830,10 +1835,7 @@ created_at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
                     tc, goal_tree, assistant_msg,
                     tc, goal_tree, assistant_msg,
                 )
                 )
             else:
             else:
-                result_text = (
-                    f"⚠️ 工具 {tool_name} 执行被中断(进程异常退出),"
-                    "未获得执行结果。请根据需要重新调用。"
-                )
+                result_text = build_tool_interrupted_message(tool_name)
 
 
             synthetic_msg = Message.create(
             synthetic_msg = Message.create(
                 trace_id=trace_id,
                 trace_id=trace_id,
@@ -1905,14 +1907,12 @@ created_at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
         result: Dict[str, Any] = {
         result: Dict[str, Any] = {
             "mode": mode,
             "mode": mode,
             "status": "interrupted",
             "status": "interrupted",
-            "summary": "⚠️ 子Agent执行被中断(进程异常退出)",
+            "summary": AGENT_INTERRUPTED_SUMMARY,
             "task": task,
             "task": task,
         }
         }
         if sub_trace_id:
         if sub_trace_id:
             result["sub_trace_id"] = sub_trace_id
             result["sub_trace_id"] = sub_trace_id
-            result["hint"] = (
-                f'使用 continue_from="{sub_trace_id}" 可继续执行,保留已有进度'
-            )
+            result["hint"] = build_agent_continue_hint(sub_trace_id)
         if stats:
         if stats:
             result["stats"] = stats
             result["stats"] = stats
 
 
@@ -2109,7 +2109,7 @@ created_at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
         return self.tools.get_schemas(tool_names)
         return self.tools.get_schemas(tool_names)
 
 
     # 默认 system prompt 前缀(当 config.system_prompt 和前端都未提供 system message 时使用)
     # 默认 system prompt 前缀(当 config.system_prompt 和前端都未提供 system message 时使用)
-    DEFAULT_SYSTEM_PREFIX = "你是最顶尖的AI助手,可以拆分并调用工具逐步解决复杂问题。"
+    # 注意:此常量已迁移到 agent.core.prompts,这里保留引用以保持向后兼容
 
 
     async def _build_system_prompt(self, config: RunConfig, base_prompt: Optional[str] = None) -> Optional[str]:
     async def _build_system_prompt(self, config: RunConfig, base_prompt: Optional[str] = None) -> Optional[str]:
         """构建 system prompt(注入 skills)
         """构建 system prompt(注入 skills)
@@ -2147,7 +2147,7 @@ created_at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
             if skills_text:
             if skills_text:
                 system_prompt += f"\n\n## Skills\n{skills_text}"
                 system_prompt += f"\n\n## Skills\n{skills_text}"
         else:
         else:
-            system_prompt = self.DEFAULT_SYSTEM_PREFIX
+            system_prompt = DEFAULT_SYSTEM_PREFIX
             if skills_text:
             if skills_text:
                 system_prompt += f"\n\n## Skills\n{skills_text}"
                 system_prompt += f"\n\n## Skills\n{skills_text}"
 
 
@@ -2168,14 +2168,14 @@ created_at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
         raw_text = " ".join(text_parts).strip()
         raw_text = " ".join(text_parts).strip()
 
 
         if not raw_text:
         if not raw_text:
-            return "未命名任务"
+            return TASK_NAME_FALLBACK
 
 
         # 尝试使用 utility_llm 生成标题
         # 尝试使用 utility_llm 生成标题
         if self.utility_llm_call:
         if self.utility_llm_call:
             try:
             try:
                 result = await self.utility_llm_call(
                 result = await self.utility_llm_call(
                     messages=[
                     messages=[
-                        {"role": "system", "content": "用中文为以下任务生成一个简短标题(10-30字),只输出标题本身:"},
+                        {"role": "system", "content": TASK_NAME_GENERATION_SYSTEM_PROMPT},
                         {"role": "user", "content": raw_text[:2000]},
                         {"role": "user", "content": raw_text[:2000]},
                     ],
                     ],
                     model="gpt-4o-mini",  # 使用便宜模型
                     model="gpt-4o-mini",  # 使用便宜模型

+ 1 - 1
agent/docs/trace-api.md

@@ -304,7 +304,7 @@ GET /api/experiences
 ### 连接
 ### 连接
 
 
 ```
 ```
-ws://localhost:8000/api/traces/{trace_id}/watch?since_event_id=0
+ws://43.106.118.91:8000/api/traces/{trace_id}/watch?since_event_id=0
 ```
 ```
 
 
 ### 事件类型
 ### 事件类型

+ 2 - 2
agent/tools/builtin/knowledge.py

@@ -295,9 +295,9 @@ async def knowledge_batch_update(
         )
         )
 
 
     except Exception as e:
     except Exception as e:
-        logger.error(f"批量更新知识失败: {e}")
+        logger.error(f"列出知识失败: {e}")
         return ToolResult(
         return ToolResult(
-            title="❌ 批量更新失败",
+            title="❌ 列表失败",
             output=f"错误: {str(e)}",
             output=f"错误: {str(e)}",
             error=str(e)
             error=str(e)
         )
         )

+ 23 - 35
agent/tools/builtin/subagent.py

@@ -14,6 +14,19 @@ from agent.trace.models import Trace, Messages
 from agent.trace.trace_id import generate_sub_trace_id
 from agent.trace.trace_id import generate_sub_trace_id
 from agent.trace.goal_models import GoalTree
 from agent.trace.goal_models import GoalTree
 from agent.trace.websocket import broadcast_sub_trace_started, broadcast_sub_trace_completed
 from agent.trace.websocket import broadcast_sub_trace_started, broadcast_sub_trace_completed
+from agent.core.prompts import (
+    EVALUATE_PROMPT_TEMPLATE,
+    DELEGATE_RESULT_HEADER,
+    DELEGATE_SAVED_KNOWLEDGE_HEADER,
+    DELEGATE_STATS_HEADER,
+    EXPLORE_RESULT_HEADER,
+    EXPLORE_BRANCH_TEMPLATE,
+    EXPLORE_STATUS_SUCCESS,
+    EXPLORE_STATUS_FAILED,
+    EXPLORE_STATUS_ERROR,
+    EXPLORE_SUMMARY_HEADER,
+    build_evaluate_prompt,
+)
 
 
 
 
 def _make_run_config(**kwargs):
 def _make_run_config(**kwargs):
@@ -128,7 +141,7 @@ def _get_allowed_tools(single: bool, context: dict) -> Optional[List[str]]:
 
 
 def _format_single_result(result: Dict[str, Any], sub_trace_id: str, continued: bool) -> Dict[str, Any]:
 def _format_single_result(result: Dict[str, Any], sub_trace_id: str, continued: bool) -> Dict[str, Any]:
     """格式化单任务(delegate)结果"""
     """格式化单任务(delegate)结果"""
-    lines = ["## 委托任务完成\n"]
+    lines = [DELEGATE_RESULT_HEADER]
     summary = result.get("summary", "")
     summary = result.get("summary", "")
     if summary:
     if summary:
         lines.append(summary)
         lines.append(summary)
@@ -138,13 +151,13 @@ def _format_single_result(result: Dict[str, Any], sub_trace_id: str, continued:
     saved_knowledge_ids = result.get("saved_knowledge_ids", [])
     saved_knowledge_ids = result.get("saved_knowledge_ids", [])
     if saved_knowledge_ids:
     if saved_knowledge_ids:
         lines.append("---\n")
         lines.append("---\n")
-        lines.append(f"**保存的知识** ({len(saved_knowledge_ids)} 条):")
+        lines.append(DELEGATE_SAVED_KNOWLEDGE_HEADER.format(count=len(saved_knowledge_ids)))
         for kid in saved_knowledge_ids:
         for kid in saved_knowledge_ids:
             lines.append(f"- {kid}")
             lines.append(f"- {kid}")
         lines.append("")
         lines.append("")
 
 
     lines.append("---\n")
     lines.append("---\n")
-    lines.append("**执行统计**:")
+    lines.append(DELEGATE_STATS_HEADER)
     stats = result.get("stats", {})
     stats = result.get("stats", {})
     if stats:
     if stats:
         lines.append(f"- 消息数: {stats.get('total_messages', 0)}")
         lines.append(f"- 消息数: {stats.get('total_messages', 0)}")
@@ -166,7 +179,7 @@ def _format_multi_result(
     tasks: List[str], results: List[Dict[str, Any]], sub_trace_ids: List[Dict]
     tasks: List[str], results: List[Dict[str, Any]], sub_trace_ids: List[Dict]
 ) -> Dict[str, Any]:
 ) -> Dict[str, Any]:
     """格式化多任务(explore)聚合结果"""
     """格式化多任务(explore)聚合结果"""
-    lines = ["## 探索结果\n"]
+    lines = [EXPLORE_RESULT_HEADER]
     successful = 0
     successful = 0
     failed = 0
     failed = 0
     total_tokens = 0
     total_tokens = 0
@@ -174,15 +187,15 @@ def _format_multi_result(
 
 
     for i, (task_item, result) in enumerate(zip(tasks, results)):
     for i, (task_item, result) in enumerate(zip(tasks, results)):
         branch_name = chr(ord('A') + i)
         branch_name = chr(ord('A') + i)
-        lines.append(f"### 方案 {branch_name}: {task_item}")
+        lines.append(EXPLORE_BRANCH_TEMPLATE.format(branch_name=branch_name, task=task_item))
 
 
         if isinstance(result, dict):
         if isinstance(result, dict):
             status = result.get("status", "unknown")
             status = result.get("status", "unknown")
             if status == "completed":
             if status == "completed":
-                lines.append("**状态**: ✓ 完成")
+                lines.append(EXPLORE_STATUS_SUCCESS)
                 successful += 1
                 successful += 1
             else:
             else:
-                lines.append("**状态**: ✗ 失败")
+                lines.append(EXPLORE_STATUS_FAILED)
                 failed += 1
                 failed += 1
 
 
             summary = result.get("summary", "")
             summary = result.get("summary", "")
@@ -198,13 +211,13 @@ def _format_multi_result(
                 total_tokens += tokens
                 total_tokens += tokens
                 total_cost += cost
                 total_cost += cost
         else:
         else:
-            lines.append("**状态**: ✗ 异常")
+            lines.append(EXPLORE_STATUS_ERROR)
             failed += 1
             failed += 1
 
 
         lines.append("")
         lines.append("")
 
 
     lines.append("---\n")
     lines.append("---\n")
-    lines.append("## 总结")
+    lines.append(EXPLORE_SUMMARY_HEADER)
     lines.append(f"- 总分支数: {len(tasks)}")
     lines.append(f"- 总分支数: {len(tasks)}")
     lines.append(f"- 成功: {successful}")
     lines.append(f"- 成功: {successful}")
     lines.append(f"- 失败: {failed}")
     lines.append(f"- 失败: {failed}")
@@ -259,32 +272,7 @@ def _build_evaluate_prompt(goal_description: str, messages: Optional[Messages])
                         parts.append(item.get("text", ""))
                         parts.append(item.get("text", ""))
         result_text = "\n".join(parts)
         result_text = "\n".join(parts)
 
 
-    lines = [
-        "# 评估任务",
-        "",
-        "请评估以下任务的执行结果是否满足要求。",
-        "",
-        "## 目标描述",
-        "",
-        goal_description,
-        "",
-        "## 执行结果",
-        "",
-        result_text or "(无执行结果)",
-        "",
-        "## 输出格式",
-        "",
-        "## 评估结论",
-        "[通过/不通过]",
-        "",
-        "## 评估理由",
-        "[详细说明通过或不通过原因]",
-        "",
-        "## 修改建议(如果不通过)",
-        "1. [建议1]",
-        "2. [建议2]",
-    ]
-    return "\n".join(lines)
+    return build_evaluate_prompt(goal_description, result_text)
 
 
 
 
 def _make_event_printer(label: str):
 def _make_event_printer(label: str):

+ 9 - 48
agent/trace/compaction.py

@@ -18,6 +18,11 @@ from typing import List, Dict, Any, Optional, Set
 
 
 from .goal_models import GoalTree
 from .goal_models import GoalTree
 from .models import Message
 from .models import Message
+from agent.core.prompts import (
+    COMPRESSION_EVAL_PROMPT_TEMPLATE,
+    REFLECT_PROMPT,
+    build_compression_eval_prompt,
+)
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -297,53 +302,8 @@ def needs_level2_compression(
 
 
 
 
 # ===== Level 2: 压缩 Prompt =====
 # ===== Level 2: 压缩 Prompt =====
-
-COMPRESSION_EVAL_PROMPT = """请对以上对话历史进行压缩总结,并评价所引用的历史知识/经验。
-### 任务 1:评价已用知识
-本次任务参考了以下知识内容:{ex_reference_list}
-
-请对比”知识建议”与”实际执行轨迹”,给出三色打分:
-[[EVALUATION]]
-ID: knowledge-xxx 或 research-xxx | Result: helpful/harmful/mixed | Reason: [优点]... [局限/修正]...
-
-### 任务 2:对话历史摘要
-要求:
-1. 保留关键决策、结论和产出(如创建的文件、修改的代码、得出的分析结论)
-2. 保留重要的上下文(如用户的要求、约束条件、之前的讨论结果)
-3. 省略中间探索过程、重复的工具调用细节
-4. 使用结构化格式(标题 + 要点 + 相关资源引用,若有)
-5. 控制在 2000 字以内
-格式要求:
-[[SUMMARY]]
-(此处填写结构化的摘要内容)
-
-当前 GoalTree 状态:
-{goal_tree_prompt}
-"""
-
-REFLECT_PROMPT = """请回顾以上整个执行过程,提取有价值的经验教训。
-你必须将经验与当前的任务意图(Intent)和环境状态(State)挂钩,以便未来精准检索。
-关注以下方面:
-1. 人工干预:用户中途的指令是否说明了原来的执行过程哪里有问题
-2. 弯路:哪些尝试是不必要的,有没有更直接的方法
-3. 好的决策:哪些判断和选择是正确的,值得记住
-4. 工具使用:哪些工具用法是高效的,哪些可以改进
-
-输出格式(严格遵守):
-- 在每条经验前加一个[]中添加自定义的标签,标签要求总结实际的内容为若干词语,包括:
-    - intent: 当前的goal
-    - state: 环境状态(如果与工具相关,可以在标签中加入工具的名称)
-- 经验标签可用自然语言描述
-- 每条经验单独成段,格式固定为:- 当 [条件] 时,应该 [动作](原因:[一句话说明])。具体案例:[案例]
-- 条目之间用一个空行分隔
-- 不输出任何标题、分类、编号、分隔线或其他结构
-- 不使用 markdown 加粗、表格、代码块等格式
-- 每条经验自包含,读者无需上下文即可理解
-- 只提取最有价值的 5-10 条,宁少勿滥
-
-示例(仅供参考格式,不要复制内容):
-- [intent:示例生成 state:用户提醒,指定样本] 当用户说"给我示例"时,应该用真实数据而不是编造(原因:编造的示例无法验证质量)。具体案例:training_samples.json 中的示例全是 LLM 自己编造的,用户明确要求"基于我指定的样本"。
-"""
+# 注意:这些 prompt 已迁移到 agent.core.prompts
+# COMPRESSION_EVAL_PROMPT 和 REFLECT_PROMPT 现在从 prompts.py 导入
 
 
 
 
 def build_compression_prompt(goal_tree: Optional[GoalTree], used_ex_ids: Optional[List[str]] = None) -> str:
 def build_compression_prompt(goal_tree: Optional[GoalTree], used_ex_ids: Optional[List[str]] = None) -> str:
@@ -356,7 +316,7 @@ def build_compression_prompt(goal_tree: Optional[GoalTree], used_ex_ids: Optiona
     if used_ex_ids:
     if used_ex_ids:
         ex_reference = ", ".join(used_ex_ids)
         ex_reference = ", ".join(used_ex_ids)
 
 
-    return COMPRESSION_EVAL_PROMPT.format(
+    return build_compression_eval_prompt(
         goal_tree_prompt=goal_prompt,
         goal_tree_prompt=goal_prompt,
         ex_reference_list=ex_reference,
         ex_reference_list=ex_reference,
     )
     )
@@ -365,3 +325,4 @@ def build_compression_prompt(goal_tree: Optional[GoalTree], used_ex_ids: Optiona
 def build_reflect_prompt() -> str:
 def build_reflect_prompt() -> str:
     """构建反思 prompt"""
     """构建反思 prompt"""
     return REFLECT_PROMPT
     return REFLECT_PROMPT
+

+ 16 - 1
agent/trace/store.py

@@ -22,6 +22,7 @@ Sub-Trace 是完全独立的 Trace,有自己的目录:
 
 
 import json
 import json
 import os
 import os
+import logging
 from pathlib import Path
 from pathlib import Path
 from typing import Dict, List, Optional, Any
 from typing import Dict, List, Optional, Any
 from datetime import datetime
 from datetime import datetime
@@ -29,6 +30,8 @@ from datetime import datetime
 from .models import Trace, Message
 from .models import Trace, Message
 from .goal_models import GoalTree, Goal, GoalStats
 from .goal_models import GoalTree, Goal, GoalStats
 
 
+logger = logging.getLogger(__name__)
+
 
 
 class FileSystemTraceStore:
 class FileSystemTraceStore:
     """文件系统 Trace 存储"""
     """文件系统 Trace 存储"""
@@ -370,10 +373,22 @@ class FileSystemTraceStore:
 
 
         # 4. 追加 message_added 事件
         # 4. 追加 message_added 事件
         affected_goals = await self._get_affected_goals(trace_id, message)
         affected_goals = await self._get_affected_goals(trace_id, message)
-        await self.append_event(trace_id, "message_added", {
+        event_id = await self.append_event(trace_id, "message_added", {
             "message": message.to_dict(),
             "message": message.to_dict(),
             "affected_goals": affected_goals
             "affected_goals": affected_goals
         })
         })
+        if event_id:
+            try:
+                from . import websocket as trace_ws
+
+                await trace_ws.broadcast_message_added(
+                    trace_id=trace_id,
+                    event_id=event_id,
+                    message_dict=message.to_dict(),
+                    affected_goals=affected_goals,
+                )
+            except Exception:
+                logger.exception("Failed to broadcast message_added (trace_id=%s, event_id=%s)", trace_id, event_id)
 
 
         return message.message_id
         return message.message_id
 
 

+ 49 - 3
agent/trace/websocket.py

@@ -6,6 +6,7 @@ Trace WebSocket 推送
 
 
 from typing import Dict, Set, Any
 from typing import Dict, Set, Any
 from datetime import datetime
 from datetime import datetime
+import asyncio
 from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
 from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
 
 
 from .protocols import TraceStore
 from .protocols import TraceStore
@@ -100,6 +101,7 @@ async def watch_trace(
         })
         })
 
 
         # 补发历史事件(since_event_id=0 表示补发所有历史)
         # 补发历史事件(since_event_id=0 表示补发所有历史)
+        last_sent_event_id = since_event_id
         if since_event_id >= 0:
         if since_event_id >= 0:
             missed_events = await store.get_events(trace_id, since_event_id)
             missed_events = await store.get_events(trace_id, since_event_id)
             # 限制补发数量(最多 100 条)
             # 限制补发数量(最多 100 条)
@@ -111,16 +113,33 @@ async def watch_trace(
             else:
             else:
                 for evt in missed_events:
                 for evt in missed_events:
                     await websocket.send_json(evt)
                     await websocket.send_json(evt)
+                    if isinstance(evt, dict) and isinstance(evt.get("event_id"), int):
+                        last_sent_event_id = max(last_sent_event_id, evt["event_id"])
 
 
-        # 保持连接(等待客户端断开或接收消息)
+        # 保持连接:同时支持心跳 + 轮询 events.jsonl(跨进程写入时也能实时推送
         while True:
         while True:
             try:
             try:
-                # 接收客户端消息(心跳检测)
-                data = await websocket.receive_text()
+                # 允许在没有客户端消息时继续轮询事件流
+                data = await asyncio.wait_for(websocket.receive_text(), timeout=0.5)
                 if data == "ping":
                 if data == "ping":
                     await websocket.send_json({"event": "pong"})
                     await websocket.send_json({"event": "pong"})
             except WebSocketDisconnect:
             except WebSocketDisconnect:
                 break
                 break
+            except asyncio.TimeoutError:
+                pass
+
+            new_events = await store.get_events(trace_id, last_sent_event_id)
+            if len(new_events) > 100:
+                await websocket.send_json({
+                    "event": "error",
+                    "message": f"Too many missed events ({len(new_events)}), please reload via REST API"
+                })
+                continue
+
+            for evt in new_events:
+                await websocket.send_json(evt)
+                if isinstance(evt, dict) and isinstance(evt.get("event_id"), int):
+                    last_sent_event_id = max(last_sent_event_id, evt["event_id"])
 
 
     finally:
     finally:
         # 清理连接
         # 清理连接
@@ -196,6 +215,33 @@ async def broadcast_goal_updated(
     await _broadcast_to_trace(trace_id, message)
     await _broadcast_to_trace(trace_id, message)
 
 
 
 
+async def broadcast_message_added(
+    trace_id: str,
+    event_id: int,
+    message_dict: Dict[str, Any],
+    affected_goals: list[Dict[str, Any]] = None,
+):
+    """
+    广播 Message 添加事件(不在此处写入 events.jsonl)
+
+    说明:
+    - message_added 的 events.jsonl 写入由 TraceStore.append_event 负责
+    - 这里仅负责把“已经持久化”的事件推送给当前活跃连接
+    """
+    if trace_id not in _active_connections:
+        return
+
+    message = {
+        "event": "message_added",
+        "event_id": event_id,
+        "ts": datetime.now().isoformat(),
+        "message": message_dict,
+        "affected_goals": affected_goals or [],
+    }
+
+    await _broadcast_to_trace(trace_id, message)
+
+
 async def broadcast_sub_trace_started(
 async def broadcast_sub_trace_started(
     trace_id: str,
     trace_id: str,
     sub_trace_id: str,
     sub_trace_id: str,

+ 1 - 1
examples/analyze_story/run.py

@@ -562,7 +562,7 @@ async def main():
         print("   python3 api_server.py")
         print("   python3 api_server.py")
         print()
         print()
         print("2. 浏览器访问:")
         print("2. 浏览器访问:")
-        print("   http://localhost:8000/api/traces")
+        print("   http://43.106.118.91:8000/api/traces")
         print()
         print()
         print(f"3. Trace ID: {current_trace_id}")
         print(f"3. Trace ID: {current_trace_id}")
         print("=" * 60)
         print("=" * 60)

+ 1 - 1
examples/deep_research/run.py

@@ -570,7 +570,7 @@ async def main():
         print("   python3 api_server.py")
         print("   python3 api_server.py")
         print()
         print()
         print("2. 浏览器访问:")
         print("2. 浏览器访问:")
-        print("   http://localhost:8000/api/traces")
+        print("   http://43.106.118.91:8000/api/traces")
         print()
         print()
         print(f"3. Trace ID: {current_trace_id}")
         print(f"3. Trace ID: {current_trace_id}")
         print("=" * 60)
         print("=" * 60)

+ 1 - 1
examples/feature_extract/run.py

@@ -174,7 +174,7 @@ async def main():
     print("   python3 api_server.py")
     print("   python3 api_server.py")
     print()
     print()
     print("2. 浏览器访问:")
     print("2. 浏览器访问:")
-    print("   http://localhost:8000/api/traces")
+    print("   http://43.106.118.91:8000/api/traces")
     print()
     print()
     print(f"3. Trace ID: {current_trace_id}")
     print(f"3. Trace ID: {current_trace_id}")
     print("=" * 60)
     print("=" * 60)

+ 1 - 1
examples/how/run.py

@@ -599,7 +599,7 @@ async def main():
         print("   python3 api_server.py")
         print("   python3 api_server.py")
         print()
         print()
         print("2. 浏览器访问:")
         print("2. 浏览器访问:")
-        print("   http://localhost:8000/api/traces")
+        print("   http://43.106.118.91:8000/api/traces")
         print()
         print()
         print(f"3. Trace ID: {current_trace_id}")
         print(f"3. Trace ID: {current_trace_id}")
         print("=" * 60)
         print("=" * 60)

+ 16 - 2
examples/restore/run.py

@@ -19,6 +19,10 @@ import select
 import asyncio
 import asyncio
 from pathlib import Path
 from pathlib import Path
 
 
+# ===== 浏览器模式配置 =====
+# 可选值: "cloud" (云浏览器) 或 "local" (本地浏览器)
+BROWSER_TYPE = "cloud"  # 修改这里来切换浏览器模式
+HEADLESS = False  # 是否无头模式运行
 # Clash Verge TUN 模式兼容:禁止 httpx/urllib 自动检测系统 HTTP 代理
 # Clash Verge TUN 模式兼容:禁止 httpx/urllib 自动检测系统 HTTP 代理
 # TUN 虚拟网卡已在网络层接管所有流量,不需要应用层再走 HTTP 代理,
 # TUN 虚拟网卡已在网络层接管所有流量,不需要应用层再走 HTTP 代理,
 # 否则 httpx 检测到 macOS 系统代理 (127.0.0.1:7897) 会导致 ConnectError
 # 否则 httpx 检测到 macOS 系统代理 (127.0.0.1:7897) 会导致 ConnectError
@@ -33,6 +37,7 @@ load_dotenv()
 from agent.llm.prompts import SimplePrompt
 from agent.llm.prompts import SimplePrompt
 from agent.core.runner import AgentRunner, RunConfig
 from agent.core.runner import AgentRunner, RunConfig
 from agent.core.presets import AgentPreset, register_preset
 from agent.core.presets import AgentPreset, register_preset
+from agent.tools.builtin.browser.baseClass import init_browser_session, kill_browser_session
 from agent.trace import (
 from agent.trace import (
     FileSystemTraceStore,
     FileSystemTraceStore,
     Trace,
     Trace,
@@ -42,6 +47,7 @@ from agent.llm import create_openrouter_llm_call
 from agent.tools import get_tool_registry
 from agent.tools import get_tool_registry
 
 
 
 
+os.environ.setdefault("no_proxy", "*")
 # ===== 非阻塞 stdin 检测 =====
 # ===== 非阻塞 stdin 检测 =====
 if sys.platform == 'win32':
 if sys.platform == 'win32':
     import msvcrt
     import msvcrt
@@ -343,7 +349,15 @@ async def main():
     # 加载自定义工具
     # 加载自定义工具
     print("   - 加载自定义工具: nanobanana")
     print("   - 加载自定义工具: nanobanana")
     import examples.how.tool  # 导入自定义工具模块,触发 @tool 装饰器注册
     import examples.how.tool  # 导入自定义工具模块,触发 @tool 装饰器注册
-
+    # 3. 初始化浏览器会话
+    browser_mode_name = "云浏览器" if BROWSER_TYPE == "cloud" else "本地浏览器"
+    print(f"🌐 正在初始化{browser_mode_name}...")
+    await init_browser_session(
+        browser_type=BROWSER_TYPE,
+        headless=HEADLESS,
+        url="about:blank"
+    )
+    print(f"✅ {browser_mode_name}初始化完成\n")
     store = FileSystemTraceStore(base_path=".trace")
     store = FileSystemTraceStore(base_path=".trace")
     runner = AgentRunner(
     runner = AgentRunner(
         trace_store=store,
         trace_store=store,
@@ -612,7 +626,7 @@ async def main():
         print("   python3 api_server.py")
         print("   python3 api_server.py")
         print()
         print()
         print("2. 浏览器访问:")
         print("2. 浏览器访问:")
-        print("   http://localhost:8000/api/traces")
+        print("   http://43.106.118.91:8000/api/traces")
         print()
         print()
         print(f"3. Trace ID: {current_trace_id}")
         print(f"3. Trace ID: {current_trace_id}")
         print("=" * 60)
         print("=" * 60)

+ 1 - 1
examples/restore_old/run.py

@@ -611,7 +611,7 @@ async def main():
         print("   python3 api_server.py")
         print("   python3 api_server.py")
         print()
         print()
         print("2. 浏览器访问:")
         print("2. 浏览器访问:")
-        print("   http://localhost:8000/api/traces")
+        print("   http://43.106.118.91:8000/api/traces")
         print()
         print()
         print(f"3. Trace ID: {current_trace_id}")
         print(f"3. Trace ID: {current_trace_id}")
         print("=" * 60)
         print("=" * 60)

+ 1 - 1
examples/tool_research/run.py

@@ -488,7 +488,7 @@ async def main():
         print("   python3 api_server.py")
         print("   python3 api_server.py")
         print()
         print()
         print("2. 浏览器访问:")
         print("2. 浏览器访问:")
-        print("   http://localhost:8000/api/traces")
+        print("   http://43.106.118.91:8000/api/traces")
         print()
         print()
         print(f"3. Trace ID: {current_trace_id}")
         print(f"3. Trace ID: {current_trace_id}")
         print("=" * 60)
         print("=" * 60)

+ 3 - 3
frontend/API.md

@@ -55,7 +55,7 @@
 
 
 ### 基础信息
 ### 基础信息
 
 
-- **Base URL**: `http://localhost:8000`
+- **Base URL**: `http://43.106.118.91:8000`
 - **Content-Type**: `application/json`
 - **Content-Type**: `application/json`
 
 
 ---
 ---
@@ -396,7 +396,7 @@ GET /api/traces/abc123.A/messages?goal_id=2
 
 
 ```javascript
 ```javascript
 const ws = new WebSocket(
 const ws = new WebSocket(
-  'ws://localhost:8000/api/traces/{trace_id}/watch?since_event_id=0'
+  'ws://43.106.118.91:8000/api/traces/{trace_id}/watch?since_event_id=0'
 )
 )
 ```
 ```
 
 
@@ -1150,7 +1150,7 @@ let lastEventId = 0
 
 
 function connect(traceId) {
 function connect(traceId) {
   const ws = new WebSocket(
   const ws = new WebSocket(
-    `ws://localhost:8000/api/traces/${traceId}/watch?since_event_id=${lastEventId}`
+    `ws://43.106.118.91:8000/api/traces/${traceId}/watch?since_event_id=${lastEventId}`
   )
   )
 
 
   ws.onmessage = (event) => {
   ws.onmessage = (event) => {

+ 6 - 6
frontend/htmlTemplate/templateData.py

@@ -19,7 +19,7 @@ msgGroups: Dict[str, List[Dict[str, Any]]] = {}
 
 
 
 
 def generate_trace_list(
 def generate_trace_list(
-    base_url: str = "http://localhost:8000",
+    base_url: str = "http://43.106.118.91:8000",
     status: Optional[str] = None,
     status: Optional[str] = None,
     mode: Optional[str] = None,
     mode: Optional[str] = None,
     limit: int = 20,
     limit: int = 20,
@@ -36,7 +36,7 @@ def generate_trace_list(
 
 
 
 
 def generate_goal_list(
 def generate_goal_list(
-    trace_id: str = "trace_001", base_url: str = "http://localhost:8000"
+    trace_id: str = "trace_001", base_url: str = "http://43.106.118.91:8000"
 ) -> Dict[str, Any]:
 ) -> Dict[str, Any]:
     url = f"{base_url.rstrip('/')}/api/traces/{trace_id}"
     url = f"{base_url.rstrip('/')}/api/traces/{trace_id}"
     response = httpx.get(url, timeout=10.0)
     response = httpx.get(url, timeout=10.0)
@@ -45,7 +45,7 @@ def generate_goal_list(
 
 
 
 
 def generate_subgoal_list(
 def generate_subgoal_list(
-    sub_trace_id: str, base_url: str = "http://localhost:8000"
+    sub_trace_id: str, base_url: str = "http://43.106.118.91:8000"
 ) -> Dict[str, Any]:
 ) -> Dict[str, Any]:
     url = f"{base_url.rstrip('/')}/api/traces/{sub_trace_id}"
     url = f"{base_url.rstrip('/')}/api/traces/{sub_trace_id}"
     response = httpx.get(url, timeout=10.0)
     response = httpx.get(url, timeout=10.0)
@@ -54,7 +54,7 @@ def generate_subgoal_list(
 
 
 
 
 def generate_messages_list(
 def generate_messages_list(
-    trace_id: str, goal_id: Optional[str] = None, base_url: str = "http://localhost:8000"
+    trace_id: str, goal_id: Optional[str] = None, base_url: str = "http://43.106.118.91:8000"
 ) -> Dict[str, Any]:
 ) -> Dict[str, Any]:
     url = f"{base_url.rstrip('/')}/api/traces/{trace_id}/messages"
     url = f"{base_url.rstrip('/')}/api/traces/{trace_id}/messages"
     params = {}
     params = {}
@@ -154,7 +154,7 @@ def generate_mock_branch_detail(trace_id: str = "trace_001", branch_id: str = "b
 
 
 
 
 async def _fetch_ws_connected_event(trace_id: str, since_event_id: int = 0, ws_url: Optional[str] = None) -> Dict[str, Any]:
 async def _fetch_ws_connected_event(trace_id: str, since_event_id: int = 0, ws_url: Optional[str] = None) -> Dict[str, Any]:
-    url = ws_url or f"ws://localhost:8000/api/traces/{trace_id}/watch?since_event_id={since_event_id}"
+    url = ws_url or f"ws://43.106.118.91:8000/api/traces/{trace_id}/watch?since_event_id={since_event_id}"
     async with websockets.connect(url) as ws:
     async with websockets.connect(url) as ws:
         while True:
         while True:
             raw_message = await ws.recv()
             raw_message = await ws.recv()
@@ -234,7 +234,7 @@ def _append_event_jsonl(event_data: Dict[str, Any], mock_dir: str):
 
 
 
 
 async def _watch_ws_events(trace_id: str, since_event_id: int = 0, ws_url: Optional[str] = None):
 async def _watch_ws_events(trace_id: str, since_event_id: int = 0, ws_url: Optional[str] = None):
-    url = ws_url or f"ws://localhost:8000/api/traces/{trace_id}/watch?since_event_id={since_event_id}"
+    url = ws_url or f"ws://43.106.118.91:8000/api/traces/{trace_id}/watch?since_event_id={since_event_id}"
     mock_dir = os.path.join(os.path.dirname(__file__), "ws_data")
     mock_dir = os.path.join(os.path.dirname(__file__), "ws_data")
     os.makedirs(mock_dir, exist_ok=True)
     os.makedirs(mock_dir, exist_ok=True)
     while True:
     while True:

+ 2 - 2
frontend/htmlTemplate/需求.md

@@ -253,7 +253,7 @@ preview: string | null;
 
 
 ### 5.2 WebSocket 实时通信
 ### 5.2 WebSocket 实时通信
 
 
-**连接地址**: `ws://localhost:8000/api/traces/{trace_id}/watch?since_event_id=0`
+**连接地址**: `ws://43.106.118.91:8000/api/traces/{trace_id}/watch?since_event_id=0`
 
 
 **查询参数**:
 **查询参数**:
 | 参数 | 类型 | 默认值 | 说明 |
 | 参数 | 类型 | 默认值 | 说明 |
@@ -380,7 +380,7 @@ last_message: any;
 2. 顶部 title 显示 `trace.task`
 2. 顶部 title 显示 `trace.task`
 3. 调用 `GET /api/traces/{trace_id}` 获取 GoalTree 数据
 3. 调用 `GET /api/traces/{trace_id}` 获取 GoalTree 数据
 4. 根据 `goal_tree.goals` 渲染流程节点
 4. 根据 `goal_tree.goals` 渲染流程节点
-5. 建立 WebSocket 连接 `ws://localhost:8000/api/traces/{trace_id}/watch` 进行实时更新
+5. 建立 WebSocket 连接 `ws://43.106.118.91:8000/api/traces/{trace_id}/watch` 进行实时更新
 
 
 **节点交互流程**:
 **节点交互流程**:
 
 

+ 3 - 3
frontend/react-template/package.json

@@ -4,9 +4,9 @@
   "version": "0.1.0",
   "version": "0.1.0",
   "type": "module",
   "type": "module",
   "scripts": {
   "scripts": {
-    "dev": "vite",
+    "dev": "vite --host 0.0.0.0 --port 3000",
     "build": "tsc && vite build",
     "build": "tsc && vite build",
-    "preview": "vite preview",
+    "preview": "vite preview --host 0.0.0.0",
     "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
     "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
   },
   },
   "dependencies": {
   "dependencies": {
@@ -42,4 +42,4 @@
     "vite": "^5.0.8",
     "vite": "^5.0.8",
     "vitest": "^4.0.18"
     "vitest": "^4.0.18"
   }
   }
-}
+}

+ 4 - 4
frontend/react-template/react.md

@@ -190,7 +190,7 @@ interface DetailPanelState {
 
 
 ### 5.1 HTTP 接口
 ### 5.1 HTTP 接口
 
 
-**Base URL**: `http://localhost:8000`
+**Base URL**: `http://43.106.118.91:8000`
 
 
 #### 5.1.1 获取 Trace 列表
 #### 5.1.1 获取 Trace 列表
 
 
@@ -271,7 +271,7 @@ interface MessagesResponse {
 
 
 ### 5.2 WebSocket 实时通信
 ### 5.2 WebSocket 实时通信
 
 
-**连接地址**: `ws://localhost:8000/api/traces/{trace_id}/watch?since_event_id=0`
+**连接地址**: `ws://43.106.118.91:8000/api/traces/{trace_id}/watch?since_event_id=0`
 
 
 **事件类型**:
 **事件类型**:
 
 
@@ -397,7 +397,7 @@ export const messageApi = {
 import axios from "axios";
 import axios from "axios";
 
 
 export const client = axios.create({
 export const client = axios.create({
-  baseURL: "http://localhost:8000",
+  baseURL: "http://43.106.118.91:8000",
   timeout: 10000,
   timeout: 10000,
   headers: {
   headers: {
     "Content-Type": "application/json",
     "Content-Type": "application/json",
@@ -552,7 +552,7 @@ export const useWebSocket = (traceId: string | null, options: UseWebSocketOption
   useEffect(() => {
   useEffect(() => {
     if (!traceId) return;
     if (!traceId) return;
 
 
-    const url = `ws://localhost:8000/api/traces/${traceId}/watch?since_event_id=0`;
+    const url = `ws://43.106.118.91:8000/api/traces/${traceId}/watch?since_event_id=0`;
     const ws = new WebSocket(url);
     const ws = new WebSocket(url);
 
 
     ws.onopen = () => {
     ws.onopen = () => {

+ 6 - 2
frontend/react-template/src/api/client.ts

@@ -2,10 +2,14 @@ import axios from "axios";
 import { Toast } from "@douyinfe/semi-ui";
 import { Toast } from "@douyinfe/semi-ui";
 
 
 // Determine base URL from environment variables, or fallback to default
 // Determine base URL from environment variables, or fallback to default
-const DEFAULT_BASE_URL = "http://localhost:8000";
+const DEFAULT_BASE_URL = "http://43.106.118.91:8000";
 
 
-// Handle various environment variable formats (Vite uses import.meta.env.VITE_*)
 const getBaseUrl = () => {
 const getBaseUrl = () => {
+  const winConfig =
+    typeof window !== "undefined"
+      ? (window as unknown as { CONFIG?: { API_BASE_URL?: string } }).CONFIG?.API_BASE_URL
+      : undefined;
+  if (typeof winConfig === "string" && winConfig) return winConfig;
   if (typeof import.meta !== "undefined" && import.meta.env && import.meta.env.VITE_API_BASE_URL) {
   if (typeof import.meta !== "undefined" && import.meta.env && import.meta.env.VITE_API_BASE_URL) {
     return import.meta.env.VITE_API_BASE_URL;
     return import.meta.env.VITE_API_BASE_URL;
   }
   }

+ 68 - 19
frontend/react-template/src/components/DetailPanel/DetailPanel.tsx

@@ -1,7 +1,10 @@
 import ReactMarkdown from "react-markdown";
 import ReactMarkdown from "react-markdown";
+import { useState } from "react";
 import type { Goal } from "../../types/goal";
 import type { Goal } from "../../types/goal";
 import type { Edge, Message } from "../../types/message";
 import type { Edge, Message } from "../../types/message";
 import styles from "./DetailPanel.module.css";
 import styles from "./DetailPanel.module.css";
+import { ImagePreviewModal } from "../ImagePreview/ImagePreviewModal";
+import { extractImagesFromMessage } from "../../utils/imageExtraction";
 
 
 interface DetailPanelProps {
 interface DetailPanelProps {
   node: Goal | Message | null;
   node: Goal | Message | null;
@@ -11,6 +14,26 @@ interface DetailPanelProps {
 }
 }
 
 
 export const DetailPanel = ({ node, edge, messages = [], onClose }: DetailPanelProps) => {
 export const DetailPanel = ({ node, edge, messages = [], onClose }: DetailPanelProps) => {
+  const [previewImage, setPreviewImage] = useState<string | null>(null);
+
+  const renderImages = (msg: Message) => {
+    const images = extractImagesFromMessage(msg);
+    if (images.length === 0) return null;
+    return (
+      <div className="grid grid-cols-3 gap-2 mt-2">
+        {images.map((img, idx) => (
+          <img
+            key={idx}
+            src={img.url}
+            alt={img.alt || "Extracted"}
+            className="w-full h-20 object-cover rounded border border-gray-200 cursor-pointer hover:opacity-80 transition-opacity bg-gray-50"
+            onClick={() => setPreviewImage(img.url)}
+          />
+        ))}
+      </div>
+    );
+  };
+
   const title = node ? "节点详情" : edge ? "连线详情" : "详情";
   const title = node ? "节点详情" : edge ? "连线详情" : "详情";
 
 
   const renderMessageContent = (content: Message["content"]) => {
   const renderMessageContent = (content: Message["content"]) => {
@@ -24,15 +47,34 @@ export const DetailPanel = ({ node, edge, messages = [], onClose }: DetailPanelP
     if (content.tool_calls && content.tool_calls.length > 0) {
     if (content.tool_calls && content.tool_calls.length > 0) {
       return (
       return (
         <div className={styles.toolCalls}>
         <div className={styles.toolCalls}>
-          {content.tool_calls.map((call) => (
-            <div
-              key={call.id}
-              className={styles.toolCall}
-            >
-              <div className={styles.toolName}>工具调用: {call.name}</div>
-              <pre className={styles.toolArgs}>{JSON.stringify(call.arguments, null, 2)}</pre>
-            </div>
-          ))}
+          {content.tool_calls.map((call, idx) => {
+            const anyCall = call as unknown as Record<string, unknown>;
+            const fn = anyCall.function as Record<string, unknown> | undefined;
+            const name =
+              (fn && (fn.name as string)) ||
+              (anyCall.name as string) ||
+              ((content as unknown as Record<string, unknown>).tool_name as string) ||
+              `tool_${idx}`;
+            let args: unknown =
+              (fn && fn.arguments) || anyCall.arguments || (content as unknown as Record<string, unknown>).arguments;
+            if (typeof args === "string") {
+              try {
+                args = JSON.parse(args);
+              } catch {
+                // keep as string if JSON.parse fails
+              }
+            }
+            const key = (anyCall.id as string) || `${name}-${idx}`;
+            return (
+              <div
+                key={key}
+                className={styles.toolCall}
+              >
+                <div className={styles.toolName}>工具调用: {name}</div>
+                <pre className={styles.toolArgs}>{JSON.stringify(args, null, 2)}</pre>
+              </div>
+            );
+          })}
         </div>
         </div>
       );
       );
     }
     }
@@ -60,9 +102,7 @@ export const DetailPanel = ({ node, edge, messages = [], onClose }: DetailPanelP
             <div className={styles.knowledgeHeader}>
             <div className={styles.knowledgeHeader}>
               <span className={styles.knowledgeId}>{item.id}</span>
               <span className={styles.knowledgeId}>{item.id}</span>
               <div className={styles.knowledgeMetrics}>
               <div className={styles.knowledgeMetrics}>
-                {item.score !== undefined && (
-                  <span className={styles.metricScore}>⭐ {item.score}</span>
-                )}
+                {item.score !== undefined && <span className={styles.metricScore}>⭐ {item.score}</span>}
                 {item.quality_score !== undefined && (
                 {item.quality_score !== undefined && (
                   <span className={styles.metricQuality}>✨ {item.quality_score.toFixed(1)}</span>
                   <span className={styles.metricQuality}>✨ {item.quality_score.toFixed(1)}</span>
                 )}
                 )}
@@ -118,6 +158,12 @@ export const DetailPanel = ({ node, edge, messages = [], onClose }: DetailPanelP
               <div className={styles.label}>ID</div>
               <div className={styles.label}>ID</div>
               <div className={styles.value}>{isMessageNode(node) ? node.message_id || node.id : node.id}</div>
               <div className={styles.value}>{isMessageNode(node) ? node.message_id || node.id : node.id}</div>
             </div>
             </div>
+            {isMessageNode(node) && extractImagesFromMessage(node).length > 0 && (
+              <div className={styles.section}>
+                <div className={styles.label}>图片</div>
+                {renderImages(node)}
+              </div>
+            )}
 
 
             {isGoal(node) ? (
             {isGoal(node) ? (
               <>
               <>
@@ -162,12 +208,10 @@ export const DetailPanel = ({ node, edge, messages = [], onClose }: DetailPanelP
                     <div className={styles.value}>{node.role}</div>
                     <div className={styles.value}>{node.role}</div>
                   </div>
                   </div>
                 )}
                 )}
-                {node.content && (
-                  <div className={styles.section}>
-                    <div className={styles.label}>内容</div>
-                    <div className={styles.value}>{renderMessageContent(node.content)}</div>
-                  </div>
-                )}
+                <div className={styles.section}>
+                  <div className={styles.label}>内容</div>
+                  <div className={styles.value}>{node.content && renderMessageContent(node.content)}</div>
+                </div>
                 {node.goal_id && (
                 {node.goal_id && (
                   <div className={styles.section}>
                   <div className={styles.section}>
                     <div className={styles.label}>所属目标</div>
                     <div className={styles.label}>所属目标</div>
@@ -198,13 +242,18 @@ export const DetailPanel = ({ node, edge, messages = [], onClose }: DetailPanelP
                 </div>
                 </div>
                 <div className={styles.section}>
                 <div className={styles.section}>
                   <div className={styles.label}>内容</div>
                   <div className={styles.label}>内容</div>
-                  <div className={styles.value}>{renderMessageContent(msg.content)}</div>
+                  <div className={styles.value}>{msg.content && renderMessageContent(msg.content)}</div>
                 </div>
                 </div>
               </div>
               </div>
             ))}
             ))}
           </div>
           </div>
         )}
         )}
       </div>
       </div>
+      <ImagePreviewModal
+        visible={!!previewImage}
+        onClose={() => setPreviewImage(null)}
+        src={previewImage || ""}
+      />
     </aside>
     </aside>
   );
   );
 };
 };

+ 27 - 0
frontend/react-template/src/components/ErrorFallback/ErrorFallback.tsx

@@ -0,0 +1,27 @@
+import type { FallbackProps } from "react-error-boundary";
+
+export const ErrorFallback = ({ error, resetErrorBoundary }: FallbackProps) => {
+  return (
+    <div style={{ padding: "20px", textAlign: "center", marginTop: "50px" }}>
+      <h2>Something went wrong:</h2>
+      <pre style={{ color: "red", backgroundColor: "#fce4e4", padding: "10px", borderRadius: "4px" }}>
+        {error instanceof Error ? error.message : String(error)}
+      </pre>
+      <button
+        onClick={resetErrorBoundary}
+        style={{
+          marginTop: "10px",
+          padding: "8px 16px",
+          backgroundColor: "#3b82f6",
+          color: "white",
+          border: "none",
+          borderRadius: "4px",
+          cursor: "pointer",
+        }}
+      >
+        Try again
+      </button>
+    </div>
+  );
+};
+

+ 63 - 17
frontend/react-template/src/components/FlowChart/FlowChart.tsx

@@ -16,6 +16,8 @@ import type { Edge as EdgeType, Message } from "../../types/message";
 import { ArrowMarkers } from "./components/ArrowMarkers";
 import { ArrowMarkers } from "./components/ArrowMarkers";
 import styles from "./styles/FlowChart.module.css";
 import styles from "./styles/FlowChart.module.css";
 import { Tooltip } from "@douyinfe/semi-ui";
 import { Tooltip } from "@douyinfe/semi-ui";
+import { ImagePreviewModal } from "../ImagePreview/ImagePreviewModal";
+import { extractImagesFromMessage } from "../../utils/imageExtraction";
 
 
 /**
 /**
  * FlowChart 组件的 Props
  * FlowChart 组件的 Props
@@ -73,7 +75,7 @@ interface LayoutEdge {
 }
 }
 
 
 const FlowChartComponent: ForwardRefRenderFunction<FlowChartRef, FlowChartProps> = (
 const FlowChartComponent: ForwardRefRenderFunction<FlowChartRef, FlowChartProps> = (
-  { goals, msgGroups = {}, invalidBranches, onNodeClick, onSubTraceClick },
+  { goals, msgGroups = {}, invalidBranches, onNodeClick },
   ref,
   ref,
 ) => {
 ) => {
   // 过滤掉有父节点的 goals,只保留主链节点
   // 过滤掉有父节点的 goals,只保留主链节点
@@ -120,6 +122,9 @@ const FlowChartComponent: ForwardRefRenderFunction<FlowChartRef, FlowChartProps>
   // 视图模式状态:scroll (滚动) 或 panzoom (拖拽缩放)
   // 视图模式状态:scroll (滚动) 或 panzoom (拖拽缩放)
   const [viewMode, setViewMode] = useState<"scroll" | "panzoom">("scroll");
   const [viewMode, setViewMode] = useState<"scroll" | "panzoom">("scroll");
 
 
+  // 图片预览状态
+  const [previewImage, setPreviewImage] = useState<string | null>(null);
+
   // 限制缩放比例在允许范围内
   // 限制缩放比例在允许范围内
   const clampZoom = (value: number) => Math.min(zoomRange.max, Math.max(zoomRange.min, value));
   const clampZoom = (value: number) => Math.min(zoomRange.max, Math.max(zoomRange.min, value));
 
 
@@ -523,7 +528,12 @@ const FlowChartComponent: ForwardRefRenderFunction<FlowChartRef, FlowChartProps>
             const currentBranchNodes: LayoutNode[] = [];
             const currentBranchNodes: LayoutNode[] = [];
 
 
             branch.forEach((msg, idx) => {
             branch.forEach((msg, idx) => {
-              const nodeId = `invalid-${msg.id || Math.random()}`;
+              // Ensure we have a stable ID. If msg.id is missing, use a deterministic fallback.
+              // Note: Using Math.random() is bad for React reconciliation, but here we need something unique if ID is missing.
+              // Better fallback: `${parentNode.id}-branch-${idx}`
+              const stableId = msg.id || `${parentNode.id}-branch-${idx}`;
+              const nodeId = `invalid-${stableId}`;
+
               const node: LayoutNode = {
               const node: LayoutNode = {
                 id: nodeId,
                 id: nodeId,
                 x: parentNode.x + X_OFFSET,
                 x: parentNode.x + X_OFFSET,
@@ -810,18 +820,27 @@ const FlowChartComponent: ForwardRefRenderFunction<FlowChartRef, FlowChartProps>
   const handleNodeClick = useCallback(
   const handleNodeClick = useCallback(
     (node: LayoutNode) => {
     (node: LayoutNode) => {
       if (node.type === "goal") {
       if (node.type === "goal") {
-        // 检查是否是子节点 (subgoal)
-        if (node.parentId && onSubTraceClick) {
-          const parentNode = layoutData.nodes.find((n) => n.id === node.parentId);
-          if (parentNode && parentNode.type === "goal") {
-            // 如果是子节点,触发 onSubTraceClick
-            const entry: SubTraceEntry = { id: (node.data as Goal).id };
-            onSubTraceClick(parentNode.data as Goal, entry);
-            return;
-          }
-        }
-
-        // 主链节点,触发 onNodeClick
+        // const goalData = node.data as Goal;
+
+        // // 只有具有 sub_trace_ids 的子目标节点(agent 委托执行)才触发 trace 切换
+        // // 普通的 sub_goal 节点(蓝色节点)没有 sub_trace_ids,应该打开 DetailPanel
+        // const hasSubTraces = goalData.sub_trace_ids && goalData.sub_trace_ids.length > 0;
+
+        // if (node.parentId && onSubTraceClick && hasSubTraces) {
+        //   const parentNode = layoutData.nodes.find((n) => n.id === node.parentId);
+        //   if (parentNode && parentNode.type === "goal") {
+        //     // 取第一个 sub_trace_id 作为跳转目标(使用 trace_id,而非 goal.id)
+        //     const firstEntry = goalData.sub_trace_ids![0];
+        //     const entry: SubTraceEntry =
+        //       typeof firstEntry === "string"
+        //         ? { id: firstEntry }
+        //         : { id: firstEntry.trace_id, mission: firstEntry.mission };
+        //     onSubTraceClick(parentNode.data as Goal, entry);
+        //     return;
+        //   }
+        // }
+
+        // 主链节点 或 没有 sub_trace_ids 的普通子目标节点 → 打开 DetailPanel
         setSelectedNodeId(node.id);
         setSelectedNodeId(node.id);
         onNodeClick?.(node.data as Goal);
         onNodeClick?.(node.data as Goal);
       } else if (node.type === "message") {
       } else if (node.type === "message") {
@@ -829,7 +848,7 @@ const FlowChartComponent: ForwardRefRenderFunction<FlowChartRef, FlowChartProps>
         onNodeClick?.(node.data as Message);
         onNodeClick?.(node.data as Message);
       }
       }
     },
     },
-    [onNodeClick, onSubTraceClick, layoutData],
+    [onNodeClick],
   );
   );
 
 
   if (!layoutData) return <div>Loading...</div>;
   if (!layoutData) return <div>Loading...</div>;
@@ -989,6 +1008,12 @@ const FlowChartComponent: ForwardRefRenderFunction<FlowChartRef, FlowChartProps>
                 const data = node.data as Goal;
                 const data = node.data as Goal;
                 const text = isGoal ? data.description : (node.data as Message).description || "";
                 const text = isGoal ? data.description : (node.data as Message).description || "";
 
 
+                let thumbnail: string | null = null;
+                if (node.type === "message") {
+                  const images = extractImagesFromMessage(node.data as Message);
+                  if (images.length > 0) thumbnail = images[0].url;
+                }
+
                 let textColor = "#3b82f6"; // Blue 500
                 let textColor = "#3b82f6"; // Blue 500
                 if (node.type === "message") {
                 if (node.type === "message") {
                   textColor = "#64748b"; // Slate 500
                   textColor = "#64748b"; // Slate 500
@@ -1034,12 +1059,27 @@ const FlowChartComponent: ForwardRefRenderFunction<FlowChartRef, FlowChartProps>
                     >
                     >
                       <Tooltip content={text}>
                       <Tooltip content={text}>
                         <div
                         <div
-                          className="w-full h-full overflow-hidden flex items-center justify-center"
+                          className="w-full h-full overflow-hidden flex items-center px-2 gap-2"
                           style={{
                           style={{
                             color: textColor,
                             color: textColor,
+                            justifyContent: thumbnail ? "space-between" : "center",
                           }}
                           }}
                         >
                         >
-                          <span className="text-xs line-clamp-3 px-1">{text}</span>
+                          <span className={`text-xs line-clamp-3 ${thumbnail ? "flex-1 text-left" : "text-center"}`}>
+                            {text}
+                          </span>
+                          {thumbnail && (
+                            <img
+                              src={thumbnail}
+                              alt="thumb"
+                              className="w-8 h-8 object-cover rounded border border-gray-200 bg-white flex-shrink-0 hover:scale-110 transition-transform"
+                              loading="lazy"
+                              onClick={(e) => {
+                                e.stopPropagation();
+                                setPreviewImage(thumbnail);
+                              }}
+                            />
+                          )}
                         </div>
                         </div>
                       </Tooltip>
                       </Tooltip>
                     </foreignObject>
                     </foreignObject>
@@ -1086,6 +1126,12 @@ const FlowChartComponent: ForwardRefRenderFunction<FlowChartRef, FlowChartProps>
           {viewMode === "scroll" ? "切换拖拽" : "切换滚动"}
           {viewMode === "scroll" ? "切换拖拽" : "切换滚动"}
         </button>
         </button>
       </div>
       </div>
+
+      <ImagePreviewModal
+        visible={!!previewImage}
+        onClose={() => setPreviewImage(null)}
+        src={previewImage || ""}
+      />
     </div>
     </div>
   );
   );
 };
 };

+ 94 - 78
frontend/react-template/src/components/FlowChart/hooks/useFlowChartData.ts

@@ -1,5 +1,6 @@
 import { useCallback, useEffect, useMemo, useRef, useState } from "react";
 import { useCallback, useEffect, useMemo, useRef, useState } from "react";
 import { useWebSocket } from "../../../hooks/useWebSocket";
 import { useWebSocket } from "../../../hooks/useWebSocket";
+import { request } from "../../../api/client";
 import type { Goal } from "../../../types/goal";
 import type { Goal } from "../../../types/goal";
 import type { Message } from "../../../types/message";
 import type { Message } from "../../../types/message";
 
 
@@ -108,90 +109,82 @@ export const useFlowChartData = (traceId: string | null, refreshTrigger?: number
     setReloading(true);
     setReloading(true);
     let nextSinceEventId: number | null = null;
     let nextSinceEventId: number | null = null;
     try {
     try {
-      const [traceRes, messagesRes] = await Promise.all([
-        fetch(`http://localhost:8000/api/traces/${traceId}`),
-        fetch(`http://localhost:8000/api/traces/${traceId}/messages?mode=all`),
+      const [traceJson, messagesJson] = await Promise.all([
+        request<unknown>(`/api/traces/${traceId}`),
+        request<unknown>(`/api/traces/${traceId}/messages?mode=all`),
       ]);
       ]);
 
 
-      if (traceRes.ok) {
-        const json = (await traceRes.json()) as unknown;
-        const root = isRecord(json) ? json : {};
-        const trace = isRecord(root.trace) ? root.trace : undefined;
-        const goalTree = isRecord(root.goal_tree) ? root.goal_tree : undefined;
-        const goalList = goalTree && Array.isArray(goalTree.goals) ? (goalTree.goals as Goal[]) : [];
-
-        const lastEventId = trace && typeof trace.last_event_id === "number" ? trace.last_event_id : undefined;
-        if (typeof lastEventId === "number") {
-          currentEventIdRef.current = Math.max(currentEventIdRef.current, lastEventId);
-          setSinceEventId(lastEventId);
-          nextSinceEventId = lastEventId;
-        }
+      const traceRoot = isRecord(traceJson) ? traceJson : {};
+      const trace = isRecord(traceRoot.trace) ? traceRoot.trace : undefined;
+      const goalTree = isRecord(traceRoot.goal_tree) ? traceRoot.goal_tree : undefined;
+      const goalList = goalTree && Array.isArray(goalTree.goals) ? (goalTree.goals as Goal[]) : [];
 
 
-        if (goalList.length > 0) {
-          setGoals((prev) => {
-            const mergedFlat = goalList.map((ng) => {
-              const existing = prev.find((p) => p.id === ng.id);
-              if (!existing) return ng;
-              const merged: Goal = { ...existing, ...ng };
-              if (existing.sub_trace_ids && !merged.sub_trace_ids) {
-                merged.sub_trace_ids = existing.sub_trace_ids;
-              }
-              if (existing.agent_call_mode && !merged.agent_call_mode) {
-                merged.agent_call_mode = existing.agent_call_mode;
-              }
-              if (existing.knowledge && !merged.knowledge) {
-                merged.knowledge = existing.knowledge;
-              }
-              return merged;
-            });
-            return buildSubGoals(mergedFlat);
-          });
-        }
+      const lastEventId = trace && typeof trace.last_event_id === "number" ? trace.last_event_id : undefined;
+      if (typeof lastEventId === "number") {
+        currentEventIdRef.current = Math.max(currentEventIdRef.current, lastEventId);
+        setSinceEventId(lastEventId);
+        nextSinceEventId = lastEventId;
       }
       }
 
 
-      if (messagesRes.ok) {
-        const json = (await messagesRes.json()) as unknown;
-        const root = isRecord(json) ? json : {};
-        const list = Array.isArray(root.messages) ? (root.messages as Message[]) : [];
-        console.log("%c [ list ]-149", "font-size:13px; background:pink; color:#bf2c9f;", list);
-
-        const filtered = list.filter((message) => (message as { status?: string }).status !== "abandoned");
-        const nextMessages = [...filtered].sort(messageComparator);
-
-        const { availableData: finalMessages, invalidBranches: invalidBranchesTemp } = processRetryLogic(nextMessages);
-
-        // Update max sequence
-        const maxSeq = finalMessages.reduce((max, msg) => {
-          const seq = typeof msg.sequence === "number" ? msg.sequence : -1;
-          return Math.max(max, seq);
-        }, 0);
-        maxSequenceRef.current = maxSeq;
-
-        setMessages(finalMessages);
-        setInvalidBranches(invalidBranchesTemp);
-        const grouped: Record<string, Message[]> = {};
-        finalMessages.forEach((message) => {
-          const groupKey = typeof message.goal_id === "string" && message.goal_id ? message.goal_id : "START";
-          if (!grouped[groupKey]) grouped[groupKey] = [];
-          grouped[groupKey].push(message);
-        });
-        Object.keys(grouped).forEach((key) => {
-          grouped[key].sort(messageComparator);
+      if (goalList.length > 0) {
+        setGoals((prev) => {
+          const mergedFlat = goalList.map((ng) => {
+            const existing = prev.find((p) => p.id === ng.id);
+            if (!existing) return ng;
+            const merged: Goal = { ...existing, ...ng };
+            if (existing.sub_trace_ids && !merged.sub_trace_ids) {
+              merged.sub_trace_ids = existing.sub_trace_ids;
+            }
+            if (existing.agent_call_mode && !merged.agent_call_mode) {
+              merged.agent_call_mode = existing.agent_call_mode;
+            }
+            if (existing.knowledge && !merged.knowledge) {
+              merged.knowledge = existing.knowledge;
+            }
+            return merged;
+          });
+          return buildSubGoals(mergedFlat);
         });
         });
-        setMsgGroups(grouped);
+      }
 
 
-        if (grouped.START && grouped.START.length > 0) {
-          setGoals((prev) => {
-            if (prev.some((g) => g.id === "START")) return prev;
-            const startGoal: Goal = {
-              id: "START",
-              description: "START",
-              status: "completed",
-              created_at: "",
-            };
-            return [startGoal, ...prev];
-          });
-        }
+      const messagesRoot = isRecord(messagesJson) ? messagesJson : {};
+      const list = Array.isArray(messagesRoot.messages) ? (messagesRoot.messages as Message[]) : [];
+
+      const filtered = list.filter((message) => (message as { status?: string }).status !== "abandoned");
+      const nextMessages = [...filtered].sort(messageComparator);
+
+      const { availableData: finalMessages, invalidBranches: invalidBranchesTemp } = processRetryLogic(nextMessages);
+
+      const maxSeq = finalMessages.reduce((max, msg) => {
+        const seq = typeof msg.sequence === "number" ? msg.sequence : -1;
+        return Math.max(max, seq);
+      }, 0);
+      maxSequenceRef.current = maxSeq;
+
+      setMessages(finalMessages);
+      setInvalidBranches(invalidBranchesTemp);
+      const grouped: Record<string, Message[]> = {};
+      finalMessages.forEach((message) => {
+        const groupKey = typeof message.goal_id === "string" && message.goal_id ? message.goal_id : "START";
+        if (!grouped[groupKey]) grouped[groupKey] = [];
+        grouped[groupKey].push(message);
+      });
+      Object.keys(grouped).forEach((key) => {
+        grouped[key].sort(messageComparator);
+      });
+      setMsgGroups(grouped);
+
+      if (grouped.START && grouped.START.length > 0) {
+        setGoals((prev) => {
+          if (prev.some((g) => g.id === "START")) return prev;
+          const startGoal: Goal = {
+            id: "START",
+            description: "START",
+            status: "completed",
+            created_at: "",
+          };
+          return [startGoal, ...prev];
+        });
       }
       }
 
 
       // REST 请求完成后,允许建立 WebSocket 连接
       // REST 请求完成后,允许建立 WebSocket 连接
@@ -199,6 +192,7 @@ export const useFlowChartData = (traceId: string | null, refreshTrigger?: number
     } finally {
     } finally {
       restReloadingRef.current = false;
       restReloadingRef.current = false;
       setReloading(false);
       setReloading(false);
+      setReadyToConnect(true);
     }
     }
     return nextSinceEventId;
     return nextSinceEventId;
   }, [messageComparator, traceId]);
   }, [messageComparator, traceId]);
@@ -263,7 +257,24 @@ export const useFlowChartData = (traceId: string | null, refreshTrigger?: number
           (typeof raw.current_event_id === "number" ? raw.current_event_id : undefined);
           (typeof raw.current_event_id === "number" ? raw.current_event_id : undefined);
         if (typeof currentEventId === "number") {
         if (typeof currentEventId === "number") {
           currentEventIdRef.current = Math.max(currentEventIdRef.current, currentEventId);
           currentEventIdRef.current = Math.max(currentEventIdRef.current, currentEventId);
+          setSinceEventId(currentEventId);
         }
         }
+
+        const goalTree = isRecord(data.goal_tree)
+          ? data.goal_tree
+          : isRecord(raw.goal_tree)
+            ? raw.goal_tree
+            : undefined;
+        if (goalTree && Array.isArray(goalTree.goals)) {
+          setGoals((prev) => {
+            if (prev.length > 0) return prev;
+            return buildSubGoals(goalTree.goals as Goal[]);
+          });
+        }
+        return;
+      }
+
+      if (event === "pong") {
         return;
         return;
       }
       }
 
 
@@ -332,7 +343,12 @@ export const useFlowChartData = (traceId: string | null, refreshTrigger?: number
           (typeof data.goal_id === "string" ? data.goal_id : undefined) ||
           (typeof data.goal_id === "string" ? data.goal_id : undefined) ||
           (isRecord(data.goal) && typeof data.goal.id === "string" ? data.goal.id : undefined) ||
           (isRecord(data.goal) && typeof data.goal.id === "string" ? data.goal.id : undefined) ||
           (typeof raw.goal_id === "string" ? raw.goal_id : undefined);
           (typeof raw.goal_id === "string" ? raw.goal_id : undefined);
-        const updates = isRecord(data.updates) ? data.updates : isRecord(raw.updates) ? raw.updates : {};
+        const updates =
+          (isRecord(data.updates) ? data.updates : undefined) ||
+          (isRecord(raw.updates) ? raw.updates : undefined) ||
+          (isRecord(data.patch) ? data.patch : undefined) ||
+          (isRecord(raw.patch) ? raw.patch : undefined) ||
+          {};
         if (!goalId) return;
         if (!goalId) return;
         setGoals((prev: Goal[]) =>
         setGoals((prev: Goal[]) =>
           prev.map((g: Goal) => {
           prev.map((g: Goal) => {

+ 49 - 0
frontend/react-template/src/components/ImagePreview/ImagePreviewModal.tsx

@@ -0,0 +1,49 @@
+import type { FC } from "react";
+import { Modal } from "@douyinfe/semi-ui";
+
+interface ImagePreviewModalProps {
+  visible: boolean;
+  onClose: () => void;
+  src: string;
+  alt?: string;
+}
+
+export const ImagePreviewModal: FC<ImagePreviewModalProps> = ({ visible, onClose, src, alt }) => {
+  return (
+    <Modal
+      visible={visible}
+      onCancel={onClose}
+      footer={null}
+      centered
+      maskClosable={true}
+      width={1000}
+      bodyStyle={{
+        padding: 0,
+        display: "flex",
+        justifyContent: "center",
+        alignItems: "center",
+        background: "transparent",
+        height: "auto",
+        maxHeight: "85vh",
+      }}
+      style={{
+        maxWidth: "95vw",
+        background: "transparent",
+        boxShadow: "none",
+      }}
+    >
+      <img
+        src={src}
+        alt={alt || "Preview"}
+        style={{
+          maxWidth: "100%",
+          maxHeight: "80vh",
+          objectFit: "contain",
+          borderRadius: 8,
+          boxShadow: "0 4px 12px rgba(0,0,0,0.3)",
+        }}
+        onClick={(e) => e.stopPropagation()}
+      />
+    </Modal>
+  );
+};

+ 1 - 1
frontend/react-template/src/components/Terminal/Terminal.tsx

@@ -22,7 +22,7 @@ export const Terminal: FC<TerminalProps> = ({ onClose }) => {
 
 
   useEffect(() => {
   useEffect(() => {
     // 连接WebSocket
     // 连接WebSocket
-    const ws = new WebSocket("ws://localhost:8000/api/logs/watch");
+    const ws = new WebSocket("ws://43.106.118.91:8000/api/logs/watch");
     wsRef.current = ws;
     wsRef.current = ws;
 
 
     ws.onopen = () => {
     ws.onopen = () => {

+ 30 - 10
frontend/react-template/src/components/TopBar/TopBar.tsx

@@ -29,8 +29,13 @@ export const TopBar: FC<TopBarProps> = ({
   const [isReflectModalVisible, setIsReflectModalVisible] = useState(false);
   const [isReflectModalVisible, setIsReflectModalVisible] = useState(false);
   const [isExperienceModalVisible, setIsExperienceModalVisible] = useState(false);
   const [isExperienceModalVisible, setIsExperienceModalVisible] = useState(false);
   const [experienceContent, setExperienceContent] = useState("");
   const [experienceContent, setExperienceContent] = useState("");
-  const [exampleProjects, setExampleProjects] = useState<Array<{ name: string; path: string; has_prompt: boolean }>>([]);
-  const formApiRef = useRef<any>(null);
+  const [exampleProjects, setExampleProjects] = useState<Array<{ name: string; path: string; has_prompt: boolean }>>(
+    [],
+  );
+  const formApiRef = useRef<{
+    getValues: () => { system_prompt?: string; user_prompt?: string };
+    setValue: (field: "system_prompt" | "user_prompt", value: string) => void;
+  } | null>(null);
   const insertFormApiRef = useRef<{ getValues: () => { insert_prompt: string } } | null>(null);
   const insertFormApiRef = useRef<{ getValues: () => { insert_prompt: string } } | null>(null);
   const reflectFormApiRef = useRef<{ getValues: () => { reflect_focus: string } } | null>(null);
   const reflectFormApiRef = useRef<{ getValues: () => { reflect_focus: string } } | null>(null);
 
 
@@ -74,7 +79,7 @@ export const TopBar: FC<TopBarProps> = ({
     // 加载 example 项目列表
     // 加载 example 项目列表
     try {
     try {
       const data = await traceApi.fetchExamples();
       const data = await traceApi.fetchExamples();
-      setExampleProjects(data.projects.filter(p => p.has_prompt));
+      setExampleProjects(data.projects.filter((p) => p.has_prompt));
     } catch (error) {
     } catch (error) {
       console.error("Failed to load examples:", error);
       console.error("Failed to load examples:", error);
     }
     }
@@ -109,7 +114,13 @@ export const TopBar: FC<TopBarProps> = ({
         messages.push({ role: "user", content: values.user_prompt });
         messages.push({ role: "user", content: values.user_prompt });
       }
       }
 
 
-      await traceApi.createTrace({ messages });
+      const created = await traceApi.createTrace({ messages });
+      const nextTitle =
+        (typeof values.user_prompt === "string" && values.user_prompt.trim()
+          ? values.user_prompt.trim().split("\n")[0]
+          : "新任务") || "新任务";
+
+      onTraceSelect(created.trace_id, nextTitle);
       await loadTraces();
       await loadTraces();
       onTraceCreated?.();
       onTraceCreated?.();
       setIsModalVisible(false);
       setIsModalVisible(false);
@@ -292,8 +303,11 @@ export const TopBar: FC<TopBarProps> = ({
         centered
         centered
         style={{ width: 600 }}
         style={{ width: 600 }}
       >
       >
-        {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
-        <Form getFormApi={(api: any) => (formApiRef.current = api)}>
+        <Form
+          getFormApi={(api: unknown) => {
+            formApiRef.current = api as unknown as NonNullable<typeof formApiRef.current>;
+          }}
+        >
           <Form.Select
           <Form.Select
             field="example_project"
             field="example_project"
             label="选择示例项目(可选)"
             label="选择示例项目(可选)"
@@ -333,8 +347,11 @@ export const TopBar: FC<TopBarProps> = ({
         centered
         centered
         style={{ width: 600 }}
         style={{ width: 600 }}
       >
       >
-        {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
-        <Form getFormApi={(api: any) => (insertFormApiRef.current = api)}>
+        <Form
+          getFormApi={(api: unknown) => {
+            insertFormApiRef.current = api as unknown as NonNullable<typeof insertFormApiRef.current>;
+          }}
+        >
           <Form.TextArea
           <Form.TextArea
             field="insert_prompt"
             field="insert_prompt"
             label=" "
             label=" "
@@ -351,8 +368,11 @@ export const TopBar: FC<TopBarProps> = ({
         centered
         centered
         style={{ width: 600 }}
         style={{ width: 600 }}
       >
       >
-        {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
-        <Form getFormApi={(api: any) => (reflectFormApiRef.current = api)}>
+        <Form
+          getFormApi={(api: unknown) => {
+            reflectFormApiRef.current = api as unknown as NonNullable<typeof reflectFormApiRef.current>;
+          }}
+        >
           <Form.TextArea
           <Form.TextArea
             field="reflect_focus"
             field="reflect_focus"
             label=" "
             label=" "

+ 24 - 1
frontend/react-template/src/hooks/useWebSocket.ts

@@ -15,11 +15,26 @@ export const useWebSocket = (traceId: string | null, options: UseWebSocketOption
   useEffect(() => {
   useEffect(() => {
     if (!traceId) return;
     if (!traceId) return;
 
 
-    const url = `ws://localhost:8000/api/traces/${traceId}/watch?since_event_id=${sinceEventId}`;
+    const httpBase =
+      (typeof window !== "undefined"
+        ? (window as unknown as { CONFIG?: { API_BASE_URL?: string } }).CONFIG?.API_BASE_URL
+        : undefined) ||
+      (typeof import.meta !== "undefined" && import.meta.env && import.meta.env.VITE_API_BASE_URL
+        ? import.meta.env.VITE_API_BASE_URL
+        : "http://43.106.118.91:8000");
+
+    const wsBase = httpBase.replace(/^http(s?):\/\//, "ws$1://").replace(/\/+$/, "");
+    const url = `${wsBase}/api/traces/${traceId}/watch?since_event_id=${sinceEventId}`;
     const ws = new WebSocket(url);
     const ws = new WebSocket(url);
+    let pingTimer: number | null = null;
 
 
     ws.onopen = () => {
     ws.onopen = () => {
       setConnected(true);
       setConnected(true);
+      pingTimer = window.setInterval(() => {
+        if (ws.readyState === WebSocket.OPEN) {
+          ws.send("ping");
+        }
+      }, 15000);
     };
     };
 
 
     ws.onmessage = (event) => {
     ws.onmessage = (event) => {
@@ -37,12 +52,20 @@ export const useWebSocket = (traceId: string | null, options: UseWebSocketOption
 
 
     ws.onclose = () => {
     ws.onclose = () => {
       setConnected(false);
       setConnected(false);
+      if (pingTimer) {
+        window.clearInterval(pingTimer);
+        pingTimer = null;
+      }
       onClose?.();
       onClose?.();
     };
     };
 
 
     wsRef.current = ws;
     wsRef.current = ws;
 
 
     return () => {
     return () => {
+      if (pingTimer) {
+        window.clearInterval(pingTimer);
+        pingTimer = null;
+      }
       ws.close();
       ws.close();
     };
     };
   }, [traceId, onMessage, onError, onClose, sinceEventId]);
   }, [traceId, onMessage, onError, onClose, sinceEventId]);

+ 1 - 26
frontend/react-template/src/main.tsx

@@ -1,37 +1,12 @@
 import { createRoot } from "react-dom/client";
 import { createRoot } from "react-dom/client";
 import { ErrorBoundary } from "react-error-boundary";
 import { ErrorBoundary } from "react-error-boundary";
-import type { FallbackProps } from "react-error-boundary";
 import App from "./App";
 import App from "./App";
+import { ErrorFallback } from "./components/ErrorFallback/ErrorFallback";
 import "./styles/global.css";
 import "./styles/global.css";
 import "./styles/variables.css";
 import "./styles/variables.css";
 
 
 const container = document.getElementById("root");
 const container = document.getElementById("root");
 
 
-const ErrorFallback = ({ error, resetErrorBoundary }: FallbackProps) => {
-  return (
-    <div style={{ padding: "20px", textAlign: "center", marginTop: "50px" }}>
-      <h2>Something went wrong:</h2>
-      <pre style={{ color: "red", backgroundColor: "#fce4e4", padding: "10px", borderRadius: "4px" }}>
-        {error instanceof Error ? error.message : String(error)}
-      </pre>
-      <button
-        onClick={resetErrorBoundary}
-        style={{
-          marginTop: "10px",
-          padding: "8px 16px",
-          backgroundColor: "#3b82f6",
-          color: "white",
-          border: "none",
-          borderRadius: "4px",
-          cursor: "pointer",
-        }}
-      >
-        Try again
-      </button>
-    </div>
-  );
-};
-
 if (container) {
 if (container) {
   createRoot(container).render(
   createRoot(container).render(
     <ErrorBoundary FallbackComponent={ErrorFallback}>
     <ErrorBoundary FallbackComponent={ErrorFallback}>

+ 15 - 1
frontend/react-template/src/types/message.ts

@@ -1,12 +1,26 @@
 export interface ToolCall {
 export interface ToolCall {
   id: string;
   id: string;
+  type?: string;
+  function?: {
+    name: string;
+    arguments: string;
+  };
   name: string;
   name: string;
-  arguments: Record<string, unknown>;
+}
+export interface MsgResult {
+  type?: string;
+  text?: string;
+  image_url?: MsgResultDict;
+}
+export interface MsgResultDict {
+  url: string;
 }
 }
 
 
 export interface MessageContent {
 export interface MessageContent {
   text?: string;
   text?: string;
   tool_calls?: ToolCall[];
   tool_calls?: ToolCall[];
+  tool_name?: string;
+  result?: string | MsgResult[];
 }
 }
 
 
 export interface Message {
 export interface Message {

+ 116 - 0
frontend/react-template/src/utils/imageExtraction.ts

@@ -0,0 +1,116 @@
+import type { Message } from "../types/message";
+
+export interface ExtractedImage {
+  url: string;
+  alt?: string;
+}
+
+/**
+ * Extracts images from a message's content or result.
+ * Handles both JSON array format (MsgResult[]) and Rich Text (Markdown/HTML).
+ *
+ * @param result - The content or result field from a message
+ * @returns Array of extracted images
+ */
+export const extractImagesFromResult = (result: unknown): ExtractedImage[] => {
+  const images: ExtractedImage[] = [];
+
+  if (!result) return images;
+
+  // Case 0: result IS the message content which might be an array directly
+  if (Array.isArray(result)) {
+    result.forEach((item) => {
+      if (typeof item === "object" && item !== null) {
+        // eslint-disable-next-line @typescript-eslint/no-explicit-any
+        const msgItem = item as any;
+
+        // 1. Check for standard OpenAI-like image_url
+        if (msgItem.image_url && typeof msgItem.image_url === "object" && msgItem.image_url.url) {
+          images.push({
+            url: msgItem.image_url.url,
+            alt: "Attached Image",
+          });
+        }
+
+        // 2. Check for type="image" with source
+        if (msgItem.type === "image" && msgItem.source && typeof msgItem.source === "object") {
+          const source = msgItem.source;
+          if (source.data) {
+            const mimeType = source.media_type || "image/png";
+            images.push({
+              url: `data:${mimeType};base64,${source.data}`,
+              alt: "Base64 Image",
+            });
+          } else if (source.url) {
+            images.push({
+              url: source.url,
+              alt: "Image URL",
+            });
+          }
+        }
+      }
+    });
+  }
+
+  // Case 2: result is a string (Rich Text / Markdown)
+  if (typeof result === "string") {
+    // 1. Match Markdown images: ![alt](url)
+    const markdownRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
+    let match;
+    while ((match = markdownRegex.exec(result)) !== null) {
+      images.push({
+        alt: match[1] || "Markdown Image",
+        url: match[2],
+      });
+    }
+
+    // 2. Match HTML img tags: <img ... src="..." ...>
+    const htmlRegex = /<img\s+[^>]*src=["']([^"']+)["'][^>]*>/g;
+    while ((match = htmlRegex.exec(result)) !== null) {
+      images.push({
+        alt: "HTML Image",
+        url: match[1],
+      });
+    }
+
+    // 3. Match JSON "image_url": "..." patterns embedded in text
+    // Matches "image_url"\s*:\s*"([^"]+)"
+    const jsonRegex = /"image_url"\s*:\s*"([^"]+)"/g;
+    while ((match = jsonRegex.exec(result)) !== null) {
+      // Basic filtering to avoid matching non-URL strings if the key is reused
+      if (match[1].startsWith("http") || match[1].startsWith("data:")) {
+        images.push({
+          alt: "JSON Image",
+          url: match[1],
+        });
+      }
+    }
+  }
+  return images;
+};
+
+/**
+ * Helper to extract images from a Message object
+ */
+export const extractImagesFromMessage = (message: Message): ExtractedImage[] => {
+  if (!message.content) return [];
+
+  // If content is a string, treat it as result
+  if (typeof message.content === "string") {
+    return extractImagesFromResult(message.content);
+  }
+
+  // If content is an object (MessageContent)
+  if (typeof message.content === "object") {
+    // Check 'result' field
+    if ("result" in message.content && message.content.result) {
+      return extractImagesFromResult(message.content.result);
+    }
+    // Also check if content itself is an array (e.g. standard MessageContent array)
+    if (Array.isArray(message.content)) {
+      return extractImagesFromResult(message.content);
+    }
+  }
+
+  return [];
+};

+ 1 - 1
frontend/react-template/vite.config.ts

@@ -15,7 +15,7 @@ export default defineConfig({
     port: 3000,
     port: 3000,
     proxy: {
     proxy: {
       "/api": {
       "/api": {
-        target: "http://localhost:8000",
+        target: "http://43.106.118.91:8000",
         changeOrigin: true,
         changeOrigin: true,
       },
       },
     },
     },

+ 2 - 2
frontend/test_api.py

@@ -28,8 +28,8 @@ except ImportError:
 
 
 
 
 # 配置
 # 配置
-API_BASE = "http://localhost:8000"
-WS_BASE = "ws://localhost:8000"
+API_BASE = "http://43.106.118.91:8000"
+WS_BASE = "ws://43.106.118.91:8000"
 
 
 
 
 class TestResult:
 class TestResult: