Talegorithm 4 дней назад
Родитель
Сommit
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(tree:*)",
       "Bash(xargs grep:*)",
-      "Bash(npm run:*)"
+      "Bash(npm run:*)",
+      "Bash(sed:*)"
     ],
     "deny": [],
     "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.skill_loader import load_skills_from_dir
 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__)
 
@@ -751,18 +774,7 @@ class AgentRunner:
 """
 
         elif stage == "planning":
-            return f"""
-## 📋 研究流程 - 制定计划
-
-调研已完成(或无需调研),现在请制定执行计划。
-
-**请立即执行以下操作**:
-1. 使用 `goal` 工具创建目标树
-2. 将任务分解为可执行的子目标
-3. 为每个子目标设置合理的优先级
-
-注意:这是强制步骤,必须创建 goal tree 才能进入执行阶段。
-"""
+            return PLANNING_STAGE_PROMPT
 
         # research_decision 阶段的引导消息已移到 _build_research_decision_guide
         return ""
@@ -1238,10 +1250,7 @@ agent(
                     "[Runner] 响应被 max_tokens 截断,跳过 %d 个不完整的 tool calls",
                     len(tool_calls),
                 )
-                truncation_hint = (
-                    "你的响应因为 max_tokens 限制被截断,tool call 参数不完整,未执行。"
-                    "请将大内容拆分为多次小的工具调用(例如用 write_file 的 append 模式分批写入)。"
-                )
+                truncation_hint = TRUNCATION_HINT
                 history.append({
                     "role": "assistant",
                     "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 []
 
                     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)
 
                 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)} 条结构化经验到知识库")
                 else:
-                    logger.warning("未能解析出符合格式的经验条目,请检查 REFLECT_PROMPT。")
+                    logger.warning(EXPERIENCE_PARSE_WARNING)
                     logger.debug(f"LLM Raw Output:\n{reflection_text}")
             else:
                 logger.warning("LLM 未生成反思内容")
@@ -1635,10 +1643,7 @@ created_at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
             return history, head_seq, sequence
 
         # --- 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(
             trace_id=trace_id,
@@ -1830,10 +1835,7 @@ created_at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
                     tc, goal_tree, assistant_msg,
                 )
             else:
-                result_text = (
-                    f"⚠️ 工具 {tool_name} 执行被中断(进程异常退出),"
-                    "未获得执行结果。请根据需要重新调用。"
-                )
+                result_text = build_tool_interrupted_message(tool_name)
 
             synthetic_msg = Message.create(
                 trace_id=trace_id,
@@ -1905,14 +1907,12 @@ created_at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
         result: Dict[str, Any] = {
             "mode": mode,
             "status": "interrupted",
-            "summary": "⚠️ 子Agent执行被中断(进程异常退出)",
+            "summary": AGENT_INTERRUPTED_SUMMARY,
             "task": task,
         }
         if 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:
             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)
 
     # 默认 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]:
         """构建 system prompt(注入 skills)
@@ -2147,7 +2147,7 @@ created_at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
             if skills_text:
                 system_prompt += f"\n\n## Skills\n{skills_text}"
         else:
-            system_prompt = self.DEFAULT_SYSTEM_PREFIX
+            system_prompt = DEFAULT_SYSTEM_PREFIX
             if 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()
 
         if not raw_text:
-            return "未命名任务"
+            return TASK_NAME_FALLBACK
 
         # 尝试使用 utility_llm 生成标题
         if self.utility_llm_call:
             try:
                 result = await self.utility_llm_call(
                     messages=[
-                        {"role": "system", "content": "用中文为以下任务生成一个简短标题(10-30字),只输出标题本身:"},
+                        {"role": "system", "content": TASK_NAME_GENERATION_SYSTEM_PROMPT},
                         {"role": "user", "content": raw_text[:2000]},
                     ],
                     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:
-        logger.error(f"批量更新知识失败: {e}")
+        logger.error(f"列出知识失败: {e}")
         return ToolResult(
-            title="❌ 批量更新失败",
+            title="❌ 列表失败",
             output=f"错误: {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.goal_models import GoalTree
 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):
@@ -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]:
     """格式化单任务(delegate)结果"""
-    lines = ["## 委托任务完成\n"]
+    lines = [DELEGATE_RESULT_HEADER]
     summary = result.get("summary", "")
     if 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", [])
     if saved_knowledge_ids:
         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:
             lines.append(f"- {kid}")
         lines.append("")
 
     lines.append("---\n")
-    lines.append("**执行统计**:")
+    lines.append(DELEGATE_STATS_HEADER)
     stats = result.get("stats", {})
     if stats:
         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]
 ) -> Dict[str, Any]:
     """格式化多任务(explore)聚合结果"""
-    lines = ["## 探索结果\n"]
+    lines = [EXPLORE_RESULT_HEADER]
     successful = 0
     failed = 0
     total_tokens = 0
@@ -174,15 +187,15 @@ def _format_multi_result(
 
     for i, (task_item, result) in enumerate(zip(tasks, results)):
         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):
             status = result.get("status", "unknown")
             if status == "completed":
-                lines.append("**状态**: ✓ 完成")
+                lines.append(EXPLORE_STATUS_SUCCESS)
                 successful += 1
             else:
-                lines.append("**状态**: ✗ 失败")
+                lines.append(EXPLORE_STATUS_FAILED)
                 failed += 1
 
             summary = result.get("summary", "")
@@ -198,13 +211,13 @@ def _format_multi_result(
                 total_tokens += tokens
                 total_cost += cost
         else:
-            lines.append("**状态**: ✗ 异常")
+            lines.append(EXPLORE_STATUS_ERROR)
             failed += 1
 
         lines.append("")
 
     lines.append("---\n")
-    lines.append("## 总结")
+    lines.append(EXPLORE_SUMMARY_HEADER)
     lines.append(f"- 总分支数: {len(tasks)}")
     lines.append(f"- 成功: {successful}")
     lines.append(f"- 失败: {failed}")
@@ -259,32 +272,7 @@ def _build_evaluate_prompt(goal_description: str, messages: Optional[Messages])
                         parts.append(item.get("text", ""))
         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):

+ 9 - 48
agent/trace/compaction.py

@@ -18,6 +18,11 @@ from typing import List, Dict, Any, Optional, Set
 
 from .goal_models import GoalTree
 from .models import Message
+from agent.core.prompts import (
+    COMPRESSION_EVAL_PROMPT_TEMPLATE,
+    REFLECT_PROMPT,
+    build_compression_eval_prompt,
+)
 
 logger = logging.getLogger(__name__)
 
@@ -297,53 +302,8 @@ def needs_level2_compression(
 
 
 # ===== 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:
@@ -356,7 +316,7 @@ def build_compression_prompt(goal_tree: Optional[GoalTree], used_ex_ids: Optiona
     if used_ex_ids:
         ex_reference = ", ".join(used_ex_ids)
 
-    return COMPRESSION_EVAL_PROMPT.format(
+    return build_compression_eval_prompt(
         goal_tree_prompt=goal_prompt,
         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:
     """构建反思 prompt"""
     return REFLECT_PROMPT
+

+ 16 - 1
agent/trace/store.py

@@ -22,6 +22,7 @@ Sub-Trace 是完全独立的 Trace,有自己的目录:
 
 import json
 import os
+import logging
 from pathlib import Path
 from typing import Dict, List, Optional, Any
 from datetime import datetime
@@ -29,6 +30,8 @@ from datetime import datetime
 from .models import Trace, Message
 from .goal_models import GoalTree, Goal, GoalStats
 
+logger = logging.getLogger(__name__)
+
 
 class FileSystemTraceStore:
     """文件系统 Trace 存储"""
@@ -370,10 +373,22 @@ class FileSystemTraceStore:
 
         # 4. 追加 message_added 事件
         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(),
             "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
 

+ 49 - 3
agent/trace/websocket.py

@@ -6,6 +6,7 @@ Trace WebSocket 推送
 
 from typing import Dict, Set, Any
 from datetime import datetime
+import asyncio
 from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
 
 from .protocols import TraceStore
@@ -100,6 +101,7 @@ async def watch_trace(
         })
 
         # 补发历史事件(since_event_id=0 表示补发所有历史)
+        last_sent_event_id = since_event_id
         if since_event_id >= 0:
             missed_events = await store.get_events(trace_id, since_event_id)
             # 限制补发数量(最多 100 条)
@@ -111,16 +113,33 @@ async def watch_trace(
             else:
                 for evt in missed_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"])
 
-        # 保持连接(等待客户端断开或接收消息)
+        # 保持连接:同时支持心跳 + 轮询 events.jsonl(跨进程写入时也能实时推送
         while True:
             try:
-                # 接收客户端消息(心跳检测)
-                data = await websocket.receive_text()
+                # 允许在没有客户端消息时继续轮询事件流
+                data = await asyncio.wait_for(websocket.receive_text(), timeout=0.5)
                 if data == "ping":
                     await websocket.send_json({"event": "pong"})
             except WebSocketDisconnect:
                 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:
         # 清理连接
@@ -196,6 +215,33 @@ async def broadcast_goal_updated(
     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(
     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()
         print("2. 浏览器访问:")
-        print("   http://localhost:8000/api/traces")
+        print("   http://43.106.118.91:8000/api/traces")
         print()
         print(f"3. Trace ID: {current_trace_id}")
         print("=" * 60)

+ 1 - 1
examples/deep_research/run.py

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

+ 1 - 1
examples/feature_extract/run.py

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

+ 1 - 1
examples/how/run.py

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

+ 16 - 2
examples/restore/run.py

@@ -19,6 +19,10 @@ import select
 import asyncio
 from pathlib import Path
 
+# ===== 浏览器模式配置 =====
+# 可选值: "cloud" (云浏览器) 或 "local" (本地浏览器)
+BROWSER_TYPE = "cloud"  # 修改这里来切换浏览器模式
+HEADLESS = False  # 是否无头模式运行
 # Clash Verge TUN 模式兼容:禁止 httpx/urllib 自动检测系统 HTTP 代理
 # TUN 虚拟网卡已在网络层接管所有流量,不需要应用层再走 HTTP 代理,
 # 否则 httpx 检测到 macOS 系统代理 (127.0.0.1:7897) 会导致 ConnectError
@@ -33,6 +37,7 @@ load_dotenv()
 from agent.llm.prompts import SimplePrompt
 from agent.core.runner import AgentRunner, RunConfig
 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 (
     FileSystemTraceStore,
     Trace,
@@ -42,6 +47,7 @@ from agent.llm import create_openrouter_llm_call
 from agent.tools import get_tool_registry
 
 
+os.environ.setdefault("no_proxy", "*")
 # ===== 非阻塞 stdin 检测 =====
 if sys.platform == 'win32':
     import msvcrt
@@ -343,7 +349,15 @@ async def main():
     # 加载自定义工具
     print("   - 加载自定义工具: nanobanana")
     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")
     runner = AgentRunner(
         trace_store=store,
@@ -612,7 +626,7 @@ async def main():
         print("   python3 api_server.py")
         print()
         print("2. 浏览器访问:")
-        print("   http://localhost:8000/api/traces")
+        print("   http://43.106.118.91:8000/api/traces")
         print()
         print(f"3. Trace ID: {current_trace_id}")
         print("=" * 60)

+ 1 - 1
examples/restore_old/run.py

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

+ 1 - 1
examples/tool_research/run.py

@@ -488,7 +488,7 @@ async def main():
         print("   python3 api_server.py")
         print()
         print("2. 浏览器访问:")
-        print("   http://localhost:8000/api/traces")
+        print("   http://43.106.118.91:8000/api/traces")
         print()
         print(f"3. Trace ID: {current_trace_id}")
         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`
 
 ---
@@ -396,7 +396,7 @@ GET /api/traces/abc123.A/messages?goal_id=2
 
 ```javascript
 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) {
   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) => {

+ 6 - 6
frontend/htmlTemplate/templateData.py

@@ -19,7 +19,7 @@ msgGroups: Dict[str, List[Dict[str, Any]]] = {}
 
 
 def generate_trace_list(
-    base_url: str = "http://localhost:8000",
+    base_url: str = "http://43.106.118.91:8000",
     status: Optional[str] = None,
     mode: Optional[str] = None,
     limit: int = 20,
@@ -36,7 +36,7 @@ def generate_trace_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]:
     url = f"{base_url.rstrip('/')}/api/traces/{trace_id}"
     response = httpx.get(url, timeout=10.0)
@@ -45,7 +45,7 @@ def generate_goal_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]:
     url = f"{base_url.rstrip('/')}/api/traces/{sub_trace_id}"
     response = httpx.get(url, timeout=10.0)
@@ -54,7 +54,7 @@ def generate_subgoal_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]:
     url = f"{base_url.rstrip('/')}/api/traces/{trace_id}/messages"
     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]:
-    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:
         while True:
             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):
-    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")
     os.makedirs(mock_dir, exist_ok=True)
     while True:

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

@@ -253,7 +253,7 @@ preview: string | null;
 
 ### 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`
 3. 调用 `GET /api/traces/{trace_id}` 获取 GoalTree 数据
 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",
   "type": "module",
   "scripts": {
-    "dev": "vite",
+    "dev": "vite --host 0.0.0.0 --port 3000",
     "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"
   },
   "dependencies": {
@@ -42,4 +42,4 @@
     "vite": "^5.0.8",
     "vitest": "^4.0.18"
   }
-}
+}

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

@@ -190,7 +190,7 @@ interface DetailPanelState {
 
 ### 5.1 HTTP 接口
 
-**Base URL**: `http://localhost:8000`
+**Base URL**: `http://43.106.118.91:8000`
 
 #### 5.1.1 获取 Trace 列表
 
@@ -271,7 +271,7 @@ interface MessagesResponse {
 
 ### 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";
 
 export const client = axios.create({
-  baseURL: "http://localhost:8000",
+  baseURL: "http://43.106.118.91:8000",
   timeout: 10000,
   headers: {
     "Content-Type": "application/json",
@@ -552,7 +552,7 @@ export const useWebSocket = (traceId: string | null, options: UseWebSocketOption
   useEffect(() => {
     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);
 
     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";
 
 // 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 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) {
     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 { useState } from "react";
 import type { Goal } from "../../types/goal";
 import type { Edge, Message } from "../../types/message";
 import styles from "./DetailPanel.module.css";
+import { ImagePreviewModal } from "../ImagePreview/ImagePreviewModal";
+import { extractImagesFromMessage } from "../../utils/imageExtraction";
 
 interface DetailPanelProps {
   node: Goal | Message | null;
@@ -11,6 +14,26 @@ interface 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 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) {
       return (
         <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>
       );
     }
@@ -60,9 +102,7 @@ export const DetailPanel = ({ node, edge, messages = [], onClose }: DetailPanelP
             <div className={styles.knowledgeHeader}>
               <span className={styles.knowledgeId}>{item.id}</span>
               <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 && (
                   <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.value}>{isMessageNode(node) ? node.message_id || node.id : node.id}</div>
             </div>
+            {isMessageNode(node) && extractImagesFromMessage(node).length > 0 && (
+              <div className={styles.section}>
+                <div className={styles.label}>图片</div>
+                {renderImages(node)}
+              </div>
+            )}
 
             {isGoal(node) ? (
               <>
@@ -162,12 +208,10 @@ export const DetailPanel = ({ node, edge, messages = [], onClose }: DetailPanelP
                     <div className={styles.value}>{node.role}</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 && (
                   <div className={styles.section}>
                     <div className={styles.label}>所属目标</div>
@@ -198,13 +242,18 @@ export const DetailPanel = ({ node, edge, messages = [], onClose }: DetailPanelP
                 </div>
                 <div className={styles.section}>
                   <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>
+      <ImagePreviewModal
+        visible={!!previewImage}
+        onClose={() => setPreviewImage(null)}
+        src={previewImage || ""}
+      />
     </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 styles from "./styles/FlowChart.module.css";
 import { Tooltip } from "@douyinfe/semi-ui";
+import { ImagePreviewModal } from "../ImagePreview/ImagePreviewModal";
+import { extractImagesFromMessage } from "../../utils/imageExtraction";
 
 /**
  * FlowChart 组件的 Props
@@ -73,7 +75,7 @@ interface LayoutEdge {
 }
 
 const FlowChartComponent: ForwardRefRenderFunction<FlowChartRef, FlowChartProps> = (
-  { goals, msgGroups = {}, invalidBranches, onNodeClick, onSubTraceClick },
+  { goals, msgGroups = {}, invalidBranches, onNodeClick },
   ref,
 ) => {
   // 过滤掉有父节点的 goals,只保留主链节点
@@ -120,6 +122,9 @@ const FlowChartComponent: ForwardRefRenderFunction<FlowChartRef, FlowChartProps>
   // 视图模式状态:scroll (滚动) 或 panzoom (拖拽缩放)
   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));
 
@@ -523,7 +528,12 @@ const FlowChartComponent: ForwardRefRenderFunction<FlowChartRef, FlowChartProps>
             const currentBranchNodes: LayoutNode[] = [];
 
             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 = {
                 id: nodeId,
                 x: parentNode.x + X_OFFSET,
@@ -810,18 +820,27 @@ const FlowChartComponent: ForwardRefRenderFunction<FlowChartRef, FlowChartProps>
   const handleNodeClick = useCallback(
     (node: LayoutNode) => {
       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);
         onNodeClick?.(node.data as Goal);
       } else if (node.type === "message") {
@@ -829,7 +848,7 @@ const FlowChartComponent: ForwardRefRenderFunction<FlowChartRef, FlowChartProps>
         onNodeClick?.(node.data as Message);
       }
     },
-    [onNodeClick, onSubTraceClick, layoutData],
+    [onNodeClick],
   );
 
   if (!layoutData) return <div>Loading...</div>;
@@ -989,6 +1008,12 @@ const FlowChartComponent: ForwardRefRenderFunction<FlowChartRef, FlowChartProps>
                 const data = node.data as Goal;
                 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
                 if (node.type === "message") {
                   textColor = "#64748b"; // Slate 500
@@ -1034,12 +1059,27 @@ const FlowChartComponent: ForwardRefRenderFunction<FlowChartRef, FlowChartProps>
                     >
                       <Tooltip content={text}>
                         <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={{
                             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>
                       </Tooltip>
                     </foreignObject>
@@ -1086,6 +1126,12 @@ const FlowChartComponent: ForwardRefRenderFunction<FlowChartRef, FlowChartProps>
           {viewMode === "scroll" ? "切换拖拽" : "切换滚动"}
         </button>
       </div>
+
+      <ImagePreviewModal
+        visible={!!previewImage}
+        onClose={() => setPreviewImage(null)}
+        src={previewImage || ""}
+      />
     </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 { useWebSocket } from "../../../hooks/useWebSocket";
+import { request } from "../../../api/client";
 import type { Goal } from "../../../types/goal";
 import type { Message } from "../../../types/message";
 
@@ -108,90 +109,82 @@ export const useFlowChartData = (traceId: string | null, refreshTrigger?: number
     setReloading(true);
     let nextSinceEventId: number | null = null;
     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 连接
@@ -199,6 +192,7 @@ export const useFlowChartData = (traceId: string | null, refreshTrigger?: number
     } finally {
       restReloadingRef.current = false;
       setReloading(false);
+      setReadyToConnect(true);
     }
     return nextSinceEventId;
   }, [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);
         if (typeof currentEventId === "number") {
           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;
       }
 
@@ -332,7 +343,12 @@ export const useFlowChartData = (traceId: string | null, refreshTrigger?: number
           (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);
-        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;
         setGoals((prev: 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(() => {
     // 连接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;
 
     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 [isExperienceModalVisible, setIsExperienceModalVisible] = useState(false);
   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 reflectFormApiRef = useRef<{ getValues: () => { reflect_focus: string } } | null>(null);
 
@@ -74,7 +79,7 @@ export const TopBar: FC<TopBarProps> = ({
     // 加载 example 项目列表
     try {
       const data = await traceApi.fetchExamples();
-      setExampleProjects(data.projects.filter(p => p.has_prompt));
+      setExampleProjects(data.projects.filter((p) => p.has_prompt));
     } catch (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 });
       }
 
-      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();
       onTraceCreated?.();
       setIsModalVisible(false);
@@ -292,8 +303,11 @@ export const TopBar: FC<TopBarProps> = ({
         centered
         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
             field="example_project"
             label="选择示例项目(可选)"
@@ -333,8 +347,11 @@ export const TopBar: FC<TopBarProps> = ({
         centered
         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
             field="insert_prompt"
             label=" "
@@ -351,8 +368,11 @@ export const TopBar: FC<TopBarProps> = ({
         centered
         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
             field="reflect_focus"
             label=" "

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

@@ -15,11 +15,26 @@ export const useWebSocket = (traceId: string | null, options: UseWebSocketOption
   useEffect(() => {
     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);
+    let pingTimer: number | null = null;
 
     ws.onopen = () => {
       setConnected(true);
+      pingTimer = window.setInterval(() => {
+        if (ws.readyState === WebSocket.OPEN) {
+          ws.send("ping");
+        }
+      }, 15000);
     };
 
     ws.onmessage = (event) => {
@@ -37,12 +52,20 @@ export const useWebSocket = (traceId: string | null, options: UseWebSocketOption
 
     ws.onclose = () => {
       setConnected(false);
+      if (pingTimer) {
+        window.clearInterval(pingTimer);
+        pingTimer = null;
+      }
       onClose?.();
     };
 
     wsRef.current = ws;
 
     return () => {
+      if (pingTimer) {
+        window.clearInterval(pingTimer);
+        pingTimer = null;
+      }
       ws.close();
     };
   }, [traceId, onMessage, onError, onClose, sinceEventId]);

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

@@ -1,37 +1,12 @@
 import { createRoot } from "react-dom/client";
 import { ErrorBoundary } from "react-error-boundary";
-import type { FallbackProps } from "react-error-boundary";
 import App from "./App";
+import { ErrorFallback } from "./components/ErrorFallback/ErrorFallback";
 import "./styles/global.css";
 import "./styles/variables.css";
 
 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) {
   createRoot(container).render(
     <ErrorBoundary FallbackComponent={ErrorFallback}>

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

@@ -1,12 +1,26 @@
 export interface ToolCall {
   id: string;
+  type?: string;
+  function?: {
+    name: string;
+    arguments: 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 {
   text?: string;
   tool_calls?: ToolCall[];
+  tool_name?: string;
+  result?: string | MsgResult[];
 }
 
 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,
     proxy: {
       "/api": {
-        target: "http://localhost:8000",
+        target: "http://43.106.118.91:8000",
         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: