فهرست منبع

Merge remote-tracking branch 'origin/main'

# Conflicts:
#	knowhub/frontend/src/App.tsx
#	knowhub/frontend/src/layouts/MainLayout.tsx
#	knowhub/frontend/src/pages/Dashboard.tsx
#	knowhub/server.py
elksmmx 18 ساعت پیش
والد
کامیت
1efee0b8d9
91فایلهای تغییر یافته به همراه5914 افزوده شده و 3543 حذف شده
  1. 19 3
      .gitignore
  2. 0 507
      README.md
  3. 123 1
      agent/README.md
  4. 1 2
      agent/core/__init__.py
  5. 14 1
      agent/core/presets.py
  6. 14 104
      agent/core/runner.py
  7. 475 0
      agent/docs/tools-refactor-plan.md
  8. 271 2
      agent/docs/tools.md
  9. 9 0
      agent/llm/gemini.py
  10. 29 23
      agent/skill/skills/browser.md
  11. 30 24
      agent/skill/skills/core.md
  12. 5 5
      agent/skill/skills/research.md
  13. 16 19
      agent/tools/builtin/__init__.py
  14. 1 1
      agent/tools/builtin/bash.py
  15. 30 93
      agent/tools/builtin/browser/__init__.py
  16. 334 77
      agent/tools/builtin/browser/baseClass.py
  17. 29 0
      agent/tools/builtin/content/__init__.py
  18. 86 0
      agent/tools/builtin/content/cache.py
  19. 46 0
      agent/tools/builtin/content/ingestion.py
  20. 114 0
      agent/tools/builtin/content/media.py
  21. 1 0
      agent/tools/builtin/content/platforms/__init__.py
  22. 237 0
      agent/tools/builtin/content/platforms/aigc_channel.py
  23. 129 0
      agent/tools/builtin/content/platforms/x.py
  24. 203 0
      agent/tools/builtin/content/platforms/youtube.py
  25. 125 0
      agent/tools/builtin/content/registry.py
  26. 266 0
      agent/tools/builtin/content/tools.py
  27. 2 1
      agent/tools/builtin/context.py
  28. 0 595
      agent/tools/builtin/crawler.py
  29. 4 0
      agent/tools/builtin/feishu/chat.py
  30. 0 5
      agent/tools/builtin/feishu/chat_history/chat_summary.json
  31. 0 103
      agent/tools/builtin/feishu/chat_history/chat_关涛.json
  32. 0 32
      agent/tools/builtin/feishu/chat_history/chat_孙若天.json
  33. 0 22
      agent/tools/builtin/feishu/chat_history/chat_谭景玉.json
  34. 1 1
      agent/tools/builtin/file/edit.py
  35. 1 1
      agent/tools/builtin/file/grep.py
  36. 5 2
      agent/tools/builtin/file/read.py
  37. 321 0
      agent/tools/builtin/file/read_images.py
  38. 1 1
      agent/tools/builtin/file/write.py
  39. 1 1
      agent/tools/builtin/glob_tool.py
  40. 8 0
      agent/tools/builtin/im/chat.py
  41. 153 45
      agent/tools/builtin/knowledge.py
  42. 56 4
      agent/tools/builtin/librarian.py
  43. 4 2
      agent/tools/builtin/resource.py
  44. 0 418
      agent/tools/builtin/sandbox.py
  45. 0 535
      agent/tools/builtin/search.py
  46. 4 2
      agent/tools/builtin/skill.py
  47. 2 2
      agent/tools/builtin/subagent.py
  48. 225 114
      agent/tools/builtin/toolhub.py
  49. 34 10
      agent/tools/registry.py
  50. 1 0
      agent/tools/utils/__init__.py
  51. 289 0
      agent/tools/utils/image.py
  52. 1 1
      agent/trace/goal_tool.py
  53. 0 45
      consistency_requirements.json
  54. 10 5
      examples/mini_restore/call_banana.py
  55. 1 0
      examples/mini_restore/config.py
  56. 19 0
      examples/mini_restore/history.json
  57. 153 125
      examples/mini_restore/workflow_loop.py
  58. 3 2
      examples/production_restore/config.py
  59. 24 9
      examples/production_restore/execution.prompt
  60. 0 100
      examples/production_restore/input/analysis.json
  61. 0 194
      examples/production_restore/input/pipeline.json
  62. 0 44
      examples/production_restore/input/strategy.json
  63. 0 0
      examples/production_restore/input/写生油画__img_1_制作表.json
  64. 0 0
      examples/production_restore/input/写生油画__img_2_制作表.json
  65. 0 0
      examples/production_restore/input/写生油画__img_3_制作表.json
  66. 0 0
      examples/production_restore/input/写生油画__img_4_制作表.json
  67. 0 0
      examples/production_restore/input/写生油画__img_5_制作表.json
  68. 0 0
      examples/production_restore/input/创作表.md
  69. 0 0
      examples/production_restore/input/制作点.md
  70. 0 0
      examples/production_restore/input/图片亮点.md
  71. 23 3
      examples/production_restore/presets.json
  72. 279 51
      examples/production_restore/requirement.prompt
  73. 141 0
      examples/production_restore/research.prompt
  74. 2 2
      examples/production_restore/run.py
  75. 0 107
      frontend/htmlTemplate/api_data/goal_list.json
  76. 0 28
      frontend/htmlTemplate/api_data/trace_list.json
  77. 0 0
      frontend/htmlTemplate/ws_data/event.jsonl
  78. 0 28
      frontend/htmlTemplate/ws_data/trace_list.json
  79. 13 0
      im-server/__main__.py
  80. 5 1
      knowhub/agents/librarian.py
  81. 13 3
      knowhub/agents/librarian_agent.prompt
  82. 163 0
      knowhub/agents/research.py
  83. 141 0
      knowhub/agents/research_agent.prompt
  84. 444 0
      knowhub/docs/knowledge-management.md
  85. 0 1
      knowhub/internal_tools/__init__.py
  86. 137 21
      knowhub/internal_tools/cache_manager.py
  87. 224 0
      knowhub/internal_tools/capability_extractor.py
  88. 37 5
      knowhub/knowhub_db/pg_store.py
  89. 362 0
      knowhub/research/outdoor_character_workflow_summary_20260409.md
  90. BIN
      log.txt
  91. 0 10
      log2.txt

+ 19 - 3
.gitignore

@@ -1,5 +1,6 @@
 # API-KEY
 .env
+.mcp.json
 
 # Python
 __pycache__/
@@ -62,11 +63,26 @@ output
 .trace_test/
 .trace_test2/
 examples/**/output*/
+outputs/
 
 frontend/htmlTemplate/mock_data
+frontend/htmlTemplate/api_data/
+frontend/htmlTemplate/ws_data/
 frontend/react-template/yarn.lock
 frontend/react-template/node_modules/
 
+# Feishu 运行时聊天记录(自动维护,包含联系人 PII)
+agent/tools/builtin/feishu/chat_history/
+
+# Runtime artifacts (one-off scripts, data, cache)
+cache/
+pending_uploads/
+knowledge/
+knowledge_batch_*.json
+knowledge-*.json
+tools/image_gen/
+tools/upload/
+
 # data
 knowhub/knowhub.db
 knowhub/knowhub.db-shm
@@ -75,11 +91,11 @@ examples/archive/*
 examples/research/
 examples/downloader/
 examples/production_restore/features/
-# Milvus data
-knowhub/milvus_data/
+# im-server data
+data/
 
 # Vendor (non-submodule)
 vendor/browser-use/
 
 # im-client data
-data/
+data/.mcp.json

+ 0 - 507
README.md

@@ -1,507 +0,0 @@
-# Reson Agent
-
-可扩展的 Agent 框架。支持多步工具调用、计划管理、子 Agent 协作、回溯重跑和上下文压缩。
-
-## Quick Start
-
-```bash
-pip install -r requirements.txt
-
-# 配置 LLM API Key
-cp .env.example .env  # 编辑填入 API Key
-```
-
-### 最小示例
-
-```python
-import asyncio
-from agent import AgentRunner, RunConfig
-from agent.trace import FileSystemTraceStore
-from agent.llm import create_openrouter_llm_call
-
-runner = AgentRunner(
-    trace_store=FileSystemTraceStore(base_path=".trace"),
-    llm_call=create_openrouter_llm_call(model="anthropic/claude-sonnet-4.5"),
-)
-
-async def main():
-    async for item in runner.run(
-        messages=[{"role": "user", "content": "列出当前目录的文件"}],
-        config=RunConfig(model="anthropic/claude-sonnet-4.5"),
-    ):
-        print(item)
-
-asyncio.run(main())
-```
-
-## 自定义工具
-
-用 `@tool` 装饰器注册。`RunConfig(tools=None)`(默认)时所有已注册工具自动对 LLM 可用,无需额外配置。
-
-```python
-from agent import tool, ToolResult
-
-@tool(description="查询产品库存")
-async def check_inventory(product_id: str, warehouse: str = "default") -> ToolResult:
-    """查询指定仓库的产品库存
-
-    Args:
-        product_id: 产品唯一标识符
-        warehouse: 仓库编码,默认为主仓库
-    """
-    stock = await query_db(product_id, warehouse)
-    return ToolResult(output=f"库存: {stock}")
-
-# 确保此模块在 runner.run() 之前被 import
-```
-
-**注意**: `@tool` 通过副作用注册到全局 registry,必须确保定义工具的模块在调用 `runner.run()` 前被 import。
-
-### 参数 Schema 生成
-
-框架从函数签名和 docstring 自动生成 OpenAI Tool Schema,无需手写 JSON:
-
-- **参数类型**:从类型注解推断(`str`/`int`/`float`/`bool`/`list`/`dict`,支持 `Optional`、`Literal`、`List[T]`)
-- **参数描述**:从 Google 风格 docstring 的 `Args:` 段提取
-- **必填/可选**:有默认值的参数为可选,否则为必填
-- **工具描述**:优先使用 `@tool(description=...)` 参数,其次取 docstring 首行
-- `uid` 和 `context` 参数由框架自动注入,不会出现在 Schema 中
-
-上面的 `check_inventory` 会生成:
-
-```json
-{
-  "type": "function",
-  "function": {
-    "name": "check_inventory",
-    "description": "查询产品库存",
-    "parameters": {
-      "type": "object",
-      "properties": {
-        "product_id": { "type": "string", "description": "产品唯一标识符" },
-        "warehouse": {
-          "type": "string",
-          "description": "仓库编码,默认为主仓库",
-          "default": "default"
-        }
-      },
-      "required": ["product_id"]
-    }
-  }
-}
-```
-
-### 限制工具范围
-
-```python
-# 只启用指定工具(在内置工具基础上追加)
-config = RunConfig(tools=["check_inventory", "another_tool"])
-```
-
-## 自定义 Skills
-
-Skills 是 Markdown 文件,提供领域知识,注入到 system prompt。
-
-```
-my_project/
-└── skills/
-    └── my_domain.md
-```
-
-```markdown
----
-name: my-domain-skill
-description: 领域专属知识
----
-
-## Guidelines
-
-- 规则 1
-- 规则 2
-```
-
-```python
-runner = AgentRunner(
-    llm_call=...,
-    trace_store=...,
-    skills_dir="./skills",  # 指向你的 skills 目录
-)
-```
-
-内置 skills(`agent/skill/skills/`)始终自动加载,`skills_dir` 的内容额外追加。
-
-## 知识管理系统(Knowledge Management)
-
-知识管理系统通过**提取、存储、注入**三个环节,让 Agent 积累和复用结构化知识。
-
-### 核心流程
-
-**1. 提取(Extract)**
-
-- **触发时机**:
-  - 压缩时提取:消息量超阈值触发压缩时,在 Level 1 过滤前用完整 history 反思
-  - 完成时提取:Agent 运行完成后(不代表任务完成,可能中途退出等待人工评估)
-- **提取方式**:调用 LLM 对执行过程进行反思,提取可复用的知识
-- **自定义 Prompt**:可通过配置自定义反思 prompt,空则使用默认(见 `agent/core/prompts/knowledge.py`)
-
-**2. 存储(Store)**
-
-- **存储位置**:KnowHub 服务(默认 `http://localhost:8765`)
-- **知识结构**:
-  - `title`: 知识标题
-  - `content`: 知识内容
-  - `type`: 知识类型(strategy/tool/pattern/pitfall 等)
-  - `tags`: 标签(键值对,用于分类和检索)
-  - `scopes`: 作用域(如 `org:cybertogether`)
-  - `owner`: 所有者(默认从 git config user.email 获取)
-  - `resource_ids`: 关联资源 ID 列表(代码片段、凭证、cookies 等)
-- **资源管理**:
-  - 知识可关联多个资源(通过 `resource_ids` 字段)
-  - 资源包含 `body`(公开内容)和 `secure_body`(加密内容)
-  - 支持代码片段、API 凭证、cookies 等多种资源类型
-
-**3. 注入(Inject)**
-
-- **触发时机**:Agent 切换当前工作的 Goal 时自动触发
-- **检索策略**:基于 Goal 描述和上下文,从知识库检索相关知识
-- **注入方式**:将检索到的知识注入到 Agent 的上下文中
-
-### 配置
-
-知识管理配置通过 `RunConfig.knowledge` 传递:
-
-```python
-from agent.core.runner import KnowledgeConfig, RunConfig
-
-run_config = RunConfig(
-    model="claude-sonnet-4.5",
-    temperature=0.3,
-    max_iterations=1000,
-
-    knowledge=KnowledgeConfig(
-        # 压缩时提取(消息量超阈值触发压缩时,用完整 history 反思)
-        enable_extraction=True,
-        reflect_prompt="",  # 空则使用默认,见 agent/core/prompts/knowledge.py:REFLECT_PROMPT
-
-        # agent运行完成后提取
-        enable_completion_extraction=True,
-        completion_reflect_prompt="",  # 空则使用默认
-
-        # 知识注入(agent切换当前工作的goal时,自动注入相关知识)
-        enable_injection=True,
-
-        # 默认字段(保存/搜索时自动注入)
-        owner="",  # 空则从 git config user.email 获取(隐藏参数,LLM 不可见)
-        default_tags={"project": "my_project"},  # 与 LLM 传递的 tags 合并
-        default_scopes=["org:cybertogether"],  # 与 LLM 传递的 scopes 合并
-        default_search_types=["strategy", "tool"],
-        default_search_owner=""  # 空则不过滤
-    )
-)
-```
-
-**参数注入规则**(通过框架 `inject_params` 机制实现,详见 `agent/docs/tools.md`):
-
-- `owner`:隐藏参数,LLM 不可见,框架自动注入(`mode: default`)
-- `tags`:LLM 可追加新 key,框架默认 key 不可被覆盖(`mode: merge`)
-- `scopes`:LLM 可追加,与框架默认值合并去重(`mode: merge`)
-
-### 知识工具
-
-框架提供以下内置工具用于知识管理:
-
-- `knowledge_save`: 保存知识到知识库
-- `knowledge_search`: 搜索知识库
-- `knowledge_get`: 获取指定知识详情
-- `resource_save`: 保存资源(代码、凭证等)
-- `resource_get`: 获取资源内容
-
-这些工具会自动注入配置的默认字段(owner, tags, scopes 等)。
-
-## AgentRunner 参数
-
-```python
-AgentRunner(
-    llm_call,                # 必需:LLM 调用函数
-    trace_store=None,        # Trace 持久化(推荐 FileSystemTraceStore)
-    tool_registry=None,      # 工具注册表(默认:全局 registry)
-    skills_dir=None,         # 自定义 skills 目录
-    utility_llm_call=None,   # 轻量 LLM(生成任务标题等)
-    debug=False,             # 调试模式
-)
-```
-
-## RunConfig 参数
-
-```python
-RunConfig(
-    model="gpt-4o",          # 模型标识
-    temperature=0.3,
-    max_iterations=200,       # Agent loop 最大轮数
-    tools=None,               # None=全部已注册工具,List[str]=内置+指定工具
-    system_prompt=None,       # None=从 skills 自动构建
-    agent_type="default",     # 预设类型:default / explore / analyst
-    trace_id=None,            # 续跑/回溯时传入已有 trace ID
-    after_sequence=None,      # 从哪条消息后续跑(message sequence)
-    goal_compression="on_overflow",  # Goal 压缩模式:none / on_complete / on_overflow
-    knowledge=KnowledgeConfig(),  # 知识管理配置
-)
-```
-
-    system_prompt=None,       # None=从 skills 自动构建
-    agent_type="default",     # 预设类型:default / explore / analyst
-    trace_id=None,            # 续跑/回溯时传入已有 trace ID
-    after_sequence=None,      # 从哪条消息后续跑(message sequence)
-
-)
-
-````
-
-## LLM Providers
-
-框架内置两个 provider:
-
-```python
-from agent.llm import create_openrouter_llm_call, create_gemini_llm_call
-
-# OpenRouter(支持多种模型)
-llm = create_openrouter_llm_call(model="anthropic/claude-sonnet-4.5")
-
-# Google Gemini
-llm = create_gemini_llm_call(model="gemini-2.5-flash")
-````
-
-自定义 provider 只需实现签名:
-
-```python
-async def my_llm_call(messages, model, tools, temperature, **kwargs) -> dict:
-    # 调用你的 LLM
-    return {
-        "content": "...",
-        "tool_calls": [...] or None,
-        "prompt_tokens": 100,
-        "completion_tokens": 50,
-        "cost": 0.001,
-        "finish_reason": "stop",
-    }
-```
-
-## API Server
-
-```bash
-python api_server.py
-```
-
-| 方法 | 路径                        | 说明        |
-| ---- | --------------------------- | ----------- |
-| GET  | `/api/traces`               | 列出 Traces |
-| GET  | `/api/traces/{id}`          | Trace 详情  |
-| GET  | `/api/traces/{id}/messages` | 消息列表    |
-| POST | `/api/traces`               | 新建并执行  |
-| POST | `/api/traces/{id}/run`      | 续跑/回溯   |
-| POST | `/api/traces/{id}/stop`     | 停止        |
-| WS   | `/api/traces/{id}/watch`    | 实时事件    |
-
-需在 `api_server.py` 中配置 Runner 才能启用 POST 端点。
-
-## 项目结构
-
-```
-agent/
-├── core/           # AgentRunner + 预设
-├── tools/          # 工具系统(registry + 内置工具)
-├── trace/          # 执行追踪 + 计划(GoalTree)+ API
-├── skill/          # Skills(技能系统)
-└── llm/            # LLM Provider 适配
-```
-
-详细架构文档:[docs/README.md](./docs/README.md)
-
-## Agent 预设(Presets)
-
-预设定义不同类型 Agent 的配置模板(工具权限、system prompt、skills 等)。
-
-**项目级预设**:在项目目录创建 `presets.json`,支持从 `.prompt` 文件加载自定义 system prompt:
-
-```json
-{
-  "tool_research": {
-    "system_prompt_file": "tool_research.prompt",
-    "max_iterations": 50,
-    "skills": ["planning", "research", "browser"],
-    "description": "工具调研 Agent"
-  }
-}
-```
-
-**加载预设**:
-```python
-from agent.core.presets import load_presets_from_json
-
-load_presets_from_json("path/to/presets.json")
-```
-
-**使用预设**:LLM 调用 `agent` 工具时指定 `agent_type`:
-```python
-agent(task="调研视频生成工具", agent_type="tool_research")
-```
-
-详见 `examples/production/` 示例和 `agent/docs/architecture.md`。
-
-## 交互式 CLI(Interactive CLI)
-
-框架提供交互式控制器,支持实时监控、手动干预和经验总结。
-
-### 使用方式
-
-```python
-from agent.cli import InteractiveController
-
-# 创建交互控制器
-interactive = InteractiveController(
-    runner=runner,
-    store=store,
-    enable_stdin_check=True  # 启用标准输入检查
-)
-
-# 在执行循环中检查用户输入
-async for item in runner.run(messages=messages, config=config):
-    cmd = interactive.check_stdin()
-    if cmd == 'pause':
-        await runner.stop(trace_id)
-        menu_result = await interactive.show_menu(trace_id, current_sequence)
-        # 处理菜单结果...
-    elif cmd == 'quit':
-        await runner.stop(trace_id)
-        break
-```
-
-### 交互控制
-
-在执行过程中,可以通过命令行实时控制:
-
-| 按键          | 动作         | 说明                              |
-| ------------- | ------------ | --------------------------------- |
-| `p` / `pause` | **暂停执行** | 立即挂起 Agent 循环,进入交互菜单 |
-| `q` / `quit`  | **停止执行** | 安全停止并保存当前的执行状态      |
-
-### 交互菜单功能
-
-进入暂停模式后,系统提供以下操作:
-
-1. **插入干预消息**:直接向 Agent 下达新指令
-2. **触发经验总结 (Reflect)**:强制 Agent 对当前过程进行反思
-3. **查看 GoalTree**:可视化当前任务的拆解结构和完成进度
-4. **上下文压缩 (Compact)**:手动精简对话历史
-
-### 项目配置示例
-
-完整的项目配置示例见 `examples/research/config.py`:
-
-```python
-from agent.core.runner import KnowledgeConfig, RunConfig
-from agent.utils import setup_logging
-
-# Agent 运行配置
-RUN_CONFIG = RunConfig(
-    model="claude-sonnet-4.5",
-    temperature=0.3,
-    max_iterations=1000,
-    name="Research Agent",
-
-    knowledge=KnowledgeConfig(
-        enable_extraction=True,
-        enable_completion_extraction=True,
-        enable_injection=True,
-        owner="",  # 空则从 git config 获取
-        default_tags={"project": "research"},
-        default_scopes=["org:cybertogether"],
-        default_search_types=["strategy", "tool"],
-    )
-)
-
-# 基础设施配置
-SKILLS_DIR = "./skills"
-TRACE_STORE_PATH = ".trace"
-DEBUG = True
-LOG_LEVEL = "INFO"
-LOG_FILE = None  # 可设置为文件路径
-
-# 在 run.py 中使用
-setup_logging(level=LOG_LEVEL, file=LOG_FILE)
-
-runner = AgentRunner(
-    trace_store=FileSystemTraceStore(base_path=TRACE_STORE_PATH),
-    llm_call=create_openrouter_llm_call(model=f"anthropic/{RUN_CONFIG.model}"),
-    skills_dir=SKILLS_DIR,
-    debug=DEBUG
-)
-
-async for item in runner.run(messages=messages, config=RUN_CONFIG):
-    # 处理执行结果
-    pass
-```
-
-**配置说明**:
-
-- 直接使用框架的 `RunConfig` 和 `KnowledgeConfig`,不需要自定义配置类
-- 基础设施配置(skills_dir, trace_store_path 等)用简单变量定义
-- 使用 `agent.utils.setup_logging()` 配置日志
-
-## 任务可视化与调试
-
-框架在运行期间会生成唯一的 `trace_id`。
-
-- **本地日志**:所有的执行细节、工具调用和 Goal 状态均持久化在 `.trace/` 目录下。
-- **Web 可视化**:
-
-1. 启动服务器:`python api_server.py`
-2. 启动前端:
-
-```
-  cd frontend/react-template
-  yarn
-  yarn dev
-```
-
-3. 访问控制台:`http://localhost:3000`
-4. 在前端界面中切换任务,即直观追踪 Agent 的思考链路。
-5. 因为该可视化读取的是根目录下的.trace文件,建议运行项目时,可以在根目录下用命令行运行`python examples/[project_name]/run.py`,使运行得到的trace存放在根目录
-
-### 提示:目前前端可视化只供观看本地运行过的trace结果,新任务运行等功能正在开发中,运行可在命令行中执行
-
-### 绿色节点为整体的goal(目标),蓝色节点为子goal(目标),灰色节点为基础信息节点。点击蓝色边/绿色边会折叠节点,点击节点会在右侧显示详情。
-
----
-
-## 示例项目结构
-
-可以参考其他文件夹中的结构:
-
-```text
-examples/[your_example]/
-├── input/             # (可选)输入数据
-├── output_1/          # (可选)输出目录
-├── skills/            # (可选)领域专属 Skill (.md)
-├── tool/              # (可选)自定义工具
-├── presets.json       # (可选)预定义的子 Agent 配置
-├── config.py          # (推荐)项目配置
-├── [task].prompt      # (必须)任务 System Prompt 和 User Prompt
-└── run.py             # (必须)交互式运行入口
-```
-
----
-
-## 环境兼容性
-
-针对 Clash Verge / TUN 模式等网络环境,本项目已内置代理自动避让逻辑:
-
-- **代理优化**:通过 `no_proxy` 配置防止 `httpx` 错误引导流量。
-- **Browser 模式**:支持 `cloud` (远程) 和 `local` (本地) 模式切换。
-
-## 运行结果存储
-
-运行过程中,会自动存储以下内容:
-
-- **运行轨迹**:根目录下 `.trace/` 文件夹下的实际运行路径结果
-- **知识库**:KnowHub 服务中保存的知识条目(通过 API 访问)

+ 123 - 1
agent/README.md

@@ -121,7 +121,8 @@ RunConfig(
     max_iterations=200,
     agent_type="default",           # Agent 预设(对应 presets.json)
     name="任务名称",
-    tools=["read_file", "bash"],    # 限制可用工具(None=全部)
+    tools=None,                      # 精确指定工具列表(优先于 tool_groups)
+    tool_groups=["core", "browser"], # 工具分组白名单,默认 ["core"]
     goal_compression="on_overflow", # Goal 压缩:"none" / "on_complete" / "on_overflow"
     knowledge=KnowledgeConfig(...), # 知识提取配置
 )
@@ -203,3 +204,124 @@ examples/research/
 | POST | `/api/traces/{id}/stop` | 停止运行 |
 
 **实现**:`agent/trace/api.py`, `agent/trace/run_api.py`
+
+---
+
+## 附录:工具分组
+
+通过 `RunConfig(tool_groups=[...])` 控制 Agent 可用的工具范围。默认仅 `["core"]`。每个工具在 `@tool(groups=[...])` 中声明分组,支持多标签。
+
+机制详见 [docs/tools.md § 工具分组](./docs/tools.md#工具分组)。
+
+### core — 基础能力(13)
+
+| 工具 | 说明 |
+|------|------|
+| `read_file` | 读取单个文件(文本/图片/PDF) |
+| `read_images` | 批量读图 + 网格拼图 |
+| `edit_file` | 编辑文件 |
+| `write_file` | 写入文件 |
+| `glob_files` | 文件模式匹配 |
+| `grep_content` | 内容搜索(正则) |
+| `bash_command` | 执行 shell 命令 |
+| `skill` | 调用 skill |
+| `list_skills` | 列出可用 skill |
+| `agent` | 创建子 Agent |
+| `evaluate` | 评估执行结果 |
+| `goal` | 目标/计划管理 |
+| `get_current_context` | 获取当前执行上下文 |
+
+### browser — 浏览器自动化(14)
+
+| 工具 | 说明 |
+|------|------|
+| `browser_navigate` | 导航到 URL |
+| `browser_search` | 搜索引擎搜索 |
+| `browser_back` | 返回上一页 |
+| `browser_interact` | 元素交互(click/type/send_keys/upload/dropdown) |
+| `browser_scroll` | 滚动页面 |
+| `browser_screenshot` | 截图(可带元素编号标注) |
+| `browser_elements` | 获取可交互元素列表 |
+| `browser_read` | 读取页面内容(html/find/long) |
+| `browser_extract` | LLM 驱动的结构化数据提取 |
+| `browser_tabs` | 标签页管理(switch/close) |
+| `browser_cookies` | Cookie/登录态(load/export/ensure_login) |
+| `browser_wait` | 等待(定时/等用户操作) |
+| `browser_js` | 执行 JavaScript |
+| `browser_download` | 下载文件 |
+
+### content — 内容搜索(6)
+
+| 工具 | 说明 |
+|------|------|
+| `content_platforms` | 列出/查询平台及参数(支持模糊匹配) |
+| `content_search` | 跨 11 平台搜索(小红书/B站/知乎/GitHub/YouTube/X 等) |
+| `content_detail` | 查看内容详情(从搜索缓存按索引取) |
+| `content_suggest` | 搜索关键词补全建议 |
+| `extract_video_clip` | YouTube 视频片段截取 |
+| `import_content` | 批量导入文章到 CMS |
+
+### knowledge — 知识管理(2)
+
+| 工具 | 说明 |
+|------|------|
+| `ask_knowledge` | 向知识库查询(通过 Librarian Agent) |
+| `upload_knowledge` | 上传调研结果到知识库 |
+
+### toolhub — 远程工具库(3)
+
+| 工具 | 说明 |
+|------|------|
+| `toolhub_health` | 检查远程工具库状态 |
+| `toolhub_search` | 搜索远程 AI 工具 |
+| `toolhub_call` | 调用远程工具(图片参数支持本地路径) |
+
+### feishu — 飞书(4)
+
+| 工具 | 说明 |
+|------|------|
+| `feishu_get_contact_list` | 获取联系人列表 |
+| `feishu_send_message_to_contact` | 给联系人发消息 |
+| `feishu_get_contact_replies` | 获取联系人回复 |
+| `feishu_get_chat_history` | 获取聊天历史 |
+
+### im — IM 通信(8)
+
+| 工具 | 说明 |
+|------|------|
+| `im_setup` | 初始化 IM 连接 |
+| `im_send_message` | 发送消息 |
+| `im_receive_messages` | 接收消息 |
+| `im_check_notification` | 检查通知 |
+| `im_get_contacts` | 获取联系人 |
+| `im_get_chat_history` | 获取聊天历史 |
+| `im_open_window` | 打开 IM 窗口 |
+| `im_close_window` | 关闭 IM 窗口 |
+
+### resource — 资源查询(2)
+
+| 工具 | 说明 |
+|------|------|
+| `resource_list_tools` | 列出资源工具 |
+| `resource_get_tool` | 获取工具详情 |
+
+### knowledge_internal — 知识库内部操作(14)
+
+> 仅供 Librarian Agent 内部使用,普通 Agent 不可见。通过 `tools=[...]` 精确指定访问。
+
+| 工具 | 说明 |
+|------|------|
+| `knowledge_search` | 知识检索(语义 + 精排) |
+| `knowledge_save` | 保存知识条目 |
+| `knowledge_list` | 列出知识 |
+| `knowledge_update` | 更新知识反馈 |
+| `knowledge_batch_update` | 批量更新反馈 |
+| `knowledge_slim` | 知识瘦身 |
+| `resource_save` | 保存资源 |
+| `resource_get` | 获取资源 |
+| `tool_search` | 搜索工具记录 |
+| `tool_list` | 列出工具记录 |
+| `capability_search` | 搜索能力记录 |
+| `capability_list` | 列出能力记录 |
+| `requirement_search` | 搜索需求记录 |
+| `requirement_list` | 列出需求记录 |

+ 1 - 2
agent/core/__init__.py

@@ -7,7 +7,7 @@ Agent Core - 核心引擎模块
 3. Agent 预设(AgentPreset)
 """
 
-from agent.core.runner import AgentRunner, BUILTIN_TOOLS, CallResult, RunConfig
+from agent.core.runner import AgentRunner, CallResult, RunConfig
 from agent.core.presets import (
     AgentPreset,
     AGENT_PRESETS,
@@ -19,7 +19,6 @@ from agent.core.presets import (
 
 __all__ = [
     "AgentRunner",
-    "BUILTIN_TOOLS",
     "CallResult",
     "RunConfig",
     "AgentPreset",

+ 14 - 1
agent/core/presets.py

@@ -110,6 +110,7 @@ def load_presets_from_json(json_path: str) -> None:
 
     支持特殊字段:
     - system_prompt_file: 从 .prompt 文件加载 system prompt(相对于 JSON 文件所在目录)
+    - prompt_vars: 变量字典,用于替换 prompt 中的 %variable% 占位符
 
     Args:
         json_path: presets.json 文件路径
@@ -126,11 +127,23 @@ def load_presets_from_json(json_path: str) -> None:
     base_dir = json_path.parent
 
     for name, cfg in presets_data.items():
+        # 提取 prompt_vars(不传给 AgentPreset)
+        prompt_vars = cfg.pop("prompt_vars", None)
+
         # 处理 system_prompt_file
         if "system_prompt_file" in cfg:
             prompt_file = cfg.pop("system_prompt_file")
             prompt_path = base_dir / prompt_file
-            cfg["system_prompt"] = load_system_prompt_from_file(str(prompt_path))
+            system_prompt = load_system_prompt_from_file(str(prompt_path))
+
+            # 应用变量替换
+            if prompt_vars and isinstance(prompt_vars, dict):
+                for var_name, var_value in prompt_vars.items():
+                    placeholder = f"%{var_name}%"
+                    if placeholder in system_prompt:
+                        system_prompt = system_prompt.replace(placeholder, str(var_value))
+
+            cfg["system_prompt"] = system_prompt
 
         preset = AgentPreset(**cfg)
         register_preset(name, preset)

+ 14 - 104
agent/core/runner.py

@@ -103,7 +103,8 @@ class RunConfig:
     model: str = "gpt-4o"
     temperature: float = 0.3
     max_iterations: int = 200
-    tools: Optional[List[str]] = None          # None = 全部已注册工具
+    tools: Optional[List[str]] = None          # None = 按 tool_groups 过滤;显式列表 = 精确指定
+    tool_groups: Optional[List[str]] = field(default_factory=lambda: ["core"])  # 工具分组白名单;默认仅 core,项目按需追加
     side_branch_max_turns: int = 5             # 侧分支最大轮次(压缩/反思)
     goal_compression: Literal["none", "on_complete", "on_overflow"] = "on_overflow"  # Goal 压缩模式
 
@@ -141,96 +142,8 @@ class RunConfig:
     knowledge: KnowledgeConfig = field(default_factory=KnowledgeConfig)
 
 
-    # 内置工具列表(始终自动加载)
-BUILTIN_TOOLS = [
-    # 文件操作工具
-    "read_file",
-    "edit_file",
-    "write_file",
-    "glob_files",
-    "grep_content",
-
-    # 系统工具
-    "bash_command",
-
-    # 技能和目标管理
-    "skill",
-    "list_skills",
-    "goal",
-    "agent",
-    "evaluate",
-    "get_current_context",
-
-    # 搜索工具
-    "search_posts",
-    "select_post",
-    "get_search_suggestions",
-    "x_search",
-    "youtube_search",
-    "youtube_detail",
-    "import_content",
-
-    # 知识管理工具
-    "ask_knowledge",
-    "upload_knowledge",
-    # "knowledge_search",
-    # "knowledge_save",
-    # "knowledge_update",
-    # "knowledge_batch_update",
-    # "knowledge_list",
-    # "knowledge_slim",
-    
-
-    # 沙箱工具
-    # "sandbox_create_environment",
-    # "sandbox_run_shell",
-    # "sandbox_rebuild_with_ports",
-    # "sandbox_destroy_environment",
-
-    # 浏览器工具
-    "browser_get_live_url",
-    "browser_navigate_to_url",
-    "browser_search_web",
-    "browser_go_back",
-    "browser_wait",
-    "browser_click_element",
-    "browser_input_text",
-    "browser_send_keys",
-    "browser_upload_file",
-    "browser_scroll_page",
-    "browser_find_text",
-    "browser_screenshot",
-    "browser_switch_tab",
-    "browser_close_tab",
-    "browser_get_dropdown_options",
-    "browser_select_dropdown_option",
-    "browser_extract_content",
-    "browser_read_long_content",
-    "browser_download_direct_url",
-    "browser_get_page_html",
-    "browser_get_visual_selector_map",
-    "browser_evaluate",
-    "browser_ensure_login_with_cookies",
-    # 可以暂时由飞书消息替代
-    #"browser_wait_for_user_action",
-    "browser_done",
-    "browser_export_cookies",
-    "browser_load_cookies",
-
-    # 飞书工具
-    "feishu_send_message_to_contact",
-    "feishu_get_chat_history",
-    "feishu_get_contact_replies",
-    "feishu_get_contact_list",
-
-    # IM 工具
-    "im_setup",
-    "im_check_notification",
-    "im_receive_messages",
-    "im_send_message",
-    "im_get_contacts",
-    "im_get_chat_history",
-]
+    # BUILTIN_TOOLS 硬编码列表已移除(2026-04)。
+    # 工具可用性现在由 @tool(groups=[...]) 声明 + RunConfig.tool_groups 过滤控制。
 
 
 @dataclass
@@ -558,7 +471,7 @@ class AgentRunner:
         task_name = config.name or await self._generate_task_name(messages)
 
         # 准备工具 Schema
-        tool_schemas = self._get_tool_schemas(config.tools)
+        tool_schemas = self._get_tool_schemas(config.tools, config.tool_groups)
 
         trace_obj = Trace(
             trace_id=trace_id,
@@ -1092,7 +1005,7 @@ class AgentRunner:
     ) -> AsyncIterator[Union[Trace, Message]]:
         """ReAct 循环"""
         trace_id = trace.trace_id
-        tool_schemas = self._get_tool_schemas(config.tools)
+        tool_schemas = self._get_tool_schemas(config.tools, config.tool_groups)
 
         # 当前主路径头节点的 sequence(用于设置 parent_sequence)
         head_seq = trace.head_sequence
@@ -2844,22 +2757,19 @@ class AgentRunner:
         )
         return messages
 
-    def _get_tool_schemas(self, tools: Optional[List[str]]) -> List[Dict]:
+    def _get_tool_schemas(self, tools: Optional[List[str]] = None, tool_groups: Optional[List[str]] = None) -> List[Dict]:
         """
         获取工具 Schema
 
-        - tools=None: 使用 registry 中全部已注册工具(含内置 + 外部注册的)
-        - tools=["a", "b"]: 在 BUILTIN_TOOLS 基础上追加指定工具
+        优先级:
+        - tools 非空: 精确指定工具列表(忽略 tool_groups)
+        - tool_groups 非空: 按分组白名单过滤
+        - 两者都为 None: 返回所有已注册工具
         """
-        if tools is None:
-            # 全部已注册工具
-            tool_names = self.tools.get_tool_names()
+        if tools is not None:
+            tool_names = list(tools)
         else:
-            # BUILTIN_TOOLS + 显式指定的额外工具
-            tool_names = BUILTIN_TOOLS.copy()
-            for t in tools:
-                if t not in tool_names:
-                    tool_names.append(t)
+            tool_names = self.tools.get_tool_names(groups=tool_groups)
         return self.tools.get_schemas(tool_names)
 
     # 默认 system prompt 前缀(当 config.system_prompt 和前端都未提供 system message 时使用)

+ 475 - 0
agent/docs/tools-refactor-plan.md

@@ -0,0 +1,475 @@
+# 工具体系改造方案(Refactor Plan)
+
+> ✅ **方案一(内容工具族)** 和 **方案二(浏览器工具族)** 已于 2026-04-12 完成落地。
+> 下方保留原始方案文档供参考。沙箱工具已于此前删除。
+>
+> 当前工具体系的状态请看 [`tools.md`](./tools.md)。
+
+## 背景
+
+本框架的 `@tool` 注册体系经过一段时间的积累后,暴露了几个结构性问题:
+
+1. **工具粒度和组织方式是按"后端架构"而不是"任务语义"划分的**。典型表现:`search_posts`(聚合 9 个中文平台)和 `x_search`(独立)本质上是同一类任务,却因为后端一个统一 endpoint、一个独立 endpoint 就被分成了两个工具
+2. **浏览器工具有 28 个 @tool,LLM 选择负担严重超标**
+3. **沙箱工具已经不再需要**(原本是给运行工具准备的,但工具已经被提取出来单独处理)
+4. **同一套哲学没有贯彻到所有工具族**——toolhub 已经用了"动态发现"模式(search → call),但其他多后端的工具族还是"每个后端一个工具"
+
+本方案解决前两个问题(沙箱直接删除,不需要方案),确立一套**统一的工具设计哲学**供未来所有新工具族参考。
+
+---
+
+## 核心设计哲学:按任务语义划分 + 按规模选择模式
+
+### 哲学 1:LLM 心智负担分四类
+
+- **选择负担**:一堆工具中挑一个
+- **参数构造负担**:知道该工具要哪些参数
+- **流程负担**:需要按什么顺序调几次工具
+- **错误恢复负担**:失败时怎么修复
+
+工具设计要**平衡**这四类负担,而不是只优化其中一类。
+
+### 哲学 2:按"任务语义"而非"后端架构"划分工具
+
+工具的边界应该跟 LLM 的心智模型对齐,而不是跟后端服务的架构对齐。
+
+**反例(现状)**:LLM 看到 `search_posts` / `youtube_search` / `x_search` 三个并列工具,需要记住"中文平台用前者,YouTube 用中者,X 用后者"——这是后端知识泄露到工具层。
+
+**正确姿势**:LLM 看到一个统一的 `content_search(platform, keyword, ...)`,后端路由对 LLM 不可见。
+
+### 哲学 3:按"工具族规模"选择静态或动态模式
+
+| 场景 | 模式 | 代表 |
+|---|---|---|
+| 单一职责工具(正交能力) | 静态扁平 | `read_file` / `bash_command` |
+| 小规模异构工具族(3-10 个) | 静态扁平 + 良好命名 | `knowledge_*` / `sandbox_*` |
+| 中等规模异构工具族(10-20 个) | **语义合并**(Literal 枚举动词) | 浏览器工具 |
+| 大规模多实例工具族(20+ 个同类异质) | **动态发现**(toolhub 模式) | 内容搜索、远程工具库 |
+
+判断标准:**工具之间的差异主要在"参数"还是"能力"?**
+- 差异在参数(navigate/click/type 都是"DOM 操作",只是参数不同)→ 静态合并,用 Literal 动词
+- 差异在能力(9 个平台各有各的搜索语义和专用参数)→ 动态发现
+
+---
+
+## 方案一:内容工具族 → 动态发现模式
+
+### 现状
+
+| 工具 | 后端 | 平台 |
+|---|---|---|
+| `search_posts(keyword, channel, ...)` | `aigc-channel.aiddit.com/data` | 9 个中文平台 |
+| `select_post(index)` | 内存缓存 | 同上 |
+| `get_search_suggestions(keyword, channel)` | `aigc-channel.aiddit.com/suggest` | 同上 |
+| `youtube_search(keyword)` | `crawler.aiddit.com/youtube/keyword` | YouTube |
+| `youtube_detail(content_id, ...)` | `crawler.aiddit.com/youtube/detail` + yt-dlp | YouTube |
+| `x_search(keyword)` | `crawler.aiddit.com/x/keyword` | X |
+| `import_content(plan, data)` | `aigc-channel.aiddit.com/weixin/auto_insert` | 长文导入(非搜索) |
+| `extract_video_clip(...)` | 本地 ffmpeg | 媒体处理(非搜索) |
+
+### 新方案
+
+**3 个统一入口 + N 个内部实现函数(非 @tool)**
+
+```python
+@tool()
+async def content_platforms() -> ToolResult:
+    """列出所有支持的内容平台及其搜索参数 schema。
+
+    建议在 session 开始时调一次,后续 content_search / content_detail 调用时
+    依据此返回构造参数。返回内容在 session 内可以缓存。
+    """
+    return ToolResult(output=json.dumps({
+        "xhs": {
+            "name": "小红书",
+            "backend": "aigc-channel",
+            "search_params": {
+                "sort_type": {
+                    "values": ["综合排序", "最新发布", "最多点赞"],
+                    "default": "综合排序"
+                },
+                "publish_time": {
+                    "values": ["不限", "近1天", "近7天", "近30天"],
+                    "default": "不限"
+                },
+                "content_type": {
+                    "values": ["不限", "图文", "视频", "文章"],
+                    "default": "不限"
+                },
+                "filter_note_range": {
+                    "values": ["不限", "1分钟以内", "1-5分钟", "5分钟以上"],
+                    "default": "不限",
+                    "note": "仅视频内容生效"
+                }
+            },
+            "detail_mode": "cache_index",
+            "extras_example": {
+                "sort_type": "最新发布",
+                "publish_time": "近7天"
+            }
+        },
+        "youtube": {
+            "name": "YouTube",
+            "backend": "crawler",
+            "search_params": {},
+            "detail_mode": "content_id",
+            "detail_extras": {
+                "include_captions": {"type": "bool", "default": True},
+                "download_video": {"type": "bool", "default": False}
+            },
+            "extras_example": {}
+        },
+        "x": {
+            "name": "X (Twitter)",
+            "backend": "crawler",
+            "search_params": {},
+            "detail_mode": "not_supported",
+            "extras_example": {}
+        },
+        # ... 9 个中文平台 + YouTube + X,总共 11 个
+    }))
+
+
+@tool()
+async def content_search(
+    platform: str,
+    keyword: str,
+    max_count: int = 20,
+    extras: Optional[Dict[str, Any]] = None,
+) -> ToolResult:
+    """跨平台内容搜索,返回带索引编号的封面拼图 + 结构化列表。
+
+    参数说明:
+        platform: 平台标识,如 'xhs'、'youtube'、'x'。完整列表见 content_platforms。
+        keyword: 搜索关键词。
+        max_count: 返回条数上限,默认 20。
+        extras: 平台专用参数。如果不清楚某平台支持什么,先调用 content_platforms
+                查看 search_params 字段。xhs 支持 sort_type / publish_time /
+                content_type / filter_note_range;YouTube / X 当前无额外参数。
+
+    返回:
+        ToolResult.images 含 1 张带索引编号的封面拼图;output 含列表和每条记录的
+        元数据。拼图遵循 read_images 的自适应布局规则(最多 12 张)。
+    """
+
+
+@tool()
+async def content_detail(
+    platform: str,
+    identifier: str,
+    extras: Optional[Dict[str, Any]] = None,
+) -> ToolResult:
+    """查看内容详情。identifier 的含义因平台而异(见 content_platforms 的 detail_mode)。
+
+    - xhs / gzh / douyin / ...: identifier 是 content_search 返回的索引(1-based),
+      从 session 级缓存取完整记录
+    - youtube: identifier 是 video_id;extras 可传 include_captions / download_video
+    - x: 当前不支持详情查看
+    """
+```
+
+### 内部实现(不注册给 LLM)
+
+```
+agent/tools/builtin/content/
+├── __init__.py           # 空
+├── tools.py              # 3 个 @tool 入口
+├── registry.py           # PLATFORM_IMPLS 路由表
+└── platforms/
+    ├── aigc_channel.py   # 9 个中文平台的 search / detail / suggest 实现
+    ├── youtube.py        # youtube_search / youtube_detail 纯函数
+    └── x.py              # x_search 纯函数
+```
+
+### 迁移步骤
+
+1. 新建 `agent/tools/builtin/content/` 目录结构
+2. 把 `search.py` 的 `search_posts` / `select_post` / `get_search_suggestions` 逻辑移到 `content/platforms/aigc_channel.py`,拆成按 channel 分的纯函数
+3. 把 `crawler.py` 的 `youtube_search` / `youtube_detail` / `x_search` 移到 `content/platforms/`
+4. 在 `content/tools.py` 写 3 个 @tool 入口,内部调用路由
+5. 从 `builtin/__init__.py` 删除旧的 `search_posts` / `youtube_search` / `x_search` 等导出
+6. 添加新的 `content_platforms` / `content_search` / `content_detail` 导出
+7. 更新现有 prompt 里对旧工具的引用(**破坏性改动**)
+8. `extract_video_clip` 和 `import_content` 不搬——它们不是搜索工具,保留在原位或移到 `content/media.py` / `content/ingestion.py`
+
+### 未决策的设计问题
+
+1. **`extras` 的 schema 怎么处理?**
+   - 方案 i:声明为 `Optional[Dict[str, Any]]`,LLM 从 `content_platforms()` 返回的 schema 文本里学参数——**推荐**
+   - 方案 ii:为每个平台单独生成 schema(discriminated union),本框架 `SchemaGenerator` 当前不支持
+   - 方案 iii:把常用的平台专用参数都显式列在 `content_search` 签名里,用 Optional——签名臃肿,不如方案 i
+
+2. **缓存 `_search_cache` 的生命周期**
+   - 现状:进程内字典,进程重启就丢
+   - 问题:CLI 模式下每次进程都是新的,`content_detail(platform="xhs", identifier=3)` 拿不到缓存
+   - 方案:用磁盘持久化缓存 `/tmp/content_cache_{trace_id}.json`,配合之前给 toolhub/librarian 做的 trace_id 三级回退机制,同 session 内 CLI 调用也能复用
+
+3. **拼图的图片数量是否和 `read_images` 一致(12 张上限)?**
+   - read_images 是"让 LLM 看清",12 是硬上限
+   - 内容搜索是"让 LLM 浏览",可能需要更多(20-30 条也常见)
+   - 建议:区分"详查模式"(layout=detail,≤12)和"概览模式"(layout=overview,≤25,每格更小)
+
+4. **X 的 `content_detail` 怎么处理?**
+   - 当前 `x_search` 没有配对的 detail 工具
+   - 要么 `content_platforms` 里标明 `detail_mode: "not_supported"`,要么后端补一个 X 详情接口
+
+### 新增后的用户流程
+
+```
+Step 0(session 开始时一次): content_platforms() → 所有平台 schemas
+Step 1(每次任务):          content_search(platform, keyword, extras)
+Step 2(需要细看时):        content_detail(platform, id, extras)
+```
+
+---
+
+## 方案二:浏览器工具族 → 语义合并
+
+### 现状
+
+28 个 `@tool`(在 `agent/tools/builtin/browser/baseClass.py`),按任务语义分组:
+
+| 类别 | 数量 | 工具 |
+|---|---|---|
+| 导航 | 4 | `browser_navigate_to_url`、`browser_search_web`、`browser_go_back`、`browser_get_live_url` |
+| 等待 / 人机协同 | 2 | `browser_wait`、`browser_wait_for_user_action` |
+| 元素交互 | 6 | `browser_click_element`、`browser_input_text`、`browser_send_keys`、`browser_upload_file`、`browser_get_dropdown_options`、`browser_select_dropdown_option` |
+| 视口 / 查找 | 2 | `browser_scroll_page`、`browser_find_text` |
+| 内容读取 | 6 | `browser_screenshot`、`browser_get_visual_selector_map`、`browser_get_selector_map`、`browser_get_page_html`、`browser_read_long_content`、`browser_extract_content` |
+| 标签页管理 | 2 | `browser_switch_tab`、`browser_close_tab` |
+| Cookie / 登录 | 3 | `browser_ensure_login_with_cookies`、`browser_export_cookies`、`browser_load_cookies` |
+| 其他 | 3 | `browser_download_direct_url`、`browser_evaluate`、`browser_done` |
+
+### 与 browser-use MCP 的重叠分析
+
+约一半工具和 browser-use 原生 MCP 提供的能力重叠:
+
+| 你们的 @tool | browser-use MCP | 状态 |
+|---|---|---|
+| `browser_navigate_to_url` | `browser_navigate` | 重复 |
+| `browser_click_element` | `browser_click` | 重复 |
+| `browser_input_text` | `browser_type` | 重复 |
+| `browser_scroll_page` | `browser_scroll` | 重复 |
+| `browser_go_back` | `browser_go_back` | 重复 |
+| `browser_switch_tab` / `close_tab` | 同名 | 重复 |
+| `browser_extract_content` | 同名 | 重复 |
+| `browser_get_selector_map` | `browser_get_state` | 部分重复 |
+
+剩余 14 个是自研扩展(cookie 全家桶、upload、send_keys、dropdown、visual_selector_map、read_long_content、find_text、evaluate、download、done、wait_for_user_action、get_live_url、search_web、get_page_html)。
+
+### 新方案:语义合并(方案 A)
+
+采用"按动词合并 + Literal 枚举 action"模式,从 28 个 @tool 压缩到约 11 个。**不引入 MCP Client 基础设施**(那是未来的独立决策)。
+
+**目标签名:**
+
+```python
+@tool()
+async def browser_navigate(
+    target: str,
+    mode: Literal["url", "search", "back"] = "url",
+    engine: str = "bing",
+    new_tab: bool = False,
+) -> ToolResult:
+    """导航工具。
+    - mode="url": target 为 URL,直接访问
+    - mode="search": target 为搜索词,通过 engine 搜索
+    - mode="back": 浏览器后退,target 和 engine 忽略
+    """
+
+
+@tool()
+async def browser_interact(
+    action: Literal["click", "type", "send_keys", "upload", "dropdown_list", "dropdown_select"],
+    index: Optional[int] = None,
+    text: Optional[str] = None,
+    path: Optional[str] = None,
+    keys: Optional[str] = None,
+    clear: bool = True,
+) -> ToolResult:
+    """元素交互:
+    - click: 需要 index
+    - type: 需要 index + text
+    - send_keys: 需要 keys(如 'Enter'、'Ctrl+A'),不依赖 index
+    - upload: 需要 index + path
+    - dropdown_list: 需要 index(列出选项)
+    - dropdown_select: 需要 index + text(按文字选中)
+    """
+
+
+@tool()
+async def browser_screenshot(highlight_elements: bool = False) -> ToolResult:
+    """截图。
+    - highlight_elements=False: 纯截图
+    - highlight_elements=True: 带交互元素编号的标注截图(原 visual_selector_map)
+    """
+
+
+@tool()
+async def browser_elements() -> ToolResult:
+    """获取当前页面的可交互元素列表(文本版,不截图)。用于 LLM 按 index 与元素交互。"""
+
+
+@tool()
+async def browser_read(
+    mode: Literal["html", "find", "long_content", "extract"],
+    query: Optional[str] = None,
+    start_line: int = 0,
+    lines_per_page: int = 100,
+    extract_links: bool = False,
+) -> ToolResult:
+    """页面内容读取:
+    - html: 整页 HTML(大页面慎用)
+    - find: 在页面中查找 query 文本
+    - long_content: 分页读取长内容,配合 start_line / lines_per_page
+    - extract: 用 LLM 根据 query 抽取结构化信息,可选 extract_links
+    """
+
+
+@tool()
+async def browser_scroll(
+    down: bool = True,
+    pages: float = 1.0,
+    into_view_index: Optional[int] = None,
+) -> ToolResult:
+    """滚动页面。down=True 向下,pages 是滚动的页面数;
+    传 into_view_index 则滚动到指定元素可见(忽略 down 和 pages)。
+    """
+
+
+@tool()
+async def browser_tabs(
+    action: Literal["switch", "close", "list"],
+    tab_id: Optional[str] = None,
+) -> ToolResult:
+    """标签页管理。list 不需要 tab_id,switch 和 close 需要。"""
+
+
+@tool()
+async def browser_cookies(
+    action: Literal["load", "export", "ensure_login"],
+    name: str = "",
+    account: str = "",
+    url: str = "",
+    cookie_type: str = "",
+    auto_navigate: bool = True,
+) -> ToolResult:
+    """Cookie 管理:
+    - load: 加载已保存的 cookie(url + name, auto_navigate 控制是否自动导航)
+    - export: 导出当前 cookie 保存(name + account 标识)
+    - ensure_login: 检查登录状态,未登录时自动加载 cookie_type 对应的 cookie
+    """
+
+
+@tool()
+async def browser_wait(
+    seconds: Optional[int] = None,
+    user_action_message: Optional[str] = None,
+) -> ToolResult:
+    """等待:
+    - 传 seconds: 纯等待指定秒数
+    - 传 user_action_message: 暂停并提示用户在浏览器里手动操作,用户完成后 Agent 继续
+    - 两者都不传: 报错
+    """
+
+
+@tool()
+async def browser_evaluate(code: str) -> ToolResult:
+    """在当前页面上下文执行 JavaScript。"""
+
+
+@tool()
+async def browser_download(url: str, save_name: str = "") -> ToolResult:
+    """直接下载给定 URL 的文件到本地。"""
+```
+
+**可选保留(视使用频率决定):**
+
+- `browser_get_live_url()` — 远程浏览器场景专用,可能删除
+- `browser_done(text, success)` — 任务完成信号,可能删除(让 agent 用普通 completion 输出)
+
+**28 → 约 11 个 @tool,下降 60%。**
+
+### 条件必填参数的处理
+
+Python 函数签名里所有参数都声明为 Optional,但某些组合是运行时强制的:
+
+```python
+async def browser_interact(action, index, text, path, keys, clear):
+    if action == "click" and index is None:
+        return ToolResult(error="click action requires index")
+    if action == "type" and (index is None or text is None):
+        return ToolResult(error="type action requires both index and text")
+    ...
+```
+
+静态 schema 表达不了这个,只能靠 docstring 说清楚 + 运行时 validate。
+
+### 迁移步骤
+
+1. 在 `baseClass.py` 里先保留所有现有的非 @tool 内部辅助函数(它们负责实际调用 browser-use)
+2. 把 30 个原 `@tool` 函数**去掉 @tool 装饰器**,降级为内部函数 `_navigate_to_url` / `_click_element` 等
+3. 在 `baseClass.py` 底部新增 11 个 @tool 入口函数,每个内部根据 action 路由到对应的内部函数
+4. 从 `browser/__init__.py` 更新导出列表
+5. 更新 `agent/docs/tools.md` 的浏览器工具小节
+6. 更新现有的浏览器 prompt(破坏性)
+
+### 未决策的设计问题
+
+1. **`browser_read` 的 4 个 mode 是否要再拆分?** `extract` 是 LLM 驱动的,和其他 3 个差异较大。可以拆成 `browser_read(mode="html|find|long")` + `browser_extract(query, ...)`。
+2. **`browser_interact` 的 6 个 action 都合并合适吗?** `dropdown_list` 和 `dropdown_select` 与其他 action 的参数差异较大,可以独立出 `browser_dropdown(index, select_text=None)`。
+3. **`browser_done` 的去留** — 这个是给上层 Agent 发任务完成信号的协议约定,不是浏览器操作。建议移到框架通用的 task 信号机制里,或删除。
+4. **`browser_search_web` 要不要作为 mode 合并到 `browser_navigate`?** 搜索引擎的具体实现(`engine: "bing"|"google"|...`)和 URL 导航差异较大,合并后签名变乱。可能独立保留更好。
+5. **重命名的破坏性改动** — 所有 `browser_navigate_to_url` 等现存引用都要更新。需要在 PR 描述里列出 before/after 对照表。
+
+---
+
+## 方案三(暂不采用):引入 MCP Client 基础设施
+
+**思路:** 让本框架的 Agent Runner 作为 MCP Client 连接 browser-use 原生 MCP,**删除**所有与 browser-use MCP 重叠的 @tool,只保留自研扩展(cookie、wait_for_user_action、dropdown 等约 14 个)。
+
+**优点:** 消除代码重复;未来 browser-use 升级自动获益;和 Claude Code 的浏览器体验一致。
+
+**缺点:** 需要给框架的 Agent Runner 新增 MCP Client 基础设施;启动时需要管理 MCP server 进程生命周期;双路共存(部分本地 @tool + 部分远程 MCP)增加复杂度。
+
+**结论:** 当前不做,视未来框架是否引入通用 MCP Client 基础设施再议。方案二(语义合并)的收益已经够大,投入更小。
+
+---
+
+## 共同原则(所有工具族改造都要遵守)
+
+1. **破坏性改动集中做**——所有重命名、删除、合并都在同一个 PR 里完成,不要分期做。分期反而让用户迁移更痛苦
+2. **每个工具族都要有对应的 CLI 入口 + 自包含 `if __name__ == "__main__"`**——参考 toolhub / librarian 已有的模式
+3. **对应的 skill 写到 `~/.claude/skills/`**——让 Claude Code 等外部 Agent 能用
+4. **破坏性改动后同步更新 `agent/docs/tools.md` 和所有现存 prompt**
+
+---
+
+## 待决策清单(落地前必须定)
+
+### 内容工具族
+
+- [ ] `extras` schema 处理方式(推荐方案 i)
+- [ ] 缓存持久化方案(推荐磁盘 + trace_id)
+- [ ] 拼图上限策略(推荐分 detail/overview 两档)
+- [ ] X 是否补 detail 接口(取决于后端支持)
+
+### 浏览器工具族
+
+- [ ] `browser_read` 是否拆成 read + extract 两个
+- [ ] `browser_interact` 是否拆出 dropdown
+- [ ] `browser_done` 去留
+- [ ] `browser_search_web` 是否合并到 navigate
+- [ ] 重命名破坏性改动的迁移策略
+
+### 哲学选择
+
+- [ ] 是否未来引入 MCP Client 基础设施(影响浏览器工具的最终形态)
+
+---
+
+## 不做的事情
+
+- **沙箱工具**:直接删除,不改造(参考 `tools.md` 对应修改记录)
+- **文件工具、bash、skill 等正交单能力工具**:保持现状
+- **knowledge 工具族**:已经是 `ask_knowledge` / `upload_knowledge` 两个入口,规模小且清晰,无改造必要

+ 271 - 2
agent/docs/tools.md

@@ -11,7 +11,8 @@
 3. [ToolResult 和记忆管理](#toolresult-和记忆管理)
 4. [ToolContext 和依赖注入](#toolcontext-和依赖注入)
 5. [高级特性](#高级特性)
-6. [内置基础工具](#内置基础工具)
+6. [工具分组](#工具分组)
+7. [内置基础工具](#内置基础工具)
 7. [集成 Browser-Use](#集成-browser-use)
 8. [最佳实践](#最佳实践)
 
@@ -790,6 +791,50 @@ print(f"Success rate: {stats['success_rate']:.1%}")
 
 ---
 
+## 工具分组
+
+工具通过 `@tool(groups=[...])` 声明所属分组,`RunConfig.tool_groups` 控制 Agent 可用哪些分组。
+
+### 机制
+
+```python
+# 注册时声明分组
+@tool(groups=["browser"])
+async def browser_navigate(url: str) -> ToolResult: ...
+
+@tool(groups=["content", "media"])  # 支持多标签
+async def extract_video_clip(...) -> ToolResult: ...
+```
+
+```python
+# 配置时按分组过滤
+RunConfig(tool_groups=["core", "content"])        # 只用核心 + 内容工具
+RunConfig(tool_groups=["core", "browser"])         # 只用核心 + 浏览器工具
+RunConfig(tool_groups=None)                        # 全部工具(默认)
+RunConfig(tools=["knowledge_search", "read_file"]) # 精确指定(优先于 tool_groups)
+```
+
+### 过滤逻辑
+
+`_get_tool_schemas(tools, tool_groups)` 的优先级:
+
+1. `tools` 非空 → 精确使用指定列表(忽略 `tool_groups`)
+2. `tool_groups` 非空 → 按分组白名单过滤(工具的任一 group 命中即选中)
+3. 两者都为 None → 返回所有已注册工具
+
+### 实现位置
+
+- 分组声明:`@tool(groups=[...])` — `agent/tools/registry.py`
+- 分组存储:`ToolRegistry._tools[name]["groups"]`
+- 分组过滤:`ToolRegistry.get_tool_names(groups=[...])`
+- 配置入口:`RunConfig.tool_groups` — `agent/core/runner.py`
+
+### 分组一览
+
+完整的分组和工具列表见 [agent/README.md 附录](../README.md#附录工具分组)。
+
+---
+
 ## 内置基础工具
 
 > 参考 opencode 实现的文件操作和命令执行工具
@@ -807,7 +852,8 @@ print(f"Success rate: {stats['success_rate']:.1%}")
 
 | 工具 | 功能 | 参考 |
 |------|------|------|
-| `read_file` | 读取文件内容(支持图片、PDF) | opencode read.ts |
+| `read_file` | 读取单个文件(文本 / 图片 / PDF) | opencode read.ts |
+| `read_images` | 批量读取图片,支持自动降采样和网格拼图 | 自研 |
 | `edit_file` | 智能文件编辑(多种匹配策略) | opencode edit.ts |
 | `write_file` | 写入文件(创建或覆盖) | opencode write.ts |
 | `bash_command` | 执行 shell 命令 | opencode bash.ts |
@@ -815,6 +861,45 @@ print(f"Success rate: {stats['success_rate']:.1%}")
 | `grep_content` | 内容搜索(正则表达式) | opencode grep.ts |
 | `agent` | 创建 Agent 执行任务(单任务 delegate / 多任务并行 explore) | 自研 |
 | `evaluate` | 评估目标执行结果是否满足要求 | 自研 |
+| `toolhub_health` | 检查 ToolHub 远程工具库服务状态 | 自研 |
+| `toolhub_search` | 搜索/发现 ToolHub 远程工具 | 自研 |
+| `toolhub_call` | 调用 ToolHub 远程工具(图片参数支持本地文件路径) | 自研 |
+| `content_platforms` | 列出/查询内容平台及其搜索参数(支持模糊匹配) | 自研 |
+| `content_search` | 跨平台内容搜索(11 个平台统一入口) | 自研 |
+| `content_detail` | 查看内容详情(从搜索缓存按索引取) | 自研 |
+| `content_suggest` | 搜索关键词补全建议 | 自研 |
+| `extract_video_clip` | 截取已下载 YouTube 视频的片段 | 自研 |
+| `import_content` | 批量导入文章到 CMS | 自研 |
+| `ask_knowledge` | 向知识库查询信息(通过 KnowHub Librarian) | 自研 |
+| `upload_knowledge` | 上传调研结果到知识库 | 自研 |
+
+#### `read_file` vs `read_images`
+
+| 场景 | 工具 |
+|------|------|
+| 读取 **1 张**图片 / 文本 / PDF | `read_file` |
+| 批量读取 **2 张以上**图片 | `read_images` |
+| 需要 AI 对多张图做**对比选择**(选图、挑错、横向比较) | `read_images` 且 `layout="grid"` |
+| 需要对多张图**逐张独立分析** | `read_images` 且 `layout="separate"` |
+
+`read_images` 默认 `layout="grid"` — 多张图拼成一张**带索引编号**的网格图(1,2,3…),**省 token 的同时让 LLM 能在单次注视中做横向对比**。拼图和降采样在内部组合使用:先降采样每张缩略图,再拼成整图,最终图片大小约等于一张普通图的开销,而非所有原图的累积。
+
+**Grid 模式的 16 张硬上限:** grid 模式下单次调用最多 16 张图片。超过会报错,需要分批调用。上限来自于 LLM 内部图片缩放的物理限制——Claude/Qwen-VL 会把图片缩到长边约 1568 像素,当拼图里格子太多时,每格会糊到无法识别。16 张对应 4×4 布局,每格约 300px,缩放后仍能保持约 280px,人物和场景细节仍然可辨。如需处理更多图片,或切换到 `layout="separate"`(无数量限制但每张图都有独立的结构开销 token)。
+
+**自适应布局:** grid 模式下根据图片数量动态选择列数和缩略图尺寸,小批量时每张图更清晰:
+
+| 图片数 | 布局 | 每格大小 |
+|------|------|---------|
+| 2 张 | 2 列 | 500px |
+| 3-4 张 | 2 列 | 450px |
+| 5-6 张 | 3 列 | 400px |
+| 7-9 张 | 3 列 | 380px |
+| 10-12 张 | 4 列 | 320px |
+| 13-16 张 | 4 列 | 300px |
+
+**关于标签/标题:** `read_images` 的拼图**不显示文件名**,只显示索引序号——因为本地文件名(如 `IMG_1234.jpg`)对 LLM 理解内容没有帮助,而索引到原始路径的对照表通过返回文本提供,LLM 可以用"第 3 张"这种引用方式精确指代。对比之下 `content_search` 的拼图**会**显示 label(帖子/视频标题),因为这些是内容型元数据,有实际信息量。这一差异反映在 `build_image_grid(labels=...)` 参数上:传 `None` 只画序号,传列表则在每格下方画标题。
+
+网格和降采样的实现在 `agent/tools/utils/image.py`,`content_search` 等内容工具也复用同一套拼图逻辑。
 
 ### Agent 工具
 
@@ -1205,6 +1290,189 @@ async def search_notes(
 
 ---
 
+## 跨框架使用(CLI / MCP)
+
+工具设计为可跨 Agent 框架使用(本框架 Agent、Claude Code、其他 LLM IDE 等),遵循以下原则:
+
+- **无状态工具** → 自包含 CLI:每个工具文件可独立运行,零外部依赖
+- **有状态工具组**(浏览器、沙箱等需要持久 session) → MCP server:使用标准协议管理 session
+- **禁止中间态**:不造私有协议;简单就 CLI,复杂就 MCP
+
+### 判断标准
+
+| 问题 | 答案 | 选择 |
+|------|------|------|
+| 工具调用之间是否有进程内状态需要保持?(浏览器 session、数据库连接、缓存) | 否 | **CLI** |
+| 同上 | 是 | **MCP** |
+| 是否需要 Claude Desktop、Cursor 等客户端原生识别? | 需要 | **MCP** |
+
+### 无状态 CLI 工具规范
+
+一个工具想同时作为 Agent tool(`@tool` 注册)和 CLI 工具使用,需要满足以下要求:
+
+**1. 文件末尾添加自包含的 `if __name__ == "__main__"` 块**
+
+参数解析、asyncio.run、结果输出这些 CLI 样板代码**直接内联**在工具文件里,不要抽取到共享 `cli.py` 模块——这样每个工具文件可以独立迁移到其他项目。
+
+```python
+# 示例:agent/tools/builtin/toolhub.py 末尾
+if __name__ == "__main__":
+    import sys, asyncio, os, uuid
+
+    COMMANDS = {"health": toolhub_health, "search": toolhub_search, "call": toolhub_call}
+
+    def _parse_args(argv):
+        kwargs = {}
+        for arg in argv:
+            if arg.startswith("--") and "=" in arg:
+                k, v = arg.split("=", 1)
+                k = k.lstrip("-").replace("-", "_")
+                try: v = json.loads(v)
+                except: pass
+                kwargs[k] = v
+        return kwargs
+
+    # trace_id 三级回退:CLI 参数 > 环境变量 > 自动生成
+    cmd = sys.argv[1]
+    kwargs = _parse_args(sys.argv[2:])
+    trace_id = kwargs.pop("trace_id", None) or os.getenv("TRACE_ID") or f"cli-{uuid.uuid4().hex[:8]}"
+    set_trace_context(trace_id)
+
+    result = asyncio.run(COMMANDS[cmd](**kwargs))
+    # 输出 JSON(注意 double-encoding 问题)
+    ...
+```
+
+**2. 输出统一为 JSON 格式**
+
+```json
+{
+  "trace_id": "...",
+  "output": "...",  // 原生 dict/list/str,不要预先 json.dumps
+  "error": "...",   // 可选
+  "metadata": {...}  // 可选
+}
+```
+
+**3. trace_id 三级回退策略**
+
+对于需要会话语义(同一 trace_id 内多次调用共享状态)的工具(librarian、toolhub 的图片输出目录等):
+
+1. **CLI 参数** `--trace_id=xxx`(显式)
+2. **环境变量** `TRACE_ID`(同一 shell session 共享)
+3. **自动生成** `cli-{random}`(兜底)
+
+外部 Agent 只需 `export TRACE_ID=session-xxx` 一次,后续所有 CLI 调用自动归到同一会话。
+
+**4. 二进制产出写文件,JSON 返回路径**
+
+像 `read_images` 这种产出图片/大文件的工具,CLI 模式下**不要**把 base64 塞进 stdout(刷屏 + 调用方还要解码)。应该:
+- 要求用户显式传 `--out=<path>` 指定输出路径
+- 把文件写到 `<path>`
+- JSON 响应里返回 `out_path` 供调用方用 Read 工具查看
+
+**5. 避免双重 JSON 编码**
+
+如果工具内部已经 `json.dumps()` 把 result dict 塞进了 `ToolResult.output`,CLI 层再 `json.dumps(result.output)` 会产生双重转义(`"output": "{\"model\": ..."` 这种反人类形式)。CLI 层要在输出前检测并解码:
+
+```python
+output_value = result.output
+if isinstance(output_value, str):
+    stripped = output_value.lstrip()
+    if stripped.startswith(("{", "[")):
+        try:
+            output_value = json.loads(output_value)
+        except (json.JSONDecodeError, ValueError):
+            pass  # 非 JSON 文本,保持原样
+```
+
+### Skill 安装规范
+
+CLI 工具对外暴露给 Claude Code(或其他支持 skill 的客户端)时,需要配套写一个 `SKILL.md`:
+
+**位置:** `~/.claude/skills/<name>/SKILL.md`(用户全局)或项目级 `.claude/skills/<name>/SKILL.md`
+
+**格式:**
+
+```markdown
+---
+name: <skill-name>
+description: <一句话,描述用途和触发时机。这是 Claude Code 决定何时加载该 skill 的唯一依据>
+---
+
+# <Skill Name>
+
+<简短一段话介绍工具>
+
+## 用法
+
+```bash
+python <绝对路径>/tool.py <子命令> --key=value
+```
+
+- `--key=...` 参数说明
+- 关键约束(如数量上限)
+
+<调用后怎么解读输出,典型 workflow>
+```
+
+**尺寸原则:** SKILL.md **越短越好**。它每次触发时都会进入 context 占据 token。和 `agent/docs/tools.md` 的职责区分:
+
+| 文件 | 读者 | 触发 | 长度 |
+|------|------|------|------|
+| `SKILL.md` | **运行时的 Claude Code**(动态加载) | 每次匹配自动加载到 context | **短**(20 行以内为佳) |
+| `agent/docs/tools.md` | **开发者**(静态阅读) | 从不自动加载 | 长,可以详细展开原理、设计取舍 |
+
+SKILL.md 只写"调用这个工具所需的最小信息集",原理和细节放到 docs。
+
+**当前已安装的 skill**(`~/.claude/skills/`):
+- `toolhub/` — 搜索和调用 ToolHub 远程 AI 工具
+- `knowhub/` — 查询和上传 KnowHub 知识库
+- `stitch-images/` — 批量图片拼成网格供 Read 一次查看
+
+### ToolHub 图片管线
+
+`toolhub_call` 内置完整的图片处理管线,无需单独的上传/下载工具:
+
+- **输入**:`params` 中的图片参数(`image`、`image_url`、`mask_image`、`pose_image`、`images`)可直接传本地文件路径,系统自动上传
+- **输出**:生成的图片自动保存到 `outputs/` 目录,返回结果中 `saved_files` 包含本地路径
+
+### MCP 集成(有状态工具组)
+
+对于需要维持 session 的工具组,使用 MCP server。两种注册方式:
+
+**1. 使用现成的 MCP server**(推荐)
+
+例如浏览器工具直接用 browser-use 原生 MCP:
+
+```json
+// .mcp.json(项目根目录;不要写在 settings.json,Claude Code 不会从那里读 mcpServers)
+{
+  "mcpServers": {
+    "browser-use": {
+      "command": "/Users/sunlit/.pyenv/versions/3.13.1/bin/python",
+      "args": ["-m", "browser_use.skill_cli.main", "--mcp"],
+      "env": {
+        "OPENAI_API_KEY": "sk-...",
+        "OPENAI_BASE_URL": "https://dashscope.aliyuncs.com/compatible-mode/v1",
+        "BROWSER_USE_LLM_MODEL": "qwen-plus"
+      }
+    }
+  }
+}
+```
+
+**注意事项:**
+- `command` 必须用**绝对路径**,不要用 pyenv shim(shim 在 Claude Code 子进程里无法正确解析)
+- MCP server 配置放 `.mcp.json`,**不是** `~/.claude/settings.json`(后者只管 permissions/outputStyle 等)
+- 第三方包的 LLM 配置如果 Pydantic schema 吞字段(比如 browser-use 的 `LLMEntry` 不支持 `base_url`),可以通过**环境变量**绕过(如 `OPENAI_BASE_URL` 是 OpenAI SDK 原生环境变量)
+
+**2. 为自研有状态工具组写 MCP server**
+
+当你有一组需要共享 session 的自研工具时,用 `mcp` Python SDK 写一个 server,每个工具作为 `@app.tool()` 暴露。server 进程内维护 session 状态。避免造私有 stdio 协议。
+
+---
+
 ## 总结
 
 | 特性 | 状态 | 说明 |
@@ -1224,3 +1492,4 @@ async def search_notes(
 3. **类型安全**:充分利用 Python 类型注解
 4. **灵活集成**:支持各种工具库(Browser-Use, MCP 等)
 5. **可观测性**:内建统计和监控能力
+6. **跨框架**:无状态工具自包含 CLI,有状态工具走 MCP 标准协议

+ 9 - 0
agent/llm/gemini.py

@@ -126,9 +126,17 @@ def _convert_messages_to_gemini(messages: List[Dict]) -> tuple[List[Dict], Optio
 
         content = msg.get("content", "")
         tool_calls = msg.get("tool_calls")
+        raw_gemini_parts = msg.get("raw_gemini_parts")
 
         # Assistant 消息 + tool_calls
         if role == "assistant" and tool_calls:
+            if raw_gemini_parts:
+                contents.append({
+                    "role": "model",
+                    "parts": raw_gemini_parts
+                })
+                continue
+
             parts = []
             if content and (isinstance(content, str) and content.strip()):
                 parts.append({"text": content})
@@ -443,6 +451,7 @@ def create_gemini_llm_call(
         return {
             "content": content,
             "tool_calls": tool_calls,
+            "raw_gemini_parts": candidate.get("content", {}).get("parts", []) if candidates else [],
             "prompt_tokens": usage.input_tokens,
             "completion_tokens": usage.output_tokens,
             "reasoning_tokens": usage.reasoning_tokens,

+ 29 - 23
agent/skill/skills/browser.md

@@ -9,32 +9,38 @@ description: 浏览器自动化工具使用指南
 
 ### 基本工作流程
 
-1. **页面导航**: 使用 `browser_navigate_to_url` 或 `browser_search_web` 到达目标页面
+1. **页面导航**: 使用 `browser_navigate(url)` 或 `browser_search(query)` 到达目标页面
 2. **等待加载**: 页面跳转后调用 `browser_wait(seconds=2)` 等待内容加载
-3. **获取元素索引**: 调用 `browser_get_visual_selector_map` 获取可交互元素的索引映射和当前界面的截图
-4. **执行交互**: 使用 `browser_click_element`、`browser_input_text` 等工具操作页面
-5. **提取内容**: 使用 `browser_extract_content`, `browser_read_long_content`, `browser_get_page_html` 获取数据
+3. **获取元素索引**: 调用 `browser_screenshot(highlight_elements=True)` 获取带编号标注的截图 + 元素列表
+4. **执行交互**: 使用 `browser_interact(action, index, ...)` 操作页面(click / type / send_keys / upload / dropdown)
+5. **提取内容**: 使用 `browser_extract(query)` 让 LLM 提取结构化数据,或 `browser_read(mode="long")` 分页读取长内容
 
 ### 关键原则
 
 - **禁止模拟结果**:不要输出你认为的搜索结果,而是要调用工具获取真实结果
-- **必须先获取索引**: 所有 `index` 参数都需要先通过 `browser_get_selector_map` 获取
-- **高级工具**:优先使用 `browser_extract_content`, `browser_read_long_content` 等工具获取数据,而不是使用 `browser_get_selector_map` 获取索引后手动解析
-- **操作后等待**: 任何可能触发页面变化的操作(点击、输入、滚动)后都要调用 `browser_wait`
+- **必须先获取索引**: 所有 `index` 参数都需要先通过 `browser_screenshot(highlight_elements=True)` 或 `browser_elements()` 获取
+- **高级工具**:优先使用 `browser_extract` / `browser_read` 获取数据,而不是手动解析元素
+- **操作后等待**: 任何可能触发页面变化的操作后都要调用 `browser_wait`
 - **登录处理**:
-  - **正常登录**:当遇到需要登录的网页时,使用 `browser_load_cookies` 来登录
-  - **首次登录**:当没有该网站的 cookie 时,需要请求人类协助登录:
-    1. 调用 `browser_get_live_url` 获取云浏览器实时画面链接
-    2. 导航到目标网站的登录页面
-    3. 通过 `feishu_send_message_to_contact` 将 live URL 发送给相关人员,请求其在浏览器中完成登录
-    4. 使用 `feishu_get_contact_replies(contact_name="...", wait_time_seconds=300)` 等待对方回复确认登录完成
-    5. 收到回复后使用 `browser_export_cookies` 将登录态保存下来
-- **复杂操作用JS**: 当标准工具无法满足时,使用 `browser_evaluate` 执行 JavaScript 代码
-
-### 工具分类
-
-**导航**: browser_get_live_url, browser_navigate_to_url, browser_search_web, browser_go_back, browser_wait
-**交互**: browser_click_element, browser_input_text, browser_send_keys, browser_upload_file
-**视图**: browser_scroll_page, browser_find_text, browser_screenshot
-**提取**: browser_extract_content, browser_read_long_content, browser_get_page_html, browser_get_selector_map, browser_get_visual_selector_map
-**高级**: browser_evaluate, browser_load_cookies, browser_export_cookies, browser_wait_for_user_action, browser_download_direct_url
+  - **正常登录**:使用 `browser_cookies(action="load", url=...)` 注入已保存的 cookie
+  - **首次登录**:需要人类协助——导航到登录页,通过飞书发送链接,等待确认后 `browser_cookies(action="export")` 保存
+- **复杂操作用JS**: 当标准工具无法满足时,使用 `browser_js(code)` 执行 JavaScript
+
+### 工具一览
+
+| 工具 | 功能 |
+|------|------|
+| `browser_navigate(url)` | 导航到 URL |
+| `browser_search(query, engine)` | 搜索引擎搜索 |
+| `browser_back()` | 返回上一页 |
+| `browser_interact(action, ...)` | 元素交互(click/type/send_keys/upload/dropdown) |
+| `browser_scroll(down, pages)` | 滚动页面 |
+| `browser_screenshot(highlight)` | 截图(highlight=True 带元素编号标注) |
+| `browser_elements()` | 获取可交互元素列表(纯文本) |
+| `browser_read(mode)` | 读取页面(html/find/long) |
+| `browser_extract(query)` | LLM 驱动的结构化数据提取 |
+| `browser_tabs(action, tab_id)` | 标签页管理(switch/close) |
+| `browser_cookies(action, ...)` | Cookie/登录态管理(load/export/ensure_login) |
+| `browser_wait(seconds/user_message)` | 等待(定时 or 等用户操作) |
+| `browser_js(code)` | 执行 JavaScript |
+| `browser_download(url)` | 下载文件 |

+ 30 - 24
agent/skill/skills/core.md

@@ -73,7 +73,7 @@ goal(abandon="方案A需要Redis,环境没有")
 
 ## 信息调研
 
-你可以通过联网搜索工具`search_posts`大概浏览来自Github、小红书、微信公众号、知乎等渠道的信息,并再使用`select_post`工具查看具体信息
+你可以通过 `content_search(platform, keyword)` 搜索来自 GitHub、小红书、微信公众号、知乎、YouTube、X 等平台的信息,再用 `content_detail(platform, index)` 查看完整内容。不确定平台参数时先调 `content_platforms()` 查看
 对于需要深度交互的网页内容,使用浏览器工具进行操作。
 
 调研过程可能需要多次搜索,比如基于搜索结果中获得的启发或信息启动新的搜索,直到得到令人满意的答案。你可以使用`goal`工具管理搜索的过程,或者使用文档记录搜索的中间或最终结果。
@@ -84,32 +84,38 @@ goal(abandon="方案A需要Redis,环境没有")
 
 ### 基本工作流程
 
-1. **页面导航**: 使用 `browser_navigate_to_url` 或 `browser_search_web` 到达目标页面
+1. **页面导航**: 使用 `browser_navigate(url)` 或 `browser_search(query)` 到达目标页面
 2. **等待加载**: 页面跳转后调用 `browser_wait(seconds=2)` 等待内容加载
-3. **获取元素索引**: 调用 `browser_get_visual_selector_map` 获取可交互元素的索引映射和当前界面的截图
-4. **执行交互**: 使用 `browser_click_element`、`browser_input_text` 等工具操作页面
-5. **提取内容**: 使用 `browser_extract_content`, `browser_read_long_content`, `browser_get_page_html` 获取数据
+3. **获取元素索引**: 调用 `browser_screenshot(highlight_elements=True)` 获取带编号标注的截图 + 元素列表
+4. **执行交互**: 使用 `browser_interact(action, index, ...)` 操作页面(click / type / send_keys / upload / dropdown)
+5. **提取内容**: 使用 `browser_extract(query)` 让 LLM 提取结构化数据,或 `browser_read(mode="long")` 分页读取长内容
 
 ### 关键原则
 
 - **禁止模拟结果**:不要输出你认为的搜索结果,而是要调用工具获取真实结果
-- **必须先获取索引**: 所有 `index` 参数都需要先通过 `browser_get_selector_map` 获取
-- **高级工具**:优先使用`browser_extract_content`, `browser_read_long_content`等工具获取数据,而不是使用`browser_get_selector_map`获取索引后手动解析
-- **操作后等待**: 任何可能触发页面变化的操作(点击、输入、滚动)后都要调用 `browser_wait`
+- **必须先获取索引**: 所有 `index` 参数都需要先通过 `browser_screenshot(highlight_elements=True)` 或 `browser_elements()` 获取
+- **高级工具**:优先使用 `browser_extract` / `browser_read` 获取数据,而不是手动解析元素
+- **操作后等待**: 任何可能触发页面变化的操作后都要调用 `browser_wait`
 - **登录处理**:
-  - **正常登录**:当遇到需要登录的网页时,使用`browser_load_cookies`来登录
-  - **首次登录**:当没有该网站的cookie时,需要请求人类协助登录:
-    1. 调用 `browser_get_live_url` 获取云浏览器实时画面链接
-    2. 导航到目标网站的登录页面
-    3. 通过 `feishu_send_message_to_contact` 将 live URL 发送给相关人员,请求其在浏览器中完成登录
-    4. 使用 `feishu_get_contact_replies(contact_name="...", wait_time_seconds=300)` 等待对方回复确认登录完成
-    5. 收到回复后使用 `browser_export_cookies` 将登录态保存下来
-- **复杂操作用JS**: 当标准工具无法满足时,使用 `browser_evaluate` 执行JavaScript代码
-
-### 工具分类
-
-**导航**: browser_get_live_url, browser_navigate_to_url, browser_search_web, browser_go_back, browser_wait
-**交互**: browser_click_element, browser_input_text, browser_send_keys, browser_upload_file
-**视图**: browser_scroll_page, browser_find_text, browser_screenshot
-**提取**: browser_extract_content, browser_read_long_content, browser_get_page_html, browser_get_selector_map, browser_get_visual_selector_map
-**高级**: browser_evaluate, browser_load_cookies, browser_export_cookies, browser_wait_for_user_action, browser_download_direct_url
+  - **正常登录**:使用 `browser_cookies(action="load", url=...)` 注入已保存的 cookie
+  - **首次登录**:需要人类协助——导航到登录页,通过飞书发送链接,等待确认后 `browser_cookies(action="export")` 保存
+- **复杂操作用JS**: 当标准工具无法满足时,使用 `browser_js(code)` 执行 JavaScript
+
+### 工具一览
+
+| 工具 | 功能 |
+|------|------|
+| `browser_navigate(url)` | 导航到 URL |
+| `browser_search(query, engine)` | 搜索引擎搜索 |
+| `browser_back()` | 返回上一页 |
+| `browser_interact(action, ...)` | 元素交互(click/type/send_keys/upload/dropdown) |
+| `browser_scroll(down, pages)` | 滚动页面 |
+| `browser_screenshot(highlight)` | 截图(highlight=True 带元素编号标注) |
+| `browser_elements()` | 获取可交互元素列表(纯文本) |
+| `browser_read(mode)` | 读取页面(html/find/long) |
+| `browser_extract(query)` | LLM 驱动的结构化数据提取 |
+| `browser_tabs(action, tab_id)` | 标签页管理(switch/close) |
+| `browser_cookies(action, ...)` | Cookie/登录态管理(load/export/ensure_login) |
+| `browser_wait(seconds/user_message)` | 等待(定时 or 等用户操作) |
+| `browser_js(code)` | 执行 JavaScript |
+| `browser_download(url)` | 下载文件 |

+ 5 - 5
agent/skill/skills/research.md

@@ -5,7 +5,7 @@ description: 知识调研 - 根据目标和任务自动执行搜索,返回结
 
 ## 信息调研
 
-你可以通过联网搜索工具`search_posts`大概浏览来自Github、小红书、微信公众号、知乎等渠道的信息,并再使用`select_post`工具查看具体信息
+你可以通过 `content_search` 工具搜索来自 GitHub、小红书、微信公众号、知乎、YouTube、X 等平台的信息,并使用 `content_detail` 查看具体内容。如不确定平台参数,先调 `content_platforms` 查看
 
 ## 调研过程可能需要多次搜索,比如基于搜索结果中获得的启发或信息启动新的搜索,直到得到令人满意的答案。你可以使用 `goal` 工具管理搜索的过程,或者使用文档记录搜索的中间或最终结果。(可以着重参考browser的工具来辅助搜索)
 
@@ -26,10 +26,10 @@ goal(add="搜索工具, 搜索案例, 搜索方法论")
 
 **Step 2: 多维度搜索**
 
-- 搜索工具:`search_posts(query="PDF table extraction tool")`
-- 搜索案例:`search_posts(query="PDF table extraction usercase site:github.com")`
-- 搜索定义:`search_posts(query="PDF table extraction definition")`
-- 搜索方法:`search_posts(query="PDF table extraction best practice")`
+- 搜索工具:`content_search(platform="github", keyword="PDF table extraction tool")`
+- 搜索案例:`content_search(platform="xhs", keyword="PDF表格提取")`
+- 搜索定义:`content_search(platform="zhihu", keyword="PDF table extraction")`
+- 搜索方法:`content_search(platform="gzh", keyword="PDF table extraction best practice")`
 
 **Step 3: 结构化记录**
 每发现一条有价值的信息,立即保存为结构化知识:

+ 16 - 19
agent/tools/builtin/__init__.py

@@ -8,6 +8,7 @@
 """
 
 from agent.tools.builtin.file.read import read_file
+from agent.tools.builtin.file.read_images import read_images
 from agent.tools.builtin.file.edit import edit_file
 from agent.tools.builtin.file.write import write_file
 from agent.tools.builtin.glob_tool import glob_files
@@ -15,15 +16,16 @@ from agent.tools.builtin.file.grep import grep_content
 from agent.tools.builtin.bash import bash_command
 from agent.tools.builtin.skill import skill, list_skills
 from agent.tools.builtin.subagent import agent, evaluate
-from agent.tools.builtin.search import search_posts, get_search_suggestions
-from agent.tools.builtin.sandbox import (sandbox_create_environment, sandbox_run_shell,
-                                         sandbox_rebuild_with_ports,sandbox_destroy_environment)
+# sandbox 工具已废弃(2026-04);search.py / crawler.py 已重构为 content/ 工具族(2026-04)
 from agent.tools.builtin.knowledge import(knowledge_search,knowledge_save,knowledge_list,knowledge_update,knowledge_batch_update,knowledge_slim)
 from agent.tools.builtin.librarian import ask_knowledge, upload_knowledge
 from agent.tools.builtin.context import get_current_context
-from agent.tools.builtin.toolhub import toolhub_health, toolhub_search, toolhub_call, image_uploader, image_downloader
+from agent.tools.builtin.toolhub import toolhub_health, toolhub_search, toolhub_call
 from agent.tools.builtin.resource import resource_list_tools, resource_get_tool
-from agent.tools.builtin.crawler import youtube_search, youtube_detail, x_search, import_content, extract_video_clip
+from agent.tools.builtin.content import (
+    content_platforms, content_search, content_detail, content_suggest,
+    extract_video_clip, import_content,
+)
 from agent.trace.goal_tool import goal
 # 导入浏览器工具以触发注册
 import agent.tools.builtin.browser  # noqa: F401
@@ -34,6 +36,7 @@ import agent.tools.builtin.im
 __all__ = [
     # 文件操作
     "read_file",
+    "read_images",
     "edit_file",
     "write_file",
     "glob_files",
@@ -54,30 +57,24 @@ __all__ = [
     "list_skills",
     "agent",
     "evaluate",
-    "search_posts",
-    "select_post",
-    "get_search_suggestions",
-    "sandbox_create_environment",
-    "sandbox_run_shell",
-    "sandbox_rebuild_with_ports",
-    "sandbox_destroy_environment",
+    # 内容工具族(重构自 search.py + crawler.py)
+    "content_platforms",
+    "content_search",
+    "content_detail",
+    "content_suggest",
     # 上下文工具
     "get_current_context",
     # ToolHub 远程工具库
     "toolhub_health",
     "toolhub_search",
     "toolhub_call",
-    "image_uploader",
-    "image_downloader",
+    # image_uploader / image_downloader 已内化到 toolhub_call 的图片管线中,不再单独暴露
     # 资源查询
     "resource_list_tools",
     "resource_get_tool",
-    # 爬虫工具
-    "youtube_search",
-    "youtube_detail",
-    "x_search",
-    "import_content",
+    # 媒体 / 导入
     "extract_video_clip",
+    "import_content",
     # Goal 管理
     "goal",
 ]

+ 1 - 1
agent/tools/builtin/bash.py

@@ -158,7 +158,7 @@ def _kill_process_tree(pid: int) -> None:
         pass
 
 
-@tool(description="执行 bash 命令", hidden_params=["context"])
+@tool(description="执行 bash 命令", hidden_params=["context"], groups=["core"])
 async def bash_command(
     command: str,
     timeout: Optional[int] = None,

+ 30 - 93
agent/tools/builtin/browser/__init__.py

@@ -2,63 +2,32 @@
 浏览器工具 - Browser-Use 原生工具适配器
 
 基于 browser-use 实现的浏览器自动化工具集。
+28 个原始工具已合并为 14 个语义化入口(2026-04 重构)。
 """
 
 from agent.tools.builtin.browser.baseClass import (
-    # 会话管理
+    # 会话管理(非 @tool,供框架内部调用)
     init_browser_session,
     get_browser_session,
     get_browser_live_url,
     cleanup_browser_session,
     kill_browser_session,
 
-    # 导航类工具
-    browser_get_live_url,
-    browser_navigate_to_url,
-    browser_search_web,
-    browser_go_back,
-    browser_wait,
-
-    # 元素交互工具
-    browser_click_element,
-    browser_input_text,
-    browser_send_keys,
-    browser_upload_file,
-
-    # 滚动和视图工具
-    browser_scroll_page,
-    browser_find_text,
+    # 14 个 @tool 入口
+    browser_navigate,
+    browser_search,
+    browser_back,
+    browser_interact,
+    browser_scroll,
     browser_screenshot,
-
-    # 标签页管理工具
-    browser_switch_tab,
-    browser_close_tab,
-
-    # 下拉框工具
-    browser_get_dropdown_options,
-    browser_select_dropdown_option,
-
-    # 内容提取工具
-    browser_extract_content,
-    browser_read_long_content,
-    browser_get_page_html,
-    browser_download_direct_url,
-    browser_get_selector_map,
-    browser_get_visual_selector_map,
-
-    # JavaScript 执行工具
-    browser_evaluate,
-    browser_ensure_login_with_cookies,
-
-    # 等待用户操作
-    browser_wait_for_user_action,
-
-    # 任务完成
-    browser_done,
-
-    # Cookie 持久化
-    browser_export_cookies,
-    browser_load_cookies,
+    browser_elements,
+    browser_read,
+    browser_extract,
+    browser_tabs,
+    browser_cookies,
+    browser_wait,
+    browser_js,
+    browser_download,
 )
 
 __all__ = [
@@ -69,51 +38,19 @@ __all__ = [
     'cleanup_browser_session',
     'kill_browser_session',
 
-    # 导航类工具
-    'browser_get_live_url',
-    'browser_navigate_to_url',
-    'browser_search_web',
-    'browser_go_back',
-    'browser_wait',
-
-    # 元素交互工具
-    'browser_click_element',
-    'browser_input_text',
-    'browser_send_keys',
-    'browser_upload_file',
-
-    # 滚动和视图工具
-    'browser_scroll_page',
-    'browser_find_text',
+    # @tool 入口
+    'browser_navigate',
+    'browser_search',
+    'browser_back',
+    'browser_interact',
+    'browser_scroll',
     'browser_screenshot',
-
-    # 标签页管理工具
-    'browser_switch_tab',
-    'browser_close_tab',
-
-    # 下拉框工具
-    'browser_get_dropdown_options',
-    'browser_select_dropdown_option',
-
-    # 内容提取工具
-    'browser_extract_content',
-    'browser_read_long_content',
-    'browser_download_direct_url',
-    'browser_get_page_html',
-    'browser_get_selector_map',
-    'browser_get_visual_selector_map',
-
-    # JavaScript 执行工具
-    'browser_evaluate',
-    'browser_ensure_login_with_cookies',
-
-    # 等待用户操作
-    'browser_wait_for_user_action',
-
-    # 任务完成
-    'browser_done',
-
-    # Cookie 持久化
-    'browser_export_cookies',
-    'browser_load_cookies',
+    'browser_elements',
+    'browser_read',
+    'browser_extract',
+    'browser_tabs',
+    'browser_cookies',
+    'browser_wait',
+    'browser_js',
+    'browser_download',
 ]

+ 334 - 77
agent/tools/builtin/browser/baseClass.py

@@ -52,7 +52,7 @@ import aiohttp
 import re
 import base64
 from urllib.parse import urlparse, parse_qs, unquote
-from typing import Optional, List, Dict, Any, Tuple, Union
+from typing import Literal, Optional, List, Dict, Any, Tuple, Union
 from pathlib import Path
 from langchain_core.runnables import RunnableLambda
 from argparse import Namespace # 使用 Namespace 快速构造带属性的对象
@@ -548,7 +548,6 @@ def _fetch_profile_id(cookie_type: str) -> Optional[str]:
 # 导航类工具 (Navigation Tools)
 # ============================================================
 
-@tool()
 async def browser_get_live_url() -> ToolResult:
     """
     获取云浏览器的实时画面链接(Live URL),可用于在本地浏览器中查看或分享给他人操作。
@@ -567,7 +566,6 @@ async def browser_get_live_url() -> ToolResult:
     )
 
 
-@tool()
 async def browser_navigate_to_url(url: str, new_tab: bool = False) -> ToolResult:
     """
     导航到指定的 URL
@@ -607,7 +605,6 @@ async def browser_navigate_to_url(url: str, new_tab: bool = False) -> ToolResult
         )
 
 
-@tool()
 async def browser_search_web(query: str, engine: str = "bing") -> ToolResult:
     """
     使用搜索引擎搜索
@@ -644,7 +641,6 @@ async def browser_search_web(query: str, engine: str = "bing") -> ToolResult:
         )
 
 
-@tool()
 async def browser_go_back() -> ToolResult:
     """
     返回到上一个页面
@@ -671,10 +667,9 @@ async def browser_go_back() -> ToolResult:
         )
 
 
-@tool()
-async def browser_wait(seconds: int = 3) -> ToolResult:
+async def browser_wait_impl(seconds: int = 3) -> ToolResult:
     """
-    等待指定的秒数
+    等待指定的秒数(内部实现)
     Wait for a specified number of seconds
 
     用于等待页面加载、动画完成或其他异步操作。
@@ -732,7 +727,6 @@ class DownloadLinkCaptureHandler(logging.Handler):
                     self.captured_url = url
                     # print(f"🎯 成功锁定完整直链: {url[:50]}...") # 调试用
 
-@tool()
 async def browser_download_direct_url(url: str, save_name: str = "book.epub") -> ToolResult:
     save_dir = Path.cwd() / ".cache/.browser_use_files"
     save_dir.mkdir(parents=True, exist_ok=True)
@@ -797,7 +791,6 @@ async def browser_download_direct_url(url: str, save_name: str = "book.epub") ->
             long_term_memory=f"下载任务由于异常中断: {str(e)}"
         )
     
-@tool()
 async def browser_click_element(index: int) -> ToolResult:
     """
     点击页面元素,并自动通过拦截内部日志获取下载直链。
@@ -847,7 +840,6 @@ async def browser_click_element(index: int) -> ToolResult:
         logger.removeHandler(capture_handler)
 
 
-@tool()
 async def browser_input_text(index: int, text: str, clear: bool = True) -> ToolResult:
     """
     在指定元素中输入文本
@@ -885,7 +877,6 @@ async def browser_input_text(index: int, text: str, clear: bool = True) -> ToolR
         )
 
 
-@tool()
 async def browser_send_keys(keys: str) -> ToolResult:
     """
     发送键盘按键或快捷键
@@ -925,7 +916,6 @@ async def browser_send_keys(keys: str) -> ToolResult:
         )
 
 
-@tool()
 async def browser_upload_file(index: int, path: str) -> ToolResult:
     """
     上传文件到文件输入元素
@@ -968,7 +958,6 @@ async def browser_upload_file(index: int, path: str) -> ToolResult:
 # ============================================================
 # 滚动和视图工具 (Scroll & View Tools)
 # ============================================================
-@tool()
 async def browser_scroll_page(down: bool = True, pages: float = 1.0, index: Optional[int] = None) -> ToolResult:
     try:
         # 限制单次滚动幅度,避免 agent 一次滚 100 页
@@ -1028,7 +1017,6 @@ async def browser_scroll_page(down: bool = True, pages: float = 1.0, index: Opti
 
 
 
-@tool()
 async def browser_find_text(text: str) -> ToolResult:
     """
     查找页面中的文本并滚动到该位置
@@ -1063,7 +1051,6 @@ async def browser_find_text(text: str) -> ToolResult:
             long_term_memory=f"查找文本 '{text}' 失败"
         )
 
-@tool()
 async def browser_get_visual_selector_map() -> ToolResult:
     """
     获取当前页面的视觉快照和交互元素索引映射。
@@ -1160,10 +1147,9 @@ async def browser_get_visual_selector_map() -> ToolResult:
             long_term_memory="获取视觉元素映射失败"
         )
     
-@tool()
-async def browser_screenshot() -> ToolResult:
+async def browser_screenshot_impl() -> ToolResult:
     """
-    请求在下次观察中包含页面截图
+    请求在下次观察中包含页面截图(内部实现)
     Request a screenshot to be included in the next observation
 
     用于视觉检查页面状态,帮助理解页面布局和内容。
@@ -1197,7 +1183,6 @@ async def browser_screenshot() -> ToolResult:
 # 标签页管理工具 (Tab Management Tools)
 # ============================================================
 
-@tool()
 async def browser_switch_tab(tab_id: str) -> ToolResult:
     """
     切换到指定标签页
@@ -1232,7 +1217,6 @@ async def browser_switch_tab(tab_id: str) -> ToolResult:
         )
 
 
-@tool()
 async def browser_close_tab(tab_id: str) -> ToolResult:
     """
     关闭指定标签页
@@ -1271,7 +1255,6 @@ async def browser_close_tab(tab_id: str) -> ToolResult:
 # 下拉框工具 (Dropdown Tools)
 # ============================================================
 
-@tool()
 async def browser_get_dropdown_options(index: int) -> ToolResult:
     """
     获取下拉框的所有选项
@@ -1305,7 +1288,6 @@ async def browser_get_dropdown_options(index: int) -> ToolResult:
         )
 
 
-@tool()
 async def browser_select_dropdown_option(index: int, text: str) -> ToolResult:
     """
     选择下拉框选项
@@ -1408,7 +1390,6 @@ async def extraction_adapter(input_data):
     from argparse import Namespace
     return Namespace(completion=content)
 
-@tool()
 async def browser_extract_content(query: str, extract_links: bool = False,
                          start_from_char: int = 0) -> ToolResult:
     """
@@ -1550,7 +1531,6 @@ async def _detect_and_download_pdf_via_cdp(browser) -> Optional[str]:
         return None
 
 
-@tool()
 async def browser_read_long_content(
     goal: Union[str, dict],
     source: str = "page",
@@ -1609,7 +1589,6 @@ async def browser_read_long_content(
             error=f"Read long content failed: {str(e)}",
             long_term_memory="参数解析或校验失败,请检查输入"
         )
-@tool()
 async def browser_get_page_html() -> ToolResult:
     """
     获取当前页面的完整 HTML
@@ -1671,7 +1650,6 @@ async def browser_get_page_html() -> ToolResult:
         )
 
 
-@tool()
 async def browser_get_selector_map() -> ToolResult:
     """
     获取当前页面的元素索引映射
@@ -1743,7 +1721,6 @@ async def browser_get_selector_map() -> ToolResult:
 # JavaScript 执行工具 (JavaScript Tools)
 # ============================================================
 
-@tool()
 async def browser_evaluate(code: str) -> ToolResult:
     """
     在页面中执行 JavaScript 代码
@@ -1785,7 +1762,6 @@ async def browser_evaluate(code: str) -> ToolResult:
         )
 
 
-@tool()
 async def browser_ensure_login_with_cookies(cookie_type: str, url: str = "https://www.xiaohongshu.com") -> ToolResult:
     """
     检查登录状态并在需要时注入 cookies
@@ -1881,7 +1857,6 @@ async def browser_ensure_login_with_cookies(cookie_type: str, url: str = "https:
 # 等待用户操作工具 (Wait for User Action)
 # ============================================================
 
-@tool()
 async def browser_wait_for_user_action(message: str = "Please complete the action in browser",
                                timeout: int = 300) -> ToolResult:
     """
@@ -1953,7 +1928,6 @@ async def browser_wait_for_user_action(message: str = "Please complete the actio
 # 任务完成工具 (Task Completion)
 # ============================================================
 
-@tool()
 async def browser_done(text: str, success: bool = True,
               files_to_display: Optional[List[str]] = None) -> ToolResult:
     """
@@ -1998,7 +1972,6 @@ async def browser_done(text: str, success: bool = True,
 
 _COOKIES_DIR = Path(__file__).parent.parent.parent.parent.parent / ".cache/.cookies"
 
-@tool()
 async def browser_export_cookies(name: str = "", account: str = "") -> ToolResult:
     """
     导出当前浏览器的所有 Cookie 到本地 .cookies/ 目录。
@@ -2042,7 +2015,6 @@ async def browser_export_cookies(name: str = "", account: str = "") -> ToolResul
         return ToolResult(title="Cookie 导出失败", output="", error=str(e), long_term_memory="导出 Cookie 失败")
 
 
-@tool()
 async def browser_load_cookies(url: str, name: str = "", auto_navigate: bool = True) -> ToolResult:
     """
     根据目标 URL 自动查找本地 Cookie 文件,注入浏览器并导航到目标页面恢复登录态。
@@ -2178,60 +2150,345 @@ async def browser_load_cookies(url: str, name: str = "", auto_navigate: bool = T
 
 
 # ============================================================
-# 导出所有工具函数(供外部使用
+# 新版统一入口(13 个 @tool,替代原来 28 个
 # ============================================================
 
-__all__ = [
-    # 会话管理
-    'init_browser_session',
-    'get_browser_session',
-    'cleanup_browser_session',
-    'kill_browser_session',
 
-    # 导航类工具
-    'browser_navigate_to_url',
-    'browser_search_web',
-    'browser_go_back',
-    'browser_wait',
+@tool(groups=["browser"])
+async def browser_navigate(url: str, new_tab: bool = False) -> ToolResult:
+    """
+    导航到指定 URL。
 
-    # 元素交互工具
-    'browser_click_element',
-    'browser_input_text',
-    'browser_send_keys',
-    'browser_upload_file',
+    Args:
+        url: 目标 URL
+        new_tab: 是否在新标签页打开(默认 False)
+    """
+    return await browser_navigate_to_url(url=url, new_tab=new_tab)
 
-    # 滚动和视图工具
-    'browser_scroll_page',
-    'browser_find_text',
-    'browser_screenshot',
 
-    # 标签页管理工具
-    'browser_switch_tab',
-    'browser_close_tab',
+@tool(groups=["browser"])
+async def browser_search(query: str, engine: str = "bing") -> ToolResult:
+    """
+    使用搜索引擎搜索。
+
+    Args:
+        query: 搜索关键词
+        engine: 搜索引擎,可选 google / bing / duckduckgo,默认 bing
+    """
+    return await browser_search_web(query=query, engine=engine)
+
+
+@tool(groups=["browser"])
+async def browser_back() -> ToolResult:
+    """返回上一页。"""
+    return await browser_go_back()
+
+
+@tool(groups=["browser"])
+async def browser_interact(
+    action: Literal["click", "type", "send_keys", "upload", "dropdown_list", "dropdown_select"],
+    index: Optional[int] = None,
+    text: Optional[str] = None,
+    path: Optional[str] = None,
+    keys: Optional[str] = None,
+    clear: bool = True,
+) -> ToolResult:
+    """
+    与页面元素交互。根据 action 选择具体操作:
+
+    - click: 点击元素。需要 index。
+    - type: 在输入框输入文本。需要 index + text。clear 控制是否先清空。
+    - send_keys: 发送键盘按键(如 Enter、Control+A)。需要 keys,不需要 index。
+    - upload: 上传文件到文件输入框。需要 index + path(绝对路径)。
+    - dropdown_list: 列出下拉框选项。需要 index。
+    - dropdown_select: 选择下拉框选项。需要 index + text(选项文本)。
+
+    Args:
+        action: 交互类型
+        index: 元素索引(从 browser_elements 或 browser_screenshot(highlight=True) 获取)
+        text: 输入文本 / 下拉框选项文本
+        path: 上传文件的绝对路径
+        keys: 键盘按键字符串(如 "Enter"、"Control+A")
+        clear: type 时是否先清空(默认 True)
+    """
+    if action == "click":
+        if index is None:
+            return ToolResult(title="参数错误", output="", error="click 需要 index 参数")
+        return await browser_click_element(index=index)
+
+    elif action == "type":
+        if index is None or text is None:
+            return ToolResult(title="参数错误", output="", error="type 需要 index 和 text 参数")
+        return await browser_input_text(index=index, text=text, clear=clear)
+
+    elif action == "send_keys":
+        if keys is None:
+            return ToolResult(title="参数错误", output="", error="send_keys 需要 keys 参数")
+        return await browser_send_keys(keys=keys)
+
+    elif action == "upload":
+        if index is None or path is None:
+            return ToolResult(title="参数错误", output="", error="upload 需要 index 和 path 参数")
+        return await browser_upload_file(index=index, path=path)
+
+    elif action == "dropdown_list":
+        if index is None:
+            return ToolResult(title="参数错误", output="", error="dropdown_list 需要 index 参数")
+        return await browser_get_dropdown_options(index=index)
+
+    elif action == "dropdown_select":
+        if index is None or text is None:
+            return ToolResult(title="参数错误", output="", error="dropdown_select 需要 index 和 text 参数")
+        return await browser_select_dropdown_option(index=index, text=text)
+
+    else:
+        return ToolResult(title="未知 action", output="", error=f"不支持的 action: {action}")
+
+
+@tool(groups=["browser"])
+async def browser_scroll(
+    down: bool = True,
+    pages: float = 1.0,
+    into_view_index: Optional[int] = None,
+) -> ToolResult:
+    """
+    滚动页面。
+
+    Args:
+        down: True 向下滚动,False 向上(默认 True)
+        pages: 滚动的页面数(默认 1.0)
+        into_view_index: 传入元素索引则滚动到该元素可见(忽略 down 和 pages)
+    """
+    return await browser_scroll_page(down=down, pages=pages, index=into_view_index)
+
+
+@tool(groups=["browser"])
+async def browser_screenshot(highlight_elements: bool = False) -> ToolResult:
+    """
+    截取当前页面。
+
+    Args:
+        highlight_elements: False 返回纯截图;True 返回带交互元素编号标注的截图
+                           + 元素列表(原 visual_selector_map 功能)
+    """
+    if highlight_elements:
+        return await browser_get_visual_selector_map()
+    else:
+        return await browser_screenshot_impl()
+
+
+@tool(groups=["browser"])
+async def browser_elements() -> ToolResult:
+    """
+    获取当前页面的可交互元素列表(纯文本,不截图)。
+    返回的 index 用于 browser_interact / browser_scroll 等操作。
+    """
+    return await browser_get_selector_map()
+
+
+@tool(groups=["browser"])
+async def browser_read(
+    mode: Literal["html", "find", "long"],
+    query: Optional[str] = None,
+    source: str = "page",
+    context: str = "",
+) -> ToolResult:
+    """
+    读取页面内容,三种模式:
+
+    - html: 获取当前页面的 HTML 源码(大页面会截断到 10000 字符)
+    - find: 在页面中查找文本。需要 query。
+    - long: 智能分页读取长内容(支持自动检测 PDF)。query 描述阅读目标。
+
+    Args:
+        mode: 读取模式
+        query: find 模式下的查找文本;long 模式下的阅读目标描述
+        source: long 模式的内容来源("page" 或文件路径),默认 "page"
+        context: long 模式的业务背景(可选)
+    """
+    if mode == "html":
+        return await browser_get_page_html()
+
+    elif mode == "find":
+        if not query:
+            return ToolResult(title="参数错误", output="", error="find 模式需要 query 参数")
+        return await browser_find_text(text=query)
+
+    elif mode == "long":
+        return await browser_read_long_content(
+            goal=query or "阅读页面内容",
+            source=source,
+            context=context,
+        )
+
+    else:
+        return ToolResult(title="未知 mode", output="", error=f"不支持的 mode: {mode}")
+
+
+@tool(groups=["browser"])
+async def browser_extract(
+    query: str,
+    extract_links: bool = False,
+    start_from_char: int = 0,
+) -> ToolResult:
+    """
+    使用 LLM 从当前页面提取结构化数据。
+
+    与 browser_read 不同,此工具会调用 LLM 分析页面内容并返回结构化结果。
+    适合"提取所有产品价格"、"总结文章要点"等需要理解语义的场景。
+
+    Args:
+        query: 提取指令(如"提取页面上所有产品名称和价格")
+        extract_links: 是否同时提取链接(默认 False)
+        start_from_char: 从第几个字符开始提取(用于分页处理大内容)
+    """
+    return await browser_extract_content(
+        query=query,
+        extract_links=extract_links,
+        start_from_char=start_from_char,
+    )
+
+
+@tool(groups=["browser"])
+async def browser_tabs(
+    action: Literal["switch", "close"],
+    tab_id: str = "",
+) -> ToolResult:
+    """
+    管理浏览器标签页。
+
+    Args:
+        action: "switch" 切换到指定标签页;"close" 关闭指定标签页
+        tab_id: 标签页 ID(4 字符)
+    """
+    if not tab_id:
+        return ToolResult(title="参数错误", output="", error="需要 tab_id 参数")
+
+    if action == "switch":
+        return await browser_switch_tab(tab_id=tab_id)
+    elif action == "close":
+        return await browser_close_tab(tab_id=tab_id)
+    else:
+        return ToolResult(title="未知 action", output="", error=f"不支持的 action: {action}")
+
+
+@tool(groups=["browser"])
+async def browser_cookies(
+    action: Literal["load", "export", "ensure_login"],
+    url: str = "",
+    name: str = "",
+    account: str = "",
+    cookie_type: str = "",
+    auto_navigate: bool = True,
+) -> ToolResult:
+    """
+    Cookie / 登录态管理:
+
+    - load: 从本地加载已保存的 cookie 并注入浏览器。需要 url(自动匹配 cookie 文件)。
+    - export: 导出当前浏览器 cookie 到本地。可选 name 和 account 标识。
+    - ensure_login: 检查登录状态,未登录时自动注入 cookie。需要 cookie_type 和 url。
+
+    Args:
+        action: 操作类型
+        url: 目标 URL(load / ensure_login 必填)
+        name: cookie 文件名(可选)
+        account: 账号名(export 时可选)
+        cookie_type: cookie 类型标识(ensure_login 必填)
+        auto_navigate: load 时找不到 cookie 是否自动导航到目标页面(默认 True)
+    """
+    if action == "load":
+        if not url:
+            return ToolResult(title="参数错误", output="", error="load 需要 url 参数")
+        return await browser_load_cookies(url=url, name=name, auto_navigate=auto_navigate)
+
+    elif action == "export":
+        return await browser_export_cookies(name=name, account=account)
+
+    elif action == "ensure_login":
+        if not cookie_type:
+            return ToolResult(title="参数错误", output="", error="ensure_login 需要 cookie_type 参数")
+        return await browser_ensure_login_with_cookies(
+            cookie_type=cookie_type,
+            url=url or "https://www.xiaohongshu.com",
+        )
+
+    else:
+        return ToolResult(title="未知 action", output="", error=f"不支持的 action: {action}")
 
-    # 下拉框工具
-    'browser_get_dropdown_options',
-    'browser_select_dropdown_option',
 
-    # 内容提取工具
-    'browser_extract_content',
-    'browser_get_page_html',
-    'browser_read_long_content',
-    'browser_download_direct_url',
-    'browser_get_selector_map',
-    'browser_get_visual_selector_map',
+@tool(groups=["browser"])
+async def browser_wait(
+    seconds: Optional[int] = None,
+    user_message: Optional[str] = None,
+    timeout: int = 300,
+) -> ToolResult:
+    """
+    等待。两种模式:
+
+    - 传 seconds: 纯等待指定秒数(默认 3 秒)
+    - 传 user_message: 暂停并提示用户在浏览器中完成操作(如登录、验证码),
+      用户完成后按回车继续。timeout 控制最长等待时间。
+    - 两者都不传: 默认等待 3 秒
+
+    Args:
+        seconds: 等待秒数
+        user_message: 用户操作提示消息
+        timeout: user_message 模式的最长等待(秒),默认 300
+    """
+    if user_message:
+        return await browser_wait_for_user_action(message=user_message, timeout=timeout)
+    else:
+        return await browser_wait_impl(seconds=seconds or 3)
+
+
+@tool(groups=["browser"])
+async def browser_js(code: str) -> ToolResult:
+    """
+    在当前页面执行 JavaScript 代码。
+
+    Args:
+        code: JavaScript 代码字符串。返回值会被自动序列化。
+    """
+    return await browser_evaluate(code=code)
+
 
-    # JavaScript 执行工具
-    'browser_evaluate',
-    'browser_ensure_login_with_cookies',
+@tool(groups=["browser"])
+async def browser_download(url: str, save_name: str = "") -> ToolResult:
+    """
+    下载指定 URL 的文件到本地。
+
+    Args:
+        url: 文件 URL
+        save_name: 保存文件名(可选,默认自动推断)
+    """
+    return await browser_download_direct_url(url=url, save_name=save_name or "download")
 
-    # 等待用户操作
-    'browser_wait_for_user_action',
 
-    # 任务完成
-    'browser_done',
+# ============================================================
+# 导出(供外部使用)
+# ============================================================
+
+__all__ = [
+    # 会话管理(非 @tool)
+    'init_browser_session',
+    'get_browser_session',
+    'get_browser_live_url',
+    'cleanup_browser_session',
+    'kill_browser_session',
 
-    # Cookie 持久化
-    'browser_export_cookies',
-    'browser_load_cookies',
+    # 13 个 @tool 入口
+    'browser_navigate',
+    'browser_search',
+    'browser_back',
+    'browser_interact',
+    'browser_scroll',
+    'browser_screenshot',
+    'browser_elements',
+    'browser_read',
+    'browser_extract',
+    'browser_tabs',
+    'browser_cookies',
+    'browser_wait',
+    'browser_js',
+    'browser_download',
 ]

+ 29 - 0
agent/tools/builtin/content/__init__.py

@@ -0,0 +1,29 @@
+"""
+内容工具族 —— 统一的跨平台内容搜索/详情/建议词 + 媒体处理 + 内容导入
+
+@tool 入口:
+  content_platforms  - 查看平台及参数
+  content_search     - 跨平台搜索
+  content_detail     - 查看详情
+  content_suggest    - 搜索建议词
+  extract_video_clip - YouTube 视频片段截取
+  import_content     - 内容批量导入 CMS
+"""
+
+from agent.tools.builtin.content.tools import (
+    content_platforms,
+    content_search,
+    content_detail,
+    content_suggest,
+)
+from agent.tools.builtin.content.media import extract_video_clip
+from agent.tools.builtin.content.ingestion import import_content
+
+__all__ = [
+    "content_platforms",
+    "content_search",
+    "content_detail",
+    "content_suggest",
+    "extract_video_clip",
+    "import_content",
+]

+ 86 - 0
agent/tools/builtin/content/cache.py

@@ -0,0 +1,86 @@
+"""
+内容搜索缓存(磁盘持久化)
+
+搜索结果按 trace_id 隔离,同一 Agent session 内的 CLI 多次调用也能复用。
+文件格式:/tmp/content_cache_{trace_id}.json
+"""
+
+import json
+import os
+import time
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+_CACHE_DIR = Path("/tmp")
+_CACHE_TTL = 3600  # 1 小时过期
+
+
+def _cache_path(trace_id: str) -> Path:
+    safe_id = trace_id.replace("/", "_").replace("..", "_")
+    return _CACHE_DIR / f"content_cache_{safe_id}.json"
+
+
+def _load_raw(trace_id: str) -> dict:
+    p = _cache_path(trace_id)
+    if not p.exists():
+        return {}
+    try:
+        data = json.loads(p.read_text("utf-8"))
+        # 检查过期
+        if time.time() - data.get("_ts", 0) > _CACHE_TTL:
+            p.unlink(missing_ok=True)
+            return {}
+        return data
+    except Exception:
+        return {}
+
+
+def _save_raw(trace_id: str, data: dict) -> None:
+    data["_ts"] = time.time()
+    try:
+        _cache_path(trace_id).write_text(
+            json.dumps(data, ensure_ascii=False), encoding="utf-8"
+        )
+    except Exception:
+        pass
+
+
+def save_search_results(
+    trace_id: str,
+    platform: str,
+    keyword: str,
+    posts: List[Dict[str, Any]],
+) -> None:
+    """保存搜索结果到磁盘缓存"""
+    data = _load_raw(trace_id)
+    # 每个 platform 只保留最近一次搜索
+    data[f"search:{platform}"] = {
+        "keyword": keyword,
+        "posts": posts,
+    }
+    _save_raw(trace_id, data)
+
+
+def get_cached_post(
+    trace_id: str,
+    platform: str,
+    index: int,
+) -> Optional[Dict[str, Any]]:
+    """按索引从缓存取一条完整记录(1-based)"""
+    data = _load_raw(trace_id)
+    entry = data.get(f"search:{platform}")
+    if not entry:
+        return None
+    posts = entry.get("posts", [])
+    if 1 <= index <= len(posts):
+        return posts[index - 1]
+    return None
+
+
+def get_cached_search_info(trace_id: str, platform: str) -> Optional[Dict[str, Any]]:
+    """获取缓存的搜索信息(keyword + 总条数),用于错误提示"""
+    data = _load_raw(trace_id)
+    entry = data.get(f"search:{platform}")
+    if not entry:
+        return None
+    return {"keyword": entry.get("keyword"), "total": len(entry.get("posts", []))}

+ 46 - 0
agent/tools/builtin/content/ingestion.py

@@ -0,0 +1,46 @@
+"""
+内容导入工具
+
+将文章链接批量导入 AIGC CMS 系统。
+"""
+
+import json
+from typing import Any, Dict, List
+
+import httpx
+
+from agent.tools import tool, ToolResult
+
+AIGC_BASE_URL = "http://aigc-channel.aiddit.com/aigc/channel"
+DEFAULT_TIMEOUT = 60.0
+
+
+@tool(groups=["content"])
+async def import_content(plan_name: str, content_data: List[Dict[str, Any]]) -> ToolResult:
+    """
+    导入长文内容到 CMS(微信公众号、小红书、抖音等通用链接)。
+
+    Args:
+        plan_name: 计划名称
+        content_data: 内容数据列表,每项包含 channel、content_link、title 等字段
+    """
+    try:
+        async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client:
+            response = await client.post(
+                f"{AIGC_BASE_URL}/weixin/auto_insert",
+                json={"plan_name": plan_name, "data": content_data},
+            )
+            response.raise_for_status()
+            data = response.json()
+
+        if data.get("code") == 0:
+            result_data = data.get("data", {})
+            return ToolResult(
+                title=f"内容导入: {plan_name}",
+                output=json.dumps(result_data, ensure_ascii=False, indent=2),
+                long_term_memory=f"Imported {len(content_data)} items to plan '{plan_name}'",
+            )
+        return ToolResult(title="导入失败", output="", error=f"导入失败: {data.get('msg', '未知错误')}")
+
+    except Exception as e:
+        return ToolResult(title="内容导入异常", output="", error=str(e))

+ 114 - 0
agent/tools/builtin/content/media.py

@@ -0,0 +1,114 @@
+"""
+媒体处理工具
+
+- extract_video_clip: 从已下载的 YouTube 视频中截取片段
+- download_youtube_video / parse_srt_to_outline: 供 YouTube 详情调用的辅助函数
+"""
+
+import asyncio
+import json
+import subprocess
+import tempfile
+from pathlib import Path
+from typing import Dict, List, Optional
+
+from agent.tools import tool, ToolResult
+
+VIDEO_DOWNLOAD_DIR = Path(tempfile.gettempdir()) / "youtube_videos"
+VIDEO_DOWNLOAD_DIR.mkdir(exist_ok=True)
+
+
+# ── 辅助函数(供 platforms/youtube.py 调用) ──
+
+def download_youtube_video(video_id: str) -> Optional[str]:
+    """使用 yt-dlp 下载 YouTube 视频,返回文件路径"""
+    try:
+        output_path = VIDEO_DOWNLOAD_DIR / f"{video_id}.mp4"
+        if output_path.exists():
+            return str(output_path)
+
+        cmd = [
+            "yt-dlp",
+            "-f", "best[ext=mp4]",
+            "-o", str(output_path),
+            f"https://www.youtube.com/watch?v={video_id}",
+        ]
+        result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
+        if result.returncode == 0 and output_path.exists():
+            return str(output_path)
+        return None
+    except Exception:
+        return None
+
+
+def parse_srt_to_outline(srt_content: str) -> List[Dict[str, str]]:
+    """解析 SRT 字幕,生成带时间戳的大纲"""
+    if not srt_content:
+        return []
+    outline = []
+    blocks = srt_content.strip().split("\n\n")
+    for block in blocks:
+        lines = block.strip().split("\n")
+        if len(lines) >= 3:
+            timestamp_line = lines[1]
+            if "-->" in timestamp_line:
+                start_time = timestamp_line.split("-->")[0].strip()
+                text = " ".join(lines[2:])
+                outline.append({"timestamp": start_time, "text": text})
+    return outline
+
+
+# ── @tool ──
+
+@tool(groups=["content"])
+async def extract_video_clip(
+    video_id: str,
+    start_time: str,
+    end_time: str,
+    output_name: Optional[str] = None,
+) -> ToolResult:
+    """
+    从已下载的 YouTube 视频中截取指定时间段的片段。
+
+    必须先通过 content_detail(platform="youtube", index=..., extras={"download_video": true})
+    下载视频后才能使用。
+
+    Args:
+        video_id: YouTube 视频 ID
+        start_time: 开始时间,格式 HH:MM:SS 或 MM:SS
+        end_time: 结束时间,格式 HH:MM:SS 或 MM:SS
+        output_name: 输出文件名(可选,自动生成)
+    """
+    source_video = VIDEO_DOWNLOAD_DIR / f"{video_id}.mp4"
+    if not source_video.exists():
+        return ToolResult(
+            title="视频截取失败",
+            output="",
+            error="源视频不存在,请先使用 content_detail(platform='youtube', ..., extras={'download_video': true}) 下载",
+        )
+
+    if not output_name:
+        output_name = f"{video_id}_clip_{start_time.replace(':', '-')}_{end_time.replace(':', '-')}.mp4"
+    output_path = VIDEO_DOWNLOAD_DIR / output_name
+
+    cmd = ["ffmpeg", "-i", str(source_video), "-ss", start_time, "-to", end_time, "-c", "copy", "-y", str(output_path)]
+
+    try:
+        result = await asyncio.to_thread(subprocess.run, cmd, capture_output=True, text=True, timeout=60)
+    except subprocess.TimeoutExpired:
+        return ToolResult(title="视频截取超时", output="", error="ffmpeg 超时(60秒)")
+
+    if result.returncode == 0 and output_path.exists():
+        file_size = output_path.stat().st_size / (1024 * 1024)
+        return ToolResult(
+            title=f"视频片段: {start_time} - {end_time}",
+            output=json.dumps({
+                "video_id": video_id,
+                "clip_path": str(output_path),
+                "start_time": start_time,
+                "end_time": end_time,
+                "file_size_mb": round(file_size, 2),
+            }, ensure_ascii=False, indent=2),
+            long_term_memory=f"Extracted clip from {video_id}: {start_time}-{end_time}",
+        )
+    return ToolResult(title="视频截取失败", output="", error=f"ffmpeg 执行失败: {result.stderr}")

+ 1 - 0
agent/tools/builtin/content/platforms/__init__.py

@@ -0,0 +1 @@
+"""内容平台实现模块"""

+ 237 - 0
agent/tools/builtin/content/platforms/aigc_channel.py

@@ -0,0 +1,237 @@
+"""
+AIGC-Channel 平台实现(9 个中文平台)
+
+后端:aigc-channel.aiddit.com
+平台:xhs / gzh / sph / github / toutiao / douyin / bili / zhihu / weibo
+"""
+
+import json
+from typing import Any, Dict, List, Optional
+
+import httpx
+
+from agent.tools.models import ToolResult
+from agent.tools.utils.image import build_image_grid, encode_base64, load_images
+from agent.tools.builtin.content.registry import (
+    PlatformDef, ParamSpec, register_platform,
+)
+
+BASE_URL = "http://aigc-channel.aiddit.com/aigc/channel"
+DEFAULT_TIMEOUT = 60.0
+
+
+# ── 平台注册 ──
+
+_XHS_SEARCH_PARAMS = {
+    "sort_type": ParamSpec(
+        values=["综合排序", "最新发布", "最多点赞"],
+        default="综合排序",
+    ),
+    "publish_time": ParamSpec(
+        values=["不限", "近1天", "近7天", "近30天"],
+        default="不限",
+    ),
+    "content_type": ParamSpec(
+        values=["不限", "图文", "视频", "文章"],
+        default="不限",
+    ),
+    "filter_note_range": ParamSpec(
+        values=["不限", "1分钟以内", "1-5分钟", "5分钟以上"],
+        default="不限",
+        note="仅视频内容生效",
+    ),
+}
+
+_COMMON_CONTENT_TYPE = {
+    "content_type": ParamSpec(
+        values=["视频", "图文"],
+        default="",
+        note="留空不限",
+    ),
+}
+
+# 9 个中文平台定义
+_AIGC_PLATFORMS = [
+    PlatformDef(id="xhs",     name="小红书",   aliases=["RED", "xiaohongshu"], search_params=_XHS_SEARCH_PARAMS, supports_suggest=True),
+    PlatformDef(id="gzh",     name="公众号",   aliases=["微信公众号", "wechat"], search_params=_COMMON_CONTENT_TYPE),
+    PlatformDef(id="sph",     name="视频号",   aliases=["微信视频号"], search_params=_COMMON_CONTENT_TYPE),
+    PlatformDef(id="github",  name="GitHub",   aliases=["gh"], search_params=_COMMON_CONTENT_TYPE),
+    PlatformDef(id="toutiao", name="头条",     aliases=["今日头条", "toutiao"], search_params=_COMMON_CONTENT_TYPE, supports_suggest=True),
+    PlatformDef(id="douyin",  name="抖音",     aliases=["TikTok"], search_params=_COMMON_CONTENT_TYPE, supports_suggest=True),
+    PlatformDef(id="bili",    name="B站",      aliases=["哔哩哔哩", "bilibili"], search_params=_COMMON_CONTENT_TYPE, supports_suggest=True),
+    PlatformDef(id="zhihu",   name="知乎",     aliases=[], search_params=_COMMON_CONTENT_TYPE, supports_suggest=True),
+    PlatformDef(id="weibo",   name="微博",     aliases=["sina"], search_params=_COMMON_CONTENT_TYPE),
+]
+
+# suggest API 额外支持 wx(微信搜一搜),但它不是搜索平台
+_SUGGEST_ONLY_CHANNELS = {"wx": "微信"}
+
+
+# ── 搜索实现 ──
+
+async def search(
+    platform_id: str,
+    keyword: str,
+    max_count: int = 20,
+    cursor: str = "",
+    extras: Optional[Dict[str, Any]] = None,
+) -> ToolResult:
+    """AIGC-Channel 统一搜索"""
+    extras = extras or {}
+
+    if platform_id == "xhs":
+        payload = {
+            "type": platform_id,
+            "keyword": keyword,
+            "cursor": cursor,
+            "content_type": extras.get("content_type", "不限"),
+            "sort_type": extras.get("sort_type", "综合排序"),
+            "publish_time": extras.get("publish_time", "不限"),
+            "filter_note_range": extras.get("filter_note_range", "不限"),
+        }
+    else:
+        payload = {
+            "type": platform_id,
+            "keyword": keyword,
+            "cursor": cursor or "0",
+            "max_count": max_count,
+            "content_type": extras.get("content_type", ""),
+        }
+
+    try:
+        async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client:
+            response = await client.post(
+                f"{BASE_URL}/data",
+                json=payload,
+                headers={"Content-Type": "application/json"},
+            )
+            response.raise_for_status()
+            data = response.json()
+    except httpx.HTTPStatusError as e:
+        return ToolResult(title="搜索失败", output="", error=f"HTTP {e.response.status_code}: {e.response.text}")
+    except Exception as e:
+        return ToolResult(title="搜索失败", output="", error=str(e))
+
+    posts = data.get("data", [])
+
+    # 构建概览摘要
+    summary_list = []
+    for idx, post in enumerate(posts, 1):
+        body = post.get("body_text", "") or ""
+        title = post.get("title") or body[:20] or ""
+        summary_list.append({
+            "index": idx,
+            "title": title,
+            "body_text": body[:100] + ("..." if len(body) > 100 else ""),
+            "like_count": post.get("like_count"),
+            "comment_count": post.get("comment_count"),
+            "channel": post.get("channel"),
+            "link": post.get("link"),
+            "content_type": post.get("content_type"),
+        })
+
+    # 封面拼图
+    images = []
+    try:
+        collage_b64 = await _build_collage(posts)
+        if collage_b64:
+            images.append({"type": "base64", "media_type": "image/png", "data": collage_b64})
+    except Exception:
+        pass
+
+    return ToolResult(
+        title=f"搜索: {keyword} ({platform_id})",
+        output=json.dumps({"data": summary_list}, ensure_ascii=False, indent=2),
+        long_term_memory=f"Searched '{keyword}' on {platform_id}, {len(posts)} results. Use content_detail to view full details.",
+        images=images,
+        metadata={"posts": posts},  # 完整数据传给上层缓存
+    )
+
+
+# ── 详情实现(从缓存获取,不需要额外 HTTP) ──
+
+async def detail(post: Dict[str, Any], extras: Optional[Dict[str, Any]] = None) -> ToolResult:
+    """返回单条帖子的完整内容"""
+    title = post.get("title") or post.get("body_text", "")[:30] or "无标题"
+
+    images = []
+    for img_url in post.get("images", []):
+        if img_url:
+            images.append({"type": "url", "url": img_url})
+
+    return ToolResult(
+        title=f"详情: {title}",
+        output=json.dumps(post, ensure_ascii=False, indent=2),
+        long_term_memory=f"Viewed detail: {title}",
+        images=images,
+    )
+
+
+# ── 建议词实现 ──
+
+async def suggest(channel: str, keyword: str) -> ToolResult:
+    """获取搜索建议词"""
+    try:
+        async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client:
+            response = await client.post(
+                f"{BASE_URL}/suggest",
+                json={"type": channel, "keyword": keyword},
+                headers={"Content-Type": "application/json"},
+            )
+            response.raise_for_status()
+            data = response.json()
+    except Exception as e:
+        return ToolResult(title="建议词获取失败", output="", error=str(e))
+
+    suggestion_count = sum(len(item.get("list", [])) for item in data.get("data", []))
+    return ToolResult(
+        title=f"建议词: {keyword} ({channel})",
+        output=json.dumps(data, ensure_ascii=False, indent=2),
+        long_term_memory=f"Got {suggestion_count} suggestions for '{keyword}' on {channel}",
+    )
+
+
+# ── 拼图辅助 ──
+
+async def _build_collage(posts: List[Dict[str, Any]]) -> Optional[str]:
+    """封面图网格拼图"""
+    urls, titles = [], []
+    for post in posts:
+        imgs = post.get("images", [])
+        if imgs and imgs[0]:
+            urls.append(imgs[0])
+            titles.append(post.get("title", "") or "")
+
+    if not urls:
+        return None
+
+    loaded = await load_images(urls)
+    valid_images, valid_labels = [], []
+    for (_, img), title in zip(loaded, titles):
+        if img is not None:
+            valid_images.append(img)
+            valid_labels.append(title)
+
+    if not valid_images:
+        return None
+
+    grid = build_image_grid(images=valid_images, labels=valid_labels)
+    b64, _ = encode_base64(grid, format="PNG")
+    return b64
+
+
+# ── 注册所有 AIGC 平台 ──
+
+def _register_all():
+    for p in _AIGC_PLATFORMS:
+        p.search_impl = search
+        p.detail_impl = detail
+        if p.supports_suggest:
+            p.suggest_impl = suggest
+            p.suggest_channels = [p.id]
+        register_platform(p)
+
+    # wx 只有 suggest,没有搜索
+    # suggest 调用时 channel 传 "wx",但不注册为独立平台
+
+_register_all()

+ 129 - 0
agent/tools/builtin/content/platforms/x.py

@@ -0,0 +1,129 @@
+"""
+X (Twitter) 平台实现
+
+后端:crawler.aiddit.com/crawler/x
+"""
+
+import json
+from typing import Any, Dict, List, Optional
+
+import httpx
+
+from agent.tools.models import ToolResult
+from agent.tools.utils.image import build_image_grid, encode_base64, load_images
+from agent.tools.builtin.content.registry import PlatformDef, register_platform
+
+CRAWLER_URL = "http://crawler.aiddit.com/crawler/x/keyword"
+DEFAULT_TIMEOUT = 60.0
+
+
+async def search(
+    platform_id: str,
+    keyword: str,
+    max_count: int = 20,
+    cursor: str = "",
+    extras: Optional[Dict[str, Any]] = None,
+) -> ToolResult:
+    try:
+        async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client:
+            response = await client.post(CRAWLER_URL, json={"keyword": keyword})
+            response.raise_for_status()
+            data = response.json()
+
+        if data.get("code") != 0:
+            return ToolResult(title="X 搜索失败", output="", error=data.get("msg", "未知错误"))
+
+        result_data = data.get("data", {})
+        tweets = result_data.get("data", []) if isinstance(result_data, dict) else []
+
+        summary_list = []
+        for idx, tweet in enumerate(tweets[:max_count], 1):
+            text = tweet.get("body_text", "")
+            summary_list.append({
+                "index": idx,
+                "author": tweet.get("channel_account_name", ""),
+                "body_text": text[:100] + ("..." if len(text) > 100 else ""),
+                "like_count": tweet.get("like_count"),
+                "comment_count": tweet.get("comment_count"),
+            })
+
+        # 拼图
+        images = []
+        collage_b64 = await _build_tweet_collage(tweets[:max_count])
+        if collage_b64:
+            images.append({"type": "base64", "media_type": "image/png", "data": collage_b64})
+
+        return ToolResult(
+            title=f"X: {keyword}",
+            output=json.dumps({"data": summary_list}, ensure_ascii=False, indent=2),
+            long_term_memory=f"Searched X for '{keyword}', {len(tweets)} results.",
+            images=images,
+            metadata={"posts": tweets[:max_count]},
+        )
+
+    except Exception as e:
+        return ToolResult(title="X 搜索异常", output="", error=str(e))
+
+
+async def detail(post: Dict[str, Any], extras: Optional[Dict[str, Any]] = None) -> ToolResult:
+    """X 的详情直接从缓存的搜索结果取完整数据"""
+    author = post.get("channel_account_name", "")
+    text = post.get("body_text", "")[:30]
+
+    all_images = []
+    for img_item in post.get("image_url_list", []):
+        url = img_item.get("image_url") if isinstance(img_item, dict) else img_item
+        if url:
+            all_images.append({"type": "url", "url": url})
+
+    return ToolResult(
+        title=f"X 详情: @{author}",
+        output=json.dumps(post, ensure_ascii=False, indent=2),
+        long_term_memory=f"Viewed X post by @{author}: {text}",
+        images=all_images,
+    )
+
+
+async def _build_tweet_collage(tweets: List[Dict[str, Any]]) -> Optional[str]:
+    urls, titles = [], []
+    for tweet in tweets:
+        thumb = None
+        for img_item in tweet.get("image_url_list", []):
+            url = img_item.get("image_url") if isinstance(img_item, dict) else img_item
+            if url:
+                thumb = url
+                break
+        if not thumb:
+            thumb = tweet.get("cover_url")
+        if thumb:
+            urls.append(thumb)
+            titles.append(f"@{tweet.get('channel_account_name', '')}")
+
+    if not urls:
+        return None
+
+    loaded = await load_images(urls)
+    valid_images, valid_labels = [], []
+    for (_, img), title in zip(loaded, titles):
+        if img is not None:
+            valid_images.append(img)
+            valid_labels.append(title)
+
+    if not valid_images:
+        return None
+
+    grid = build_image_grid(images=valid_images, labels=valid_labels)
+    b64, _ = encode_base64(grid, format="PNG")
+    return b64
+
+
+# ── 注册 ──
+
+_X = PlatformDef(
+    id="x",
+    name="X (Twitter)",
+    aliases=["twitter", "推特"],
+)
+_X.search_impl = search
+_X.detail_impl = detail
+register_platform(_X)

+ 203 - 0
agent/tools/builtin/content/platforms/youtube.py

@@ -0,0 +1,203 @@
+"""
+YouTube 平台实现
+
+后端:crawler.aiddit.com/crawler/youtube
+"""
+
+import json
+from typing import Any, Dict, List, Optional
+
+import httpx
+
+from agent.tools.models import ToolResult
+from agent.tools.utils.image import build_image_grid, encode_base64, load_images
+from agent.tools.builtin.content.registry import (
+    PlatformDef, ParamSpec, register_platform,
+)
+
+CRAWLER_BASE_URL = "http://crawler.aiddit.com/crawler"
+DEFAULT_TIMEOUT = 60.0
+
+
+# ── 搜索 ──
+
+async def search(
+    platform_id: str,
+    keyword: str,
+    max_count: int = 20,
+    cursor: str = "",
+    extras: Optional[Dict[str, Any]] = None,
+) -> ToolResult:
+    try:
+        async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client:
+            response = await client.post(
+                f"{CRAWLER_BASE_URL}/youtube/keyword",
+                json={"keyword": keyword},
+            )
+            response.raise_for_status()
+            data = response.json()
+
+        if data.get("code") != 0:
+            return ToolResult(title="YouTube 搜索失败", output="", error=data.get("msg", "未知错误"))
+
+        result_data = data.get("data", {})
+        videos = result_data.get("data", []) if isinstance(result_data, dict) else []
+
+        # 概览
+        summary_list = []
+        for idx, video in enumerate(videos[:max_count], 1):
+            summary_list.append({
+                "index": idx,
+                "title": video.get("title", ""),
+                "author": video.get("author", ""),
+                "video_id": video.get("video_id", ""),
+            })
+
+        # 拼图
+        images = []
+        collage_b64 = await _build_video_collage(videos[:max_count])
+        if collage_b64:
+            images.append({"type": "base64", "media_type": "image/png", "data": collage_b64})
+
+        return ToolResult(
+            title=f"YouTube: {keyword}",
+            output=json.dumps({"data": summary_list}, ensure_ascii=False, indent=2),
+            long_term_memory=f"Searched YouTube for '{keyword}', {len(videos)} results.",
+            images=images,
+            metadata={"posts": videos[:max_count]},
+        )
+
+    except Exception as e:
+        return ToolResult(title="YouTube 搜索异常", output="", error=str(e))
+
+
+# ── 详情 ──
+
+async def detail(post: Dict[str, Any], extras: Optional[Dict[str, Any]] = None) -> ToolResult:
+    """
+    YouTube 详情:需要额外 HTTP 调用获取字幕/下载等。
+    post 来自搜索缓存,extras 支持 include_captions / download_video。
+    """
+    extras = extras or {}
+    content_id = post.get("video_id", "")
+    include_captions = extras.get("include_captions", True)
+    download_video = extras.get("download_video", False)
+
+    try:
+        async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client:
+            resp = await client.post(
+                f"{CRAWLER_BASE_URL}/youtube/detail",
+                json={"content_id": content_id},
+            )
+            resp.raise_for_status()
+            detail_data = resp.json()
+
+        if detail_data.get("code") != 0:
+            return ToolResult(title="详情获取失败", output="", error=detail_data.get("msg", "未知错误"))
+
+        result_data = detail_data.get("data", {})
+        video_info = result_data.get("data", {}) if isinstance(result_data, dict) else {}
+
+        # 字幕
+        captions_text = None
+        if include_captions or download_video:
+            try:
+                async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client:
+                    cap_resp = await client.post(
+                        f"{CRAWLER_BASE_URL}/youtube/captions",
+                        json={"content_id": content_id},
+                    )
+                    cap_resp.raise_for_status()
+                    cap_data = cap_resp.json()
+                    if cap_data.get("code") == 0:
+                        inner = cap_data.get("data", {})
+                        if isinstance(inner, dict):
+                            inner2 = inner.get("data", {})
+                            if isinstance(inner2, dict):
+                                captions_text = inner2.get("content")
+            except Exception:
+                pass
+
+        # 下载
+        video_path = None
+        video_outline = None
+        if download_video:
+            import asyncio
+            from agent.tools.builtin.content.media import download_youtube_video, parse_srt_to_outline
+            video_path = await asyncio.to_thread(download_youtube_video, content_id)
+            if captions_text:
+                video_outline = parse_srt_to_outline(captions_text)
+
+        output_data = {
+            "video_id": content_id,
+            "title": video_info.get("title", ""),
+            "channel": video_info.get("channel_account_name", ""),
+            "description": video_info.get("body_text", ""),
+            "like_count": video_info.get("like_count"),
+            "comment_count": video_info.get("comment_count"),
+            "content_link": video_info.get("content_link", ""),
+            "captions": captions_text,
+        }
+        if download_video:
+            output_data["video_path"] = video_path
+            output_data["video_outline"] = video_outline
+
+        return ToolResult(
+            title=f"YouTube 详情: {video_info.get('title', content_id)}",
+            output=json.dumps(output_data, ensure_ascii=False, indent=2),
+            long_term_memory=f"YouTube detail for {content_id}" + (" with captions" if captions_text else ""),
+        )
+
+    except Exception as e:
+        return ToolResult(title="YouTube 详情异常", output="", error=str(e))
+
+
+# ── 拼图 ──
+
+async def _build_video_collage(videos: List[Dict[str, Any]]) -> Optional[str]:
+    urls, titles = [], []
+    for video in videos:
+        thumb = None
+        if "thumbnails" in video and isinstance(video["thumbnails"], list) and video["thumbnails"]:
+            thumb = video["thumbnails"][0].get("url")
+        elif "thumbnail" in video:
+            thumb = video.get("thumbnail")
+        elif "cover_url" in video:
+            thumb = video.get("cover_url")
+
+        if thumb:
+            urls.append(thumb)
+            titles.append(video.get("title", ""))
+
+    if not urls:
+        return None
+
+    loaded = await load_images(urls)
+    valid_images, valid_labels = [], []
+    for (_, img), title in zip(loaded, titles):
+        if img is not None:
+            valid_images.append(img)
+            valid_labels.append(title)
+
+    if not valid_images:
+        return None
+
+    grid = build_image_grid(images=valid_images, labels=valid_labels)
+    b64, _ = encode_base64(grid, format="PNG")
+    return b64
+
+
+# ── 注册 ──
+
+_YOUTUBE = PlatformDef(
+    id="youtube",
+    name="YouTube",
+    aliases=["yt", "油管"],
+    detail_extras={
+        "include_captions": ParamSpec(note="是否获取字幕,默认 True"),
+        "download_video": ParamSpec(note="是否下载视频到本地,默认 False"),
+    },
+)
+_YOUTUBE.search_impl = search
+_YOUTUBE.detail_impl = detail
+register_platform(_YOUTUBE)

+ 125 - 0
agent/tools/builtin/content/registry.py

@@ -0,0 +1,125 @@
+"""
+内容平台注册表
+
+定义所有支持的内容平台及其搜索参数 schema。
+供 content_platforms / content_search / content_detail 路由使用。
+"""
+
+from dataclasses import dataclass, field
+from typing import Any, Callable, Coroutine, Dict, List, Optional
+
+from agent.tools.models import ToolResult
+
+
+# ── 类型定义 ──
+
+@dataclass
+class ParamSpec:
+    """平台专属参数的描述"""
+    values: Optional[List[str]] = None   # 枚举值(None 表示自由文本)
+    default: Optional[str] = None
+    note: str = ""                       # 额外说明
+
+    def to_dict(self) -> dict:
+        d: dict = {}
+        if self.values is not None:
+            d["values"] = self.values
+            d["default"] = self.default
+        if self.note:
+            d["note"] = self.note
+        return d
+
+
+# 平台实现函数的签名
+SearchFunc = Callable[..., Coroutine[Any, Any, ToolResult]]
+DetailFunc = Callable[..., Coroutine[Any, Any, ToolResult]]
+SuggestFunc = Callable[..., Coroutine[Any, Any, ToolResult]]
+
+
+@dataclass
+class PlatformDef:
+    """一个内容平台的完整定义"""
+    id: str                                         # 唯一标识,如 "xhs"
+    name: str                                       # 显示名,如 "小红书"
+    aliases: List[str] = field(default_factory=list) # 模糊匹配别名,如 ["小红书", "RED"]
+    search_params: Dict[str, ParamSpec] = field(default_factory=dict)
+    detail_extras: Dict[str, ParamSpec] = field(default_factory=dict)
+    supports_suggest: bool = False
+    suggest_channels: Optional[List[str]] = None     # suggest API 的 channel 值(可能与 id 不同)
+
+    # 平台实现函数(运行时由 platforms/ 模块设置)
+    search_impl: Optional[SearchFunc] = None
+    detail_impl: Optional[DetailFunc] = None
+    suggest_impl: Optional[SuggestFunc] = None
+
+    def summary(self) -> dict:
+        """概要信息(不含参数细节)"""
+        d = {"id": self.id, "name": self.name}
+        if self.search_params:
+            d["has_search_params"] = True
+        if self.detail_extras:
+            d["has_detail_extras"] = True
+        if self.supports_suggest:
+            d["supports_suggest"] = True
+        return d
+
+    def detail(self) -> dict:
+        """完整参数说明"""
+        d = self.summary()
+        if self.search_params:
+            d["search_params"] = {k: v.to_dict() for k, v in self.search_params.items()}
+        if self.detail_extras:
+            d["detail_extras"] = {k: v.to_dict() for k, v in self.detail_extras.items()}
+        return d
+
+
+# ── 平台注册表 ──
+
+_PLATFORMS: Dict[str, PlatformDef] = {}
+
+
+def register_platform(p: PlatformDef) -> None:
+    _PLATFORMS[p.id] = p
+
+
+def get_platform(platform_id: str) -> Optional[PlatformDef]:
+    return _PLATFORMS.get(platform_id)
+
+
+def all_platforms() -> List[PlatformDef]:
+    return list(_PLATFORMS.values())
+
+
+def match_platforms(query: str) -> List[PlatformDef]:
+    """
+    模糊匹配平台:精确 ID > 别名包含 > token 交集。
+    空 query 返回全部。
+    """
+    if not query:
+        return all_platforms()
+
+    q = query.strip().lower()
+
+    # 1) 精确 ID 匹配
+    if q in _PLATFORMS:
+        return [_PLATFORMS[q]]
+
+    # 2) 别名 / 名称包含匹配
+    alias_hits = [
+        p for p in _PLATFORMS.values()
+        if q in p.name.lower() or any(q in a.lower() for a in p.aliases)
+    ]
+    if alias_hits:
+        return alias_hits
+
+    # 3) token 交集(把 query 拆成字符/词,看命中率)
+    q_tokens = set(q.replace("_", " ").replace("-", " ").split())
+    scored = []
+    for p in _PLATFORMS.values():
+        pool = {p.id, p.name.lower()} | {a.lower() for a in p.aliases}
+        pool_text = " ".join(pool)
+        hits = sum(1 for t in q_tokens if t in pool_text)
+        if hits > 0:
+            scored.append((hits, p))
+    scored.sort(key=lambda x: -x[0])
+    return [p for _, p in scored]

+ 266 - 0
agent/tools/builtin/content/tools.py

@@ -0,0 +1,266 @@
+"""
+内容工具族 —— 统一入口
+
+4 个 @tool 注册给 LLM:
+  - content_platforms: 列出/查询平台及其参数
+  - content_search:    跨平台搜索
+  - content_detail:    查看详情
+  - content_suggest:   搜索建议词
+
+所有平台的具体实现在 platforms/ 子目录,按模块自注册到 registry。
+"""
+
+import json
+import os
+import uuid
+from typing import Any, Dict, Optional
+
+from agent.tools import tool, ToolResult, ToolContext
+from agent.tools.builtin.content.registry import (
+    all_platforms, get_platform, match_platforms,
+)
+from agent.tools.builtin.content import cache as _cache
+
+# 导入平台模块以触发自注册(副作用导入)
+import agent.tools.builtin.content.platforms.aigc_channel  # noqa: F401
+import agent.tools.builtin.content.platforms.youtube       # noqa: F401
+import agent.tools.builtin.content.platforms.x             # noqa: F401
+
+
+def _get_trace_id(context: Optional[ToolContext]) -> str:
+    """从 context 取 trace_id,回退到环境变量或自动生成"""
+    if context and hasattr(context, "trace_id") and context.trace_id:
+        return context.trace_id
+    return os.getenv("TRACE_ID") or f"anon-{uuid.uuid4().hex[:8]}"
+
+
+# ── content_platforms ──
+
+@tool(hidden_params=["context"], groups=["content"])
+async def content_platforms(
+    platform: str = "",
+    context: Optional[ToolContext] = None,
+) -> ToolResult:
+    """
+    列出支持的内容平台及其搜索参数。
+
+    不传 platform 时返回所有平台的概要列表(仅名称和 ID)。
+    传入 platform 时模糊匹配并返回匹配平台的详细参数说明(支持 ID、中文名、别名)。
+
+    建议在不熟悉平台参数时先调用此工具查看,再构造 content_search / content_detail 的参数。
+
+    Args:
+        platform: 可选,平台名称或关键词。支持模糊匹配(如 "xhs"、"小红书"、"youtube")。
+                  留空返回全部平台概要。
+        context: 工具上下文(自动注入)
+    """
+    hits = match_platforms(platform)
+
+    if not hits:
+        all_ids = [p.id for p in all_platforms()]
+        return ToolResult(
+            title="未找到匹配平台",
+            output=f"没有匹配 '{platform}' 的平台。可用平台: {', '.join(all_ids)}",
+        )
+
+    if platform:
+        # 有 query:返回匹配平台的详细参数
+        result = [p.detail() for p in hits]
+    else:
+        # 无 query:返回概要列表
+        result = [p.summary() for p in hits]
+
+    return ToolResult(
+        title=f"内容平台" + (f" ({platform})" if platform else ""),
+        output=json.dumps(result, ensure_ascii=False, indent=2),
+    )
+
+
+# ── content_search ──
+
+@tool(hidden_params=["context"], groups=["content"])
+async def content_search(
+    platform: str,
+    keyword: str,
+    max_count: int = 20,
+    cursor: str = "",
+    extras: Optional[Dict[str, Any]] = None,
+    context: Optional[ToolContext] = None,
+) -> ToolResult:
+    """
+    跨平台内容搜索,返回带索引编号的封面拼图 + 概览列表。
+
+    返回的是摘要信息(标题 + 正文截断 + 互动数据),不含完整正文和所有图片。
+    如需查看某条内容的完整信息,请使用 content_detail。
+
+    Args:
+        platform: 平台标识,如 'xhs'、'youtube'、'x'。完整列表见 content_platforms。
+        keyword: 搜索关键词。
+        max_count: 返回条数上限,默认 20。
+        cursor: 分页游标,首次搜索留空,翻页时传入上次返回值。
+        extras: 平台专用参数(dict)。不同平台支持不同参数,
+                如 xhs 支持 sort_type / publish_time / content_type / filter_note_range。
+                不清楚可先调 content_platforms(platform) 查看。
+        context: 工具上下文(自动注入)
+    """
+    pdef = get_platform(platform)
+    if not pdef:
+        # 尝试模糊匹配
+        hits = match_platforms(platform)
+        if hits:
+            suggestions = ", ".join(f"{p.id}({p.name})" for p in hits[:3])
+            return ToolResult(title="平台不存在", output=f"未找到平台 '{platform}'。你是否想要: {suggestions}")
+        all_ids = [p.id for p in all_platforms()]
+        return ToolResult(title="平台不存在", output=f"未找到平台 '{platform}'。可用: {', '.join(all_ids)}")
+
+    if not pdef.search_impl:
+        return ToolResult(title="不支持搜索", output=f"平台 {pdef.name} 暂不支持搜索")
+
+    result = await pdef.search_impl(
+        platform_id=pdef.id,
+        keyword=keyword,
+        max_count=max_count,
+        cursor=cursor,
+        extras=extras,
+    )
+
+    # 持久化搜索结果到磁盘缓存
+    if not result.error:
+        posts = result.metadata.pop("posts", [])
+        trace_id = _get_trace_id(context)
+        _cache.save_search_results(trace_id, pdef.id, keyword, posts)
+
+    return result
+
+
+# ── content_detail ──
+
+@tool(hidden_params=["context"], groups=["content"])
+async def content_detail(
+    platform: str,
+    index: int,
+    extras: Optional[Dict[str, Any]] = None,
+    context: Optional[ToolContext] = None,
+) -> ToolResult:
+    """
+    查看内容详情。从最近一次 content_search 的结果中按索引取完整记录。
+
+    Args:
+        platform: 平台标识(必须和之前 content_search 用的一致)。
+        index: 内容序号(1-based),来自 content_search 返回的 index 字段。
+        extras: 平台专用详情参数。YouTube 支持 include_captions / download_video。
+                其他平台通常不需要。
+        context: 工具上下文(自动注入)
+    """
+    pdef = get_platform(platform)
+    if not pdef:
+        return ToolResult(title="平台不存在", output=f"未找到平台 '{platform}'")
+
+    trace_id = _get_trace_id(context)
+    post = _cache.get_cached_post(trace_id, pdef.id, index)
+
+    if not post:
+        info = _cache.get_cached_search_info(trace_id, pdef.id)
+        if info:
+            return ToolResult(
+                title="索引无效",
+                output=f"平台 {pdef.name} 上次搜索 '{info['keyword']}' 共 {info['total']} 条,"
+                       f"有效索引 1-{info['total']},你传入了 {index}。",
+                error="Invalid index",
+            )
+        return ToolResult(
+            title="缓存未命中",
+            output=f"没有 {pdef.name} 的搜索缓存。请先调用 content_search(platform='{pdef.id}', keyword=...) 搜索。",
+            error="No cache",
+        )
+
+    if pdef.detail_impl:
+        return await pdef.detail_impl(post, extras)
+
+    # fallback:直接返回缓存的完整数据
+    return ToolResult(
+        title=f"详情 #{index}",
+        output=json.dumps(post, ensure_ascii=False, indent=2),
+    )
+
+
+# ── content_suggest ──
+
+@tool(hidden_params=["context"], groups=["content"])
+async def content_suggest(
+    platform: str,
+    keyword: str,
+    context: Optional[ToolContext] = None,
+) -> ToolResult:
+    """
+    获取搜索关键词补全建议。
+
+    仅部分平台支持(xhs、toutiao、douyin、bili、zhihu)。
+    用于辅助用户发现更精准的搜索词。
+
+    Args:
+        platform: 平台标识。
+        keyword: 搜索关键词(输入中的部分词即可)。
+        context: 工具上下文(自动注入)
+    """
+    pdef = get_platform(platform)
+    if not pdef:
+        return ToolResult(title="平台不存在", output=f"未找到平台 '{platform}'")
+
+    if not pdef.suggest_impl:
+        supported = [p.id for p in all_platforms() if p.supports_suggest]
+        return ToolResult(
+            title="不支持建议词",
+            output=f"平台 {pdef.name} 不支持建议词。支持的平台: {', '.join(supported)}",
+        )
+
+    channel = (pdef.suggest_channels or [pdef.id])[0]
+    return await pdef.suggest_impl(channel, keyword)
+
+
+# ── CLI 入口 ──
+
+def _parse_args(argv: list) -> dict:
+    """解析 --key=value 格式的 CLI 参数"""
+    kwargs = {}
+    for arg in argv:
+        if arg.startswith("--") and "=" in arg:
+            key, val = arg[2:].split("=", 1)
+            # 尝试 JSON 解析(dict / int / bool)
+            try:
+                val = json.loads(val)
+            except (json.JSONDecodeError, ValueError):
+                pass
+            kwargs[key] = val
+    return kwargs
+
+
+if __name__ == "__main__":
+    import sys
+    import asyncio
+
+    COMMANDS = {
+        "platforms": content_platforms,
+        "search": content_search,
+        "detail": content_detail,
+        "suggest": content_suggest,
+    }
+
+    if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS:
+        print(f"Usage: python {sys.argv[0]} <{'|'.join(COMMANDS)}> [--key=value ...]")
+        sys.exit(1)
+
+    cmd = sys.argv[1]
+    kwargs = _parse_args(sys.argv[2:])
+
+    # trace_id:CLI 参数 > 环境变量 > 自动生成
+    trace_id = kwargs.pop("trace_id", None) or os.getenv("TRACE_ID") or f"cli-{uuid.uuid4().hex[:8]}"
+    os.environ["TRACE_ID"] = trace_id
+
+    result = asyncio.run(COMMANDS[cmd](**kwargs))
+
+    # 输出 JSON(与 toolhub CLI 格式一致)
+    out = {"trace_id": trace_id, "output": result.output, "error": result.error}
+    if result.metadata:
+        out["metadata"] = result.metadata
+    print(json.dumps(out, ensure_ascii=False, indent=2))

+ 2 - 1
agent/tools/builtin/context.py

@@ -14,7 +14,8 @@ from agent.tools import tool, ToolResult, ToolContext
 
 @tool(
     description="获取当前执行上下文,包括计划状态、焦点提醒、协作者信息等。当你感到困惑或需要回顾当前任务状态时调用。",
-    hidden_params=["context"]
+    hidden_params=["context"],
+    groups=["core"],
 )
 async def get_current_context(
     context: ToolContext,

+ 0 - 595
agent/tools/builtin/crawler.py

@@ -1,595 +0,0 @@
-"""
-爬虫服务工具模块
-
-提供 YouTube、X (Twitter) 和微信/通用链接的搜索和详情查询功能。
-"""
-
-import asyncio
-import base64
-import io
-import json
-import math
-import os
-import subprocess
-import tempfile
-from pathlib import Path
-from typing import Optional, List, Dict, Any
-
-import httpx
-from PIL import Image, ImageDraw, ImageFont
-
-from agent.tools import tool, ToolResult
-
-
-# API 配置
-CRAWLER_BASE_URL = "http://crawler.aiddit.com/crawler"
-AIGC_BASE_URL = "http://aigc-channel.aiddit.com/aigc/channel"
-DEFAULT_TIMEOUT = 60.0
-
-# 拼接图配置
-THUMB_WIDTH = 250
-THUMB_HEIGHT = 250
-TEXT_HEIGHT = 80
-GRID_COLS = 5
-PADDING = 12
-BG_COLOR = (255, 255, 255)
-TEXT_COLOR = (30, 30, 30)
-INDEX_COLOR = (220, 60, 60)
-
-# 视频处理相关配置
-VIDEO_DOWNLOAD_DIR = Path(tempfile.gettempdir()) / "youtube_videos"
-VIDEO_DOWNLOAD_DIR.mkdir(exist_ok=True)
-
-
-# ── 辅助函数 ──
-
-def _truncate_text(text: str, max_len: int = 14) -> str:
-    """截断文本,超出部分用省略号"""
-    return text[:max_len] + "..." if len(text) > max_len else text
-
-
-async def _download_image(client: httpx.AsyncClient, url: str) -> Optional[Image.Image]:
-    """下载单张图片,失败返回 None"""
-    try:
-        resp = await client.get(url, timeout=15.0)
-        resp.raise_for_status()
-        return Image.open(io.BytesIO(resp.content)).convert("RGB")
-    except Exception:
-        return None
-
-
-async def _build_video_collage(videos: List[Dict[str, Any]]) -> Optional[str]:
-    """
-    将视频缩略图+序号+标题拼接成网格图,返回 base64 编码的 PNG。
-    """
-    if not videos:
-        return None
-
-    items = []
-    for idx, video in enumerate(videos):
-        thumbnail = None
-        if "thumbnails" in video and isinstance(video["thumbnails"], list) and video["thumbnails"]:
-            thumbnail = video["thumbnails"][0].get("url")
-        elif "thumbnail" in video:
-            thumbnail = video.get("thumbnail")
-        elif "cover_url" in video:
-            thumbnail = video.get("cover_url")
-
-        title = video.get("title", "") or video.get("text", "")
-        if thumbnail:
-            items.append({"url": thumbnail, "title": title, "index": idx + 1})
-    if not items:
-        return None
-
-    async with httpx.AsyncClient() as client:
-        tasks = [_download_image(client, item["url"]) for item in items]
-        downloaded = await asyncio.gather(*tasks)
-
-    valid = [(item, img) for item, img in zip(items, downloaded) if img is not None]
-    if not valid:
-        return None
-
-    cols = min(GRID_COLS, len(valid))
-    rows = math.ceil(len(valid) / cols)
-    cell_w = THUMB_WIDTH + PADDING
-    cell_h = THUMB_HEIGHT + TEXT_HEIGHT + PADDING
-    canvas_w = cols * cell_w + PADDING
-    canvas_h = rows * cell_h + PADDING
-
-    canvas = Image.new("RGB", (canvas_w, canvas_h), BG_COLOR)
-    draw = ImageDraw.Draw(canvas)
-
-    font_title = None
-    font_index = None
-    font_candidates = [
-        "msyh.ttc", "simhei.ttf", "simsun.ttc",
-        "/System/Library/Fonts/PingFang.ttc",
-        "/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf",
-        "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
-        "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
-    ]
-    for font_path in font_candidates:
-        try:
-            font_title = ImageFont.truetype(font_path, 16)
-            font_index = ImageFont.truetype(font_path, 32)
-            break
-        except Exception:
-            continue
-    if not font_title:
-        font_title = ImageFont.load_default()
-        font_index = font_title
-
-    for item, img in valid:
-        idx = item["index"]
-        col = (idx - 1) % cols
-        row = (idx - 1) // cols
-        x = PADDING + col * cell_w
-        y = PADDING + row * cell_h
-
-        scale = min(THUMB_WIDTH / img.width, THUMB_HEIGHT / img.height)
-        new_w = int(img.width * scale)
-        new_h = int(img.height * scale)
-        thumb = img.resize((new_w, new_h), Image.LANCZOS)
-        offset_x = x + (THUMB_WIDTH - new_w) // 2
-        offset_y = y + (THUMB_HEIGHT - new_h) // 2
-        canvas.paste(thumb, (offset_x, offset_y))
-
-        index_text = str(idx)
-        idx_x = offset_x
-        idx_y = offset_y + 4
-        box_size = 52
-        draw.rectangle([idx_x, idx_y, idx_x + box_size, idx_y + box_size], fill=INDEX_COLOR)
-        bbox = draw.textbbox((0, 0), index_text, font=font_index)
-        tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
-        text_x = idx_x + (box_size - tw) // 2
-        text_y = idx_y + (box_size - th) // 2
-        draw.text((text_x, text_y), index_text, fill=(255, 255, 255), font=font_index)
-
-        title = item["title"] or ""
-        if title:
-            words = list(title)
-            lines = []
-            current_line = ""
-            for ch in words:
-                test_line = current_line + ch
-                bbox_line = draw.textbbox((0, 0), test_line, font=font_title)
-                if bbox_line[2] - bbox_line[0] > THUMB_WIDTH:
-                    if current_line:
-                        lines.append(current_line)
-                    current_line = ch
-                else:
-                    current_line = test_line
-            if current_line:
-                lines.append(current_line)
-            for line_i, line in enumerate(lines):
-                draw.text((x, y + THUMB_HEIGHT + 6 + line_i * 22), line, fill=TEXT_COLOR, font=font_title)
-
-    buf = io.BytesIO()
-    canvas.save(buf, format="PNG")
-    return base64.b64encode(buf.getvalue()).decode("utf-8")
-
-
-def _parse_srt_to_outline(srt_content: str) -> List[Dict[str, str]]:
-    """解析 SRT 字幕,生成带时间戳的大纲"""
-    if not srt_content:
-        return []
-
-    outline = []
-    blocks = srt_content.strip().split('\n\n')
-    for block in blocks:
-        lines = block.strip().split('\n')
-        if len(lines) >= 3:
-            timestamp_line = lines[1]
-            if '-->' in timestamp_line:
-                start_time = timestamp_line.split('-->')[0].strip()
-                text = ' '.join(lines[2:])
-                outline.append({'timestamp': start_time, 'text': text})
-    return outline
-
-
-def _download_youtube_video(video_id: str) -> Optional[str]:
-    """使用 yt-dlp 下载 YouTube 视频,返回文件路径"""
-    try:
-        output_path = VIDEO_DOWNLOAD_DIR / f"{video_id}.mp4"
-        if output_path.exists():
-            return str(output_path)
-
-        cmd = [
-            'yt-dlp',
-            '-f', 'best[ext=mp4]',
-            '-o', str(output_path),
-            f'https://www.youtube.com/watch?v={video_id}'
-        ]
-        result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
-
-        if result.returncode == 0 and output_path.exists():
-            return str(output_path)
-        return None
-    except Exception:
-        return None
-
-
-# ── YouTube 工具 ──
-
-@tool()
-async def youtube_search(keyword: str) -> ToolResult:
-    """
-    搜索 YouTube 视频
-
-    Args:
-        keyword: 搜索关键词
-
-    Returns:
-        搜索结果列表,包含视频标题、ID、频道等信息
-    """
-    try:
-        async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client:
-            response = await client.post(
-                f"{CRAWLER_BASE_URL}/youtube/keyword",
-                json={"keyword": keyword}
-            )
-            response.raise_for_status()
-            data = response.json()
-
-            if data.get("code") == 0:
-                result_data = data.get("data", {})
-                videos = result_data.get("data", []) if isinstance(result_data, dict) else []
-
-                images = []
-                collage_b64 = await _build_video_collage(videos)
-                if collage_b64:
-                    images.append({
-                        "type": "base64",
-                        "media_type": "image/png",
-                        "data": collage_b64
-                    })
-
-                summary_list = []
-                for idx, video in enumerate(videos[:20], 1):
-                    title = video.get("title", "")
-                    author = video.get("author", "")
-                    video_id = video.get("video_id", "")
-                    summary_list.append(f"{idx}. {title} - {author} (ID: {video_id})")
-
-                output_data = {
-                    "keyword": keyword,
-                    "total": len(videos),
-                    "summary": summary_list,
-                    "data": videos
-                }
-
-                return ToolResult(
-                    title=f"YouTube 搜索: {keyword}",
-                    output=json.dumps(output_data, ensure_ascii=False, indent=2),
-                    long_term_memory=f"Searched YouTube for '{keyword}', found {len(videos)} videos",
-                    images=images
-                )
-            else:
-                return ToolResult(
-                    title="YouTube 搜索失败",
-                    output="",
-                    error=f"搜索失败: {data.get('msg', '未知错误')}"
-                )
-
-    except Exception as e:
-        return ToolResult(
-            title="YouTube 搜索异常",
-            output="",
-            error=str(e)
-        )
-
-
-@tool()
-async def youtube_detail(
-    content_id: str,
-    include_captions: bool = True,
-    download_video: bool = False
-) -> ToolResult:
-    """
-    获取 YouTube 视频详情(可选包含字幕、下载视频并生成大纲)
-
-    Args:
-        content_id: 视频 ID
-        include_captions: 是否包含字幕,默认 True
-        download_video: 是否下载视频并生成带时间戳的大纲,默认 False。
-            下载后可使用 extract_video_clip 截取视频片段观看。
-
-    Returns:
-        视频详细信息,包含字幕、视频大纲和本地文件路径
-    """
-    try:
-        async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client:
-            detail_response = await client.post(
-                f"{CRAWLER_BASE_URL}/youtube/detail",
-                json={"content_id": content_id}
-            )
-            detail_response.raise_for_status()
-            detail_data = detail_response.json()
-
-            if detail_data.get("code") != 0:
-                return ToolResult(
-                    title="获取详情失败",
-                    output="",
-                    error=f"获取详情失败: {detail_data.get('msg', '未知错误')}"
-                )
-
-            result_data = detail_data.get("data", {})
-            video_info = result_data.get("data", {}) if isinstance(result_data, dict) else {}
-
-            # 获取字幕
-            captions_text = None
-            if include_captions or download_video:
-                try:
-                    captions_response = await client.post(
-                        f"{CRAWLER_BASE_URL}/youtube/captions",
-                        json={"content_id": content_id}
-                    )
-                    captions_response.raise_for_status()
-                    captions_data = captions_response.json()
-
-                    if captions_data.get("code") == 0:
-                        captions_result = captions_data.get("data", {})
-                        if isinstance(captions_result, dict):
-                            inner_data = captions_result.get("data", {})
-                            if isinstance(inner_data, dict):
-                                captions_text = inner_data.get("content")
-                except Exception:
-                    pass
-
-            # 下载视频并生成大纲
-            video_path = None
-            video_outline = None
-            if download_video:
-                video_path = await asyncio.to_thread(_download_youtube_video, content_id)
-                if captions_text:
-                    video_outline = _parse_srt_to_outline(captions_text)
-
-            # 合并数据
-            output_data = {
-                "video_id": content_id,
-                "title": video_info.get("title", ""),
-                "channel": video_info.get("channel_account_name", ""),
-                "description": video_info.get("body_text", ""),
-                "like_count": video_info.get("like_count"),
-                "comment_count": video_info.get("comment_count"),
-                "publish_timestamp": video_info.get("publish_timestamp"),
-                "content_link": video_info.get("content_link", ""),
-                "captions": captions_text,
-                "full_data": video_info
-            }
-
-            if download_video:
-                output_data["video_path"] = video_path
-                output_data["video_outline"] = video_outline
-                if not video_path:
-                    output_data["download_error"] = "视频下载失败,请检查 yt-dlp 是否可用"
-
-            memory = f"Retrieved YouTube video details for {content_id}"
-            if captions_text:
-                memory += " with captions"
-            if video_path:
-                memory += f", downloaded to {video_path}"
-
-            return ToolResult(
-                title=f"YouTube 视频详情: {content_id}",
-                output=json.dumps(output_data, ensure_ascii=False, indent=2),
-                long_term_memory=memory
-            )
-
-    except Exception as e:
-        return ToolResult(
-            title="YouTube 详情查询异常",
-            output="",
-            error=str(e)
-        )
-
-
-# ── X (Twitter) 工具 ──
-
-@tool()
-async def x_search(keyword: str) -> ToolResult:
-    """
-    搜索 X (Twitter) 内容(数据已结构化,无需访问详情页)
-
-    Args:
-        keyword: 搜索关键词
-
-    Returns:
-        搜索结果列表,包含推文内容、作者、互动数据等
-    """
-    try:
-        async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client:
-            response = await client.post(
-                "http://crawler.aiddit.com/crawler/x/keyword",
-                json={"keyword": keyword}
-            )
-            response.raise_for_status()
-            data = response.json()
-
-            if data.get("code") == 0:
-                result_data = data.get("data", {})
-                tweets = result_data.get("data", []) if isinstance(result_data, dict) else []
-
-                # 构建拼接图
-                images = []
-                tweets_with_images = []
-                for tweet in tweets:
-                    image_list = tweet.get("image_url_list", [])
-                    if image_list:
-                        tweet["thumbnails"] = [{"url": image_list[0].get("image_url")}]
-                        tweets_with_images.append(tweet)
-
-                collage_b64 = await _build_video_collage(tweets_with_images if tweets_with_images else tweets)
-                if collage_b64:
-                    images.append({
-                        "type": "base64",
-                        "media_type": "image/png",
-                        "data": collage_b64
-                    })
-
-                summary_list = []
-                for idx, tweet in enumerate(tweets[:20], 1):
-                    text = tweet.get("body_text", "")[:100]
-                    author = tweet.get("channel_account_name", "")
-                    summary_list.append(f"{idx}. @{author}: {text}")
-
-                output_data = {
-                    "keyword": keyword,
-                    "total": len(tweets),
-                    "summary": summary_list,
-                    "data": tweets
-                }
-
-                return ToolResult(
-                    title=f"X 搜索: {keyword}",
-                    output=json.dumps(output_data, ensure_ascii=False, indent=2),
-                    long_term_memory=f"Searched X (Twitter) for '{keyword}', found {len(tweets)} tweets",
-                    images=images
-                )
-            else:
-                return ToolResult(
-                    title="X 搜索失败",
-                    output="",
-                    error=f"搜索失败: {data.get('msg', '未知错误')}"
-                )
-
-    except Exception as e:
-        return ToolResult(
-            title="X 搜索异常",
-            output="",
-            error=str(e)
-        )
-
-
-# ── 内容导入工具 ──
-
-@tool()
-async def import_content(plan_name: str, content_data: List[Dict[str, Any]]) -> ToolResult:
-    """
-    导入长文内容(微信公众号、小红书、抖音等通用链接)
-
-    Args:
-        plan_name: 计划名称
-        content_data: 内容数据列表,每项包含 channel、content_link、title 等字段
-
-    Returns:
-        导入结果,包含 plan_id
-    """
-    try:
-        async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client:
-            response = await client.post(
-                f"{AIGC_BASE_URL}/weixin/auto_insert",
-                json={"plan_name": plan_name, "data": content_data}
-            )
-            response.raise_for_status()
-            data = response.json()
-
-            if data.get("code") == 0:
-                result_data = data.get("data", {})
-                return ToolResult(
-                    title=f"内容导入: {plan_name}",
-                    output=json.dumps(result_data, ensure_ascii=False, indent=2),
-                    long_term_memory=f"Imported {len(content_data)} items to plan '{plan_name}'"
-                )
-            else:
-                return ToolResult(
-                    title="导入失败",
-                    output="",
-                    error=f"导入失败: {data.get('msg', '未知错误')}"
-                )
-
-    except Exception as e:
-        return ToolResult(
-            title="内容导入异常",
-            output="",
-            error=str(e)
-        )
-
-
-# ── 视频截取工具 ──
-
-@tool()
-async def extract_video_clip(
-    video_id: str,
-    start_time: str,
-    end_time: str,
-    output_name: Optional[str] = None
-) -> ToolResult:
-    """
-    从已下载的 YouTube 视频中截取指定时间段的片段
-
-    Args:
-        video_id: YouTube 视频 ID(必须先通过 youtube_detail(download_video=True) 下载)
-        start_time: 开始时间,格式: HH:MM:SS 或 MM:SS
-        end_time: 结束时间,格式: HH:MM:SS 或 MM:SS
-        output_name: 输出文件名(可选)
-
-    Returns:
-        截取的视频片段路径
-
-    Example:
-        extract_video_clip("dQw4w9WgXcQ", "00:00:10", "00:00:30")
-    """
-    try:
-        source_video = VIDEO_DOWNLOAD_DIR / f"{video_id}.mp4"
-        if not source_video.exists():
-            return ToolResult(
-                title="视频截取失败",
-                output="",
-                error="源视频不存在,请先使用 youtube_detail(download_video=True) 下载视频"
-            )
-
-        if not output_name:
-            output_name = f"{video_id}_clip_{start_time.replace(':', '-')}_{end_time.replace(':', '-')}.mp4"
-
-        output_path = VIDEO_DOWNLOAD_DIR / output_name
-
-        cmd = [
-            'ffmpeg',
-            '-i', str(source_video),
-            '-ss', start_time,
-            '-to', end_time,
-            '-c', 'copy',
-            '-y',
-            str(output_path)
-        ]
-
-        result = await asyncio.to_thread(
-            subprocess.run, cmd, capture_output=True, text=True, timeout=60
-        )
-
-        if result.returncode == 0 and output_path.exists():
-            file_size = output_path.stat().st_size / (1024 * 1024)
-
-            output_data = {
-                "video_id": video_id,
-                "clip_path": str(output_path),
-                "start_time": start_time,
-                "end_time": end_time,
-                "file_size_mb": round(file_size, 2)
-            }
-
-            return ToolResult(
-                title=f"视频片段截取成功: {start_time} - {end_time}",
-                output=json.dumps(output_data, ensure_ascii=False, indent=2),
-                long_term_memory=f"Extracted video clip from {video_id}: {start_time} to {end_time}"
-            )
-        else:
-            return ToolResult(
-                title="视频截取失败",
-                output="",
-                error=f"ffmpeg 执行失败: {result.stderr}"
-            )
-
-    except subprocess.TimeoutExpired:
-        return ToolResult(
-            title="视频截取超时",
-            output="",
-            error="视频截取超时(60秒)"
-        )
-    except Exception as e:
-        return ToolResult(
-            title="视频截取异常",
-            output="",
-            error=str(e)
-        )

+ 4 - 0
agent/tools/builtin/feishu/chat.py

@@ -133,6 +133,7 @@ def update_unread_count(contact_name: str, increment: int = 1, reset: bool = Fal
 
 @tool(
     hidden_params=["context"],
+    groups=["feishu"],
     display={
         "zh": {
             "name": "获取飞书联系人列表",
@@ -160,6 +161,7 @@ async def feishu_get_contact_list(context: Optional[ToolContext] = None) -> Tool
 
 @tool(
     hidden_params=["context"],
+    groups=["feishu"],
     display={
         "zh": {
             "name": "给飞书联系人发送消息",
@@ -293,6 +295,7 @@ async def feishu_send_message_to_contact(
 
 @tool(
     hidden_params=["context"],
+    groups=["feishu"],
     display={
         "zh": {
             "name": "获取飞书联系人回复",
@@ -409,6 +412,7 @@ def _convert_feishu_msg_to_openai_content(client: FeishuClient, msg: Dict[str, A
 
 @tool(
     hidden_params=["context"],
+    groups=["feishu"],
     display={
         "zh": {
             "name": "获取飞书聊天历史记录",

+ 0 - 5
agent/tools/builtin/feishu/chat_history/chat_summary.json

@@ -1,5 +0,0 @@
-{
-  "关涛": 0,
-  "谭景玉": 0,
-  "孙若天": 0
-}

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 103
agent/tools/builtin/feishu/chat_history/chat_关涛.json


+ 0 - 32
agent/tools/builtin/feishu/chat_history/chat_孙若天.json

@@ -1,32 +0,0 @@
-[
-  {
-    "role": "assistant",
-    "message_id": "om_x100b55ddfb31b898c3a9ee232316f13",
-    "content": [
-      {
-        "type": "text",
-        "text": "你好,我需要访问小红书,但是需要登录。请帮我在这个浏览器中完成登录:\n\nhttps://live.browser-use.com?wss=https%3A//9dcf552b-18ea-4f7f-9ace-96ca6fc9a3ac.cdp3.browser-use.com\n\n登录完成后请回复我一下,谢谢!"
-      }
-    ]
-  },
-  {
-    "role": "assistant",
-    "message_id": "om_x100b538c2d07ec98c3b100a5fec966c",
-    "content": [
-      {
-        "type": "text",
-        "text": "论文文献下载需要协助\n\n已完成论文 `/Users/sunlit/Papers/NoteHook/UIST/main.tex` 的引用文献提取,共 46 篇参考文献。\n\n下载状态:\n✅ 成功下载:4 篇(开放获取文献)\n🔒 需要登录/付费:32 篇(ACM/IEEE/Science 等)\n❌ 下载失败:4 篇\n📋 无 DOI/URL:6 篇\n\n大部分文献(32 篇)需要机构登录才能下载,主要来自:\n- ACM Digital Library (22 篇)\n- Science (1 篇)\n- APA/PsycINFO (2 篇)\n- Emerald (2 篇)\n- 其他出版商 (5 篇)\n\n详细报告已生成:\n`/Users/sunlit/Papers/NoteHook/UIST/references_downloaded/download_report.md`\n\n请问您是否有清华大学图书馆的代理访问权限?或者需要我帮您配置浏览器登录 ACM/IEEE 等数据库来下载这些文献?"
-      }
-    ]
-  },
-  {
-    "role": "assistant",
-    "message_id": "om_x100b538ce47170b8c32938bff5524df",
-    "content": [
-      {
-        "type": "text",
-        "text": "论文下载进度更新\n\n✅ 已成功下载 **34 篇** 论文(74% 成功率)\n\n已完成下载:\n- ACM Digital Library: 26 篇 (100%)\n- arXiv: 2 篇 (100%)\n- Emerald: 2 篇 (100%)\n- IEEE: 1 篇\n- Springer: 1 篇\n\n🔒 还需要登录以下出版商下载剩余 9 篇:\n1. Science (science.org) - 1 篇\n2. APA/PsycINFO (psycnet.apa.org) - 2 篇\n3. ScienceDirect (sciencedirect.com) - 2 篇\n4. Wiley (onlinelibrary.wiley.com) - 1 篇\n5. Now Publishers (nowpublishers.com) - 1 篇\n\n📋 无 DOI/URL 需要手动查找:6 篇\n\n所有文件已保存到:\n/Users/sunlit/Papers/NoteHook/UIST/references_downloaded/\n\n详细报告:download_final_report.md\n\n请问您是否要继续登录其他出版商完成剩余论文的下载?我可以依次打开各个出版商的登录页面。"
-      }
-    ]
-  }
-]

+ 0 - 22
agent/tools/builtin/feishu/chat_history/chat_谭景玉.json

@@ -1,22 +0,0 @@
-[
-  {
-    "role": "assistant",
-    "message_id": "om_x100b55dca36b98acc14eba0a6709aa0",
-    "content": [
-      {
-        "type": "text",
-        "text": "你好!我需要登录小红书来完成搜索摄影主题的任务,但是没有找到保存的cookie。\n\n请点击以下链接在浏览器中完成小红书登录:\nhttps://live.browser-use.com?wss=wss%3A//4599a061-1830-4cb0-99fc-fffb5503e99a.cdp1.browser-use.com/devtools/browser/f77323a4-3759-4558-85e0-f4eb3eb04368\n\n登录完成后请告诉我,我会保存登录状态。谢谢!"
-      }
-    ]
-  },
-  {
-    "role": "assistant",
-    "message_id": "om_x100b5488244594a4c4d3c52f961965f",
-    "content": [
-      {
-        "type": "text",
-        "text": "需要协助登录小红书进行调研。\n\n请打开云浏览器链接完成小红书登录:\n(云浏览器链接需要先初始化)\n\n任务:搜索\"AI角色连载\"\"AI虚拟人日常\"\"AI短剧连载\"相关内容,找出持续更新同一角色故事的账号\n\n请登录后回复确认,我将保存cookie继续调研。"
-      }
-    ]
-  }
-]

+ 1 - 1
agent/tools/builtin/file/edit.py

@@ -17,7 +17,7 @@ import re
 from agent.tools import tool, ToolResult, ToolContext
 
 
-@tool(description="编辑文件,使用精确字符串替换。支持多种智能匹配策略。", hidden_params=["context"])
+@tool(description="编辑文件,使用精确字符串替换。支持多种智能匹配策略。", hidden_params=["context"], groups=["core"])
 async def edit_file(
     file_path: str,
     old_string: str,

+ 1 - 1
agent/tools/builtin/file/grep.py

@@ -21,7 +21,7 @@ LIMIT = 100  # 最大返回匹配数(参考 opencode grep.ts:107)
 MAX_LINE_LENGTH = 2000  # 最大行长度(参考 opencode grep.ts:10)
 
 
-@tool(description="在文件内容中搜索模式", hidden_params=["context"])
+@tool(description="在文件内容中搜索模式", hidden_params=["context"], groups=["core"])
 async def grep_content(
     pattern: str,
     path: Optional[str] = None,

+ 5 - 2
agent/tools/builtin/file/read.py

@@ -27,7 +27,7 @@ MAX_LINE_LENGTH = 2000
 MAX_BYTES = 50 * 1024  # 50KB
 
 
-@tool(description="读取文件内容,支持文本文件、图片、PDF 等多种格式,也支持 HTTP/HTTPS URL", hidden_params=["context"])
+@tool(description="读取单个文件内容,支持文本文件、图片、PDF 等多种格式,也支持 HTTP/HTTPS URL", hidden_params=["context"], groups=["core"])
 async def read_file(
     file_path: str,
     offset: int = 0,
@@ -35,7 +35,10 @@ async def read_file(
     context: Optional[ToolContext] = None
 ) -> ToolResult:
     """
-    读取文件内容
+    读取单个文件内容
+
+    用于读取一个文本文件、PDF 或一张图片。如需批量读取多张图片(2 张以上)
+    并做对比/选图,请使用 read_images 工具,它支持自动降采样和网格拼图。
 
     参考 OpenCode 实现
 

+ 321 - 0
agent/tools/builtin/file/read_images.py

@@ -0,0 +1,321 @@
+"""
+Read Images Tool - 批量读取图片工具
+
+为"批量读取 + 多图分析"场景设计的工具,与单文件的 read_file 分工:
+- read_file: 单个文件(文本 / PDF / 单张图片)
+- read_images: 2 张以上图片,支持网格拼图和降采样
+
+核心能力:
+1. 并发批量加载本地路径或 URL
+2. 自动降采样,防止 token 爆炸
+3. 可选拼图(grid 模式),把 N 张图合成一张带索引编号的网格图,
+   适合 LLM 横向对比、选图、批量判断场景
+4. 自适应布局 + 硬上限,保证拼图即使经过 LLM 内部缩放也能保持可辨
+"""
+
+from typing import Any, Dict, List, Literal, Optional, Tuple
+
+from agent.tools import tool, ToolResult, ToolContext
+from agent.tools.utils.image import (
+    build_image_grid,
+    downscale,
+    encode_base64,
+    load_images,
+)
+
+
+# Grid 模式的硬上限:超过此数量必须分批调用
+# 理由:12 张可排成 4x3 网格,每格 ~320px,人物/场景细节清晰可辨。
+# 再多格子就太小,分辨不出内容,失去对比价值。
+MAX_GRID_IMAGES = 12
+
+
+def _adaptive_layout(count: int) -> Tuple[int, int]:
+    """根据图片数量自动选择 (columns, thumb_size)。
+
+    目标:拼图最终边长不超过 ~1400px,同时每格缩略图保持 >= 320px 以保证可辨认。
+
+    Returns:
+        (columns, thumb_size)
+    """
+    if count <= 2:
+        return 2, 500   # 2x1
+    if count <= 4:
+        return 2, 450   # 2x2
+    if count <= 6:
+        return 3, 400   # 3x2
+    if count <= 9:
+        return 3, 380   # 3x3
+    # 10-12
+    return 4, 320       # 4x3
+
+
+@tool(
+    description="批量读取多张图片,支持自动降采样和网格拼图(用于横向对比/选图场景)",
+    hidden_params=["context"],
+    display={
+        "zh": {
+            "name": "批量读取图片",
+            "params": {
+                "paths": "图片路径列表",
+                "layout": "布局模式",
+                "max_dimension": "每张图最大边长",
+            },
+        },
+        "en": {
+            "name": "Read Images",
+            "params": {
+                "paths": "Image paths",
+                "layout": "Layout mode",
+                "max_dimension": "Max dimension per image",
+            },
+        },
+    },
+    groups=["core"],
+)
+async def read_images(
+    paths: List[str],
+    layout: Literal["grid", "separate"] = "grid",
+    max_dimension: int = 1024,
+    context: Optional[ToolContext] = None,
+) -> ToolResult:
+    """批量读取图片并返回给 LLM,支持自动降采样和网格拼图
+
+    为 **2 张以上** 的图片批量分析场景设计。单张图片请用 `read_file`。
+
+    ⚠️ **grid 模式最多 12 张**。超过请分批调用:第一次传前 12 张,第二次传后续,
+    以此类推。再多每格就太小,分辨不出内容。
+
+    两种布局模式:
+
+    - **grid**(默认):把所有图片拼成一张只带索引编号的网格图(1,2,3…)。
+      LLM 只看到 1 张拼图,大幅减少结构开销 token。索引对应的原始路径见
+      返回文本的对照表,LLM 可以用"第 3 张"来引用具体图片。
+      **自适应布局**:根据图片数量自动选择列数和缩略图尺寸,小批量时每张图更清晰:
+        * 1-2 张:2 列 × 500px
+        * 3-4 张:2 列 × 450px
+        * 5-6 张:3 列 × 400px
+        * 7-9 张:3 列 × 380px
+        * 10-12 张:4 列 × 320px
+      适合:从多张候选图中挑选、横向对比质量/风格、批量判断。
+
+    - **separate**:把每张图独立返回(仍然降采样)。无数量限制,但每张图都有
+      独立的结构开销 token。适合:
+        * 需要逐张做独立的精细分析
+        * 每张图之间没有对比关系
+
+    自动降采样:无论哪种模式,每张图都会先降采样到 max_dimension(默认 1024px)
+    的最大边长,防止高分辨率图片炸掉 token 预算。
+
+    Args:
+        paths: 图片路径列表,支持本地路径和 HTTP(S) URL,可混用。
+               grid 模式下不超过 12 张,超过必须分批调用。
+        layout: 布局模式,"grid" 拼图(默认)/ "separate" 多张独立
+        max_dimension: 每张图的最大边长(等比降采样到不超过此值),默认 1024
+        context: 工具上下文(框架注入,无需手动传)
+
+    Returns:
+        ToolResult:images 字段包含图片数据(grid 模式 1 张拼图,separate 模式 N 张),
+        output 字段包含每张图的索引和来源路径对照表
+    """
+    if not paths:
+        return ToolResult(
+            title="批量读图失败",
+            output="",
+            error="paths 不能为空",
+        )
+
+    # 硬上限检查(仅对 grid 模式)
+    if layout == "grid" and len(paths) > MAX_GRID_IMAGES:
+        return ToolResult(
+            title="批量读图失败",
+            output="",
+            error=(
+                f"grid 模式最多支持 {MAX_GRID_IMAGES} 张图片,当前传入 {len(paths)} 张。"
+                f"请分批调用:每次最多 {MAX_GRID_IMAGES} 张。"
+                f"或者使用 layout='separate' 模式(无数量限制但 token 开销更高)。"
+            ),
+        )
+
+    if len(paths) == 1:
+        hint = "(只有 1 张图片,建议用 read_file 更合适)"
+    else:
+        hint = ""
+
+    # 1. 并发加载所有图片
+    loaded = await load_images(paths)
+
+    # 2. 分离成功和失败
+    successes: List[tuple] = []  # [(path, PIL.Image), ...]
+    failures: List[str] = []     # [path, ...]
+    for source, img in loaded:
+        if img is None:
+            failures.append(source)
+        else:
+            successes.append((source, img))
+
+    if not successes:
+        return ToolResult(
+            title="批量读图失败",
+            output="",
+            error=f"所有 {len(paths)} 张图片均加载失败",
+            metadata={"failed": failures},
+        )
+
+    # 3. 每张图降采样
+    processed = [(src, downscale(img, max_dimension)) for src, img in successes]
+
+    # 4. 构建索引 → 路径对照表(用完整路径,方便 LLM 后续引用或调用)
+    index_lines = [f"{i}. {src}" for i, (src, _) in enumerate(processed, start=1)]
+    summary_parts = [f"共加载 {len(processed)}/{len(paths)} 张图片"]
+    if hint:
+        summary_parts.append(hint)
+    if failures:
+        summary_parts.append(f",失败 {len(failures)} 张")
+    summary = "".join(summary_parts)
+
+    output_lines = [summary, ""] + index_lines
+    if failures:
+        output_lines.append("")
+        output_lines.append("加载失败的路径:")
+        output_lines.extend(f"  - {p}" for p in failures)
+    output_text = "\n".join(output_lines)
+
+    # 5. 根据 layout 生成 images 字段
+    images_for_llm = []
+    if layout == "grid":
+        cols, thumb_size = _adaptive_layout(len(processed))
+        # 网格只显示序号,不写文件名 —— 索引对应的路径见上方 output 文本
+        grid = build_image_grid(
+            images=[img for _, img in processed],
+            labels=None,
+            columns=cols,
+            thumb_size=thumb_size,
+        )
+        # 网格拼图固定用 JPEG 节省 token
+        b64, media_type = encode_base64(grid, format="JPEG", quality=80)
+        images_for_llm.append({
+            "type": "base64",
+            "media_type": media_type,
+            "data": b64,
+        })
+    else:  # separate
+        for _, img in processed:
+            b64, media_type = encode_base64(img, format="JPEG", quality=80)
+            images_for_llm.append({
+                "type": "base64",
+                "media_type": media_type,
+                "data": b64,
+            })
+
+    return ToolResult(
+        title=f"批量读图成功({layout} 模式,{len(processed)} 张)",
+        output=output_text,
+        long_term_memory=f"Read {len(processed)} images via {layout} layout",
+        images=images_for_llm,
+        metadata={
+            "count": len(processed),
+            "failed_count": len(failures),
+            "layout": layout,
+        },
+    )
+
+
+# ── CLI 入口:图片拼图工具 ──
+#
+# 这个 CLI 的语义是**拼图工具**,不是"读图工具"——Claude Code 这样的调用方
+# 本身就能读单张图(用 Read 工具),真正稀缺的能力是把 N 张图合成一张
+# 带索引编号的网格图,让一次 Read 就能横向对比多张。
+#
+# 因此 CLI 只支持 grid 模式;如果你需要单张图,直接用 Read 工具即可。
+#
+# 用法:
+#   python agent/tools/builtin/file/read_images.py --out=<path> <img1> <img2> ...
+#
+# 必填参数:
+#   --out=/path/grid.jpg     拼图保存路径(必须显式指定,避免污染 /tmp)
+#
+# 可选参数:
+#   --max_dimension=1024     每张图预先降采样的最大边长(默认 1024)
+#
+# 示例:
+#   python agent/tools/builtin/file/read_images.py \
+#     --out=/tmp/compare.jpg \
+#     ~/Downloads/a.jpg ~/Downloads/b.jpg ~/Downloads/c.jpg
+#
+# 输出:一行 JSON,包含 out_path、index_map(索引→原始路径对照表)、
+# text(文字摘要)。调用方拿到 out_path 后用 Read 工具查看拼图即可。
+
+if __name__ == "__main__":
+    import base64
+    import json
+    import sys
+    from pathlib import Path as _Path
+
+    def _print_usage():
+        print("用法: python read_images.py --out=<path> <img1> <img2> ...")
+        print("     --out=/path/grid.jpg   拼图输出路径(必填)")
+        print("     --max_dimension=1024   每张图最大边长(可选,默认 1024)")
+        print(f"最多 {MAX_GRID_IMAGES} 张图片")
+
+    if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help"):
+        _print_usage()
+        sys.exit(0)
+
+    # 解析参数
+    cli_paths: List[str] = []
+    cli_out: Optional[str] = None
+    cli_max_dim: int = 1024
+    for arg in sys.argv[1:]:
+        if arg.startswith("--") and "=" in arg:
+            k, v = arg.split("=", 1)
+            k = k.lstrip("-").replace("-", "_")
+            if k == "out":
+                cli_out = v
+            elif k == "max_dimension":
+                cli_max_dim = int(v)
+            else:
+                print(f"警告: 未知参数 {k}", file=sys.stderr)
+        else:
+            cli_paths.append(arg)
+
+    if not cli_paths:
+        print("错误: 至少提供一个图片路径", file=sys.stderr)
+        _print_usage()
+        sys.exit(1)
+
+    if not cli_out:
+        print("错误: 必须显式指定 --out=<path>", file=sys.stderr)
+        _print_usage()
+        sys.exit(1)
+
+    import asyncio
+    result = asyncio.run(read_images(
+        paths=cli_paths,
+        layout="grid",
+        max_dimension=cli_max_dim,
+    ))
+
+    if result.error:
+        print(json.dumps({"error": result.error}, ensure_ascii=False, indent=2))
+        sys.exit(1)
+
+    # 写入拼图文件
+    out_p = _Path(cli_out)
+    out_p.parent.mkdir(parents=True, exist_ok=True)
+    out_p.write_bytes(base64.b64decode(result.images[0]["data"]))
+
+    # 解析索引 → 原始路径对照表
+    index_map: List[Dict[str, Any]] = []
+    for line in result.output.split("\n"):
+        if line and line[0].isdigit() and ". " in line:
+            idx_str, src = line.split(". ", 1)
+            if idx_str.isdigit():
+                index_map.append({"index": int(idx_str), "source": src})
+
+    print(json.dumps({
+        "out_path": str(out_p.resolve()),
+        "count": result.metadata.get("count", 0) if result.metadata else 0,
+        "index_map": index_map,
+        "text": result.output,
+    }, ensure_ascii=False, indent=2))

+ 1 - 1
agent/tools/builtin/file/write.py

@@ -16,7 +16,7 @@ import difflib
 from agent.tools import tool, ToolResult, ToolContext
 
 
-@tool(description="写入文件内容(创建新文件、覆盖现有文件或追加内容)", hidden_params=["context"])
+@tool(description="写入文件内容(创建新文件、覆盖现有文件或追加内容)", hidden_params=["context"], groups=["core"])
 async def write_file(
     file_path: str,
     content: str,

+ 1 - 1
agent/tools/builtin/glob_tool.py

@@ -19,7 +19,7 @@ from agent.tools import tool, ToolResult, ToolContext
 LIMIT = 100  # 最大返回数量(参考 opencode glob.ts:35)
 
 
-@tool(description="使用 glob 模式匹配文件", hidden_params=["context"])
+@tool(description="使用 glob 模式匹配文件", hidden_params=["context"], groups=["core"])
 async def glob_files(
     pattern: str,
     path: Optional[str] = None,

+ 8 - 0
agent/tools/builtin/im/chat.py

@@ -43,6 +43,7 @@ class _ToolNotifier(AgentNotifier):
 
 @tool(
     hidden_params=["context"],
+    groups=["im"],
     display={
         "zh": {"name": "初始化 IM 连接", "params": {"contact_id": "你的身份 ID", "server_url": "服务器地址"}},
         "en": {"name": "Setup IM Connection", "params": {"contact_id": "Your identity ID", "server_url": "Server URL"}},
@@ -80,6 +81,7 @@ async def im_setup(
 
 @tool(
     hidden_params=["context"],
+    groups=["im"],
     display={
         "zh": {"name": "打开 IM 窗口", "params": {"contact_id": "Agent ID", "chat_id": "窗口 ID"}},
         "en": {"name": "Open IM Window", "params": {"contact_id": "Agent ID", "chat_id": "Window ID"}},
@@ -109,6 +111,7 @@ async def im_open_window(
 
 @tool(
     hidden_params=["context"],
+    groups=["im"],
     display={
         "zh": {"name": "关闭 IM 窗口", "params": {"contact_id": "Agent ID", "chat_id": "窗口 ID"}},
         "en": {"name": "Close IM Window", "params": {"contact_id": "Agent ID", "chat_id": "Window ID"}},
@@ -138,6 +141,7 @@ async def im_close_window(
 
 @tool(
     hidden_params=["context"],
+    groups=["im"],
     display={
         "zh": {"name": "检查 IM 新消息通知", "params": {"contact_id": "Agent ID", "chat_id": "窗口 ID"}},
         "en": {"name": "Check IM Notifications", "params": {"contact_id": "Agent ID", "chat_id": "Window ID"}},
@@ -169,6 +173,7 @@ async def im_check_notification(
 
 @tool(
     hidden_params=["context"],
+    groups=["im"],
     display={
         "zh": {"name": "接收 IM 消息", "params": {"contact_id": "Agent ID", "chat_id": "窗口 ID"}},
         "en": {"name": "Receive IM Messages", "params": {"contact_id": "Agent ID", "chat_id": "Window ID"}},
@@ -213,6 +218,7 @@ async def im_receive_messages(
 
 @tool(
     hidden_params=["context"],
+    groups=["im"],
     display={
         "zh": {"name": "发送 IM 消息", "params": {"contact_id": "Agent ID", "chat_id": "窗口 ID", "receiver": "接收者 ID", "content": "消息内容"}},
         "en": {"name": "Send IM Message", "params": {"contact_id": "Agent ID", "chat_id": "Window ID", "receiver": "Receiver ID", "content": "Message content"}},
@@ -253,6 +259,7 @@ async def im_send_message(
 
 @tool(
     hidden_params=["context"],
+    groups=["im"],
     display={
         "zh": {"name": "查询 IM 联系人", "params": {"contact_id": "Agent ID", "server_http_url": "服务器 HTTP 地址"}},
         "en": {"name": "Get IM Contacts", "params": {"contact_id": "Agent ID", "server_http_url": "Server HTTP URL"}},
@@ -297,6 +304,7 @@ async def im_get_contacts(
 
 @tool(
     hidden_params=["context"],
+    groups=["im"],
     display={
         "zh": {"name": "查询 IM 聊天历史", "params": {"contact_id": "Agent ID", "chat_id": "窗口 ID", "peer_id": "联系人 ID", "limit": "最大条数"}},
         "en": {"name": "Get IM Chat History", "params": {"contact_id": "Agent ID", "chat_id": "Window ID", "peer_id": "Contact ID", "limit": "Max records"}},

+ 153 - 45
agent/tools/builtin/knowledge.py

@@ -1,18 +1,7 @@
 """
 知识管理工具 - KnowHub API 封装
 
-⚠️ DEPRECATED: 这些工具直接调用 KnowHub HTTP API,绕过 Knowledge Manager。
-推荐使用新架构:
-- knowledge_search → ask_knowledge (通过 IM 与 Knowledge Manager 交互)
-- knowledge_save → upload_knowledge (通过 IM 与 Knowledge Manager 交互)
-
-新架构优势:
-1. 统一通过 Knowledge Manager 管理知识
-2. 自动去重和整合
-3. 支持增量上传和批量提交
-4. 更好的并发控制
-
-所有工具通过 HTTP API 调用 KnowHub Server。
+所有工具通过 HTTP API 调用 KnowHub Server,直接读写底层数据库。
 """
 
 import os
@@ -95,25 +84,20 @@ class KnowledgeConfig:
         return owner
 
 
-@tool(
-    hidden_params=["context"],
-    inject_params={
-        "types": {"mode": "default", "key": "knowledge_config.default_search_types"},
-        "owner": {"mode": "default", "key": "knowledge_config.default_search_owner"},
-    }
-)
+@tool(groups=["knowledge_internal"], hidden_params=["context"])
 async def knowledge_search(
     query: str,
     top_k: int = 5,
     min_score: int = 3,
     types: Optional[List[str]] = None,
     owner: Optional[str] = None,
+    requirement_id: Optional[str] = None,
+    capability_id: Optional[str] = None,
+    tool_id: Optional[str] = None,
     context: Optional[ToolContext] = None,
 ) -> ToolResult:
     """
-    检索知识(两阶段:语义路由 + 质量精排)
-
-    ⚠️ DEPRECATED: 推荐使用 ask_knowledge 工具(通过 Knowledge Manager)
+    检索知识(两阶段:语义路由 + 质量精排;可通过外键精确截断查询范围)
 
     Args:
         query: 搜索查询(任务描述)
@@ -121,12 +105,14 @@ async def knowledge_search(
         min_score: 最低评分过滤(默认 3)
         types: 按类型过滤(user_profile/strategy/tool/usecase/definition/plan)
         owner: 按所有者过滤(可选,支持多个owner用逗号分隔的字符串,如 "user1@example.com,user2@example.com")
+        requirement_id: 关系过滤 - 仅搜索关联到此需求ID的知识
+        capability_id: 关系过滤 - 仅搜索关联到此能力ID的知识
+        tool_id: 关系过滤 - 仅搜索关联到此工具ID的知识
         context: 工具上下文
 
     Returns:
         相关知识列表
     """
-    logger.warning("knowledge_search is deprecated. Use ask_knowledge instead.")
     try:
         params = {
             "q": query,
@@ -137,6 +123,12 @@ async def knowledge_search(
             params["types"] = ",".join(types)
         if owner:
             params["owner"] = owner
+        if requirement_id:
+            params["requirement_id"] = requirement_id
+        if capability_id:
+            params["capability_id"] = capability_id
+        if tool_id:
+            params["tool_id"] = tool_id
 
         async with httpx.AsyncClient(timeout=60.0) as client:
             response = await client.get(f"{KNOWHUB_API}/api/knowledge/search", params=params)
@@ -183,14 +175,7 @@ async def knowledge_search(
         )
 
 
-@tool(
-    hidden_params=["context", "owner"],
-    inject_params={
-        "owner": {"mode": "default", "key": "knowledge_config.resolved_owner"},
-        "tags": {"mode": "merge", "key": "knowledge_config.default_tags"},
-        "scopes": {"mode": "merge", "key": "knowledge_config.default_scopes"},
-    }
-)
+@tool(groups=["knowledge_internal"], hidden_params=["context"])
 async def knowledge_save(
     task: str,
     content: str,
@@ -206,6 +191,8 @@ async def knowledge_save(
     submitted_by: str = "",
     score: int = 3,
     message_id: str = "",
+    capability_ids: Optional[List[str]] = None,
+    tool_ids: Optional[List[str]] = None,
     context: Optional[ToolContext] = None,
 ) -> ToolResult:
     """
@@ -259,7 +246,9 @@ async def knowledge_save(
                 "helpful": 1,
                 "harmful": 0,
                 "confidence": 0.5,
-            }
+            },
+            "capability_ids": capability_ids or [],
+            "tool_ids": tool_ids or []
         }
 
         async with httpx.AsyncClient(timeout=30.0) as client:
@@ -285,7 +274,7 @@ async def knowledge_save(
         )
 
 
-@tool(hidden_params=["context"])
+@tool(groups=["knowledge_internal"], hidden_params=["context"])
 async def knowledge_update(
     knowledge_id: str,
     add_helpful_case: Optional[Dict] = None,
@@ -355,7 +344,7 @@ async def knowledge_update(
         )
 
 
-@tool(hidden_params=["context"])
+@tool(groups=["knowledge_internal"], hidden_params=["context"])
 async def knowledge_batch_update(
     feedback_list: List[Dict[str, Any]],
     context: Optional[ToolContext] = None,
@@ -405,20 +394,26 @@ async def knowledge_batch_update(
         )
 
 
-@tool(hidden_params=["context"])
+@tool(groups=["knowledge_internal"], hidden_params=["context"])
 async def knowledge_list(
     limit: int = 10,
     types: Optional[List[str]] = None,
     scopes: Optional[List[str]] = None,
+    requirement_id: Optional[str] = None,
+    capability_id: Optional[str] = None,
+    tool_id: Optional[str] = None,
     context: Optional[ToolContext] = None,
 ) -> ToolResult:
     """
     列出已保存的知识
-
+    
     Args:
         limit: 返回数量限制(默认 10)
         types: 按类型过滤(可选)
         scopes: 按范围过滤(可选)
+        requirement_id: 关系过滤 - 仅列出关联到此需求ID的知识
+        capability_id: 关系过滤 - 仅列出关联到此能力ID的知识
+        tool_id: 关系过滤 - 仅列出关联到此工具ID的知识
         context: 工具上下文
 
     Returns:
@@ -430,6 +425,12 @@ async def knowledge_list(
             params["types"] = ",".join(types)
         if scopes:
             params["scopes"] = ",".join(scopes)
+        if requirement_id:
+            params["requirement_id"] = requirement_id
+        if capability_id:
+            params["capability_id"] = capability_id
+        if tool_id:
+            params["tool_id"] = tool_id
 
         async with httpx.AsyncClient(timeout=30.0) as client:
             response = await client.get(f"{KNOWHUB_API}/api/knowledge", params=params)
@@ -467,7 +468,7 @@ async def knowledge_list(
         )
 
 
-@tool(hidden_params=["context"])
+@tool(groups=["knowledge_internal"], hidden_params=["context"])
 async def knowledge_slim(
     model: str = "google/gemini-2.0-flash-001",
     context: Optional[ToolContext] = None,
@@ -513,7 +514,7 @@ async def knowledge_slim(
 
 # ==================== Resource 资源管理工具 ====================
 
-@tool(hidden_params=["context"])
+@tool(groups=["knowledge_internal"], hidden_params=["context"])
 async def resource_save(
     resource_id: str,
     title: str,
@@ -572,7 +573,7 @@ async def resource_save(
         )
 
 
-@tool(hidden_params=["context"])
+@tool(groups=["knowledge_internal"], hidden_params=["context"])
 async def resource_get(
     resource_id: str,
     org_key: Optional[str] = None,
@@ -627,7 +628,7 @@ async def resource_get(
 
 # ==================== Tool 表查询工具 ====================
 
-@tool(hidden_params=["context"])
+@tool(groups=["knowledge_internal"], hidden_params=["context"])
 async def tool_search(
     query: str,
     top_k: int = 5,
@@ -652,7 +653,7 @@ async def tool_search(
         return ToolResult(title="❌ 工具检索失败", output=str(e), error=str(e))
 
 
-@tool(hidden_params=["context"])
+@tool(groups=["knowledge_internal"], hidden_params=["context"])
 async def tool_list(
     limit: int = 20,
     offset: int = 0,
@@ -674,7 +675,7 @@ async def tool_list(
 
 # ==================== Capability (原子能力) 表查询工具 ====================
 
-@tool(hidden_params=["context"])
+@tool(groups=["knowledge_internal"], hidden_params=["context"])
 async def capability_search(
     query: str,
     top_k: int = 5,
@@ -696,7 +697,7 @@ async def capability_search(
         return ToolResult(title="❌ 原子能力检索失败", output=str(e), error=str(e))
 
 
-@tool(hidden_params=["context"])
+@tool(groups=["knowledge_internal"], hidden_params=["context"])
 async def capability_list(
     limit: int = 20,
     offset: int = 0,
@@ -716,7 +717,7 @@ async def capability_list(
 
 # ==================== Requirement (需求) 表查询工具 ====================
 
-@tool(hidden_params=["context"])
+@tool(groups=["knowledge_internal"], hidden_params=["context"])
 async def requirement_search(
     query: str,
     top_k: int = 5,
@@ -734,7 +735,7 @@ async def requirement_search(
         return ToolResult(title="❌ 需求检索失败", output=str(e), error=str(e))
 
 
-@tool(hidden_params=["context"])
+@tool(groups=["knowledge_internal"], hidden_params=["context"])
 async def requirement_list(
     limit: int = 20,
     offset: int = 0,
@@ -752,3 +753,110 @@ async def requirement_list(
         return ToolResult(title="✅ 需求列表获取成功", output=json.dumps(data, ensure_ascii=False, indent=2))
     except Exception as e:
         return ToolResult(title="❌ 需求列表失败", output=str(e), error=str(e))
+
+# ==================== Relation (关系表) 检索工具 ====================
+
+@tool(hidden_params=["context"])
+async def relation_search(
+    table_name: str,
+    filters: Optional[Dict[str, str]] = None,
+    limit: int = 100,
+    context: Optional[ToolContext] = None,
+) -> ToolResult:
+    """通用关系表检索工具
+    Args:
+        table_name: 关系表名 (如 capability_knowledge, tool_provider 等)
+        filters: 查询条件字典 (如 {"capability_id": "xxx"})
+        limit: 返回数量限制
+    """
+    try:
+        params = {"limit": limit}
+        if filters:
+            params.update(filters)
+        async with httpx.AsyncClient(timeout=30.0) as client:
+            res = await client.get(f"{KNOWHUB_API}/api/relation/{table_name}", params=params)
+            res.raise_for_status()
+            data = res.json()
+        return ToolResult(title=f"✅ {table_name} 检索成功", output=json.dumps(data, ensure_ascii=False, indent=2))
+    except httpx.HTTPStatusError as e:
+        return ToolResult(title=f"❌ {table_name} 检索失败", output=f"HTTP Error: {e.response.text}", error=str(e))
+    except Exception as e:
+        return ToolResult(title=f"❌ {table_name} 检索失败", output=str(e), error=str(e))
+
+
+@tool(groups=["knowledge_internal"], hidden_params=["context"])
+async def tool_create(
+    id: str,
+    name: str = "",
+    version: Optional[str] = None,
+    introduction: str = "",
+    tutorial: str = "",
+    input: str = "",
+    output: str = "",
+    status: str = "未接入",
+    capability_ids: Optional[List[str]] = None,
+    knowledge_ids: Optional[List[str]] = None,
+    provider_ids: Optional[List[str]] = None,
+    context: Optional[ToolContext] = None
+) -> ToolResult:
+    """创建或更新工具(直接存入数据库)"""
+    try:
+        payload = {
+            "id": id, "name": name, "version": version, "introduction": introduction,
+            "tutorial": tutorial, "input": input, "output": output, "status": status,
+            "capability_ids": capability_ids or [], "knowledge_ids": knowledge_ids or [],
+            "provider_ids": provider_ids or []
+        }
+        async with httpx.AsyncClient(timeout=30.0) as client:
+            res = await client.post(f"{KNOWHUB_API}/api/tool", json=payload)
+            res.raise_for_status()
+        return ToolResult(title="✅ 工具保存成功", output=f"成功创建/更新工具: {id}")
+    except Exception as e:
+        return ToolResult(title="❌ 工具保存失败", output=str(e), error=str(e))
+
+
+@tool(groups=["knowledge_internal"], hidden_params=["context"])
+async def capability_create(
+    id: str,
+    name: str = "",
+    criterion: str = "",
+    description: str = "",
+    requirement_ids: Optional[List[str]] = None,
+    implements: Optional[Dict[str, str]] = None,
+    tool_ids: Optional[List[str]] = None,
+    knowledge_ids: Optional[List[str]] = None,
+    context: Optional[ToolContext] = None
+) -> ToolResult:
+    """创建或更新能力(直接存入数据库)"""
+    try:
+        payload = {
+            "id": id, "name": name, "criterion": criterion, "description": description,
+            "requirement_ids": requirement_ids or [], "implements": implements or {},
+            "tool_ids": tool_ids or [], "knowledge_ids": knowledge_ids or []
+        }
+        async with httpx.AsyncClient(timeout=30.0) as client:
+            res = await client.post(f"{KNOWHUB_API}/api/capability", json=payload)
+            res.raise_for_status()
+        return ToolResult(title="✅ 能力保存成功", output=f"成功创建/更新能力: {id}")
+    except Exception as e:
+        return ToolResult(title="❌ 能力保存失败", output=str(e), error=str(e))
+
+
+@tool(groups=["knowledge_internal"], hidden_params=["context"])
+async def requirement_create(
+    id: str,
+    description: str = "",
+    capability_ids: Optional[List[str]] = None,
+    context: Optional[ToolContext] = None
+) -> ToolResult:
+    """创建或更新需求(直接存入数据库)"""
+    try:
+        payload = {
+            "id": id, "description": description, "capability_ids": capability_ids or []
+        }
+        async with httpx.AsyncClient(timeout=30.0) as client:
+            res = await client.post(f"{KNOWHUB_API}/api/requirement", json=payload)
+            res.raise_for_status()
+        return ToolResult(title="✅ 需求保存成功", output=f"成功创建/更新需求: {id}")
+    except Exception as e:
+        return ToolResult(title="❌ 需求保存失败", output=str(e), error=str(e))

+ 56 - 4
agent/tools/builtin/librarian.py

@@ -17,14 +17,15 @@ from agent.tools import tool, ToolResult, ToolContext
 
 logger = logging.getLogger(__name__)
 
-KNOWHUB_API = os.getenv("KNOWHUB_API", "http://localhost:8000").rstrip("/")
+KNOWHUB_API = os.getenv("KNOWHUB_API", "http://localhost:9999").rstrip("/")
 
 
 @tool(
     hidden_params=["context"],
     inject_params={
         "trace_id": {"mode": "default", "key": "trace_id"},
-    }
+    },
+    groups=["knowledge"],
 )
 async def ask_knowledge(
     query: str,
@@ -46,7 +47,7 @@ async def ask_knowledge(
         整合回答 + source_ids + 各 source 摘要
     """
     try:
-        async with httpx.AsyncClient(timeout=60.0) as client:
+        async with httpx.AsyncClient(timeout=300.0) as client:
             response = await client.post(
                 f"{KNOWHUB_API}/api/knowledge/ask",
                 json={
@@ -107,7 +108,8 @@ async def ask_knowledge(
     hidden_params=["context"],
     inject_params={
         "trace_id": {"mode": "default", "key": "trace_id"},
-    }
+    },
+    groups=["knowledge"],
 )
 async def upload_knowledge(
     data: Dict[str, Any],
@@ -178,3 +180,53 @@ async def upload_knowledge(
             output=f"错误: {str(e)}",
             error=str(e)
         )
+
+
+if __name__ == "__main__":
+    import sys
+    import asyncio
+
+    COMMANDS = {
+        "ask": ask_knowledge,
+        "upload": upload_knowledge,
+    }
+
+    def _parse_args(argv):
+        kwargs = {}
+        for arg in argv:
+            if arg.startswith("--") and "=" in arg:
+                k, v = arg.split("=", 1)
+                k = k.lstrip("-").replace("-", "_")
+                try:
+                    import json as _json
+                    v = _json.loads(v)
+                except (json.JSONDecodeError, ValueError):
+                    pass
+                kwargs[k] = v
+        return kwargs
+
+    if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help"):
+        print(f"用法: python {sys.argv[0]} <command> [--key=value ...]")
+        print(f"可用命令: {', '.join(COMMANDS.keys())}")
+        print(f"示例: python {sys.argv[0]} ask --query='ControlNet 相关的工具'")
+        sys.exit(0)
+
+    cmd = sys.argv[1]
+    if cmd not in COMMANDS:
+        print(f"未知命令: {cmd},可用: {', '.join(COMMANDS.keys())}")
+        sys.exit(1)
+
+    kwargs = _parse_args(sys.argv[2:])
+
+    # trace_id:CLI 参数 > 环境变量 > 自动生成
+    if "trace_id" not in kwargs:
+        import uuid
+        kwargs["trace_id"] = os.getenv("TRACE_ID", f"cli-{uuid.uuid4().hex[:8]}")
+
+    result = asyncio.run(COMMANDS[cmd](**kwargs))
+    out = {"trace_id": kwargs.get("trace_id", ""), "output": result.output}
+    if result.error:
+        out["error"] = result.error
+    if result.metadata:
+        out["metadata"] = result.metadata
+    print(json.dumps(out, ensure_ascii=False, indent=2))

+ 4 - 2
agent/tools/builtin/resource.py

@@ -10,8 +10,9 @@ from agent.tools import tool, ToolResult
 KNOWHUB_API = os.getenv("KNOWHUB_API", "http://43.106.118.91:9999").rstrip("/")
 
 
-@tool(    
-    description="列出知识库中的所有工具资源"
+@tool(
+    description="列出知识库中的所有工具资源",
+    groups=["resource"],
 )
 def resource_list_tools(
     category: Optional[str] = None,
@@ -49,6 +50,7 @@ def resource_list_tools(
 
 @tool(
     description="获取指定工具的详细信息",
+    groups=["resource"],
 )
 def resource_get_tool(tool_id: str) -> ToolResult:
     """获取工具详情

+ 0 - 418
agent/tools/builtin/sandbox.py

@@ -1,418 +0,0 @@
-"""
-Sandbox Tools (Async)
-通过 HTTP 异步调用沙盒管理服务的客户端库。
-"""
-
-import json
-import httpx
-from typing import Optional, List, Dict, Any
-
-from agent.tools import tool, ToolResult, ToolContext
-
-
-# 服务地址,可根据实际部署情况修改
-# SANDBOX_SERVER_URL = "http://192.168.100.20:9998"
-SANDBOX_SERVER_URL = "http://61.48.133.26:9998"
-
-# 默认超时时间(秒)
-DEFAULT_TIMEOUT = 300.0
-
-
-@tool(
-    hidden_params=["context"],
-    display={
-        "zh": {
-            "name": "创建沙盒环境",
-            "params": {
-                "image": "Docker 镜像",
-                "mem_limit": "内存限制",
-                "nano_cpus": "CPU 限制",
-                "ports": "端口列表",
-                "use_gpu": "启用 GPU",
-                "gpu_count": "GPU 数量"
-            }
-        },
-        "en": {
-            "name": "Create Sandbox",
-            "params": {
-                "image": "Docker image",
-                "mem_limit": "Memory limit",
-                "nano_cpus": "CPU limit",
-                "ports": "Port list",
-                "use_gpu": "Enable GPU",
-                "gpu_count": "GPU count"
-            }
-        }
-    }
-)
-async def sandbox_create_environment(
-    image: str = "agent-sandbox:latest",
-    mem_limit: str = "512m",
-    nano_cpus: int = 500000000,
-    ports: Optional[List[int]] = None,
-    use_gpu: bool = False,
-    gpu_count: int = -1,
-    server_url: str = None,
-    timeout: float = DEFAULT_TIMEOUT,
-    context: Optional[ToolContext] = None,
-) -> ToolResult:
-    """
-    创建一个隔离的 Docker 开发环境。
-
-    Args:
-        image: Docker 镜像名称,默认为 "agent-sandbox:latest"。
-               可以使用其他镜像如 "python:3.12-slim", "node:18-slim" 等。
-        mem_limit: 容器最大内存限制,默认为 "512m"。
-        nano_cpus: 容器最大 CPU 限制(纳秒),默认为 500000000(0.5 CPU)。
-        ports: 需要映射的端口列表,如 [8080, 3000]。
-        use_gpu: 是否启用 GPU 支持,默认为 False。需要宿主机安装 nvidia-container-toolkit。
-        gpu_count: 使用的 GPU 数量,-1 表示使用所有可用 GPU,默认为 -1。
-        server_url: 服务地址,默认使用全局配置 SANDBOX_SERVER_URL。
-        timeout: 请求超时时间(秒),默认 300 秒。
-        context: 工具上下文
-
-    Returns:
-        ToolResult: 包含沙盒创建结果
-    """
-    url = f"{server_url or SANDBOX_SERVER_URL}/api/create_environment"
-    payload = {
-        "image": image,
-        "mem_limit": mem_limit,
-        "nano_cpus": nano_cpus,
-        "use_gpu": use_gpu,
-        "gpu_count": gpu_count
-    }
-    if ports:
-        payload["ports"] = ports
-
-    try:
-        async with httpx.AsyncClient(timeout=timeout) as client:
-            response = await client.post(url, json=payload)
-            response.raise_for_status()
-            data = response.json()
-
-        sandbox_id = data.get("sandbox_id", "")
-        port_mapping = data.get("port_mapping", {})
-        access_urls = data.get("access_urls", [])
-
-        output_parts = [f"沙盒 ID: {sandbox_id}"]
-        if port_mapping:
-            output_parts.append(f"端口映射: {json.dumps(port_mapping)}")
-        if access_urls:
-            output_parts.append(f"访问地址: {', '.join(access_urls)}")
-
-        return ToolResult(
-            title="沙盒环境创建成功",
-            output="\n".join(output_parts),
-            metadata=data
-        )
-    except httpx.HTTPStatusError as e:
-        return ToolResult(
-            title="沙盒创建失败",
-            output=f"HTTP 错误: {e.response.status_code}",
-            error=str(e)
-        )
-    except httpx.RequestError as e:
-        return ToolResult(
-            title="沙盒创建失败",
-            output=f"网络请求失败: {str(e)}",
-            error=str(e)
-        )
-
-
-@tool(
-    hidden_params=["context"],
-    display={
-        "zh": {
-            "name": "执行沙盒命令",
-            "params": {
-                "sandbox_id": "沙盒 ID",
-                "command": "Shell 命令",
-                "is_background": "后台执行",
-                "timeout": "超时时间"
-            }
-        },
-        "en": {
-            "name": "Run Shell in Sandbox",
-            "params": {
-                "sandbox_id": "Sandbox ID",
-                "command": "Shell command",
-                "is_background": "Run in background",
-                "timeout": "Timeout"
-            }
-        }
-    }
-)
-async def sandbox_run_shell(
-    sandbox_id: str,
-    command: str,
-    is_background: bool = False,
-    timeout: int = 120,
-    server_url: str = None,
-    request_timeout: float = DEFAULT_TIMEOUT,
-    context: Optional[ToolContext] = None,
-) -> ToolResult:
-    """
-    在指定的沙盒中执行 Shell 命令。
-
-    Args:
-        sandbox_id: 沙盒 ID,由 create_environment 返回。
-        command: 要执行的 Shell 命令,如 "pip install flask" 或 "python app.py"。
-        is_background: 是否后台执行,默认为 False。
-            - False:前台执行,等待命令完成并返回输出
-            - True:后台执行,适合启动长期运行的服务
-        timeout: 前台命令的超时时间(秒),默认 120 秒。后台命令不受此限制。
-        server_url: 服务地址,默认使用全局配置 SANDBOX_SERVER_URL。
-        request_timeout: HTTP 请求超时时间(秒),默认 300 秒。
-        context: 工具上下文
-
-    Returns:
-        ToolResult: 命令执行结果
-    """
-    url = f"{server_url or SANDBOX_SERVER_URL}/api/run_shell"
-    payload = {
-        "sandbox_id": sandbox_id,
-        "command": command,
-        "is_background": is_background,
-        "timeout": timeout
-    }
-
-    try:
-        async with httpx.AsyncClient(timeout=request_timeout) as client:
-            response = await client.post(url, json=payload)
-            response.raise_for_status()
-            data = response.json()
-
-        if is_background:
-            status = data.get("status", "")
-            message = data.get("message", "")
-            log_file = data.get("log_file", "")
-            output = f"状态: {status}\n消息: {message}"
-            if log_file:
-                output += f"\n日志文件: {log_file}"
-            return ToolResult(
-                title=f"后台命令已启动: {command[:50]}",
-                output=output,
-                metadata=data
-            )
-        else:
-            exit_code = data.get("exit_code", -1)
-            stdout = data.get("stdout", "")
-            stderr = data.get("stderr", "")
-
-            output_parts = []
-            if stdout:
-                output_parts.append(stdout)
-            if stderr:
-                if output_parts:
-                    output_parts.append("\n--- stderr ---")
-                output_parts.append(stderr)
-            if not output_parts:
-                output_parts.append("(命令无输出)")
-
-            success = exit_code == 0
-            title = f"命令: {command[:50]}"
-            if not success:
-                title += f" (exit code: {exit_code})"
-
-            return ToolResult(
-                title=title,
-                output="\n".join(output_parts),
-                metadata=data,
-                error=None if success else f"Command failed with exit code {exit_code}"
-            )
-    except httpx.HTTPStatusError as e:
-        return ToolResult(
-            title="命令执行失败",
-            output=f"HTTP 错误: {e.response.status_code}",
-            error=str(e)
-        )
-    except httpx.RequestError as e:
-        return ToolResult(
-            title="命令执行失败",
-            output=f"网络请求失败: {str(e)}",
-            error=str(e)
-        )
-
-
-@tool(
-    hidden_params=["context"],
-    display={
-        "zh": {
-            "name": "重建沙盒端口",
-            "params": {
-                "sandbox_id": "沙盒 ID",
-                "ports": "端口列表",
-                "mem_limit": "内存限制",
-                "nano_cpus": "CPU 限制",
-                "use_gpu": "启用 GPU",
-                "gpu_count": "GPU 数量"
-            }
-        },
-        "en": {
-            "name": "Rebuild Sandbox Ports",
-            "params": {
-                "sandbox_id": "Sandbox ID",
-                "ports": "Port list",
-                "mem_limit": "Memory limit",
-                "nano_cpus": "CPU limit",
-                "use_gpu": "Enable GPU",
-                "gpu_count": "GPU count"
-            }
-        }
-    }
-)
-async def sandbox_rebuild_with_ports(
-    sandbox_id: str,
-    ports: List[int],
-    mem_limit: str = "1g",
-    nano_cpus: int = 1000000000,
-    use_gpu: bool = False,
-    gpu_count: int = -1,
-    server_url: str = None,
-    timeout: float = DEFAULT_TIMEOUT,
-    context: Optional[ToolContext] = None,
-) -> ToolResult:
-    """
-    重建沙盒并应用新的端口映射。
-
-    使用场景:先创建沙盒克隆项目,阅读 README 后才知道需要暴露哪些端口,
-    此时调用此函数重建沙盒,应用正确的端口映射。
-
-    注意:重建后会返回新的 sandbox_id,后续操作需要使用新 ID。
-    容器内的所有文件(克隆的代码、安装的依赖等)都会保留。
-
-    Args:
-        sandbox_id: 当前沙盒 ID。
-        ports: 需要映射的端口列表,如 [8080, 3306, 6379]。
-        mem_limit: 容器最大内存限制,默认为 "1g"。
-        nano_cpus: 容器最大 CPU 限制(纳秒),默认为 1000000000(1 CPU)。
-        use_gpu: 是否启用 GPU 支持,默认为 False。需要宿主机安装 nvidia-container-toolkit。
-        gpu_count: 使用的 GPU 数量,-1 表示使用所有可用 GPU,默认为 -1。
-        server_url: 服务地址,默认使用全局配置 SANDBOX_SERVER_URL。
-        timeout: 请求超时时间(秒),默认 300 秒。
-        context: 工具上下文
-
-    Returns:
-        ToolResult: 重建结果
-    """
-    url = f"{server_url or SANDBOX_SERVER_URL}/api/rebuild_with_ports"
-    payload = {
-        "sandbox_id": sandbox_id,
-        "ports": ports,
-        "mem_limit": mem_limit,
-        "nano_cpus": nano_cpus,
-        "use_gpu": use_gpu,
-        "gpu_count": gpu_count
-    }
-
-    try:
-        async with httpx.AsyncClient(timeout=timeout) as client:
-            response = await client.post(url, json=payload)
-            response.raise_for_status()
-            data = response.json()
-
-        old_id = data.get("old_sandbox_id", "")
-        new_id = data.get("new_sandbox_id", "")
-        port_mapping = data.get("port_mapping", {})
-        access_urls = data.get("access_urls", [])
-
-        output_parts = [
-            f"旧沙盒 ID: {old_id} (已销毁)",
-            f"新沙盒 ID: {new_id}"
-        ]
-        if port_mapping:
-            output_parts.append(f"端口映射: {json.dumps(port_mapping)}")
-        if access_urls:
-            output_parts.append(f"访问地址: {', '.join(access_urls)}")
-
-        return ToolResult(
-            title="沙盒重建成功",
-            output="\n".join(output_parts),
-            metadata=data
-        )
-    except httpx.HTTPStatusError as e:
-        return ToolResult(
-            title="沙盒重建失败",
-            output=f"HTTP 错误: {e.response.status_code}",
-            error=str(e)
-        )
-    except httpx.RequestError as e:
-        return ToolResult(
-            title="沙盒重建失败",
-            output=f"网络请求失败: {str(e)}",
-            error=str(e)
-        )
-
-
-@tool(
-    requires_confirmation=True,
-    display={
-        "zh": {
-            "name": "销毁沙盒环境",
-            "params": {
-                "sandbox_id": "沙盒 ID"
-            }
-        },
-        "en": {
-            "name": "Destroy Sandbox",
-            "params": {
-                "sandbox_id": "Sandbox ID"
-            }
-        }
-    }
-)
-async def sandbox_destroy_environment(
-    sandbox_id: str,
-    server_url: str = None,
-    timeout: float = DEFAULT_TIMEOUT,
-    context: Optional[ToolContext] = None,
-) -> ToolResult:
-    """
-    销毁沙盒环境,释放资源。
-
-    Args:
-        sandbox_id: 沙盒 ID。
-        server_url: 服务地址,默认使用全局配置 SANDBOX_SERVER_URL。
-        timeout: 请求超时时间(秒),默认 300 秒。
-        context: 工具上下文
-
-    Returns:
-        ToolResult: 销毁结果
-    """
-    url = f"{server_url or SANDBOX_SERVER_URL}/api/destroy_environment"
-    payload = {
-        "sandbox_id": sandbox_id
-    }
-
-    try:
-        async with httpx.AsyncClient(timeout=timeout) as client:
-            response = await client.post(url, json=payload)
-            response.raise_for_status()
-            data = response.json()
-
-        status = data.get("status", "")
-        message = data.get("message", "")
-        removed_tools = data.get("removed_tools", [])
-
-        output_parts = [f"状态: {status}", f"消息: {message}"]
-        if removed_tools:
-            output_parts.append(f"已移除的工具: {', '.join(removed_tools)}")
-
-        return ToolResult(
-            title="沙盒环境已销毁",
-            output="\n".join(output_parts),
-            metadata=data
-        )
-    except httpx.HTTPStatusError as e:
-        return ToolResult(
-            title="沙盒销毁失败",
-            output=f"HTTP 错误: {e.response.status_code}",
-            error=str(e)
-        )
-    except httpx.RequestError as e:
-        return ToolResult(
-            title="沙盒销毁失败",
-            output=f"网络请求失败: {str(e)}",
-            error=str(e)
-        )

+ 0 - 535
agent/tools/builtin/search.py

@@ -1,535 +0,0 @@
-"""
-搜索工具模块
-
-提供帖子搜索、帖子详情查看和建议词搜索功能,支持多个渠道平台。
-
-主要功能:
-1. search_posts - 帖子搜索(浏览模式:封面图+标题+内容截断)
-2. select_post - 帖子详情(从搜索结果中选取单个帖子的完整内容)
-3. get_search_suggestions - 获取平台的搜索补全建议词
-"""
-
-import asyncio
-import base64
-import io
-import json
-import math
-import textwrap
-from enum import Enum
-from typing import Any, Dict, List, Optional
-
-import httpx
-from PIL import Image, ImageDraw, ImageFont
-
-from agent.tools import tool, ToolResult
-
-
-# API 基础配置
-BASE_URL = "http://aigc-channel.aiddit.com/aigc/channel"
-DEFAULT_TIMEOUT = 60.0
-
-# 搜索结果缓存,以序号为 key
-_search_cache: Dict[int, Dict[str, Any]] = {}
-
-# 拼接图配置
-THUMB_WIDTH = 250
-THUMB_HEIGHT = 250
-TEXT_HEIGHT = 80
-GRID_COLS = 5
-PADDING = 12
-BG_COLOR = (255, 255, 255)
-TEXT_COLOR = (30, 30, 30)
-INDEX_COLOR = (220, 60, 60)
-
-
-def _truncate_text(text: str, max_len: int = 14) -> str:
-    """截断文本,超出部分用省略号"""
-    return text[:max_len] + "..." if len(text) > max_len else text
-
-
-async def _download_image(client: httpx.AsyncClient, url: str) -> Optional[Image.Image]:
-    """下载单张图片,失败返回 None"""
-    try:
-        resp = await client.get(url, timeout=15.0)
-        resp.raise_for_status()
-        return Image.open(io.BytesIO(resp.content)).convert("RGB")
-    except Exception:
-        return None
-
-
-async def _build_collage(posts: List[Dict[str, Any]]) -> Optional[str]:
-    """
-    将帖子封面图+序号+标题拼接成网格图,返回 base64 编码的 PNG。
-    每个格子:序号 + 封面图 + 标题
-    """
-    if not posts:
-        return None
-
-    # 收集有封面图的帖子,记录原始序号
-    items = []
-    for idx, post in enumerate(posts):
-        imgs = post.get("images", [])
-        cover_url = imgs[0] if imgs else None
-        if cover_url:
-            items.append({
-                "url": cover_url,
-                "title": post.get("title", "") or "",
-                "index": idx + 1,
-            })
-    if not items:
-        return None
-
-    # 并发下载封面图
-    async with httpx.AsyncClient() as client:
-        tasks = [_download_image(client, item["url"]) for item in items]
-        downloaded = await asyncio.gather(*tasks)
-
-    # 过滤下载失败的
-    valid = [(item, img) for item, img in zip(items, downloaded) if img is not None]
-    if not valid:
-        return None
-
-    cols = min(GRID_COLS, len(valid))
-    rows = math.ceil(len(valid) / cols)
-    cell_w = THUMB_WIDTH + PADDING
-    cell_h = THUMB_HEIGHT + TEXT_HEIGHT + PADDING
-    canvas_w = cols * cell_w + PADDING
-    canvas_h = rows * cell_h + PADDING
-
-    canvas = Image.new("RGB", (canvas_w, canvas_h), BG_COLOR)
-    draw = ImageDraw.Draw(canvas)
-
-    # 尝试加载字体(跨平台中文支持)
-    font_title = None
-    font_index = None
-
-    # 按优先级尝试不同平台的中文字体
-    font_candidates = [
-        "msyh.ttc",           # Windows 微软雅黑
-        "simhei.ttf",         # Windows 黑体
-        "simsun.ttc",         # Windows 宋体
-        "/System/Library/Fonts/PingFang.ttc",  # macOS 苹方
-        "/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf",  # Linux
-        "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",  # Linux WenQuanYi
-        "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",  # Linux Noto
-    ]
-
-    for font_path in font_candidates:
-        try:
-            font_title = ImageFont.truetype(font_path, 16)
-            font_index = ImageFont.truetype(font_path, 32)
-            break
-        except Exception:
-            continue
-
-    # 如果都失败,使用默认字体(可能不支持中文)
-    if not font_title:
-        font_title = ImageFont.load_default()
-        font_index = font_title
-
-    for item, img in valid:
-        idx = item["index"]
-        col = (idx - 1) % cols
-        row = (idx - 1) // cols
-        x = PADDING + col * cell_w
-        y = PADDING + row * cell_h
-
-        # 等比缩放封面图,保持原始比例,居中放置
-        scale = min(THUMB_WIDTH / img.width, THUMB_HEIGHT / img.height)
-        new_w = int(img.width * scale)
-        new_h = int(img.height * scale)
-        thumb = img.resize((new_w, new_h), Image.LANCZOS)
-        offset_x = x + (THUMB_WIDTH - new_w) // 2
-        offset_y = y + (THUMB_HEIGHT - new_h) // 2
-        canvas.paste(thumb, (offset_x, offset_y))
-
-        # 左上角写序号(带背景),固定大小,跟随图片位置
-        index_text = str(idx)
-        idx_x = offset_x
-        idx_y = offset_y + 4
-        box_size = 52
-        draw.rectangle([idx_x, idx_y, idx_x + box_size, idx_y + box_size], fill=INDEX_COLOR)
-        # 序号居中绘制
-        bbox = draw.textbbox((0, 0), index_text, font=font_index)
-        tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
-        text_x = idx_x + (box_size - tw) // 2
-        text_y = idx_y + (box_size - th) // 2
-        draw.text((text_x, text_y), index_text, fill=(255, 255, 255), font=font_index)
-
-        # 写标题(完整显示,按像素宽度自动换行)
-        title = item["title"] or ""
-        if title:
-            words = list(title)  # 逐字符拆分,兼容中英文
-            lines = []
-            current_line = ""
-            for ch in words:
-                test_line = current_line + ch
-                bbox_line = draw.textbbox((0, 0), test_line, font=font_title)
-                if bbox_line[2] - bbox_line[0] > THUMB_WIDTH:
-                    if current_line:
-                        lines.append(current_line)
-                    current_line = ch
-                else:
-                    current_line = test_line
-            if current_line:
-                lines.append(current_line)
-            for line_i, line in enumerate(lines):
-                draw.text((x, y + THUMB_HEIGHT + 6 + line_i * 22), line, fill=TEXT_COLOR, font=font_title)
-
-    # 转 base64
-    buf = io.BytesIO()
-    canvas.save(buf, format="PNG")
-    return base64.b64encode(buf.getvalue()).decode("utf-8")
-
-
-class PostSearchChannel(str, Enum):
-    """
-    帖子搜索支持的渠道类型
-    """
-    XHS = "xhs"           # 小红书
-    GZH = "gzh"           # 公众号
-    SPH = "sph"           # 视频号
-    GITHUB = "github"     # GitHub
-    TOUTIAO = "toutiao"   # 头条
-    DOUYIN = "douyin"     # 抖音
-    BILI = "bili"         # B站
-    ZHIHU = "zhihu"       # 知乎
-    WEIBO = "weibo"       # 微博
-
-
-class SuggestSearchChannel(str, Enum):
-    """
-    建议词搜索支持的渠道类型
-    """
-    XHS = "xhs"           # 小红书
-    WX = "wx"             # 微信
-    GITHUB = "github"     # GitHub
-    TOUTIAO = "toutiao"   # 头条
-    DOUYIN = "douyin"     # 抖音
-    BILI = "bili"         # B站
-    ZHIHU = "zhihu"       # 知乎
-
-
-@tool(
-    display={
-        "zh": {
-            "name": "帖子搜索",
-            "params": {
-                "keyword": "搜索关键词",
-                "channel": "搜索渠道(xhs=小红书, gzh=公众号, sph=视频号, github, toutiao=头条, douyin=抖音, bili=B站, zhihu=知乎, weibo=微博)",
-                "cursor": "分页游标",
-                "max_count": "返回条数",
-                "content_type": "内容类型-视频/图文",
-                "sort_type": "排序方式(xhs专用)",
-                "publish_time": "发布时间筛选(xhs专用)",
-                "filter_note_range": "笔记时长筛选(xhs专用)"
-            }
-        },
-        "en": {
-            "name": "Search Posts",
-            "params": {
-                "keyword": "Search keyword",
-                "channel": "Search channel (xhs=XiaoHongShu, gzh=WeChat Official Account, sph=WeChat Channels, github, toutiao, douyin, bili, zhihu, weibo)",
-                "cursor": "Pagination cursor",
-                "max_count": "Max results",
-                "content_type": "content type-视频/图文",
-                "sort_type": "Sort type (xhs only)",
-                "publish_time": "Publish time filter (xhs only)",
-                "filter_note_range": "Note duration filter (xhs only)"
-            }
-        }
-    }
-)
-async def search_posts(
-    keyword: str,
-    channel: str = "xhs",
-    cursor: str = "",
-    max_count: int = 20,
-    content_type: str = "",
-    sort_type: str = "综合排序",
-    publish_time: str = "不限",
-    filter_note_range: str = "不限"
-) -> ToolResult:
-    """
-    帖子搜索(浏览模式)
-
-    根据关键词在指定渠道平台搜索帖子,返回封面图+标题+内容摘要,用于快速浏览。
-    如需查看某个帖子的完整内容,请使用 select_post 工具。
-
-    Args:
-        keyword: 搜索关键词
-        channel: 搜索渠道,支持的渠道有:
-            - xhs: 小红书
-            - gzh: 公众号
-            - sph: 视频号
-            - github: GitHub
-            - toutiao: 头条
-            - douyin: 抖音
-            - bili: B站
-            - zhihu: 知乎
-            - weibo: 微博
-        cursor: 分页游标,首次请求为空字符串,后续使用上次返回的 cursor
-        max_count: 返回的最大条数,默认为 20
-        content_type: 内容类型筛选,默认不限;
-            xhs 可选值:'不限' | '图文' | '视频' | '文章';
-            其他渠道可选值:'视频' | '图文'
-        sort_type: 排序方式(仅 xhs 有效),可选值:'综合排序' | '最新发布' | '最多点赞',默认'综合排序'
-        publish_time: 发布时间筛选(仅 xhs 有效),可选值:'不限' | '近30天' | '近7天' | '近1天',默认'不限'
-        filter_note_range: 笔记时长筛选,视频内容有效(仅 xhs 有效),可选值:'不限' | '1分钟以内' | '1-5分钟' | '5分钟以上',默认'不限'
-
-    Returns:
-        ToolResult 包含搜索结果摘要列表(封面图+标题+内容截断),
-        可通过 channel_content_id 调用 select_post 查看完整内容。
-    """
-    global _search_cache
-    try:
-        channel_value = channel.value if isinstance(channel, PostSearchChannel) else channel
-
-        url = f"{BASE_URL}/data"
-        if channel_value == "xhs":
-            payload = {
-                "type": channel_value,
-                "keyword": keyword,
-                "cursor": cursor,
-                "content_type": content_type if content_type else "不限",
-                "sort_type": sort_type,
-                "publish_time": publish_time,
-                "filter_note_range": filter_note_range,
-            }
-        else:
-            payload = {
-                "type": channel_value,
-                "keyword": keyword,
-                "cursor": cursor if cursor else "0",
-                "max_count": max_count,
-                "content_type": content_type,
-            }
-
-        async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client:
-            response = await client.post(
-                url,
-                json=payload,
-                headers={"Content-Type": "application/json"},
-            )
-            response.raise_for_status()
-            data = response.json()
-
-        posts = data.get("data", [])
-
-        # 缓存完整结果(以序号为 key)
-        _search_cache.clear()
-        for idx, post in enumerate(posts):
-            _search_cache[idx + 1] = post
-
-        # 构建摘要列表(带序号)
-        summary_list = []
-        for idx, post in enumerate(posts):
-            body = post.get("body_text", "") or ""
-            title = post.get("title") or body[:20] or ""
-            summary_list.append({
-                "index": idx + 1,
-                "channel_content_id": post.get("channel_content_id"),
-                "title": title,
-                "body_text": body[:100] + ("..." if len(body) > 100 else ""),
-                "like_count": post.get("like_count"),
-                "collect_count": post.get("collect_count"),
-                "comment_count": post.get("comment_count"),
-                "channel": post.get("channel"),
-                "link": post.get("link"),
-                "content_type": post.get("content_type"),
-                "publish_timestamp": post.get("publish_timestamp"),
-            })
-
-        # 拼接封面图网格
-        images = []
-        try:
-            collage_b64 = await _build_collage(posts)
-            if collage_b64:
-                images.append({
-                    "type": "base64",
-                    "media_type": "image/png",
-                    "data": collage_b64
-                })
-        except Exception as collage_error:
-            # 图片拼接失败不影响主流程,记录错误但继续返回结果
-            import logging
-            logging.warning(f"Failed to build collage for {channel_value}: {collage_error}")
-
-        output_data = {
-            "code": data.get("code"),
-            "message": data.get("message"),
-            "data": summary_list
-        }
-
-        return ToolResult(
-            title=f"搜索结果: {keyword} ({channel_value})",
-            output=json.dumps(output_data, ensure_ascii=False, indent=2),
-            long_term_memory=f"Searched '{keyword}' on {channel_value}, found {len(posts)} posts. Use select_post(index) to view full details of a specific post.",
-            images=images
-        )
-    except httpx.HTTPStatusError as e:
-        return ToolResult(
-            title="搜索失败",
-            output="",
-            error=f"HTTP error {e.response.status_code}: {e.response.text}"
-        )
-    except Exception as e:
-        return ToolResult(
-            title="搜索失败",
-            output="",
-            error=str(e)
-        )
-
-
-@tool(
-    display={
-        "zh": {
-            "name": "帖子详情",
-            "params": {
-                "index": "帖子序号"
-            }
-        },
-        "en": {
-            "name": "Select Post",
-            "params": {
-                "index": "Post index"
-            }
-        }
-    }
-)
-async def select_post(
-    index: int,
-) -> ToolResult:
-    """
-    查看帖子详情
-
-    从最近一次 search_posts 的搜索结果中,根据序号选取指定帖子并返回完整内容(全部正文、全部图片、视频等)。
-    需要先调用 search_posts 进行搜索。
-
-    Args:
-        index: 帖子序号,来自 search_posts 返回结果中的 index 字段(从 1 开始)
-
-    Returns:
-        ToolResult 包含该帖子的完整信息和所有图片。
-    """
-    post = _search_cache.get(index)
-    if not post:
-        return ToolResult(
-            title="未找到帖子",
-            output="",
-            error=f"未找到序号 {index} 的帖子,请先调用 search_posts 搜索。"
-        )
-
-    # 返回所有图片
-    images = []
-    for img_url in post.get("images", []):
-        if img_url:
-            images.append({
-                "type": "url",
-                "url": img_url
-            })
-
-    return ToolResult(
-        title=f"帖子详情 #{index}: {post.get('title', '')}",
-        output=json.dumps(post, ensure_ascii=False, indent=2),
-        long_term_memory=f"Viewed post detail #{index}: {post.get('title', '')}",
-        images=images
-    )
-
-
-@tool(
-    display={
-        "zh": {
-            "name": "获取搜索关键词补全建议",
-            "params": {
-                "keyword": "搜索关键词",
-                "channel": "搜索渠道"
-            }
-        },
-        "en": {
-            "name": "Get Search Suggestions",
-            "params": {
-                "keyword": "Search keyword",
-                "channel": "Search channel"
-            }
-        }
-    }
-)
-async def get_search_suggestions(
-    keyword: str,
-    channel: str = "xhs",
-) -> ToolResult:
-    """
-    获取搜索关键词补全建议
-
-    根据关键词在指定渠道平台获取搜索建议词。
-
-    Args:
-        keyword: 搜索关键词
-        channel: 搜索渠道,支持的渠道有:
-            - xhs: 小红书
-            - wx: 微信
-            - github: GitHub
-            - toutiao: 头条
-            - douyin: 抖音
-            - bili: B站
-            - zhihu: 知乎
-
-    Returns:
-        ToolResult 包含建议词数据:
-        {
-            "code": 0,                    # 状态码,0 表示成功
-            "message": "success",         # 状态消息
-            "data": [                     # 建议词数据
-                {
-                    "type": "xhs",        # 渠道类型
-                    "list": [             # 建议词列表
-                        {
-                            "name": "彩虹染发"  # 建议词
-                        }
-                    ]
-                }
-            ]
-        }
-    """
-    try:
-        # 处理 channel 参数,支持枚举和字符串
-        channel_value = channel.value if isinstance(channel, SuggestSearchChannel) else channel
-
-        url = f"{BASE_URL}/suggest"
-        payload = {
-            "type": channel_value,
-            "keyword": keyword,
-        }
-
-        async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client:
-            response = await client.post(
-                url,
-                json=payload,
-                headers={"Content-Type": "application/json"},
-            )
-            response.raise_for_status()
-            data = response.json()
-
-        # 计算建议词数量
-        suggestion_count = 0
-        for item in data.get("data", []):
-            suggestion_count += len(item.get("list", []))
-
-        return ToolResult(
-            title=f"建议词: {keyword} ({channel_value})",
-            output=json.dumps(data, ensure_ascii=False, indent=2),
-            long_term_memory=f"Got {suggestion_count} suggestions for '{keyword}' on {channel_value}"
-        )
-    except httpx.HTTPStatusError as e:
-        return ToolResult(
-            title="获取建议词失败",
-            output="",
-            error=f"HTTP error {e.response.status_code}: {e.response.text}"
-        )
-    except Exception as e:
-        return ToolResult(
-            title="获取建议词失败",
-            output="",
-            error=str(e)
-        )

+ 4 - 2
agent/tools/builtin/skill.py

@@ -66,7 +66,8 @@ def _check_skill_setup(skill_name: str) -> Optional[str]:
 
 
 @tool(
-    description="加载指定的 skill 文档。Skills 提供领域知识和最佳实践指导。"
+    description="加载指定的 skill 文档。Skills 提供领域知识和最佳实践指导。",
+    groups=["core"],
 )
 async def skill(
     skill_name: str,
@@ -181,7 +182,8 @@ async def skill(
 
 
 @tool(
-    description="列出所有可用的 skills"
+    description="列出所有可用的 skills",
+    groups=["core"],
 )
 async def list_skills(
     skills_dir: Optional[str] = None,

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

@@ -610,7 +610,7 @@ async def _run_agents(
 
 # ===== 工具定义 =====
 
-@tool(description="创建 Agent 执行任务", hidden_params=["context"])
+@tool(description="创建 Agent 执行任务", hidden_params=["context"], groups=["core"])
 async def agent(
     task: Union[str, List[str]],
     messages: Optional[Union[Messages, List[Messages]]] = None,
@@ -677,7 +677,7 @@ async def agent(
     )
 
 
-@tool(description="评估目标执行结果是否满足要求", hidden_params=["context"])
+@tool(description="评估目标执行结果是否满足要求", hidden_params=["context"], groups=["core"])
 async def evaluate(
     messages: Optional[Messages] = None,
     target_goal_id: Optional[str] = None,

+ 225 - 114
agent/tools/builtin/toolhub.py

@@ -7,11 +7,20 @@ ToolHub - 远程工具库集成模块
 2. toolhub_search   - 搜索/发现远程工具(GET /tools)
 3. toolhub_call     - 调用远程工具(POST /run_tool)
 
+图片参数统一使用本地文件路径:
+  - 输入:params 中的 image/image_url 等参数直接传本地路径,内部自动上传
+  - 输出:生成的图片自动保存到 outputs/ 目录,返回本地路径
+
 实际 API 端点(通过 /openapi.json 确认):
   GET  /health      → 健康检查
   GET  /tools       → 列出所有工具(含分组、参数 schema)
   POST /run_tool    → 调用工具 {"tool_id": str, "params": dict}
   POST /chat        → 对话接口(不在此封装)
+
+CLI 用法:
+  python -m agent.tools.builtin.toolhub health
+  python -m agent.tools.builtin.toolhub search --keyword=image
+  python -m agent.tools.builtin.toolhub call --tool_id=flux_gen --params='{"prompt":"a cat"}'
 """
 
 import base64
@@ -19,6 +28,7 @@ import contextvars
 import json
 import logging
 import mimetypes
+import os
 import time
 from pathlib import Path
 from typing import Any, Dict, List, Optional
@@ -159,62 +169,77 @@ async def _process_images(raw_images: List[str], tool_id: str) -> tuple:
     return images_for_llm, cdn_urls, saved_paths
 
 
+_SINGLE_IMAGE_PARAMS = ("image", "image_url", "mask_image", "pose_image", "reference_image")
+_ARRAY_IMAGE_PARAMS = ("images", "image_urls", "reference_images")
+
+
+async def _maybe_upload_local(val: str) -> Optional[str]:
+    """如果 val 是存在的本地文件路径,上传 OSS 并返回 CDN URL;否则返回 None。"""
+    if not isinstance(val, str):
+        return None
+    if val.startswith(("http://", "https://", "data:")):
+        return None
+    try:
+        p = Path(val)
+        if p.exists() and p.is_file():
+            return await _upload_to_oss(str(p.resolve()))
+    except Exception as e:
+        logger.warning(f"[ToolHub] 本地路径处理失败 {val}: {e}")
+    return None
+
+
 async def _preprocess_params(params: Dict[str, Any]) -> Dict[str, Any]:
     """
     预处理工具参数:检测本地文件路径,自动上传到 OSS 并替换为 CDN URL。
 
-    支持的参数名:image, image_url, mask_image, pose_image, images (数组)
+    支持的单值参数:image, image_url, mask_image, pose_image, reference_image
+    支持的数组参数:images, image_urls, reference_images
+
+    设计要点:远程工具服务的 cwd 和调用方不一样,相对路径在服务器上会找不到文件。
+    所以必须在客户端就把本地路径转成 CDN URL,不能期望服务器侧有 fallback。
     """
     if not params:
         return params
 
     processed = params.copy()
 
-    # 单个图片参数
-    for key in ("image", "image_url", "mask_image", "pose_image"):
+    # 单图片参数
+    for key in _SINGLE_IMAGE_PARAMS:
         if key in processed and isinstance(processed[key], str):
             val = processed[key]
-            # 检测是否为本地路径(不是 http/https/data: 开头)
-            if not val.startswith(("http://", "https://", "data:")):
-                # 尝试读取本地文件
-                try:
-                    from pathlib import Path
-                    p = Path(val)
-                    if p.exists() and p.is_file():
-                        logger.info(f"[ToolHub] 检测到本地文件 {key}={val},上传到 OSS...")
-                        cdn_url = await _upload_to_oss(str(p.resolve()))
-                        if cdn_url:
-                            processed[key] = cdn_url
-                            logger.info(f"[ToolHub] {key} 已替换为 CDN URL: {cdn_url}")
-                        else:
-                            logger.warning(f"[ToolHub] {key} 上传失败,保持原路径")
-                except Exception as e:
-                    logger.warning(f"[ToolHub] {key} 路径处理失败: {e}")
-
-    # images 数组参数
-    if "images" in processed and isinstance(processed["images"], list):
-        new_images = []
-        for idx, img in enumerate(processed["images"]):
-            if isinstance(img, str) and not img.startswith(("http://", "https://", "data:")):
-                try:
-                    from pathlib import Path
-                    p = Path(img)
-                    if p.exists() and p.is_file():
-                        logger.info(f"[ToolHub] 检测到本地文件 images[{idx}]={img},上传到 OSS...")
-                        cdn_url = await _upload_to_oss(str(p.resolve()))
-                        if cdn_url:
-                            new_images.append(cdn_url)
-                            logger.info(f"[ToolHub] images[{idx}] 已替换为 CDN URL: {cdn_url}")
-                        else:
-                            new_images.append(img)
-                    else:
-                        new_images.append(img)
-                except Exception as e:
-                    logger.warning(f"[ToolHub] images[{idx}] 路径处理失败: {e}")
-                    new_images.append(img)
+            if val.startswith(("http://", "https://", "data:")):
+                continue
+            cdn_url = await _maybe_upload_local(val)
+            if cdn_url:
+                processed[key] = cdn_url
+                logger.info(f"[ToolHub] {key} 本地路径已替换为 CDN: {cdn_url}")
+            elif not os.path.isfile(val):
+                # 既不是远程 URL 也不是已存在的本地文件,直接报错比让远程服务抛神秘的 base64 错误强
+                logger.warning(f"[ToolHub] {key}={val!r} 既不是 URL 也不是存在的本地文件")
+
+    # 数组型图片参数
+    for array_key in _ARRAY_IMAGE_PARAMS:
+        if array_key not in processed or not isinstance(processed[array_key], list):
+            continue
+        new_list = []
+        for idx, item in enumerate(processed[array_key]):
+            if not isinstance(item, str):
+                new_list.append(item)
+                continue
+            if item.startswith(("http://", "https://", "data:")):
+                new_list.append(item)
+                continue
+            cdn_url = await _maybe_upload_local(item)
+            if cdn_url:
+                new_list.append(cdn_url)
+                logger.info(f"[ToolHub] {array_key}[{idx}] 本地路径已替换为 CDN: {cdn_url}")
             else:
-                new_images.append(img)
-        processed["images"] = new_images
+                new_list.append(item)
+                if not os.path.isfile(item):
+                    logger.warning(
+                        f"[ToolHub] {array_key}[{idx}]={item!r} 既不是 URL 也不是存在的本地文件"
+                    )
+        processed[array_key] = new_list
 
     return processed
 
@@ -225,7 +250,8 @@ async def _preprocess_params(params: Dict[str, Any]) -> Dict[str, Any]:
     display={
         "zh": {"name": "ToolHub 健康检查", "params": {}},
         "en": {"name": "ToolHub Health Check", "params": {}},
-    }
+    },
+    groups=["toolhub"],
 )
 async def toolhub_health() -> ToolResult:
     """检查 ToolHub 远程工具库服务是否可用
@@ -254,10 +280,11 @@ async def toolhub_health() -> ToolResult:
             error=f"无法连接到 ToolHub 服务 {TOOLHUB_BASE_URL},请确认服务已启动。",
         )
     except Exception as e:
+        err_msg = f"{type(e).__name__}: {e}" if str(e) else type(e).__name__
         return ToolResult(
             title="ToolHub 健康检查",
             output="",
-            error=str(e),
+            error=err_msg,
         )
 
 
@@ -265,7 +292,8 @@ async def toolhub_health() -> ToolResult:
     display={
         "zh": {"name": "搜索 ToolHub 工具", "params": {"keyword": "搜索关键词"}},
         "en": {"name": "Search ToolHub", "params": {"keyword": "Search keyword"}},
-    }
+    },
+    groups=["toolhub"],
 )
 async def toolhub_search(keyword: Optional[str] = None) -> ToolResult:
     """搜索 ToolHub 远程工具库中可用的工具
@@ -292,16 +320,46 @@ async def toolhub_search(keyword: Optional[str] = None) -> ToolResult:
         tools = data.get("tools", [])
         groups = data.get("groups", [])
 
-        # 客户端关键词过滤
+        # 客户端关键词过滤:三层匹配策略
+        # 1) 原始子串匹配(最严格,但会被分隔符切断:nanobanana vs nano_banana)
+        # 2) 归一化子串匹配(去掉 _ - 空格 .,解决分隔符问题)
+        # 3) 分词交集匹配(keyword 拆成 token,任意 token 命中即保留,解决多词查询)
         if keyword:
-            kw = keyword.lower()
-            tools = [
-                t for t in tools
-                if kw in t.get("name", "").lower()
-                or kw in t.get("description", "").lower()
-                or kw in t.get("tool_id", "").lower()
-                or kw in t.get("category", "").lower()
-            ]
+            def _normalize(s: str) -> str:
+                """去掉分隔符和空白,全小写"""
+                return "".join(c for c in s.lower() if c.isalnum())
+
+            def _tokenize(s: str) -> set:
+                """按分隔符拆成 token 集合"""
+                import re
+                return {t for t in re.split(r"[\s_\-.,/]+", s.lower()) if t}
+
+            kw_raw = keyword.lower()
+            kw_norm = _normalize(keyword)
+            kw_tokens = _tokenize(keyword)
+
+            def _matches(t: dict) -> bool:
+                fields = [
+                    t.get("name", ""),
+                    t.get("description", ""),
+                    t.get("tool_id", ""),
+                    t.get("category", ""),
+                ]
+                combined = " ".join(fields).lower()
+                # 原始子串
+                if kw_raw in combined:
+                    return True
+                # 归一化子串(容忍分隔符差异)
+                if kw_norm and kw_norm in _normalize(combined):
+                    return True
+                # token 交集(多词关键词的 OR 匹配)
+                if kw_tokens:
+                    field_tokens = _tokenize(combined)
+                    if kw_tokens & field_tokens:
+                        return True
+                return False
+
+            tools = [t for t in tools if _matches(t)]
 
         total = len(tools)
 
@@ -337,9 +395,16 @@ async def toolhub_search(keyword: Optional[str] = None) -> ToolResult:
 
             summaries.append(tool_block)
 
-        # 分组使用说明
+        # 分组使用说明:仅显示搜出的工具实际所属的分组,避免噪音
+        relevant_group_ids = set()
+        for t in tools:
+            for gid in t.get("group_ids", []) or []:
+                relevant_group_ids.add(gid)
+
         group_summary = []
         for g in groups:
+            if g["group_id"] not in relevant_group_ids:
+                continue
             group_summary.append(
                 f"[组: {g['group_id']}] {g['name']}\n"
                 f"  调用顺序: {' → '.join(g.get('usage_order', []))}\n"
@@ -360,11 +425,26 @@ async def toolhub_search(keyword: Optional[str] = None) -> ToolResult:
                 + ("..." if total > 15 else "")
             ),
         )
+    except (httpx.ReadTimeout, httpx.ConnectTimeout, httpx.WriteTimeout, httpx.PoolTimeout) as e:
+        return ToolResult(
+            title="ToolHub /tools 超时",
+            output="",
+            error=f"ToolHub 的 /tools 接口在 {DEFAULT_TIMEOUT:.0f}s 内未响应({type(e).__name__})。"
+                  f"服务器可能在检测各工具状态导致列表慢,请稍后重试或联系维护者。",
+        )
+    except httpx.ConnectError as e:
+        return ToolResult(
+            title="ToolHub 连接失败",
+            output="",
+            error=f"无法连接到 ToolHub 服务 {TOOLHUB_BASE_URL}:{type(e).__name__}: {e}",
+        )
     except Exception as e:
+        # 注意 httpx 的部分异常 str(e) 是空的,必须带 type name
+        err_msg = f"{type(e).__name__}: {e}" if str(e) else type(e).__name__
         return ToolResult(
             title="搜索 ToolHub 工具失败",
             output="",
-            error=str(e),
+            error=err_msg,
         )
 
 
@@ -378,7 +458,8 @@ async def toolhub_search(keyword: Optional[str] = None) -> ToolResult:
             "name": "Call ToolHub Tool",
             "params": {"tool_id": "Tool ID", "params": "Tool parameters"},
         },
-    }
+    },
+    groups=["toolhub"],
 )
 async def toolhub_call(
     tool_id: str,
@@ -389,20 +470,20 @@ async def toolhub_call(
     通过 tool_id 调用 ToolHub 工具库中的某个工具,传入该工具所需的参数。
     不同工具的参数不同,请先用 toolhub_search 查询目标工具的参数说明。
 
+    图片参数(image、image_url、mask_image、pose_image、images)直接传本地文件路径即可,
+    系统会自动上传。生成的图片会自动保存到本地 outputs/ 目录,返回结果中的
+    saved_files 字段包含本地文件路径。
+
     注意:部分工具为异步生命周期(如 RunComfy、即梦、FLUX),需要按分组顺序
     依次调用多个工具(如先 launch → 再 executor → 再 stop)。
 
-    参数通过 params 字典传入,键名和类型需与工具定义一致。
-    例如调用图片拼接工具:
-        tool_id="image_stitcher"
-        params={"images": [...], "direction": "grid", "columns": 2}
-
     Args:
         tool_id: 要调用的工具 ID(从 toolhub_search 获取)
-        params: 工具参数字典,键值对根据目标工具的参数定义决定
+        params: 工具参数字典,键值对根据目标工具的参数定义决定。
+                图片参数可直接使用本地文件路径(如 "/path/to/image.png")。
 
     Returns:
-        ToolResult 包含工具执行结果
+        ToolResult 包含工具执行结果,图片结果通过 saved_files 返回本地路径
     """
     try:
         # 预处理参数:本地文件路径自动上传成 CDN URL
@@ -443,17 +524,13 @@ async def toolhub_call(
                 if raw_images:
                     images, cdn_urls, saved_paths = await _process_images(raw_images, tool_id)
 
-                    # 构建文本输出(去掉原始图片数据)
+                    # 构建文本输出(去掉原始图片数据,以本地路径为主
                     result_display = {k: v for k, v in result.items() if k not in ("image", "images")}
-                    if cdn_urls:
-                        result_display["cdn_urls"] = cdn_urls
-                        result_display["_note"] = (
-                            "图片已上传至 CDN(永久链接),可通过 cdn_urls 访问、传给其他工具或下载保存。"
-                            "同时也作为附件附加在本条消息中可直接查看。"
-                        )
+                    result_display["image_count"] = len(images)
                     if saved_paths:
                         result_display["saved_files"] = saved_paths
-                    result_display["image_count"] = len(images)
+                    if cdn_urls:
+                        result_display["cdn_urls"] = cdn_urls
                     result_str = json.dumps(result_display, ensure_ascii=False, indent=2)
 
             return ToolResult(
@@ -469,41 +546,29 @@ async def toolhub_call(
                 output=json.dumps(data, ensure_ascii=False, indent=2),
                 error=error_msg,
             )
-    except httpx.TimeoutException:
+    except httpx.TimeoutException as e:
         return ToolResult(
             title=f"ToolHub [{tool_id}] 调用超时",
             output="",
-            error=f"调用工具 {tool_id} 超时({CALL_TIMEOUT:.0f}s),图像生成类工具可能需要更长时间。",
+            error=f"调用工具 {tool_id} 超时({CALL_TIMEOUT:.0f}s,{type(e).__name__}),"
+                  f"图像生成类工具可能需要更长时间。",
         )
     except Exception as e:
+        err_msg = f"{type(e).__name__}: {e}" if str(e) else type(e).__name__
         return ToolResult(
             title=f"ToolHub [{tool_id}] 调用失败",
             output="",
-            error=str(e),
+            error=err_msg,
         )
 
 
-@tool(
-    display={
-        "zh": {"name": "上传本地图片", "params": {"local_path": "本地文件路径"}},
-        "en": {"name": "Upload Local Image", "params": {"local_path": "Local file path"}},
-    }
-)
-async def image_uploader(local_path: str) -> ToolResult:
-    """将本地图片上传到 OSS,返回可用的 CDN URL(image_url)
-
-    当你需要获取一张本地图片的 HTTP 链接时使用此工具。
-    传入本地文件路径,自动上传到 OSS 并返回永久 CDN URL。
+# 注意:image_uploader 和 image_downloader 不再注册为 Agent 工具。
+# toolhub_call 已内置完整的图片管线(输入自动上传,输出自动下载保存),
+# 无需单独暴露上传/下载工具。以下函数保留供内部或 CLI 使用。
 
-    注意:在调用 toolhub_call 时,image/image_url 等参数可以直接传本地路径,
-    系统会自动上传。此工具适用于你需要单独获取图片 URL 的场景。
-
-    Args:
-        local_path: 本地图片文件路径(相对路径或绝对路径均可)
 
-    Returns:
-        ToolResult 包含上传后的 CDN URL
-    """
+async def image_uploader(local_path: str) -> ToolResult:
+    """将本地图片上传到 OSS,返回可用的 CDN URL(内部工具,不注册给 Agent)"""
     import os
     from pathlib import Path
 
@@ -541,26 +606,8 @@ async def image_uploader(local_path: str) -> ToolResult:
         )
 
 
-@tool(
-    display={
-        "zh": {"name": "下载图片到本地", "params": {"url": "图片URL", "save_path": "保存路径"}},
-        "en": {"name": "Download Image", "params": {"url": "Image URL", "save_path": "Save path"}},
-    }
-)
 async def image_downloader(url: str, save_path: str = "") -> ToolResult:
-    """下载网络图片到本地文件
-
-    从 HTTP/HTTPS 链接下载图片并保存到本地。
-    适用于需要将 CDN 图片、生成结果等保存到本地目录的场景。
-
-    Args:
-        url: 图片的 HTTP/HTTPS 链接
-        save_path: 本地保存路径(相对或绝对路径均可)。
-                   如不指定,自动保存到当前输出目录,文件名从 URL 提取。
-
-    Returns:
-        ToolResult 包含下载后的本地文件路径和文件大小
-    """
+    """下载网络图片到本地文件(内部工具,不注册给 Agent)"""
     import os
     from pathlib import Path
     from urllib.parse import urlparse, unquote
@@ -615,3 +662,67 @@ async def image_downloader(url: str, save_path: str = "") -> ToolResult:
             output="",
             error=f"下载失败: {e}",
         )
+
+
+if __name__ == "__main__":
+    import sys
+
+    COMMANDS = {
+        "health": toolhub_health,
+        "search": toolhub_search,
+        "call": toolhub_call,
+    }
+
+    def _parse_args(argv):
+        kwargs = {}
+        for arg in argv:
+            if arg.startswith("--") and "=" in arg:
+                k, v = arg.split("=", 1)
+                k = k.lstrip("-").replace("-", "_")
+                try:
+                    v = json.loads(v)
+                except (json.JSONDecodeError, ValueError):
+                    pass
+                kwargs[k] = v
+        return kwargs
+
+    if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help"):
+        print(f"用法: python {sys.argv[0]} <command> [--key=value ...]")
+        print(f"可用命令: {', '.join(COMMANDS.keys())}")
+        sys.exit(0)
+
+    cmd = sys.argv[1]
+    if cmd not in COMMANDS:
+        print(f"未知命令: {cmd},可用: {', '.join(COMMANDS.keys())}")
+        sys.exit(1)
+
+    import asyncio
+    import uuid
+    import os
+
+    kwargs = _parse_args(sys.argv[2:])
+
+    # trace_id:CLI 参数 > 环境变量 > 自动生成(用于图片输出目录)
+    trace_id = kwargs.pop("trace_id", None) or os.getenv("TRACE_ID") or f"cli-{uuid.uuid4().hex[:8]}"
+    set_trace_context(trace_id)
+
+    result = asyncio.run(COMMANDS[cmd](**kwargs))
+
+    # 修复双重 JSON 编码:如果 output 已经是一段 JSON 字符串(toolhub_call 内部
+    # 把 result dict 做过 json.dumps),解析回原生 dict 再嵌入 CLI 的最终 JSON,
+    # 避免调用方拿到"output 字段是被字符串化的 JSON"这种反人类形式。
+    output_value = result.output
+    if isinstance(output_value, str):
+        stripped = output_value.lstrip()
+        if stripped.startswith("{") or stripped.startswith("["):
+            try:
+                output_value = json.loads(output_value)
+            except (json.JSONDecodeError, ValueError):
+                pass  # 非 JSON 文本,保持原样
+
+    out = {"trace_id": trace_id, "output": output_value}
+    if result.error:
+        out["error"] = result.error
+    if result.metadata:
+        out["metadata"] = result.metadata
+    print(json.dumps(out, ensure_ascii=False, indent=2))

+ 34 - 10
agent/tools/registry.py

@@ -68,7 +68,8 @@ class ToolRegistry:
 		display: Optional[Dict[str, Dict[str, Any]]] = None,
 		url_patterns: Optional[List[str]] = None,
 		hidden_params: Optional[List[str]] = None,
-		inject_params: Optional[Dict[str, Any]] = None
+		inject_params: Optional[Dict[str, Any]] = None,
+		groups: Optional[List[str]] = None,
 	):
 		"""
 		注册工具
@@ -82,6 +83,7 @@ class ToolRegistry:
 			url_patterns: URL 模式列表(如 ["*.google.com"],None = 无限制)
 			hidden_params: 隐藏参数列表(不生成 schema,LLM 看不到)
 			inject_params: 注入参数规则 {param_name: injector_func}
+			groups: 工具分组标签(如 ["core"]、["browser"]),用于 RunConfig.tool_groups 过滤
 		"""
 		func_name = func.__name__
 
@@ -100,6 +102,7 @@ class ToolRegistry:
 			"url_patterns": url_patterns,
 			"hidden_params": hidden_params or [],
 			"inject_params": inject_params or {},
+			"groups": groups or [],
 			"ui_metadata": {
 				"requires_confirmation": requires_confirmation,
 				"editable_params": editable_params or [],
@@ -166,27 +169,45 @@ class ToolRegistry:
 
 		return schemas
 
-	def get_tool_names(self, current_url: Optional[str] = None) -> List[str]:
+	def get_tool_names(self, current_url: Optional[str] = None, groups: Optional[List[str]] = None) -> List[str]:
 		"""
-		获取工具名称列表(可选 URL 过滤)
+		获取工具名称列表(可选 URL 过滤 + group 过滤
 
 		Args:
-			current_url: 当前 URL(None = 返回所有工具)
+			current_url: 当前 URL(None = 不过滤 URL)
+			groups: 工具分组白名单(None = 不过滤 group,返回所有工具)
 
 		Returns:
 			工具名称列表
 		"""
+		# 1. group 过滤
+		if groups is not None:
+			group_set = set(groups)
+			candidates = {
+				name for name, tool in self._tools.items()
+				if group_set & set(tool.get("groups", []))
+			}
+		else:
+			candidates = set(self._tools.keys())
+
+		# 2. URL 过滤
 		if current_url is None:
-			return list(self._tools.keys())
+			return list(candidates)
 
-		# 过滤工具
 		tool_items = [
-			{"name": name, "url_patterns": tool["url_patterns"]}
-			for name, tool in self._tools.items()
+			{"name": name, "url_patterns": self._tools[name]["url_patterns"]}
+			for name in candidates
 		]
 		filtered = filter_by_url(tool_items, current_url, url_field="url_patterns")
 		return [item["name"] for item in filtered]
 
+	def get_available_groups(self) -> List[str]:
+		"""获取所有已注册的工具分组"""
+		groups = set()
+		for tool in self._tools.values():
+			groups.update(tool.get("groups", []))
+		return sorted(groups)
+
 	def get_schemas_for_url(self, current_url: Optional[str] = None) -> List[Dict]:
 		"""
 		根据当前 URL 获取匹配的工具 Schema
@@ -484,7 +505,8 @@ def tool(
 	display: Optional[Dict[str, Dict[str, Any]]] = None,
 	url_patterns: Optional[List[str]] = None,
 	hidden_params: Optional[List[str]] = None,
-	inject_params: Optional[Dict[str, Any]] = None
+	inject_params: Optional[Dict[str, Any]] = None,
+	groups: Optional[List[str]] = None,
 ):
 	"""
 	工具装饰器 - 自动注册工具并生成 Schema
@@ -498,6 +520,7 @@ def tool(
 		url_patterns: URL 模式列表(如 ["*.google.com"],None = 无限制)
 		hidden_params: 隐藏参数列表(不生成 schema,LLM 看不到)
 		inject_params: 注入参数规则 {param_name: injector_func}
+		groups: 工具分组标签(如 ["core"]、["browser"]),用于 RunConfig.tool_groups 过滤
 
 	Example:
 		@tool(
@@ -531,7 +554,8 @@ def tool(
 			display=display,
 			url_patterns=url_patterns,
 			hidden_params=hidden_params,
-			inject_params=inject_params
+			inject_params=inject_params,
+			groups=groups,
 		)
 		return func
 

+ 1 - 0
agent/tools/utils/__init__.py

@@ -0,0 +1 @@
+"""工具辅助模块 - 供多个工具共享的底层逻辑"""

+ 289 - 0
agent/tools/utils/image.py

@@ -0,0 +1,289 @@
+"""
+图片处理共享工具
+
+提供批量读图、降采样、网格拼图等通用逻辑。供 read_images、content 工具族
+等共享,避免代码重复。
+
+核心函数:
+- load_image: 从本地路径或 URL 加载为 PIL Image
+- downscale: 等比降采样到指定最大边长
+- build_image_grid: 将多张图片拼成带索引编号 + 标题的网格图
+- encode_base64: PIL Image → base64 字符串(默认 JPEG 以节省 token)
+"""
+
+import asyncio
+import base64
+import io
+import math
+from pathlib import Path
+from typing import List, Optional, Sequence, Tuple
+
+import httpx
+from PIL import Image, ImageDraw, ImageFont
+
+
+# ── 网格拼图默认参数 ──
+DEFAULT_THUMB_SIZE = 250         # 每格缩略图边长
+DEFAULT_TEXT_HEIGHT = 80          # 每格下方文字区高度
+DEFAULT_GRID_COLS = 5             # 每行几格
+DEFAULT_PADDING = 12
+DEFAULT_BG_COLOR = (255, 255, 255)
+DEFAULT_TEXT_COLOR = (30, 30, 30)
+DEFAULT_INDEX_COLOR = (220, 60, 60)
+
+# ── 字体候选(跨平台中文支持) ──
+# 注意:macOS 的 PingFang.ttc 因为格式原因 PIL/FreeType 无法读取,
+# 必须使用 Hiragino 或 STHeiti 等其他中文字体。
+_FONT_CANDIDATES = [
+    # macOS(按优先级)
+    "/System/Library/Fonts/Hiragino Sans GB.ttc",   # 冬青黑体,macOS 自带
+    "/System/Library/Fonts/STHeiti Medium.ttc",     # 华文黑体
+    "/System/Library/Fonts/Supplemental/Arial Unicode.ttf",
+    # Linux
+    "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
+    "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
+    "/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf",
+    # Windows
+    "msyh.ttc",           # 微软雅黑
+    "simhei.ttf",         # 黑体
+    "simsun.ttc",         # 宋体
+]
+
+
+def _load_fonts(title_size: int = 16, index_size: int = 32):
+    """加载中文字体,全部失败则退回默认字体"""
+    for path in _FONT_CANDIDATES:
+        try:
+            return (
+                ImageFont.truetype(path, title_size),
+                ImageFont.truetype(path, index_size),
+            )
+        except Exception:
+            continue
+    default = ImageFont.load_default()
+    return default, default
+
+
+# ── 加载图片 ──
+
+async def _load_image_from_url(client: httpx.AsyncClient, url: str) -> Optional[Image.Image]:
+    """下载单张图片,失败返回 None"""
+    try:
+        resp = await client.get(url, timeout=15.0)
+        resp.raise_for_status()
+        return Image.open(io.BytesIO(resp.content)).convert("RGB")
+    except Exception:
+        return None
+
+
+def _load_image_from_path(path: str) -> Optional[Image.Image]:
+    """从本地路径加载图片,失败返回 None"""
+    try:
+        return Image.open(path).convert("RGB")
+    except Exception:
+        return None
+
+
+async def load_image(source: str, client: Optional[httpx.AsyncClient] = None) -> Optional[Image.Image]:
+    """
+    通用图片加载:自动识别 URL 或本地路径。
+
+    Args:
+        source: HTTP(S) URL 或本地文件路径
+        client: 可选的 httpx 客户端(URL 加载时复用连接)
+
+    Returns:
+        PIL Image 对象(RGB 模式),失败返回 None
+    """
+    if source.startswith(("http://", "https://")):
+        if client is not None:
+            return await _load_image_from_url(client, source)
+        async with httpx.AsyncClient() as c:
+            return await _load_image_from_url(c, source)
+    else:
+        # 本地路径:在 executor 中执行以避免阻塞事件循环
+        loop = asyncio.get_event_loop()
+        return await loop.run_in_executor(None, _load_image_from_path, source)
+
+
+async def load_images(sources: Sequence[str]) -> List[Tuple[str, Optional[Image.Image]]]:
+    """
+    并发批量加载图片。
+
+    Returns:
+        [(source, image_or_none), ...] — 保留原始顺序,失败项值为 None
+    """
+    async with httpx.AsyncClient() as client:
+        tasks = [load_image(src, client) for src in sources]
+        images = await asyncio.gather(*tasks)
+    return list(zip(sources, images))
+
+
+# ── 降采样 ──
+
+def downscale(image: Image.Image, max_dimension: int) -> Image.Image:
+    """
+    等比降采样到最大边不超过 max_dimension。
+    如果图片已经足够小则原样返回。
+    """
+    if max(image.width, image.height) <= max_dimension:
+        return image
+    scale = max_dimension / max(image.width, image.height)
+    new_size = (int(image.width * scale), int(image.height * scale))
+    return image.resize(new_size, Image.LANCZOS)
+
+
+# ── 网格拼图 ──
+
+def build_image_grid(
+    images: Sequence[Image.Image],
+    labels: Optional[Sequence[str]] = None,
+    columns: int = DEFAULT_GRID_COLS,
+    thumb_size: int = DEFAULT_THUMB_SIZE,
+    text_height: int = DEFAULT_TEXT_HEIGHT,
+    padding: int = DEFAULT_PADDING,
+    show_index: bool = True,
+) -> Image.Image:
+    """
+    将多张图片拼成带索引编号 + 标题的网格图。
+
+    每个单元格包含:
+      - 左上角红底白字的序号(1, 2, 3...)
+      - 等比缩放居中的缩略图
+      - 下方的标题文字(可选,自动按像素宽度换行)
+
+    Args:
+        images: 待拼接的 PIL Image 列表
+        labels: 每张图的标题(与 images 等长);None 则不显示标题
+        columns: 每行几格
+        thumb_size: 每个缩略图格子的边长
+        text_height: 每格下方文字区高度(labels 为 None 时自动置 0)
+        padding: 格子间距和画布边距
+        show_index: 是否显示左上角序号
+
+    Returns:
+        拼接后的 PIL Image
+    """
+    if not images:
+        raise ValueError("images 不能为空")
+
+    if labels is None:
+        labels = [""] * len(images)
+        text_height = 0
+    elif len(labels) != len(images):
+        raise ValueError(f"labels 长度 {len(labels)} 与 images {len(images)} 不匹配")
+
+    count = len(images)
+    cols = min(columns, count)
+    rows = math.ceil(count / cols)
+
+    cell_w = thumb_size + padding
+    cell_h = thumb_size + text_height + padding
+    canvas_w = cols * cell_w + padding
+    canvas_h = rows * cell_h + padding
+
+    canvas = Image.new("RGB", (canvas_w, canvas_h), DEFAULT_BG_COLOR)
+    draw = ImageDraw.Draw(canvas)
+
+    # 索引框按 thumb_size 比例缩放,保证视觉比例恒定(约 20% 占比)
+    index_box_size = max(40, thumb_size // 5)
+    index_font_size = int(index_box_size * 0.65)
+    # 标题字体略与 thumb_size 相关,但下限保证小图时可读
+    title_font_size = max(14, thumb_size // 18)
+    font_title, font_index = _load_fonts(
+        title_size=title_font_size,
+        index_size=index_font_size,
+    )
+
+    for idx, (img, label) in enumerate(zip(images, labels), start=1):
+        col = (idx - 1) % cols
+        row = (idx - 1) // cols
+        x = padding + col * cell_w
+        y = padding + row * cell_h
+
+        # 等比缩放居中
+        scale = min(thumb_size / img.width, thumb_size / img.height)
+        new_w = int(img.width * scale)
+        new_h = int(img.height * scale)
+        thumb = img.resize((new_w, new_h), Image.LANCZOS)
+        offset_x = x + (thumb_size - new_w) // 2
+        offset_y = y + (thumb_size - new_h) // 2
+        canvas.paste(thumb, (offset_x, offset_y))
+
+        # 左上角序号(跟随实际缩略图位置,大小按比例)
+        if show_index:
+            index_text = str(idx)
+            idx_x = offset_x
+            idx_y = offset_y
+            draw.rectangle(
+                [idx_x, idx_y, idx_x + index_box_size, idx_y + index_box_size],
+                fill=DEFAULT_INDEX_COLOR,
+            )
+            bbox = draw.textbbox((0, 0), index_text, font=font_index)
+            tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
+            # 文本垂直对齐用 bbox 的 top 偏移修正(font bbox 的 top 可能不为 0)
+            text_x = idx_x + (index_box_size - tw) // 2 - bbox[0]
+            text_y = idx_y + (index_box_size - th) // 2 - bbox[1]
+            draw.text((text_x, text_y), index_text, fill=(255, 255, 255), font=font_index)
+
+        # 下方标题(自动按像素宽度换行)
+        if label and text_height > 0:
+            lines = _wrap_text_by_pixel(label, font_title, thumb_size, draw)
+            for line_i, line in enumerate(lines):
+                draw.text(
+                    (x, y + thumb_size + 6 + line_i * 22),
+                    line,
+                    fill=DEFAULT_TEXT_COLOR,
+                    font=font_title,
+                )
+
+    return canvas
+
+
+def _wrap_text_by_pixel(text: str, font, max_width: int, draw: ImageDraw.ImageDraw) -> List[str]:
+    """按像素宽度自动换行,兼容中英文混排(逐字符判断)"""
+    lines = []
+    current = ""
+    for ch in text:
+        test = current + ch
+        bbox = draw.textbbox((0, 0), test, font=font)
+        if bbox[2] - bbox[0] > max_width:
+            if current:
+                lines.append(current)
+            current = ch
+        else:
+            current = test
+    if current:
+        lines.append(current)
+    return lines
+
+
+# ── 编码为 base64 ──
+
+def encode_base64(image: Image.Image, format: str = "JPEG", quality: int = 75) -> Tuple[str, str]:
+    """
+    将 PIL Image 编码为 base64 字符串。
+
+    Args:
+        image: PIL Image 对象
+        format: "JPEG" 或 "PNG"。JPEG 体积更小,推荐用于多模态 LLM 输入
+        quality: JPEG 质量(1-100),PNG 忽略此参数
+
+    Returns:
+        (base64_data, media_type) 元组,如 ("iVBOR...", "image/png")
+    """
+    buf = io.BytesIO()
+    save_kwargs = {"format": format}
+    if format.upper() == "JPEG":
+        # JPEG 不支持透明通道
+        if image.mode in ("RGBA", "LA", "P"):
+            image = image.convert("RGB")
+        save_kwargs["quality"] = quality
+        save_kwargs["optimize"] = True
+    image.save(buf, **save_kwargs)
+
+    data = base64.b64encode(buf.getvalue()).decode("utf-8")
+    media_type = f"image/{format.lower()}"
+    if format.upper() == "JPEG":
+        media_type = "image/jpeg"
+    return data, media_type

+ 1 - 1
agent/trace/goal_tool.py

@@ -103,7 +103,7 @@ async def inject_knowledge_for_goal(
 
 # ===== LLM 可调用的 goal 工具 =====
 
-@tool(description="管理执行计划,添加/完成/放弃目标,切换焦点", hidden_params=["context"])
+@tool(description="管理执行计划,添加/完成/放弃目标,切换焦点", hidden_params=["context"], groups=["core"])
 async def goal(
     add: Optional[str] = None,
     reason: Optional[str] = None,

+ 0 - 45
consistency_requirements.json

@@ -1,45 +0,0 @@
-{
-  "consistency_check_standards": {
-    "1_character_consistency": {
-      "description": "角色一致性",
-      "criteria": [
-        "面部特征一致",
-        "发型:棕色长发",
-        "肤色一致"
-      ]
-    },
-    "2_costume_consistency": {
-      "description": "服装一致性",
-      "criteria": [
-        "白色长裙",
-        "V 字露背设计",
-        "腰部系带",
-        "轻薄棉麻质地"
-      ]
-    },
-    "3_color_scheme": {
-      "description": "配色方案",
-      "criteria": [
-        "白绿配色",
-        "绿色饱和度中等偏高",
-        "纯白服装"
-      ]
-    },
-    "4_lighting_consistency": {
-      "description": "光影一致性",
-      "criteria": [
-        "逆光/轮廓光从左上方照射",
-        "光晕效果统一"
-      ]
-    },
-    "5_style_consistency": {
-      "description": "风格一致性",
-      "criteria": [
-        "真实摄影风格",
-        "85mm 镜头效果",
-        "f/1.8 浅景深"
-      ]
-    }
-  },
-  "evaluation_instruction": "请逐项检查每张图像在 5 个维度上的表现,识别不符合标准的图像,并详细说明问题所在。"
-}

+ 10 - 5
examples/mini_restore/call_banana.py

@@ -30,11 +30,12 @@ import json
 
 # 动态引入我们系统现成的 CDN 上传脚本
 sys.path.append(os.path.dirname(os.path.abspath(__file__)))
-sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'production_restore'))
+sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '..', 'examples', 'production_restore'))
 try:
     from upload import upload_image
-except ImportError:
-    print("错误: 找不到 upload.py。请确保在 tests 目录下运行此脚本。")
+except Exception as e:
+    import traceback
+    print(f"错误: 导入 upload.py 及其依赖链失败: {e}\n{traceback.format_exc()}")
     sys.exit(1)
 
 ROUTER_URL = "http://43.106.118.91:8001/run_tool"
@@ -61,7 +62,7 @@ async def process_images(images_list: list[str]) -> list[str]:
             print(f"⚠️ 跳过找不到的本地文件或无法识别的格式: {item}")
     return final_urls
 
-async def run_nano_banana(prompt: str, images: list[str] = None, model: str = None):
+async def run_nano_banana(prompt: str, images: list[str] = None, model: str = None, aspect_ratio: str = None):
     print(f"\n=======================")
     print(f"🍌 Nano Banana 启动中...")
     print(f"=======================")
@@ -80,6 +81,9 @@ async def run_nano_banana(prompt: str, images: list[str] = None, model: str = No
     
     if model:
         params["model"] = model
+        
+    if aspect_ratio:
+        params["aspect_ratio"] = aspect_ratio
 
     payload = {
         "tool_id": "nano_banana",
@@ -130,7 +134,8 @@ if __name__ == "__main__":
     parser.add_argument("-p", "--prompt", type=str, required=True, help="你想对 AI 喊瞎什么 (比如:用图1的赛博风画一只图2里的猫)")
     parser.add_argument("-i", "--images", type=str, nargs="+", help="无限追加的垫图清单(可以是现成的 http 链接,也可以是你电脑里的硬盘文件如 example.png)")
     parser.add_argument("-m", "--model", type=str, default=None, help="覆盖模型 (默认后台会走 gemini-3.1-flash-image-preview)")
+    parser.add_argument("-a", "--aspect_ratio", type=str, default=None, help="图片比例,例如 3:4, 16:9, 1:1 等")
     
     args = parser.parse_args()
     
-    asyncio.run(run_nano_banana(prompt=args.prompt, images=args.images, model=args.model))
+    asyncio.run(run_nano_banana(prompt=args.prompt, images=args.images, model=args.model, aspect_ratio=args.aspect_ratio))

+ 1 - 0
examples/mini_restore/config.py

@@ -11,6 +11,7 @@ RUN_CONFIG = RunConfig(
     model="qwen3.5-plus",
     temperature=0.3,
     max_iterations=200,
+    tool_groups=["core", "browser", "content", "knowledge", "toolhub", "feishu", "im"],
 
     extra_llm_params={"extra_body": {"enable_thinking": True}},
 

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 19 - 0
examples/mini_restore/history.json


+ 153 - 125
examples/mini_restore/workflow_loop.py

@@ -8,22 +8,52 @@ import re
 # 将项目根目录加入,方便导入内部包
 sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../')))
 
-from agent.llm.qwen import qwen_llm_call
-from agent.tools.builtin.search import search_posts
+from agent.tools.builtin.toolhub import toolhub_call
+from agent.llm.gemini import create_gemini_llm_call
+
+from dotenv import load_dotenv
+load_dotenv()
+
+try:
+    gemini_llm_call = create_gemini_llm_call()
+except ValueError as e:
+    print(f"初始化 Gemini 失败: {e},请检查 .env。")
+    sys.exit(1)
+
+from agent.tools.builtin.content import content_search
+
+# -----------------
+# Utility Functions
+# -----------------
+def encode_image(image_path: str) -> str:
+    with open(image_path, "rb") as image_file:
+        return base64.b64encode(image_file.read()).decode('utf-8')
+
+def get_base64_url(image_path: str) -> str:
+    b64_data = encode_image(image_path)
+    ext = image_path.split('.')[-1].lower()
+    if ext == 'jpg': ext = 'jpeg'
+    return f"data:image/{ext};base64,{b64_data}"
 
 # -----------------
 # Tools definitions
 # -----------------
-async def call_banana_tool(prompt: str) -> str:
-    """包装 call_banana.py 工具的调用方法,抓取它保存本地的文件路径"""
-    print(f"\n[Tool] ✨ 正在调用 call_banana 生成图片, Prompt: {prompt[:50]}...")
+async def call_banana_tool(prompt: str, aspect_ratio: str = None, reference_image: str = None, is_final: bool = True) -> str:
+    """包装 call_banana.py 生成图片,返回一张图的路径"""
+    print(f"\n[Tool] ✨ 正在调用 call_banana 生成图片 (is_final={is_final}), Prompt: {prompt[:50]}...")
     script_path = os.path.join(os.path.dirname(__file__), "call_banana.py")
     
-    # 设置环境变量走兼容模式,同时强制指定 UTF-8 编码避免 Windows 下输出由于表情符号崩溃
     env = os.environ.copy()
     env["PYTHONIOENCODING"] = "utf-8"
+    
+    cmd_args = [sys.executable, script_path, "-p", prompt]
+    if aspect_ratio:
+        cmd_args.extend(["-a", aspect_ratio])
+    if reference_image:
+        cmd_args.extend(["-i", reference_image])
+        
     process = await asyncio.create_subprocess_exec(
-        sys.executable, script_path, "-p", prompt,
+        *cmd_args,
         stdout=asyncio.subprocess.PIPE,
         stderr=asyncio.subprocess.PIPE,
         env=env
@@ -34,20 +64,19 @@ async def call_banana_tool(prompt: str) -> str:
     if err_output:
         output += "\n" + err_output
     
-    # 解析输出:"💾 已保存到本地 -> banana_output_0.jpeg"
     match = re.search(r"已保存到本地 -> (.+)", output)
     if match:
         path = match.group(1).strip()
         print(f"[Tool] ✅ call_banana 返回图片路径: {path}")
         return path
     else:
-        print(f"[Tool] ❌ call_banana 似乎未成功生成文件, 控制台输出:\n{output}")
+        print(f"[Tool] ❌ call_banana 执行失败:\n{output}")
         return f"Tool Execution Failed. output:\n{output}"
 
 async def search_tool(keyword: str) -> str:
     print(f"\n[Tool] 🔍 启动小红书调研, 关键词: {keyword}")
     try:
-        result = await search_posts(keyword=keyword, channel="xhs", max_count=3)
+        result = await content_search(platform="xhs", keyword=keyword, max_count=3)
         return result.output
     except Exception as e:
         return f"查询失败: {e}"
@@ -82,6 +111,18 @@ def get_agent_tools():
                         "prompt": {
                             "type": "string",
                             "description": "英语或中文详细的生图提示词"
+                        },
+                        "aspect_ratio": {
+                            "type": "string",
+                            "description": "(可选)你期望生成的图片宽高比,例如 3:4, 16:9, 1:1,请根据目标参考图的比例传入该参数"
+                        },
+                        "reference_image": {
+                            "type": "string",
+                            "description": "(动作控制底图)如果你在这一步设 is_final=true,请将你在上一阶段生成的【辅助骨架素材(is_final=false)】产生的本地路径填入此处。绝对禁止传入原始目标照片!"
+                        },
+                        "is_final": {
+                            "type": "boolean",
+                            "description": "指示本次生成是否是本轮次的最终产物。如果你需要先生成一张『白底火柴人/3D骨架』作为辅助垫图素材,请设为 false;拿到素材后,你必须继续将它的本地路径填给 `reference_image` 并使用最终 Prompt 和 is_final=true 完成最后合成。"
                         }
                     },
                     "required": ["prompt"]
@@ -90,67 +131,7 @@ def get_agent_tools():
         }
     ]
 
-# -----------------
-# Agent 2: Image Evaluator (Qwen-VL-Max)
-# -----------------
-async def evaluate_images(target_image_path: str, generated_image_path: str, previous_feedback: str = None) -> str:
-    print(f"\n[Agent 2] 👁️ Qwen-VL 开始视觉评估...")
-    print(f"         - 目标图: {target_image_path}")
-    print(f"         - 生成图: {generated_image_path}")
-    
-    def encode_image(image_path):
-        with open(image_path, "rb") as image_file:
-            return base64.b64encode(image_file.read()).decode('utf-8')
-            
-    try:
-        target_b64 = encode_image(target_image_path)
-        gen_b64 = encode_image(generated_image_path)
-        
-        target_ext = target_image_path.split('.')[-1].lower()
-        if target_ext == 'jpg': target_ext = 'jpeg'
-        gen_ext = generated_image_path.split('.')[-1].lower()
-        if gen_ext == 'jpg': gen_ext = 'jpeg'
-    except Exception as e:
-        return f"无法读取图片以进行评估: {e}"
-
-    system_content = "你是专业的AI生图评审师。你的工作是对比【目标参考图】和当前【生成图】,找出具体的差异,并给出针对性的修改意见给生图Prompt工程师。"
-    if previous_feedback:
-        system_content += "\n你还会收到你【上一轮的评估反馈】。请结合你的旧反馈,检查这轮新图片是否修正了你上次提出的问题,避免重复说一样的话,而是要有动态进展意识!"
 
-    text_prompt = "请做详细的差异点分析:从构图、色彩、人物或物体细节、整体质感等方面指出当前生成图与目标图的差距。"
-    if previous_feedback:
-        text_prompt += f"\n\n你对上一版旧图的评估反馈曾经是:\n{previous_feedback}\n\n请比对这张【新生成图】,告诉我:上一版的问题被解决了吗?画面的进步点和退步点在哪里?请给出更新的针对性修改意见!"
-    else:
-        text_prompt += "结束时,请给出具体的 Prompt 修改建议。"
-
-    messages = [
-        {
-            "role": "system",
-            "content": system_content
-        },
-        {
-            "role": "user",
-            "content": [
-                {"type": "text", "text": "【目标参考图(理想状态)】:"},
-                {"type": "image_url", "image_url": {"url": f"data:image/{target_ext};base64,{target_b64}"}},
-                {"type": "text", "text": "【本次生成的图片】:"},
-                {"type": "image_url", "image_url": {"url": f"data:image/{gen_ext};base64,{gen_b64}"}},
-                {"type": "text", "text": text_prompt}
-            ]
-        }
-    ]
-    
-    try:
-        response = await qwen_llm_call(
-            messages=messages,
-            model="qwen3.5-plus" 
-        )
-        analysis = response["content"]
-        print(f"\n[Agent 2] 📃 评估反馈:\n{analysis}\n")
-        return analysis
-    except Exception as e:
-        print(f"\n[Agent 2] ⚠️ 评估发生错误: {e}")
-        return f"VL模型调用失败: {e}"
 
 # -----------------
 # Main Workflow Loop
@@ -166,14 +147,18 @@ def get_base64_url(image_path: str) -> str:
 async def main():
     import argparse
     import os
+    import json
     
     default_target = os.path.join(os.path.dirname(os.path.abspath(__file__)), "input", "img_1.png")
     parser = argparse.ArgumentParser(description="多智能体画图自动优化 Workflow")
     parser.add_argument("-t", "--target", default=default_target, help="你想逼近的目标参考图本地路径")
-    parser.add_argument("-m", "--max_loops", type=int, default=10, help="优化的最大迭代论调")
+    parser.add_argument("-p", "--pose", default=None, help="你提供的姿势参考图(如果有的话,给 Agent 用来走捷径垫底)")
+    parser.add_argument("-m", "--max_loops", type=int, default=15, help="优化的最大迭代论调")
+    parser.add_argument("-r", "--resume", action="store_true", help="是否从上次的 history.json 继续运行")
     args = parser.parse_args()
     
     target_image = args.target
+    pose_image = args.pose
 
     print("\n" + "="*50)
     print("🤖 启动双 Agent 生图闭环工作流 (纯 Vision-Language 架构)")
@@ -183,15 +168,35 @@ async def main():
         print(f"⚠️ 找不到目标图片: {target_image}")
         print("提示: 系统依然会运行寻找文件,但 Agent 2 将无法给出评估。可随便放一个图片来模拟。")
     
+    sys_content = f"你是一个高度自治的闭环生图优化 AI 架构师。你的目标是:生成一张与【目标参考图】在主角姿势、整体结构上无限接近的图片。\n你拥有极强的视觉反思能力和 Prompt 编写能力。\n\n【核心工作流与防坑指南】:\n- 你会看到你的【目标参考图】和你的【往期历史尝试与生成结果】。\n- 请你先利用你的**多模态火眼金睛**,无情地对自己上一轮生成的图片进行找茬。绝不允许说客套话!重点对比人物骨架、姿势和构图的偏离程度。\n- 紧接着,请在反思的基础上,直接重构或调整你的 Prompt,并在一次回复中调用 `call_banana_tool` 下发生图指令!\n- 【防作弊铁律】:你**绝对禁止**直接将【目标参考图】的路径传进 `reference_image` 来作弊!如果你想用图生图垫出完美动作,必须使用【中间素材战法】亲手画一张骨架出来垫。\n- 【中间素材战法】:如果原图姿态过于刁钻复杂,**要求你必须**分两步走:\n   第一步:设置 `is_final=false` 并写一段专门用于抽出单一维度的动作骨架/白模 Prompt(如: \"a generic white 3d mannequin jumping in mid-air, clean white background, high contrast skeleton\"),专门用于抽出干净的辅助骨架。\n   第二步:拿到这只纯净骨架的本地路径后,在同回合的下一次调用中,把这只骨架当做 `reference_image` 垫进去,配合你华丽的最终描述(如: \"a neon cyberpunk assassin jumping\"),设置 `is_final=true` 完成高阶对齐兼防污染! \n\n"
+    
+    if pose_image and os.path.exists(pose_image):
+        sys_content += f"【🔥终极开挂特权】:\n天啊!用户居然为你额外提供了一张极致完美的【姿势参考图】!既然有了这张现成的动作骨架底图,你**立刻抛弃**两步走去抽骨架的方法。你应当直接使用特权,将这张姿势参考图的绝对物理路径 `{os.path.abspath(pose_image)}` 作为 `reference_image` 无脑传给引擎,配合你的终极词汇,并在第一回合内设置 `is_final=true` 完成终极绝杀生成!\n\n"
+
+    sys_content += "流程要求:\n1. 仔细分析差异,在你的纯文本回复段落写出【犀利的反思和执行步骤】。\n2. 反思结束后,使用工具发号施令。\n3. 当调用 `is_final=true` 时,视为你的本轮彻底结束。"
+
     system_msg = {
         "role": "system",
-        "content": "你是一个超级提示词工程师(Prompt Engineer)。目标:生成一张无限接近【目标参考图】的图片。\n作为多模态大模型,每一轮我都会给你看你上次生成的图片结果和评估专家的犀利分析反馈。你需要利用这些反馈进行修改。\n流程要求:\n1. (可选)如果你对风格不确定,可以请求 search_tool 调研别人怎么写相关提示词。\n2. 使用 call_banana_tool 来实际提交你的提示词并生成图片。\n3. 调用生成工具后,你本轮的工作就结束了,系统会把成果拿去评估并在下一轮找你。"
+        "content": sys_content
     }
 
     max_loops = args.max_loops
     current_generation_loop_count = 0
     last_gen_info = None
     prompt_history = [] # 记录完整的历史 Prompt 轨迹,防止反复抽卡
+    
+    history_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "history.json")
+    if args.resume and os.path.exists(history_file):
+        try:
+            with open(history_file, "r", encoding="utf-8") as f:
+                prompt_history = json.load(f)
+            if prompt_history:
+                current_generation_loop_count = len(prompt_history)
+                last_gen_info = prompt_history[-1]
+                print(f"✅ [状态恢复] 已成功从 history.json 加载 {current_generation_loop_count} 轮历史,即将开始第 {current_generation_loop_count + 1} 轮...")
+        except Exception as e:
+            print(f"⚠️ [状态恢复失败] 读取历史记录报错: {e},将重新开始第一轮。")
+            prompt_history = []
 
     while current_generation_loop_count < max_loops:
         print(f"\n" + "="*40)
@@ -204,42 +209,60 @@ async def main():
         if last_gen_info is None:
             try:
                 target_b64_url = get_base64_url(target_image)
+                content_list = [
+                    {"type": "text", "text": "【首轮启动】\n这是你需要逼近的【目标参考图】。现在请你仔细观察它,提炼出一份初步生图 Prompt。\n因为是第一轮,请直接凭借直觉观察,并使用 call_banana_tool 生成原型。"},
+                    {"type": "image_url", "image_url": {"url": target_b64_url}}
+                ]
+                
+                if pose_image and os.path.exists(pose_image):
+                    content_list.append({"type": "text", "text": "并且,下面是用户良心为你提供的【开挂级·姿势参考图】!你可以直接在接下来的提示词工具调用中将此图拿去垫图!"})
+                    content_list.append({"type": "image_url", "image_url": {"url": get_base64_url(pose_image)}})
+                    
                 messages.append({
                     "role": "user",
-                    "content": [
-                        {"type": "text", "text": "这是你需要逼近的【目标参考图】。现在请你仔细观察它,并提炼出一份详尽的初步生图 Prompt。你可以酌情使用 search_tool 调研,最后必须使用 call_banana_tool 提交你的 Prompt 生成最初的原型。"},
-                        {"type": "image_url", "image_url": {"url": target_b64_url}}
-                    ]
+                    "content": content_list
                 })
             except Exception as e:
                 messages.append({
                     "role": "user",
-                    "content": f"目标图片凭据读取失败({e}),请盲猜一个初始 Prompt 并使用 call_banana_tool 生成。"
+                    "content": f"目标图片读取失败({e}),请盲猜一个初始 Prompt 用 call_banana_tool 生成。"
                 })
         else:
             try:
-                gen_image_url = get_base64_url(last_gen_info["image_path"])
+                target_b64_url = get_base64_url(target_image)
+                user_content = [
+                    {"type": "text", "text": "【持续干预闭环】\n这是不可动摇的【目标参考图】,它是一切评判的唯一基准:"},
+                    {"type": "image_url", "image_url": {"url": target_b64_url}}
+                ]
+                
+                if pose_image and os.path.exists(pose_image):
+                    user_content.append({"type": "text", "text": "【外挂辅助】\n这是不可动摇的【姿势参考图】,请毫不犹豫地拿它去填进 reference_image 控制动作:"})
+                    user_content.append({"type": "image_url", "image_url": {"url": get_base64_url(pose_image)}})
+                    
+                user_content.append({"type": "text", "text": "\n==== 【你的历史试错轨迹】 ====\n为了防止你在这场试错过程中来回打转(所谓的废卡反复抽卡),我为你列出了你*从古至今*所有的失败作品和对应的提示词!请认真观察下面每一张你过去的废片:\n"})
                 
-                # 构建历史记录描述,让它知道自己之前走过哪些弯路避免抽卡
-                history_text = "【你的历史迭代轨迹 (包含往期Prompt与评估专家对其的批评,用于防复读和总结改进)】:\n"
                 for i, record in enumerate(prompt_history):
-                    history_text += f"==== 第 {i+1} 轮 ====\n"
-                    history_text += f"[使用的 Prompt]:\n{record['prompt']}\n"
-                    history_text += f"[收到的反馈批评]:\n{record['feedback']}\n\n"
+                    user_content.append({"type": "text", "text": f"-- 第 {i+1} 轮 --\n[上次使用的 Prompt]:\n{record['prompt']}\n[此轮的废片结果]:"})
+                    
+                    try: 
+                        img_path = record.get("image_paths", [record.get("image_path")])[0]
+                        # 节约上下文 Token 和视觉注意力:只渲染第一张(由于打底盲测)和最近一次的历史原图,中间的全部折叠仅保留反思文本
+                        if i == 0 or i == len(prompt_history) - 1:
+                            user_content.append({"type": "image_url", "image_url": {"url": get_base64_url(img_path)}})
+                        else:
+                            user_content.append({"type": "text", "text": "*(由于历史过于久远,中间轮次图片已省去展示,请聚焦于下面你对它的纯文本反思)*"})
+                    except:
+                        pass
+                        
+                    if record.get("feedback"):
+                        user_content.append({"type": "text", "text": f"[你在本轮结束后的反思]:\n{record['feedback']}\n"})
+                
+                user_content.append({"type": "text", "text": "====================\n\n现在,结合上述轨迹与那张【目标参考图】,请在回复中写出最新的【极度苛刻自我反思】,然后立马调用工具生成这轮新的 Prompt!"})
+                
+                messages.append({"role": "user", "content": user_content})
                 
-                messages.append({
-                    "role": "user",
-                    "content": [
-                        {"type": "text", "text": f"{history_text}\n这可以帮你回顾你之前走过的路径。现在聚焦到上一轮:\n\n你上一轮({len(prompt_history)})使用的生图Prompt为:\n{last_gen_info['prompt']}\n\n这里是你上一轮生成的图片结果,请仔细查看对比:"},
-                        {"type": "image_url", "image_url": {"url": gen_image_url}},
-                        {"type": "text", "text": f"【视觉评估专家的分析反馈】:\n{last_gen_info['feedback']}\n\n请针对上述反馈,思考到底哪里不像,参考上述的历史轨迹避免重蹈覆辙,进行新的调研修正(如果需要),或者直接使用 call_banana_tool 生成优化后的版本。"}
-                    ]
-                })
             except Exception as e:
-                messages.append({
-                    "role": "user",
-                    "content": f"上一轮信息读取失败 ({e})。请重新尝试凭感觉用 call_banana_tool 再次生成。"
-                })
+                messages.append({"role": "user", "content": f"上下文读取失败 ({e})。请重试用 call_banana_tool 生成。"})
 
         # Agent 1 内部工具调研微循环 (Agent 1 minor logic loop)
         agent1_finished_generation = False
@@ -248,9 +271,9 @@ async def main():
         while not agent1_finished_generation:
             print(f"---\n💬 正在请求 Agent 1 (Prompt 师)...")
             # 这里 Agent 1 也换成 qwen-vl-max,这样它才能看到传给它的上一轮图片
-            response = await qwen_llm_call(
+            response = await gemini_llm_call(
                 messages=messages,
-                model="qwen3.5-plus",
+                model="gemini-3.1-pro-preview",
                 tools=get_agent_tools()
             )
             
@@ -272,6 +295,7 @@ async def main():
             assistant_reply = {"role": "assistant"}
             if content: assistant_reply["content"] = content
             if tool_calls: assistant_reply["tool_calls"] = tool_calls
+            if "raw_gemini_parts" in response: assistant_reply["raw_gemini_parts"] = response["raw_gemini_parts"]
             messages.append(assistant_reply)
 
             if tool_calls:
@@ -289,49 +313,49 @@ async def main():
                         })
                     
                     elif func_name == "call_banana_tool":
-                        print(f"\n⚙️ Agent 1 决定提交生图请求!")
+                        is_final = args_dict.get("is_final", True)
+                        print(f"\n⚙️ 节点发起了生图请求 (是否为终极图: {is_final})!")
                         gen_path = await call_banana_tool(**args_dict)
                         
-                        # ⚠️ 把生成的图片按轮次重命名防覆盖,保存中间过程
                         if os.path.exists(gen_path):
                             ext = gen_path.split('.')[-1]
-                            new_gen_path = f"gen_loop_{current_generation_loop_count + 1}.{ext}"
                             import shutil
+                            if is_final:
+                                new_gen_path = f"gen_loop_{current_generation_loop_count + 1}.{ext}"
+                            else:
+                                import uuid
+                                new_gen_path = f"gen_loop_{current_generation_loop_count + 1}_material_{str(uuid.uuid4())[:8]}.{ext}"
                             shutil.move(gen_path, new_gen_path)
                             gen_path = new_gen_path
-                            print(f"[文件管理] 中间图片已重命名并保存为: {new_gen_path}")
+                            print(f"[文件管理] 生图结果已重命名并保存为: {new_gen_path}")
                         
                         prompt_used = args_dict.get("prompt", "")
                         
-                        # 把消息补齐,虽然这一轮马上就要重置销毁了
                         messages.append({
                             "role": "tool",
                             "tool_call_id": tc_id,
-                            "content": f"已生成,路径: {gen_path}"
+                            "content": f"已成功生成,图片路径: {os.path.abspath(gen_path)}"
                         })
                         
-                        agent1_finished_generation = True
-                        current_generation_loop_count += 1
-                        
-                        # 进行评估并记录,传递给下一大轮
-                        if os.path.exists(gen_path) and os.path.exists(target_image):
-                            prev_feedback = last_gen_info["feedback"] if last_gen_info else None
-                            evaluation_feedback = await evaluate_images(target_image, gen_path, prev_feedback)
+                        if is_final:
+                            agent1_finished_generation = True
+                            current_generation_loop_count += 1
+                            
                             last_gen_info = {
                                 "prompt": prompt_used,
                                 "image_path": gen_path,
-                                "feedback": evaluation_feedback
+                                "feedback": content if content else "无反思内容"
                             }
+                            
+                            prompt_history.append(last_gen_info)
+                            try:
+                                with open(history_file, "w", encoding="utf-8") as f:
+                                    json.dump(prompt_history, f, ensure_ascii=False, indent=2)
+                            except Exception as e:
+                                print(f"[警告] 历史记录保存失败: {e}")
+                            break # 跳出 tool_calls for loop 并进入下一大轮
                         else:
-                            last_gen_info = {
-                                "prompt": prompt_used,
-                                "image_path": gen_path,
-                                "feedback": f"系统提示:由于目标图 {target_image} 或生成图 {gen_path} 不存在,评估被跳过。"
-                            }
-                        
-                        # 记录到全局大历史中,供它长线参考防重踩坑
-                        prompt_history.append(last_gen_info)
-                        break # 跳出 tool_calls for loop
+                            print(f"[战术回馈] 这是辅助素材,已将路径返回给 Agent1 继续思考。")
             else:
                 # 没调工具
                 print("\n[控制中心] Agent 1 没有继续使用任何工具。结束其周期。")
@@ -346,10 +370,14 @@ async def main():
         print("🏆 正在生成【专家最终多维度反馈报告】...")
         print("="*50)
         
-        first_gen = prompt_history[0]["image_path"]
-        last_gen = prompt_history[-1]["image_path"]
+        first_gen_record = prompt_history[0]
+        last_gen_record = prompt_history[-1]
+        
+        # 兼容旧版本的单图记录和新版本的多图记录
+        first_gen = first_gen_record.get("image_paths", [first_gen_record.get("image_path")])[0]
+        last_gen = last_gen_record.get("image_paths", [last_gen_record.get("image_path")])[0]
         
-        if os.path.exists(first_gen) and os.path.exists(last_gen):
+        if first_gen and last_gen and os.path.exists(first_gen) and os.path.exists(last_gen):
             try:
                 target_b64 = encode_image(target_image)
                 first_b64 = encode_image(first_gen)
@@ -382,9 +410,9 @@ async def main():
                     }
                 ]
                 
-                response = await qwen_llm_call(
+                response = await gemini_llm_call(
                     messages=final_messages,
-                    model="qwen3.5-plus"
+                    model="gemini-3.1-pro-preview"
                 )
                 print(f"\n[Agent 2] 📋 【最终多维度评估报告】:\n{response['content']}\n")
             except Exception as e:

+ 3 - 2
examples/production_restore/config.py

@@ -49,8 +49,9 @@ RUN_CONFIG = RunConfig(
 
 # ===== 任务配置 =====
 
-INPUT_DIR = "examples/production_restore/input"       # 输入目录(pipeline.json、analysis.json、research.json)
-OUTPUT_DIR = "examples/production_restore/output_feature"     # 输出目录
+INPUT_DIR = "examples/production_restore/input"       # 输入目录(pipeline.json、analysis.json 等)
+OUTPUT_DIR = "examples/production_restore/output_origin"     # 输出目录
+FEATURES_DIR = "examples/production_restore/features"         # 素材目录
 
 
 # ===== 基础设施配置 =====

+ 24 - 9
examples/production_restore/execution.prompt

@@ -18,13 +18,17 @@ $system$
 - Prompt 文本
 - 图生图配置(img2img_config)
 
-**参考源信息(raw_info)**:
-在输入目录中有一个 `raw_info` 文件,包含原始图片的源信息(如图片描述、风格标签、技术参数、色彩分析等)。在构建或优化 prompt 时,**应先读取 raw_info 作为参考**,从中提取有价值的细节描述融入 prompt,以更精准地还原目标效果。raw_info 是辅助参考,最终 prompt 仍以任务指派中的要求为准。
+**参考源信息(输入目录)**:
+输入目录 `%input_dir%/` 中包含原始图片的源信息:
+- 每张图的制作表:`%input_dir%/写生油画__img_X_制作表.json`(对应 img_1 ~ img_5)
+- 通用创作信息:`%input_dir%/创作表.md`、`%input_dir%/制作点.md`、`%input_dir%/图片亮点.md`
+
+在构建或优化 prompt 时,**应先读取当前图片对应的制作表 JSON 和通用文件作为参考**,从中提取有价值的细节描述融入 prompt。这些源信息是辅助参考,最终 prompt 仍以任务指派中的要求为准。
 
 ### 第二步:验证素材可用性
 在执行生成前,先验证所需文件存在且可读:
 - 使用 `read_file` 工具检查各参考素材文件(**`read_file` 支持读取图片,会自动转为 base64 供你查看,无需打开浏览器**)
-- 读取 `raw_info` 获取图片源信息,提取可用于 prompt 的细节
+- 读取 `%input_dir%/` 目录下对应的制作表 JSON(如生成 img_1 则读 `写生油画__img_1_制作表.json`)和通用文件(`创作表.md`、`图片亮点.md`),提取可用于 prompt 的细节
 - 如有素材缺失,立即在结果中标注(不要自行跳过)
 
 ### 第三步:通过 ToolHub 工具库执行生成
@@ -49,7 +53,7 @@ toolhub.py 内置了 `_preprocess_params` 函数,会自动将本地路径上
 ✅ **正确做法**:
 ```json
 {
-  "image_url": "examples/production_restore/features/character_asset/character_ref_back.png"
+  "image_url": "%features_dir%/character_asset/character_ref_back.png"
 }
 ```
 
@@ -77,19 +81,21 @@ toolhub.py 内置了 `_preprocess_params` 函数,会自动将本地路径上
 
 **单图评估**:传入需求文档路径和单张图片路径
 ```json
-{"requirement_path": "examples/production_restore/input/pipeline.json", "image_paths": "输出图片路径"}
+{"requirement_path": "%output_dir%/pipeline.json", "image_paths": "输出图片路径"}
 ```
 
 **多图一致性评估**:传入需求文档路径和多张图片路径列表,自动检查跨图一致性
 ```json
-{"requirement_path": "examples/production_restore/input/pipeline.json", "image_paths": ["img_1路径", "img_2路径", "img_3路径"]}
+{"requirement_path": "%output_dir%/pipeline.json", "image_paths": ["img_1路径", "img_2路径", "img_3路径"]}
 ```
 
 - 单图模式:从姿态、服装、光影、背景、材质、构图 6 个维度打分(0-10)
 - 多图模式:从角色、服装、色调、光影、风格 5 个一致性维度打分,并逐图给分,指出不一致的图片
 - 根据评估结果判断是否需要重新生成或调整参数
 
-同时人工对照 output_spec 逐项检查:
+**人工对照制作表逐项检查(极其重要)**:
+
+自动评估工具只能给出维度打分,但无法判断是否符合原始制作表的具体要求。**你必须读取 `%input_dir%/` 下该图对应的制作表 JSON(如生成 img_1 则读 `写生油画__img_1_制作表.json`)**,对照其中的详细描述逐项检查:
 - 姿态是否符合需求描述(手臂位置、站/跪姿态)
 - 景深/散景效果是否达到要求
 - 人物参考是否注入(发型、服装款式)
@@ -98,7 +104,15 @@ toolhub.py 内置了 `_preprocess_params` 函数,会自动将本地路径上
 
 ### 第五步:输出结果报告
 
-**输出格式**:
+**输出文件命名规则(极其重要)**:
+- **图片文件使用版本号命名,绝不覆盖已有文件**
+  - 首次生成:`img_1_restored_v1.png`
+  - 迭代优化:`img_1_restored_v2.png`、`img_1_restored_v3.png`...
+  - 最终定稿:`img_1_restored_final.png`
+- **检查输出目录中是否已有同名文件**,如有则递增版本号
+- **每次生成都必须追加记录到 `%output_dir%/generation_log.md`**,而不是覆盖
+
+**generation_log.md 追加格式**:
 
 ```
 ## 任务:[img_X 还原]
@@ -116,8 +130,9 @@ toolhub.py 内置了 `_preprocess_params` 函数,会自动将本地路径上
 - 分辨率: [宽]x[高]
 
 ### 生成结果
-- 输出路径: [路径]
+- 输出路径: [路径,含版本号如 img_1_restored_v2.png]
 - 生成耗时: [X]s
+- 版本说明: [v1=首次生成 / v2=调整prompt后重试 / vN=第N次迭代]
 
 ### 验证结果
 - 姿态符合度: [通过/不通过] — [说明]

+ 0 - 100
examples/production_restore/input/analysis.json

@@ -1,100 +0,0 @@
-{
-  "category": {
-    "name": "户外白裙写生少女",
-    "traits": [
-      "穿着白色连衣裙的女性在户外草地进行油画写生",
-      "白绿配色为主,清新自然的森系视觉基调",
-      "包含画架、画布、调色板、画笔等写生道具",
-      "逆光/轮廓光与浅景深虚化营造梦幻氛围",
-      "画布内容与现实场景形成画中画呼应结构"
-    ],
-    "quality_challenges": [
-      "人物姿态自然度:需要呈现专注绘画的动态姿态,避免僵硬",
-      "白裙质感表现:轻薄面料的垂坠感、褶皱细节和透光效果",
-      "调色板颜料质感:厚重油画的 Impasto 堆叠感和色彩混合细节",
-      "光影层次:逆光下的轮廓光晕与主体细节的平衡",
-      "景深控制:前景清晰、背景虚化的自然过渡",
-      "画中画一致性:画布上的油画内容需与现实场景形成视觉呼应",
-      "发丝细节:逆光下头发边缘的光晕效果和发丝清晰度"
-    ],
-    "reasoning": "基于图片亮点.md 的 6 个聚类主题(优雅白裙少女、斑斓颜料、写生道具、白绿配色、光影景深、画中画结构)和制作点.md 的 6 大元素权重(女性 84.5、绘画工具 78.5、自然背景 74.0、画架画布 67.67、画架油画 60.97、女性衣物 44.7),以及 5 张制作表中对姿态、服装、光影、清晰度的详细规格描述综合判断"
-  },
-  "highlight": [
-    {
-      "name": "优雅的白裙写生少女",
-      "description": "女性主体需呈现文艺气质与娴静美,包括专注的绘画姿态(身体前倾、手臂抬起握笔)、自然垂坠的白色长裙(V 字露背或圆领、腰部系带收腰、裙摆飘逸)、棕色长发(自然披散、逆光下边缘光晕),整体塑造写生缪斯形象",
-      "reasoning": "图片亮点.md 中该聚类覆盖全部 5 张图片,制作点.md 中'女性'元素综合权重 84.5 为最高,5 张制作表中人物段落评分均在 0.745-0.858 之间,是画面核心主体",
-      "references": ["descriptions/图片亮点.md", "descriptions/制作点.md", "descriptions/写生油画__img_1_制作表.json#段落 1.1", "descriptions/写生油画__img_2_制作表.json#段落 2.1", "descriptions/写生油画__img_3_制作表.json#段落 3.1", "descriptions/写生油画__img_4_制作表.json#段落 4.1", "descriptions/写生油画__img_5_制作表.json#段落 5.1"]
-    },
-    {
-      "name": "斑斓厚重的油画颜料",
-      "description": "木质调色板上堆积的厚重油画颜料(Impasto),呈不规则块状分布,色彩鲜艳杂乱(绿色、蓝色、红色、黄色、白色、紫色等至少 10 种颜色),颜料具有膏状质感和明显堆叠厚度,与周围大面积纯白衣物形成强烈视觉反差",
-      "reasoning": "图片亮点.md 专门聚焦该实质物体,制作点.md 中'绘画工具'权重 78.5 排第二,img_1 和 img_5 制作表中调色板颜料细节描述详尽(颜色种类、分布模式、质地),img_5 中颜料段落评分达 0.754",
-      "references": ["descriptions/图片亮点.md", "descriptions/制作点.md", "descriptions/写生油画__img_1_制作表.json#段落 1.1.2.3", "descriptions/写生油画__img_5_制作表.json#段落 5.1.3.1"]
-    },
-    {
-      "name": "清新雅致的白绿配色",
-      "description": "大面积高饱和度自然草木绿背景(鲜绿色草地、深浅不一的绿色树木)与人物纯白服装形成鲜明对比,确立清新、自然、治愈的森系视觉基调,色彩饱和度中等偏高但不刺眼",
-      "reasoning": "图片亮点.md 中该形式类聚类强调色彩构成的形式美感,覆盖 img_1 和 img_4,5 张制作表的背景段落均描述'以绿色为主',色彩形式评分普遍较高",
-      "references": ["descriptions/图片亮点.md", "descriptions/写生油画__img_1_制作表.json#段落 1.3", "descriptions/写生油画__img_4_制作表.json#段落 4.3"]
-    },
-    {
-      "name": "唯美梦幻的光影与景深",
-      "description": "温暖的逆光/轮廓光从画面左上方或右上方照射,人物和物体边缘形成明显光晕;大光圈浅景深虚化(Bokeh)使背景呈现柔和散景,前景主体清晰锐利(头发丝、服装纹理、手部细节可见),营造脱离现实的梦幻浪漫氛围",
-      "reasoning": "图片亮点.md 中该形式类聚类覆盖 img_2、img_3、img_5,5 张制作表的形式段落均描述'逆光'或'自然光从左上方/右上方照射',清晰度描述均为'前景清晰、背景虚化',img_2 特别强调'强烈光斑和光晕效果'",
-      "references": ["descriptions/图片亮点.md", "descriptions/写生油画__img_2_制作表.json#段落 2 形式", "descriptions/写生油画__img_3_制作表.json#段落 3 形式", "descriptions/写生油画__img_5_制作表.json#段落 5 形式"]
-    },
-    {
-      "name": "虚实呼应的画中画结构",
-      "description": "画架上的画布内容与现实场景形成'镜像'或'互文'关系——画布上描绘身穿白色裙子的女性在户外草地/花丛中的背影,与现实中的画家形成视觉呼应,构建增加叙事深度的画中画结构,画布采用印象派风格(笔触粗犷、色彩鲜明)",
-      "reasoning": "图片亮点.md 中该形式类聚类覆盖 img_1 和 img_2,img_1、img_2、img_3 制作表中画布段落均详细描述'描绘了一名背对镜头的女性',内容主题与现实场景呼应,画布段落评分高达 0.891-0.958",
-      "references": ["descriptions/图片亮点.md", "descriptions/写生油画__img_1_制作表.json#段落 1.2.1", "descriptions/写生油画__img_2_制作表.json#段落 2.2.1", "descriptions/写生油画__img_3_制作表.json#段落 3.2.1"]
-    }
-  ],
-  "baseline": [
-    {
-      "name": "人物姿态自然度",
-      "description": "女性需呈现专注绘画的动态姿态(身体略微前倾、右臂抬起握笔、左臂弯曲持调色板、头部转向画布),避免僵硬呆板的摆拍感",
-      "why_critical": "制作表中 5 张图的人物姿态描述均强调'专注绘画的动态姿态',姿态不自然会导致人物像假人模特,破坏'优雅写生少女'的核心亮点",
-      "reasoning": "img_1 制作表#段落 1.1.2 描述'身体略微前倾,右臂抬起握持画笔,左臂弯曲握持调色板',img_2、img_3、img_4 均有类似姿态描述,姿态形式评分在 0.455-0.765 之间",
-      "references": ["descriptions/写生油画__img_1_制作表.json#段落 1.1.2", "descriptions/写生油画__img_2_制作表.json#段落 2.1.2", "descriptions/写生油画__img_3_制作表.json#段落 3.1.2", "descriptions/写生油画__img_4_制作表.json#段落 4.1.2"]
-    },
-    {
-      "name": "白裙材质与褶皱真实感",
-      "description": "白色长裙需呈现轻薄棉麻或丝绸质地的垂坠感和透气性,有自然形成的垂坠褶皱(裙摆、腰部、背部 V 领处),服装颜色为纯白色无图案",
-      "why_critical": "制作点.md 中'女性衣物'权重 44.7,5 张制作表的服装段落评分均高达 0.806-0.847,材质和褶皱描述详尽,做不好会导致服装像塑料或纸片,一眼假",
-      "reasoning": "img_1 制作表#段落 1.1.2.1 描述'轻薄的棉麻或丝绸质地,具有良好的垂坠感',img_2、img_3、img_4 均有'丝绸或棉麻混纺材质,表面光滑,有轻微光泽感'等描述",
-      "references": ["descriptions/制作点.md", "descriptions/写生油画__img_1_制作表.json#段落 1.1.2.1", "descriptions/写生油画__img_2_制作表.json#段落 2.1.2.1", "descriptions/写生油画__img_3_制作表.json#段落 3.1.2.1", "descriptions/写生油画__img_4_制作表.json#段落 4.1.2.1"]
-    },
-    {
-      "name": "调色板颜料质感",
-      "description": "调色板上的颜料需呈现膏状堆积的厚度和立体感,表面有光泽显示湿润质地,颜色种类丰富(至少 10 种),呈不规则块状和条状分布,部分颜色相互混合形成过渡色",
-      "why_critical": "图片亮点.md 专门将'斑斓厚重的油画颜料'列为实质类亮点,img_5 制作表中颜料段落评分 0.754,质感描述详尽,做不好会像平面贴图而非真实颜料",
-      "reasoning": "img_5 制作表#段落 5.1.3.1 详细描述'颜料呈膏状,堆积在调色板表面,具有一定的厚度和立体感,表面有光泽,显示出湿润的质地',img_1 也有'颜料堆叠感明显'描述",
-      "references": ["descriptions/图片亮点.md", "descriptions/写生油画__img_1_制作表.json#段落 1.1.2.3", "descriptions/写生油画__img_5_制作表.json#段落 5.1.3.1"]
-    },
-    {
-      "name": "前景主体清晰度",
-      "description": "人物主体(头发丝、服装纹理、手部细节)、调色板(木质纹理、颜料堆叠)、画架需清晰锐利,细节可见,与虚化背景形成对比",
-      "why_critical": "5 张制作表的形式段落均强调'前景人物和画架清晰锐利',清晰度形式评分普遍较高,做不好会导致画面整体模糊,失去专业摄影质感",
-      "reasoning": "img_1 制作表#段落 1 形式描述'画面中心区域清晰度高,细节锐利可见',img_2、img_3、img_4、img_5 均有类似描述,清晰度是所有制作表的必选形式参数",
-      "references": ["descriptions/写生油画__img_1_制作表.json#段落 1 形式", "descriptions/写生油画__img_2_制作表.json#段落 2 形式", "descriptions/写生油画__img_3_制作表.json#段落 3 形式", "descriptions/写生油画__img_4_制作表.json#段落 4 形式", "descriptions/写生油画__img_5_制作表.json#段落 5 形式"]
-    },
-    {
-      "name": "背景虚化自然度",
-      "description": "背景树木和草地需呈现柔和的虚化效果(浅景深),与前景清晰主体形成自然过渡,虚化区域有光斑效果(尤其是逆光场景)",
-      "why_critical": "图片亮点.md 将'唯美梦幻的光影与景深'列为形式类亮点,5 张制作表均描述'景深较浅,背景虚化',做不好会导致画面缺乏层次感和专业感",
-      "reasoning": "img_2 制作表#段落 2.3 描述'背景整体呈现柔和的虚化效果,景深较浅,树木轮廓模糊,光斑明显',所有制作表的背景段落均有景深描述",
-      "references": ["descriptions/图片亮点.md", "descriptions/写生油画__img_2_制作表.json#段落 2.3", "descriptions/写生油画__img_3_制作表.json#段落 3.3", "descriptions/写生油画__img_5_制作表.json#段落 5.3"]
-    }
-  ],
-  "requirement_summary": [
-    "高质量人物生成:白裙女性、自然绘画姿态、棕色长发、逆光轮廓光",
-    "服装材质还原:轻薄棉麻/丝绸质地、垂坠褶皱、纯白色",
-    "调色板与颜料:木质调色板、厚重油画颜料 Impasto 质感、多色混合",
-    "光影效果:逆光/轮廓光、温暖光晕、前景清晰背景虚化",
-    "画中画结构:画布上油画内容需与现实场景呼应、印象派风格",
-    "自然背景:高饱和度绿色草地和树木、清新森系色调",
-    "道具细节:画架结构、画笔、白玫瑰点缀"
-  ]
-}

+ 0 - 194
examples/production_restore/input/pipeline.json

@@ -1,194 +0,0 @@
-{
-  "project": "户外白裙写生少女图像还原",
-  "category": "户外白裙写生少女",
-  "total_images": 5,
-  "stages": {
-    "stage_1": {
-      "name": "链式图生图生成",
-      "status": "pending",
-      "description": "以 features 目录中的参考素材为底图,按顺序逐张生成。从 img_1 开始,每张图生成后,将结果作为下一张图的额外参考(chain_from),确保角色、服装、色调的跨图一致性。必须严格按 img_1 → img_2 → img_3 → img_4 → img_5 的顺序串行执行,不可并行",
-      "images": ["img_1", "img_2", "img_3", "img_4", "img_5"],
-      "execution_order": "sequential_chain",
-      "tool_priority": ["nano_banana", "flux_generate", "seedream_generate"],
-      "fallback": "仅当端到端工具效果不足时,才考虑使用 liblibai_controlnet"
-    },
-    "stage_2": {
-      "name": "迭代优化",
-      "status": "pending",
-      "description": "以 Stage 1 生成的图像作为底图,再次图生图迭代优化,增强材质、光影、细节的真实感"
-    },
-    "stage_3": {
-      "name": "跨图一致性检查",
-      "status": "pending",
-      "description": "对比 5 张生成图,检查角色、服装、色彩、光影的一致性。对不一致的图以当前结果为底图重新图生图优化"
-    },
-    "stage_4": {
-      "name": "细节修复与输出",
-      "status": "pending",
-      "description": "逐项检查并修复细节问题,以当前结果为底图进行定向图生图修复,输出最终成品"
-    }
-  },
-  "images": {
-    "img_1": {
-      "required_spec": {
-        "pose": "侧身背对,站姿,身体前倾,右臂抬起握笔,左臂持调色板",
-        "服装": "白色长裙,V 字露背,腰部系带,轻薄棉麻质地,垂坠褶皱",
-        "光影": "逆光从左上方照射,轮廓光晕,发丝金边",
-        "背景": "高饱和度绿色草地和树木,浅景深虚化",
-        "道具": "木质画架,白色画布(有画中画内容),木质调色板(多色颜料)",
-        "画中画": "画布上描绘背对镜头的女性 + 草地 + 蓝花,印象派风格"
-      },
-      "input_from": {
-        "character_ref": "character_asset/character_ref_img1.png",
-        "background_ref": "background_asset/background_green_img1.png",
-        "palette_ref": "palette_asset/palette_impasto_img1_v2.png"
-      },
-      "img2img_config": {
-        "base_image": "character_ref",
-        "reference_images": ["background_ref", "palette_ref"],
-        "chain_from": null,
-        "note": "第 1 张图,无前序结果。以人物参考图为底图做图生图,prompt 中融合背景、道具、光影描述。此图确立角色基准外观"
-      },
-      "prompt": "一位穿着纯白色长裙的女性在户外草地写生,身体略微前倾专注绘画,右手持画笔左手持调色板,棕色长发自然披散在背部,轻薄棉麻质地的白色长裙,V 字露背设计,腰部系带收腰,裙摆自然垂坠有飘逸感,温暖的逆光从左上方照射,人物边缘形成金色轮廓光,发丝呈现明亮光晕,全画幅 85mm 人像定焦镜头,光圈 f/1.8 大光圈,浅景深效果,前景人物清晰锐利,背景草地和树木柔和虚化,高饱和度自然草木绿背景,纯白服装与绿色形成鲜明对比,清新森系色调,真实摄影风格",
-      "negative_prompt": "AI 假人感,塑料质感,过度修饰,模糊,低质量,变形,cgi,3d 渲染,平滑无纹理",
-      "output_spec": {
-        "file": "img_1_restored.png",
-        "resolution": "1024x1365",
-        "critical_checks": ["人物姿态自然度", "白裙材质与褶皱真实感", "前景主体清晰度"],
-        "high_checks": ["逆光轮廓光效果", "背景虚化自然度", "调色板颜料质感"]
-      }
-    },
-    "img_2": {
-      "required_spec": {
-        "pose": "背对,逆光,半剪影效果,身体略微右倾",
-        "服装": "白色长裙,轻薄材质,逆光下透光效果",
-        "光影": "强烈逆光,明显光斑和光晕效果,发丝金边",
-        "背景": "绿色树木,柔和虚化,圆形光斑 (bokeh)",
-        "道具": "木质画架,白色画布(有画中画内容),调色板",
-        "画中画": "画布上描绘背对女性 + 植物 + 花朵 + 光斑,印象派风格"
-      },
-      "input_from": {
-        "character_ref": "character_asset/character_ref_back.png",
-        "background_ref": "background_asset/background_bokeh_img2.png"
-      },
-      "img2img_config": {
-        "base_image": "character_ref",
-        "reference_images": ["background_ref"],
-        "chain_from": "img_1",
-        "note": "以背影参考图为底图,同时将 img_1 的生成结果作为参考图传入,保持角色一致性。prompt 中强化逆光和散景效果"
-      },
-      "prompt": "一位穿着纯白色长裙的女性在户外草地写生,背对镜头,身体略微右倾,强烈逆光效果,人物呈半剪影,发丝边缘明亮金边,轻薄白裙在逆光下呈现透光效果,木质画架和画布,调色板,背景绿色树木柔和虚化,圆形光斑效果,梦幻浪漫氛围,85mm 镜头,f/1.8 大光圈,真实摄影风格",
-      "negative_prompt": "AI 假人感,塑料质感,过度修饰,模糊,低质量,变形,cgi,3d 渲染,光线平淡",
-      "output_spec": {
-        "file": "img_2_restored.png",
-        "resolution": "1024x1365",
-        "critical_checks": ["逆光轮廓光效果", "人物姿态自然度", "前景主体清晰度"],
-        "high_checks": ["背景光斑效果", "白裙透光感", "发丝金边细节"]
-      }
-    },
-    "img_3": {
-      "required_spec": {
-        "pose": "跪坐姿态,身体前倾,左臂靠近地面调色板",
-        "服装": "白色长裙,跪坐时裙摆自然铺展",
-        "光影": "自然光从左上方照射,柔和轮廓光",
-        "背景": "绿色草地,蓝紫色花丛,浅景深",
-        "道具": "木质画架,白色画布(有画中画内容),地面调色板",
-        "画中画": "画布上描绘白裙女性 + 花丛 + 遮阳伞,印象派风格"
-      },
-      "input_from": {
-        "character_ref": "character_asset/character_ref_kneel.png",
-        "background_ref": "background_asset/background_green_img1.png"
-      },
-      "img2img_config": {
-        "base_image": "character_ref",
-        "reference_images": ["background_ref"],
-        "chain_from": "img_2",
-        "note": "以跪姿参考图为底图,将 img_2 的结果作为参考保持角色一致性"
-      },
-      "prompt": "一位穿着纯白色长裙的女性跪坐在户外草地写生,身体前倾专注绘画,左臂靠近地面调色板,棕色长发自然披散,轻薄棉麻质地白裙,裙摆自然铺展在草地上,自然光从左上方照射,柔和轮廓光,背景绿色草地和蓝紫色花丛,浅景深虚化,85mm 镜头,f/1.8 光圈,清新森系色调,真实摄影风格",
-      "negative_prompt": "AI 假人感,塑料质感,过度修饰,模糊,低质量,变形,cgi,3d 渲染,姿态僵硬",
-      "output_spec": {
-        "file": "img_3_restored.png",
-        "resolution": "1024x1365",
-        "critical_checks": ["跪坐姿态自然度", "白裙材质与褶皱真实感", "前景主体清晰度"],
-        "high_checks": ["花丛背景虚化", "裙摆铺展效果", "光影柔和度"]
-      }
-    },
-    "img_4": {
-      "required_spec": {
-        "pose": "侧身面对镜头,仰望,右臂抬起握笔",
-        "服装": "白色长裙,侧身视角可见服装轮廓",
-        "光影": "自然光照射,柔和光影",
-        "背景": "高饱和度绿色草地和树木,浅景深",
-        "道具": "木质画架,空白白色画布(无内容),调色板",
-        "画中画": "空白画布,纯白色表面,无颜料痕迹"
-      },
-      "input_from": {
-        "character_ref": "character_asset/character_ref_side.png",
-        "background_ref": "background_asset/background_green_img4.png",
-        "easel_ref": "easel_asset/easel_blank_canvas_img4.png"
-      },
-      "img2img_config": {
-        "base_image": "character_ref",
-        "reference_images": ["background_ref", "easel_ref"],
-        "chain_from": "img_3",
-        "note": "以侧面参考图为底图,将 img_3 的结果作为参考保持角色一致性。prompt 中强化'空白画布无内容'"
-      },
-      "prompt": "一位穿着纯白色长裙的女性侧身站立面对镜头,头部仰望向左上方,右臂抬起握持画笔,左臂持调色板,轻薄棉麻质地白裙,自然光照射,柔和光影,背景高饱和度绿色草地和树木,浅景深虚化,木质画架上有空白白色画布,纯白色表面无颜料痕迹,85mm 镜头,f/1.8 光圈,清新森系色调,真实摄影风格",
-      "negative_prompt": "AI 假人感,塑料质感,过度修饰,模糊,低质量,变形,cgi,3d 渲染,画布上有内容,颜料痕迹",
-      "output_spec": {
-        "file": "img_4_restored.png",
-        "resolution": "1024x1365",
-        "critical_checks": ["人物姿态自然度", "白裙材质真实感", "空白画布纯白无内容"],
-        "high_checks": ["仰望表情自然", "侧身轮廓清晰", "背景虚化"]
-      }
-    },
-    "img_5": {
-      "required_spec": {
-        "pose": "特写镜头,聚焦手部和调色板,上半身可见",
-        "服装": "白色长裙,特写可见材质细节",
-        "光影": "温暖逆光,调色板颜料光泽",
-        "背景": "绿色背景,强烈虚化",
-        "道具": "木质调色板(多色厚重颜料 Impasto 质感),画笔",
-        "画中画": "画布部分可见于背景,绿蓝色调,笔触粗犷"
-      },
-      "input_from": {
-        "character_ref": "character_asset/character_ref_main.png",
-        "palette_ref": "palette_asset/palette_impasto_img1_v2.png"
-      },
-      "img2img_config": {
-        "base_image": "character_ref",
-        "reference_images": ["palette_ref"],
-        "chain_from": "img_4",
-        "note": "以人物正面参考图为底图,将 img_4 的结果作为参考保持角色一致性。prompt 中强化特写镜头和 Impasto 颜料质感"
-      },
-      "prompt": "特写镜头,聚焦女性双手握持木质调色板和画笔,调色板上有厚重油画颜料 Impasto 质感,膏状堆积,多色混合(绿色、蓝色、红色、黄色、白色、紫色),颜料表面湿润光泽,立体厚度可见,女性穿着纯白色长裙,上半身可见,温暖逆光照射,颜料光泽反射,背景绿色强烈虚化,85mm 微距镜头,f/1.8 光圈,真实摄影风格",
-      "negative_prompt": "AI 假人感,塑料质感,平滑无纹理,模糊,低质量,变形,cgi,3d 渲染,颜料扁平无厚度",
-      "output_spec": {
-        "file": "img_5_restored.png",
-        "resolution": "1024x1365",
-        "critical_checks": ["调色板颜料 Impasto 质感", "颜料湿润光泽", "特写清晰度"],
-        "high_checks": ["多色颜料混合", "手部细节", "背景虚化"]
-      }
-    }
-  },
-  "consistency_checks": {
-    "stage_3": {
-      "character_consistency": "5 张图中女性角色面部特征、发型、肤色应保持一致",
-      "costume_consistency": "白色长裙的材质、款式、颜色应 100% 一致(V 字露背、腰部系带、轻薄棉麻)",
-      "color_scheme": "白绿配色方案贯穿所有图像,绿色饱和度中等偏高,纯白服装",
-      "lighting_consistency": "逆光/轮廓光方向一致(左上方),光晕效果统一",
-      "style_consistency": "真实摄影风格,85mm 镜头,f/1.8 景深效果统一"
-    }
-  },
-  "repair_items": {
-    "stage_4": [
-      {"item": "人物面部细节", "check": "肌肤纹理清晰,无 AI 假人感", "repair_tool": "图生图局部重绘"},
-      {"item": "服装褶皱", "check": "白裙垂坠褶皱自然,材质真实", "repair_tool": "图生图局部重绘"},
-      {"item": "调色板颜料", "check": "Impasto 质感明显,膏状堆积,湿润光泽", "repair_tool": "图生图局部重绘"},
-      {"item": "画布内容", "check": "画中画结构清晰,印象派风格", "repair_tool": "图生图局部重绘"},
-      {"item": "光影效果", "check": "逆光轮廓光自然,发丝金边清晰", "repair_tool": "图生图局部重绘"},
-      {"item": "背景虚化", "check": "浅景深效果自然,光斑柔和", "repair_tool": "图生图局部重绘"}
-    ]
-  }
-}

+ 0 - 44
examples/production_restore/input/strategy.json

@@ -1,44 +0,0 @@
-{
-  "selected_strategy": {
-    "name": "素材驱动端到端工作流:参考素材图生图 → 迭代优化 → 一致性检查 → 细节修复",
-    "workflow_outline": [
-      "阶段 1 - 素材驱动图生图:以 features 中的参考素材(人物参考图)为底图传入 image_url,配合详细 prompt 描述服装材质、光影效果、相机参数,通过 nano_banana / flux_generate / seedream_generate 从头生成图像",
-      "阶段 2 - 迭代优化:以阶段 1 生成结果为底图再次图生图,增强材质、光影、细节真实感",
-      "阶段 3 - 跨图一致性检查:对比 5 张生成图的角色、服装、色彩、光影一致性,对不一致的图重新图生图优化",
-      "阶段 4 - 细节修复与输出:以当前结果为底图,针对局部细节(面部、褶皱、颜料质感等)进行定向图生图修复"
-    ],
-    "tool_priority": [
-      "nano_banana — 首选,速度快、效果好",
-      "flux_generate — 备选,支持 image_url 图生图",
-      "seedream_generate — 备选,支持 image_url 图生图",
-      "liblibai_controlnet — 最后回退,仅当需要精细姿态/边缘/深度控制且端到端工具效果不足时使用"
-    ],
-    "highlight_coverage": [
-      "优雅的白裙写生少女:端到端图生图保留原型构图 + 增强材质真实感",
-      "斑斓厚重的油画颜料:通过 prompt 强化 Impasto 质感描述 + palette_ref 素材辅助",
-      "清新雅致的白绿配色:prompt 中控制色彩 + background_ref 素材引导",
-      "唯美梦幻的光影与景深:prompt 中描述逆光轮廓光 + 相机参数(85mm、f/1.8)",
-      "虚实呼应的画中画结构:prompt 中同时描述现实场景和画布内容的镜像关系"
-    ],
-    "baseline_coverage": [
-      "人物姿态自然度:以原型图为底图保留姿态,端到端工具增强真实感",
-      "白裙材质与褶皱真实感:prompt 中详细描述布料材质(棉麻、垂坠、透光)",
-      "调色板颜料质感:prompt 强化 Impasto 描述 + palette_ref 素材参考",
-      "前景主体清晰度:prompt 中描述相机参数确保主体锐利",
-      "背景虚化自然度:prompt 中描述 f/1.8 大光圈、浅景深效果"
-    ],
-    "reasoning": "该策略的核心优势:1)端到端图生图工具(nano_banana/flux/seedream)通过 ToolHub 直接可用,无需额外配置;2)以原型图为底图可保留已验证的构图和姿态,只增强真实感;3)参考素材(人物、背景、道具)提供风格引导;4)全流程使用 ToolHub 工具,链路简短、执行效率高;5)仅在端到端工具效果不足时才回退到 ControlNet,避免不必要的复杂度"
-  },
-  "vs_alternatives": [
-    {
-      "alternative": "ControlNet 精确控制方案",
-      "why_not": "需要先提取控制信号(骨骼图、深度图、边缘图),当前素材中不包含这些文件;链路更长、配置更复杂;对于本项目已有原型图+参考素材的情况,端到端图生图更直接高效",
-      "could_switch_if": "端到端工具无法精确控制姿态或构图细节;需要逐像素级别的结构控制"
-    },
-    {
-      "alternative": "纯文生图方案(不使用底图)",
-      "why_not": "放弃已有原型图的构图和姿态信息,每次从零生成;一致性难以保证;浪费已有资源",
-      "could_switch_if": "原型图质量太差无法作为底图使用"
-    }
-  ]
-}

+ 0 - 0
examples/production_restore/input/raw_info/写生油画__img_1_制作表.json → examples/production_restore/input/写生油画__img_1_制作表.json


+ 0 - 0
examples/production_restore/input/raw_info/写生油画__img_2_制作表.json → examples/production_restore/input/写生油画__img_2_制作表.json


+ 0 - 0
examples/production_restore/input/raw_info/写生油画__img_3_制作表.json → examples/production_restore/input/写生油画__img_3_制作表.json


+ 0 - 0
examples/production_restore/input/raw_info/写生油画__img_4_制作表.json → examples/production_restore/input/写生油画__img_4_制作表.json


+ 0 - 0
examples/production_restore/input/raw_info/写生油画__img_5_制作表.json → examples/production_restore/input/写生油画__img_5_制作表.json


+ 0 - 0
examples/production_restore/input/raw_info/创作表.md → examples/production_restore/input/创作表.md


+ 0 - 0
examples/production_restore/input/raw_info/制作点.md → examples/production_restore/input/制作点.md


+ 0 - 0
examples/production_restore/input/raw_info/图片亮点.md → examples/production_restore/input/图片亮点.md


+ 23 - 3
examples/production_restore/presets.json

@@ -3,13 +3,33 @@
     "system_prompt_file": "requirement.prompt",
     "max_iterations": 1000,
     "skills": ["planning"],
-    "description": "主 Agent - 还原流程管理与协调,按照 pipeline.json 逐阶段指派执行"
+    "prompt_vars": {
+      "input_dir": "examples/production_restore/input",
+      "output_dir": "examples/production_restore/output_origin",
+      "features_dir": "examples/production_restore/features"
+    },
+    "description": "Business Agent - 决策循环编排器,驱动 goal → evaluate → decide → dispatch 循环"
   },
-  "executor": {
+  "craftsman": {
     "system_prompt_file": "execution.prompt",
     "max_iterations": 200,
     "temperature": 0.3,
     "skills": ["planning"],
-    "description": "执行 Agent - 根据指令执行图像生成、素材验证、细节修复等具体操作"
+    "prompt_vars": {
+      "input_dir": "examples/production_restore/input",
+      "output_dir": "examples/production_restore/output_origin",
+      "features_dir": "examples/production_restore/features"
+    },
+    "description": "Craftsman - 单步执行专家,调用 ToolHub 工具执行图像生成"
+  },
+  "researcher": {
+    "system_prompt_file": "research.prompt",
+    "max_iterations": 200,
+    "temperature": 0.3,
+    "skills": ["planning"],
+    "prompt_vars": {
+      "output_dir": "examples/production_restore/output_origin"
+    },
+    "description": "Researcher - 外部知识获取专家,搜索线上教程和案例"
   }
 }

+ 279 - 51
examples/production_restore/requirement.prompt

@@ -6,14 +6,24 @@ temperature: 0.3
 $system$
 
 ## 角色
-你是一个专注执行的 AI 图像还原专家。你已拿到完整的还原方案(pipeline.json),任务是严格按照方案,逐阶段指派执行 agent 完成图像生成,并在每阶段验证结果后推进下一阶段。
+你是 Business Agent(决策循环编排器)。你的职责是驱动 **goal → evaluate → decide → dispatch** 循环,将图像还原任务分解为具体步骤,通过调度不同角色完成。
+
+**你不直接执行生成或调研**,而是通过以下角色协作:
+
+| 角色 | 调用方式 | 职责 |
+|------|---------|------|
+| **Librarian** | `ask_knowledge(query=...)` | 内部知识顾问,基于 KnowHub 已有知识给出方案建议 |
+| **Requirement DB** | `requirement_search(query=..., top_k=20)` | 需求库检索,查找已有的制作需求 |
+| **Craftsman** | `agent(task=..., agent_type="craftsman")` | 单步执行专家,调用 ToolHub 工具执行图像生成 |
+| **Researcher** | `agent(task=..., agent_type="researcher")` | 外部知识获取,搜索线上教程和案例 |
+| **evaluate** | `evaluate_image(requirement_path=..., image_paths=...)` | 质量评估工具,对照需求打分 |
 
 ## 路径约定
 - 输入目录(JSON 方案文件):`%input_dir%`
-- 素材目录(参考图等素材):`%input_dir%/../features`
+- 素材目录(参考图等素材):`%features_dir%`
 - 输出目录:`%output_dir%`
 
-pipeline.json 中 `input_from` 字段的路径均相对于素材目录(`features/`)。**指派任务时必须将其展开为完整路径**,传给 executor 的路径必须以 `examples/production_restore/features/` 开头。
+`%output_dir%/pipeline.json` 中 `base_image` 和 `reference_images` 的路径必须以 `%features_dir%/` 开头。
 
 ### 素材目录结构
 ```
@@ -24,72 +34,290 @@ features/
 ├── palette_asset/     — 调色板道具素材(Impasto 厚涂颜料)
 ```
 
-## 指派子 agent 时的要求
+## 决策循环
 
-指派 executor 执行每张图的生成任务时,**必须在任务描述中明确包含以下信息**
+你的核心工作模式是不断执行以下循环
 
-1. **底图路径**:pipeline.json 中 `img2img_config.base_image` 指定了哪张素材作为底图(如 `character_ref`),将对应的 `input_from` 路径展开为完整路径,例如:
-   - 底图: `examples/production_restore/features/character_asset/character_ref_img1.png`
+```
+1. 设定目标(Goal)
+2. 问策(ask_knowledge)→ Librarian 返回建议
+3. 如知识不足 → 派发 Researcher 调研 → 结果存入 KnowHub
+4. 决策(Decide)→ 确定执行方案
+5. 派发(Dispatch)→ Craftsman 执行具体任务
+6. 评估(Evaluate)→ evaluate_image 检查结果
+7. 根据评估结果:通过 → 下一目标;不通过 → 回到步骤 2 调整方案
+```
 
-2. **参考素材的完整路径**:`img2img_config.reference_images` 中列出的参考素材,展开为完整路径,例如:
-   - 背景参考: `examples/production_restore/features/background_asset/background_green_img1.png`
-   - 调色板参考: `examples/production_restore/features/palette_asset/palette_impasto_img1_v2.png`
+**context 管理原则**:你只保留当前目标 + 当前结果 + 当前评估,不累积历史。每轮循环的 context 保持小且聚焦。
 
-3. **素材使用方式**:告知 executor 如何使用这些素材:
-   - **底图** → 作为端到端工具(nano_banana / flux_generate / seedream_generate)的 `image_url` 参数传入,进行图生图生成
-   - **参考素材** → 辅助 prompt 描述,帮助 executor 理解目标效果;如工具支持多图输入,可作为参考图传入
-   - executor 必须先读取素材文件,确认存在后再调用工具
-   - **必须使用底图做图生图**,不能跳过底图用纯文生图替代
-   - 如果底图或关键素材文件缺失必须上报
+## 工作流程
 
-4. **Prompt 和生成配置**:从 pipeline.json 中提取的完整 prompt、negative prompt、img2img_config 等
+### 第零步:需求分析(从内容描述提取制作需求)
 
-5. **源信息参考**:告知 executor 读取 `%input_dir%/raw_info` 文件获取图片源信息,从中提取有价值的细节融入 prompt
+在开始执行前,先从输入目录的内容描述文件中提取制作层需求。
 
-6. **输出路径**:指定输出保存到 `%output_dir%/`
+**1. 读取内容描述文件**
 
-## 工作流程
+读取 `%input_dir%` 下的核心描述文件(文件名可能略有差异,按实际存在的读取):
+- `制作点.md`:核心制作元素及权重
+- `图片亮点.md` 或 `制作亮点.md`:视觉亮点聚类
+- `创作表.md`:创作视角描述(可选)
+- `*_制作表.json`:各图的详细制作表(按需抽查 1-2 个了解细节)
+
+**2. 检索已有需求**
+
+用 `requirement_search` 对每个核心制作元素和亮点主题分别检索需求库,了解已有哪些相关需求:
+```
+requirement_search(query="人物写生 白裙女性 角色一致性", top_k=20)
+requirement_search(query="油画颜料质感 Impasto厚涂", top_k=20)
+requirement_search(query="户外自然背景 逆光散景 浅景深虚化", top_k=20)
+```
+- **分词搜索**:不要把所有关键词拼成一个 query,而是对每个核心制作元素、亮点主题分别搜索
+- 对制作点中权重最高的元素、亮点中覆盖图片最多的聚类,分别发起检索
+- 了解已有哪些需求,避免重复提取;已有需求可直接引用而不重复创建
+
+**3. 提取制作需求**
+
+站在图片制作者的角度,从内容描述中提取"用 AI 图像工具制作这组内容时,需要实现什么视觉效果":
+- **从制作点出发**:按权重从高到低,识别需要 AI 工具还原的核心视觉元素
+- **从亮点出发**:识别图组中必须保持高表现力的视觉特征,转化为制作需求
+- **从制作表验证**:抽查具体图片的制作表,确认需求的具体性和可行性
+- **合并同类**:将指向同一视觉能力的元素和亮点合并为一个需求
+- **对比已有**:将提取结果与检索到的已有需求对比,标注哪些是新需求、哪些已存在
+
+**注意**:
+- 需求描述最终的视觉呈现效果,不要使用技术术语(如"ControlNet""LoRA"等)
+- 多个制作元素或亮点指向同一类视觉效果时,合并为一个需求
+- 每组内容的需求数量一般为 3-8 个
+
+**4. 保存需求文件**
 
-### 第一步:读取还原方案
-读取 `%input_dir%/pipeline.json`,提取每张图的 `required_spec`、`input_from`、`img2img_config` 和 prompt,直接进入生成阶段。
+将提取结果保存到 `%output_dir%/requirements.json`。
+
+**输出格式要求**:需求必须按来源分为两组,先列出从需求库查询匹配到的已有需求(`matched_requirements`),再列出新提取的需求(`new_requirements`),让读者一目了然哪些是已积累的能力、哪些是本次新增的挑战。
+
+每个需求采用"抽象需求 + 具象点"的结构:
+- **抽象需求(abstract)**:概括性的视觉能力描述,如"保持多图人物一致性"
+- **具象点(concrete_points)**:该抽象需求下的具体视觉表现要求,如"白裙轮廓在不同角度下保持一致""发丝细节和耳饰在各图中统一"
+
+```json
+{
+  "content_name": "内容名称(从创作表或文件夹名获取)",
+  "matched_requirements": [
+    {
+      "abstract": "抽象需求描述(与需求库中已有需求匹配)",
+      "concrete_points": [
+        "具象点1:来自需求库的已有要求",
+        "具象点2:本次内容补充的新要求(如有)"
+      ],
+      "priority": "high | medium | low",
+      "db_match_id": "需求库中匹配到的需求 ID",
+      "match_confidence": "exact | partial",
+      "delta_from_db": "与需求库原始需求相比,本次新增或调整的具象点说明(无新增则为 null)",
+      "source_elements": ["制作点名称或亮点名称"],
+      "reasoning": "为什么这是关键制作需求,做不好会怎样"
+    }
+  ],
+  "new_requirements": [
+    {
+      "abstract": "抽象需求描述(需求库中无匹配,从内容描述中新提取)",
+      "concrete_points": [
+        "具象点1:具体的视觉表现要求",
+        "具象点2:具体的视觉表现要求"
+      ],
+      "priority": "high | medium | low",
+      "source_elements": ["制作点名称或亮点名称"],
+      "reasoning": "为什么这是关键制作需求,做不好会怎样"
+    }
+  ]
+}
+```
+
+**分组说明**:
+- `matched_requirements`:通过 `requirement_search` 在需求库中找到了匹配的已有需求。`match_confidence` 标注匹配程度(`exact`=完全匹配,`partial`=部分匹配需补充)。`delta_from_db` 说明本次在已有需求基础上的增量变化
+- `new_requirements`:需求库中无匹配,从内容描述文件中新提取的需求。这些需求后续应考虑入库积累
+
+### 第一步:基于需求问策 Librarian
+
+读取 `%output_dir%/requirements.json`,按以下优先级逐层向 Librarian 查询:
+
+**第一层:查关系表(已有需求 → 关联的工序和案例)**
+
+对 `matched_requirements` 中的需求,优先通过关系表查找已有的工序方案和用户案例:
+```
+ask_knowledge(query="需求[db_match_id]关联的工序方案和用户案例")
+```
+- 关系表中可能已记录:该需求对应的工具链、参数配置、成功案例
+- 如果关系表有完整的工序 → 直接采纳,无需进一步查询
+
+**第二层:查能力和知识**
+
+对关系表未覆盖的需求(包括 `new_requirements` 中的新需求),查询相关的能力和通用知识:
+```
+ask_knowledge(query="AI图生图工具实现[抽象需求]的能力和方法,具体要求:[列出concrete_points]")
+```
+- 查找 KnowHub 中是否有相关的工具能力评估、技巧总结、参数经验
+- 将抽象需求和具象点一起传入,让 Librarian 给出针对性建议
+
+**第三层:标记知识缺口**
+
+经过前两层查询后,汇总哪些需求仍缺乏足够的工序或知识支撑,标记为待调研项,交给第三步处理。
+
+### 第二步:按需调研
+对 Librarian 无法充分覆盖的需求,派发 Researcher 调研:
+```
+agent(task="调研以下制作需求的实现方案和用户案例:
+  [列出知识缺口的需求,包含 abstract + concrete_points]
+  重点关注:实际用户的成功案例、工具选择、参数配置、踩坑经验...", agent_type="researcher")
+```
+- Researcher 会搜索外部平台并返回调研结果
+- **调研结果保存到 `%output_dir%/research_result.json`**,供后续设计执行方案时参考
+- 同时通过 `upload_knowledge` 存入 KnowHub 供跨任务复用
+- 特别关注用户案例中的工序流程,可补充到关系表中
+
+**research_result.json 结构**:
+```json
+{
+  "researched_requirements": [
+    {
+      "abstract": "对应的抽象需求",
+      "findings": [
+        {
+          "type": "workflow | case_study | tool_tip | parameter",
+          "summary": "发现摘要",
+          "source": "来源平台和链接",
+          "detail": "详细内容"
+        }
+      ],
+      "recommended_approach": "综合调研结果后的推荐方案"
+    }
+  ]
+}
+```
+
+### 第三步:设计执行方案(生成 pipeline.json)
+
+综合以下信息,自行设计 `pipeline.json` 并保存到 `%output_dir%/pipeline.json`:
+
+**输入**:
+- `%output_dir%/requirements.json`:制作需求(抽象需求 + 具象点)
+- `%output_dir%/research_result.json`:Researcher 调研结果(工序、案例、工具技巧)
+- `%input_dir%/` 下的制作表 JSON:每张图的详细描述和构图信息
+- `%input_dir%/创作表.md`:整体创作视角
+- `%features_dir%/`:可用素材清单
+- Librarian 返回的工序方案、用户案例、工具能力信息
+
+**设计要点**:
+1. **确定图片顺序和链式关系**:根据制作表中各图的角色、构图关系,决定生成顺序和 chain_from 依赖
+2. **为每张图规划生成配置**:底图选择、参考素材、prompt、negative prompt、img2img 参数(strength 等)
+3. **将需求映射到具体图片**:每个 requirement 的 concrete_points 落实到对应图片的生成配置中
+4. **采纳 Librarian 推荐的工序**:优先使用关系表中已验证的工具链和参数配置
+5. **规划修复项**:根据需求优先级,列出 repair_items(Stage 4 细节修复清单)
+
+**pipeline.json 结构**:
+
+pipeline 是一个有序的 steps 列表,每个 step 对应一个可执行的操作。agent 设计 pipeline 时,应将生成、评估、迭代优化、一致性检查、细节修复等阶段都编排为具体的 step。
+
+```json
+{
+  "content_name": "内容名称",
+  "steps": [
+    {
+      "step_id": "step_1",
+      "type": "generate | evaluate | consistency_check | repair | custom",
+      "description": "该步骤的目标描述",
+      "expected_effect": "该步骤完成后图片应达到的视觉效果描述(具体、可感知)",
+      "checkpoints": [
+        "核心检查点1:可评估的具体标准(如'人物白裙V字露背设计清晰可见')",
+        "核心检查点2:可评估的具体标准(如'调色板颜料厚度感明显,有立体笔触纹理')"
+      ],
+      "depends_on": ["前置 step_id,为空则无依赖"],
+      "config": {
+        "// type=generate 时": "",
+        "target": "img_1",
+        "base_image": "%features_dir%/...",
+        "reference_images": ["%features_dir%/..."],
+        "chain_from_step": "null 或前序 generate step_id",
+        "prompt": "生成 prompt",
+        "negative_prompt": "负面 prompt",
+        "img2img_config": { "strength": 0.6 },
+        "mapped_requirements": ["对应的抽象需求"],
+
+        "// type=evaluate 时": "",
+        "target_step": "要评估的 generate step_id",
+        "pass_threshold": 7,
+        "retry_strategy": "问策 Librarian 后重新生成",
+
+        "// type=consistency_check 时": "",
+        "target_steps": ["要对比的多个 generate step_id"],
+        "dimensions": ["character", "clothing", "color", "lighting", "style"],
+
+        "// type=repair 时": "",
+        "target_step": "要修复的 step_id",
+        "repair_items": ["修复项描述"]
+      },
+      "output_path": "%output_dir%/img_1_restored_v1.png"
+    }
+  ],
+  "strategy_source": "工序方案来源说明(Librarian/Researcher/自行设计)"
+}
+```
+
+**设计要点**:
+1. **steps 的编排顺序即执行顺序**,通过 `depends_on` 表达依赖关系
+2. **每个 generate step 后通常跟一个 evaluate step**,评估不通过时按 `retry_strategy` 处理
+3. **将需求映射到具体 step**:每个 requirement 的 concrete_points 落实到对应 generate step 的配置中
+4. **采纳 Librarian 推荐的工序**:优先使用关系表中已验证的工具链和参数配置
+5. 设计完成后 review:每个 high priority 需求是否都在至少一个 step 中有体现
+6. **每个 step 必须填写 `expected_effect` 和 `checkpoints`**:`expected_effect` 描述该步骤完成后图片应呈现的视觉效果;`checkpoints` 列出 2-5 个可评估的具体标准,确保 evaluate step 有明确的评判依据。检查点应来自 requirements.json 中对应需求的 concrete_points
+
+### 第四步:按 pipeline 逐步执行
+
+遍历 `%output_dir%/pipeline.json` 中的 `steps`,按顺序逐步执行:
+
+- **generate**:组装该 step 的完整配置,派发 Craftsman 执行。如有 `chain_from_step`,将该前序 step 的最新输出路径一并传入。**必须提醒 Craftsman 读取 `%input_dir%/` 下该图对应的制作表 JSON(如 `写生油画__img_1_制作表.json`),从中提取构图、姿态、道具位置等细节融入 prompt**
+- **evaluate**:调用 evaluate_image 评估目标 step 的输出,**同时对照 `%input_dir%/` 下该图的制作表 JSON 中的具体描述进行逐项检查**(如制作表中描述"左手持调色板",则检查生成图是否符合)。未达 `pass_threshold` 则按 `retry_strategy` 处理(问策 Librarian → 调整配置 → 重新生成,版本号递增)
+- **consistency_check**:用 evaluate_image 多图模式检查 `target_steps` 的输出一致性。不一致的图重新生成
+- **repair**:按 `repair_items` 逐项修复目标 step 的输出
+- **custom**:按 description 描述的逻辑执行
+
+每个 step 完成后,记录结果到 `%output_dir%/generation_log.md`。
+
+### 知识回流
+每个阶段完成后,将有价值的经验存入 KnowHub:
+```
+upload_knowledge(data="使用 nano_banana 图生图时,strength=0.6 + 链式传递前序图效果最好...", source_type="experience")
+```
 
-### 第二步:链式图生图生成(Stage 1)
-按照 pipeline.json 的 stage_1,**严格按 img_1 → img_2 → img_3 → img_4 → img_5 的顺序串行生成**,不可并行:
+## 输出文件命名规则
 
-1. **img_1(基准图)**:以 `img2img_config.base_image` 指定的参考素材为底图生成。此图确立角色基准外观(面部、发型、肤色、服装),后续所有图都以此为参考锚点
-2. **img_2 ~ img_5(链式传递)**:每张图的 `img2img_config.chain_from` 字段指向前一张图(如 img_2 的 chain_from = img_1)。生成时,**必须将 chain_from 指向的前一张图的生成结果(CDN URL 或本地路径)作为额外的参考图传给 executor**,确保角色一致性
+**图片版本管理**:每次生成的图片使用版本号命名,**绝不覆盖**已有文件:
+- 首次生成:`img_X_restored_v1.png`
+- 迭代重试:`img_X_restored_v2.png`、`img_X_restored_v3.png`...
+- 最终定稿:`img_X_restored_final.png`(Stage 4 修复完成后)
 
-每张图的指派流程:
-- 以 `img2img_config.base_image` 指定的参考素材为底图,传入工具的 `image_url` 参数
-- 如有 `chain_from`,将前一张图的生成结果路径也传给 executor,说明"此为前序图的生成结果,请保持角色、服装、色调一致"
-- 将该图的 `required_spec`、`input_from`(转换为完整路径后)、prompt、`img2img_config` 一并传给 executor
-- **必须在任务描述中列出底图、前序结果图和所有参考素材的完整路径**
-- 指定输出路径到 `%output_dir%`
-- 根据该图的 `output_spec` 评估返回结果
-- 不满足 → 用 `continue_from` 追问同一 executor,说明问题并建议调整方向
-- 满足 → 记录生成结果路径(供下一张图的 chain_from 使用),将生成参数追加记录到 `%output_dir%/stage1_generation_log.md`,进入下一张图
+**生成日志**:每次生成都追加到 `%output_dir%/generation_log.md`,记录完整参数、工具、评估结果。
 
-### 第三步:迭代优化(Stage 2)
-Stage 1 全部完成后,对每张图评估是否需要进一步优化:
-- 以 Stage 1 的生成结果作为新的底图,再次图生图迭代,增强材质、光影、细节
-- 记录到 `%output_dir%/stage2_optimization_log.md`
+指派 Craftsman 时,**必须指定带版本号的输出文件名**,并告知当前是第几次迭代。
 
-### 第四步:跨图一致性检查(Stage 3)
-- 指派 executor 对比 5 张生成图,检查 pipeline.json stage_3 中所列的一致性要求
-- 对有问题的图,以当前结果为底图重新图生图优化
-- 记录检查报告(`%output_dir%/stage3_consistency_report.md`)
+## 指派 Craftsman 时的要求
 
-### 第五步:细节修复与输出(Stage 4)
-按照 pipeline.json 的 stage_4,逐项检查并修复。以当前结果为底图,结合定向 prompt 进行图生图修复。最终成品保存至 `%output_dir%/`。记录修复报告(`%output_dir%/stage4_repair_report.md`)。
+任务描述中**必须明确包含**:
+1. **底图路径**(完整路径)
+2. **参考素材路径**(完整路径)
+3. **前序结果路径**(chain_from,如有)
+4. **素材使用方式**:底图 → image_url,参考素材 → 辅助 prompt
+5. **Prompt 和生成配置**(从 `%output_dir%/pipeline.json` 中对应图片的配置提取)
+6. **源信息参考**:提醒 Craftsman 读取 `%input_dir%/` 目录下对应图片的制作表 JSON(如 `写生油画__img_1_制作表.json`)和通用文件(`创作表.md`、`图片亮点.md`)
+7. **输出路径**
 
-### 评估原则
-在评估每个 executor 的返回结果时,重点关注 analysis.json 中的优先级:
-1. **Critical 下限点**:不满足必须重做
-2. **High 下限点**:不满足尽量修复
-3. **上限点**:尽力还原,但不影响下限点完成的情况下接受次优
+## 评估原则
+1. **Critical 下限点**:不满足必须重做(评分 < 6)
+2. **High 下限点**:不满足尽量修复(评分 6-7)
+3. **上限点**:尽力还原,接受次优(评分 ≥ 7)
 
 $user$
-请读取输入目录中的还原方案,开始执行完整的图像还原流程:
+请先从输入目录的内容描述文件中提取制作需求,然后将需求交给 Librarian 调研,最后执行完整的图像还原流程:
 %input_dir%
 
 输出目录:%output_dir%

+ 141 - 0
examples/production_restore/research.prompt

@@ -0,0 +1,141 @@
+---
+model: sonnet-4.6
+temperature: 0.3
+---
+
+$system$
+## 角色
+你是一个调研专家,负责根据指令搜索并如实记录调研发现。
+
+**你的边界**:只负责搜索和记录,不负责制定策略。发现的工序流程、方案、案例都要如实记录,但不要自己设计工序。
+**调研结果的形式可以多样**:单个工具、工序流程、真实案例都可以。但无论哪种形式,**必须落到具体工具**——每个步骤用什么工具来执行,需要明确。
+
+## 可用工具
+### 内容搜索工具
+- `search_posts(keyword, channel, cursor="0", max_count=20)`: 搜索帖子
+  - **channel 参数**:xhs(小红书), gzh(公众号), zhihu(知乎), bili(B站), douyin(抖音), toutiao(头条), weibo(微博)
+  - 示例:`search_posts("flux 2.0", channel="xhs", max_count=20)`
+- `select_post(index)`: 查看帖子详情(需先调用 search_posts)
+  - 示例:`select_post(index=1)`
+- `youtube_search(keyword)`: 搜索 YouTube 视频
+  - 示例:`youtube_search("flux 2.0 tutorial")`
+- `youtube_detail(content_id, include_captions=True)`: 获取 YouTube 视频详情和字幕
+  - 示例:`youtube_detail("视频ID", include_captions=True)`
+- `x_search(keyword)`: 搜索 X (Twitter) 内容
+  - 示例:`x_search("flux 2.0 max")`
+- `ask_knowledge`: 搜索知识库
+- `browser-use`: 浏览器搜索(search_posts 不好用时使用)
+
+## 执行流程
+
+### 第一步:理解调研目标
+
+### 第二步:执行搜索
+
+**调研渠道策略**:
+1. **官网** - 获取官方介绍、技术规格、API 文档
+2. **内容平台** - 获取真实用例和使用经验
+   - 公众号:`search_posts(keyword="...", channel="gzh")`
+   - X:`x_search(keyword="...")`
+   - 知乎:`search_posts(keyword="...", channel="zhihu")`
+   - 小红书:`search_posts(keyword="...", channel="xhs")`
+3. **视频平台** - 获取用法教程和实操演示
+   - YouTube:`youtube_search(keyword="...")` → `youtube_detail(content_id="...")`
+   - B站:`search_posts(keyword="...", channel="bili")`
+
+**重要**:
+- **必须优先使用专用搜索工具**(search_posts、youtube_search、x_search)
+- **禁止使用 browser-use 搜索公众号、知乎、小红书、B站等已有专用工具的平台**
+- browser-use 仅用于搜索没有专用工具的平台或官网
+
+**Query 策略**(从以下角度搜索):
+1. **找官网** - "[工具名] 官网"、"[工具名] official website"
+2. **找用例** - "[工具名] 用例"、"[工具名] 使用案例"、"[工具名] tutorial"
+3. **找评测** - "[工具名] 评测"、"[工具名] review"、"[工具名] 测试"
+4. **找竞品讨论** - "[工具名] vs [竞品]"、"[工具名] 和 [竞品] 谁更强"
+5. **找排行** - "2026 年最强 [领域] 工具"、"[领域] 工具排行"
+
+**搜索优先级**:
+1. **知识库优先**:用 `ask_knowledge` 按需求关键词搜索,查看已有策略经验、工具评估、工作流总结
+2. **线上调研**:知识库结果不充分时,进行线上搜索
+
+### 第三步:反思与调整
+
+在搜索过程中,你需要主动进行反思和调整:
+每完成 1-2 轮搜索后,在继续前先评估:
+- 当前方向是否有效?是否偏离需求?
+- 结果质量如何?下一轮应该调整 query 还是换角度?
+- 可选调用 `reflect` 工具辅助判断
+根据反思结果调整后续搜索策略,直到你认为信息充分或遇到明确的阻塞。
+
+### 第四步:结束与输出
+
+**何时结束**:
+- 信息已充分覆盖调研目标
+- 搜索结果开始重复,无新信息
+- 方向不明确,需要用户指导
+
+**如何结束**:
+1. **必须**使用 `write_file` 将调研结果按照下面的 JSON 格式写入到任务指定的输出路径
+2. 输出文件路径由调用方在 task 中指定,如未指定则输出到 `%output_dir%/research_result.json`
+
+
+## 输出格式
+
+**Schema**:
+
+```jsonschema
+{
+  "搜索主题": "string — 本次搜索主题",
+  "搜索轨迹": "string — 搜索过程:尝试了哪些 query、如何调整方向等",
+  "调研发现": [
+    {
+      "名称": "string — 发现项名称(工具名/方案名/案例名)",
+      "类型": "tool | workflow | case — 单个工具 / 工序流程或整体方案 / 真实案例",
+      "来源": "string — 来源(knowledge_id / URL / 帖子链接)",
+      "核心描述": "string — 核心思路或能力描述",
+      "工序步骤": [
+        {
+          "步骤名称": "string — 步骤名称(如:生成线稿、角色一致性处理)",
+          "使用工具": "string — 该步骤使用的具体工具名称",
+          "说明": "string — 该步骤的操作说明"
+        }
+      ],
+      "工具信息": {
+        "工具名称": "string — 工具名称(类型为 tool 时必填)",
+        "仓库或链接": "string — 仓库或官网链接",
+        "输入格式": "string — 输入格式",
+        "输出格式": "string — 输出格式",
+        "最近更新": "string — 最近更新时间",
+        "能力": ["string — 工具能力"],
+        "限制": ["string — 工具限制"]
+      },
+      "外部评价": {
+        "专家或KOL推荐": ["string — 来源 + 评价摘要"],
+        "社区反馈": ["string — 来源 + 反馈摘要"],
+        "热度指标": "string — 提及次数、榜单排名、帖子热度等"
+      },
+      "使用案例": [
+        {
+          "描述": "string — 用例描述",
+          "来源链接": "string — 来源链接",
+          "相似度": "high | medium | low"
+        }
+      ],
+      "优点": ["string"],
+      "缺点": ["string"],
+      "风险": ["string"]
+    }
+  ]
+}
+```
+
+**字段说明**:
+- `工序步骤`:类型为 `workflow` 或 `case` 时填写,逐步骤记录用了什么工具
+- `工具信息`:类型为 `tool` 时必填;`workflow`/`case` 类型中,如果整体方案依赖某个核心工具(如 ComfyUI),也可填写
+- `外部评价`:尽量填写,是主 agent 选择工具时的重要参考;找不到可留空
+
+
+## 注意事项
+- `search_posts` 不好用时改用 `browser-use`
+- 如果调研过程中遇到不确定的问题,要停下来询问用户

+ 2 - 2
examples/production_restore/run.py

@@ -46,7 +46,7 @@ from agent.tools.builtin.toolhub import toolhub_health, toolhub_search, toolhub_
 from evaluate_tool import evaluate_image  # noqa: F401
 
 # 导入项目配置
-from config import RUN_CONFIG, SKILLS_DIR, TRACE_STORE_PATH, DEBUG, LOG_LEVEL, LOG_FILE, BROWSER_TYPE, HEADLESS, INPUT_DIR, OUTPUT_DIR
+from config import RUN_CONFIG, SKILLS_DIR, TRACE_STORE_PATH, DEBUG, LOG_LEVEL, LOG_FILE, BROWSER_TYPE, HEADLESS, INPUT_DIR, OUTPUT_DIR, FEATURES_DIR
 
 
 async def main():
@@ -86,7 +86,7 @@ async def main():
     print("4. 构建任务消息...")
     print(f"   - 输入目录: {INPUT_DIR}")
     print(f"   - 输出目录: {OUTPUT_DIR}")
-    messages = prompt.build_messages(input_dir=INPUT_DIR, output_dir=OUTPUT_DIR)
+    messages = prompt.build_messages(input_dir=INPUT_DIR, output_dir=OUTPUT_DIR, features_dir=FEATURES_DIR)
 
     # 5. 初始化浏览器
     browser_mode_names = {"cloud": "云浏览器", "local": "本地浏览器", "container": "容器浏览器"}

+ 0 - 107
frontend/htmlTemplate/api_data/goal_list.json

@@ -1,107 +0,0 @@
-{
-  "trace": {
-    "trace_id": "ffc7e6e8-6725-4bd3-8cd1-5c212856feff",
-    "mode": "agent",
-    "prompt_name": null,
-    "task": "[图片和特征描述已包含在 messages 中]",
-    "agent_type": "default",
-    "parent_trace_id": null,
-    "parent_goal_id": null,
-    "status": "completed",
-    "total_messages": 20,
-    "total_tokens": 145867,
-    "total_cost": 0.0,
-    "total_duration_ms": 0,
-    "last_sequence": 20,
-    "last_event_id": 25,
-    "uid": null,
-    "context": {
-      "model": "anthropic/claude-sonnet-4.5",
-      "temperature": 0.3
-    },
-    "current_goal_id": null,
-    "created_at": "2026-02-05T19:20:17.456366",
-    "completed_at": "2026-02-05T19:23:34.789135"
-  },
-  "goal_tree": {
-    "mission": "[图片和特征描述已包含在 messages 中]",
-    "goals": [
-      {
-        "id": "1",
-        "description": "调研构图特征的表示方案",
-        "reason": "需要系统性地调研、设计并输出构图特征表示方案",
-        "parent_id": null,
-        "type": "normal",
-        "status": "completed",
-        "summary": "基于计算机视觉领域的标准实践,构图特征应采用结构化+数值化的混合表示方式",
-        "sub_trace_ids": null,
-        "agent_call_mode": null,
-        "self_stats": {
-          "message_count": 6,
-          "total_tokens": 19265,
-          "total_cost": 0.0,
-          "preview": null
-        },
-        "cumulative_stats": {
-          "message_count": 6,
-          "total_tokens": 19265,
-          "total_cost": 0.0,
-          "preview": null
-        },
-        "created_at": "2026-02-05T19:20:26.177225"
-      },
-      {
-        "id": "2",
-        "description": "设计精简的特征表示结构",
-        "reason": "需要分步完成以确保质量",
-        "parent_id": null,
-        "type": "normal",
-        "status": "completed",
-        "summary": "已设计完整的特征表示结构,包含JSON schema、转换工具和示例",
-        "sub_trace_ids": null,
-        "agent_call_mode": null,
-        "self_stats": {
-          "message_count": 10,
-          "total_tokens": 92011,
-          "total_cost": 0.0,
-          "preview": null
-        },
-        "cumulative_stats": {
-          "message_count": 10,
-          "total_tokens": 92011,
-          "total_cost": 0.0,
-          "preview": null
-        },
-        "created_at": "2026-02-05T19:20:26.177233"
-      },
-      {
-        "id": "3",
-        "description": "输出最终方案文档",
-        "reason": "",
-        "parent_id": null,
-        "type": "normal",
-        "status": "pending",
-        "summary": null,
-        "sub_trace_ids": null,
-        "agent_call_mode": null,
-        "self_stats": {
-          "message_count": 2,
-          "total_tokens": 28740,
-          "total_cost": 0.0,
-          "preview": null
-        },
-        "cumulative_stats": {
-          "message_count": 2,
-          "total_tokens": 28740,
-          "total_cost": 0.0,
-          "preview": null
-        },
-        "created_at": "2026-02-05T19:20:26.177238"
-      }
-    ],
-    "current_id": null,
-    "_next_id": 1,
-    "created_at": "2026-02-05T19:20:17.456826"
-  },
-  "sub_traces": {}
-}

+ 0 - 28
frontend/htmlTemplate/api_data/trace_list.json

@@ -1,28 +0,0 @@
-{
-  "traces": [
-    {
-      "trace_id": "ffc7e6e8-6725-4bd3-8cd1-5c212856feff",
-      "mode": "agent",
-      "prompt_name": null,
-      "task": "[图片和特征描述已包含在 messages 中]",
-      "agent_type": "default",
-      "parent_trace_id": null,
-      "parent_goal_id": null,
-      "status": "completed",
-      "total_messages": 20,
-      "total_tokens": 145867,
-      "total_cost": 0.0,
-      "total_duration_ms": 0,
-      "last_sequence": 20,
-      "last_event_id": 25,
-      "uid": null,
-      "context": {
-        "model": "anthropic/claude-sonnet-4.5",
-        "temperature": 0.3
-      },
-      "current_goal_id": null,
-      "created_at": "2026-02-05T19:20:17.456366",
-      "completed_at": "2026-02-05T19:23:34.789135"
-    }
-  ]
-}

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
frontend/htmlTemplate/ws_data/event.jsonl


+ 0 - 28
frontend/htmlTemplate/ws_data/trace_list.json

@@ -1,28 +0,0 @@
-{
-  "traces": [
-    {
-      "trace_id": "ffc7e6e8-6725-4bd3-8cd1-5c212856feff",
-      "mode": "agent",
-      "prompt_name": null,
-      "task": "[图片和特征描述已包含在 messages 中]",
-      "agent_type": "default",
-      "parent_trace_id": null,
-      "parent_goal_id": null,
-      "status": "completed",
-      "total_messages": 20,
-      "total_tokens": 145867,
-      "total_cost": 0.0,
-      "total_duration_ms": 0,
-      "last_sequence": 20,
-      "last_event_id": 25,
-      "uid": null,
-      "context": {
-        "model": "anthropic/claude-sonnet-4.5",
-        "temperature": 0.3
-      },
-      "current_goal_id": null,
-      "created_at": "2026-02-05T19:20:17.456366",
-      "completed_at": "2026-02-05T19:23:34.789135"
-    }
-  ]
-}

+ 13 - 0
im-server/__main__.py

@@ -0,0 +1,13 @@
+import uvicorn
+import os
+import sys
+
+# 确保在导入 main 时能够找到同级模块
+current_dir = os.path.dirname(os.path.abspath(__file__))
+if current_dir not in sys.path:
+    sys.path.insert(0, current_dir)
+
+from main import app
+
+if __name__ == "__main__":
+    uvicorn.run(app, host="0.0.0.0", port=8105)

+ 5 - 1
knowhub/agents/librarian.py

@@ -41,9 +41,11 @@ def get_librarian_config(enable_db_commit: bool = ENABLE_DATABASE_COMMIT) -> Run
         "tool_search",
         "capability_search",
         "requirement_search",
+        "relation_search",
         "read_file", "write_file",
         "list_cache_status",
         "match_tree_nodes",
+        "sync_atomic_capabilities",
         "skill",
     ]
 
@@ -70,7 +72,7 @@ def get_librarian_config(enable_db_commit: bool = ENABLE_DATABASE_COMMIT) -> Run
 
 
 def _register_internal_tools():
-    """注册内部工具(缓存管理 + 树匹配),只需调用一次"""
+    """注册内部工具"""
     try:
         sys.path.insert(0, str(Path(__file__).parent.parent))
         from internal_tools.cache_manager import (
@@ -80,6 +82,7 @@ def _register_internal_tools():
             list_cache_status,
         )
         from internal_tools.tree_matcher import match_tree_nodes
+        from internal_tools.capability_extractor import sync_atomic_capabilities
         from agent.tools import get_tool_registry
         registry = get_tool_registry()
         registry.register(cache_research_data)
@@ -87,6 +90,7 @@ def _register_internal_tools():
         registry.register(commit_to_database)
         registry.register(list_cache_status)
         registry.register(match_tree_nodes)
+        registry.register(sync_atomic_capabilities)
         logger.info("✓ 已注册 Librarian 内部工具")
     except Exception as e:
         logger.error(f"✗ 注册内部工具失败: {e}")

+ 13 - 3
knowhub/agents/librarian_agent.prompt

@@ -10,7 +10,12 @@ $system$
 你是一个知识库管理员。你有两项核心职责:
 
 1. **检索整合**:面对查询时,跨多张表检索,顺着关联链拼出完整上下文,给出精准回答
-2. **入库编排**:收到新数据时,与已有知识比对去重,识别关联关系,整理为结构化条目归入正确位置
+2. **入库编排**:收到新数据时,与已有知识比对去重,识别关联关系,整理为结构化条目归入正确位置。
+   **【红线警告:绝对禁止的存储行为】**
+   - **严禁**创建 `drafts/`、`knowledge/` 或 `tools/` 等任何散装文件夹!
+   - **严禁**使用 `write_file` 去保存单独的文件,也**严禁**用它尝试拼接修改庞大的 JSON 字典!
+   - 你所有的草稿与起草必须,并且**只能**使用专用工具 `cache_research_data` 逐条将组装好的实体对象安全存入草稿箱。
+   - 当所有实体对象准备完毕要上传数据库时,直接调用 `commit_to_database()`。
 
 你只做整理和检索,不自行创造知识内容。
 
@@ -29,8 +34,8 @@ $system$
 |------|---------|---------|
 | Requirement | `REQ_XXX` | description, status, match_result |
 | Capability | `CAP-XXX` | name, criterion, description |
-| Tool | `tools/{category}/{name}` | name, introduction, tutorial, status |
-| Knowledge | `knowledge-{date}-{hash}` | task, content, types, score |
+| Tool | 自行设定 | name, introduction, tutorial, status |
+| Knowledge | 自行设定 | task, content, types, score |
 
 实体间通过关联表连接,API 返回时自动聚合为 `{entity}_ids` 字段(如 `capability_ids`, `tool_ids`, `knowledge_ids`)。
 
@@ -44,5 +49,10 @@ Knowledge 按 types 分类:
 
 所有关联通过关联表存储,数据库保证引用完整性。查到任意一个实体都可以顺藤摸瓜找到关联的其他实体。
 
+## 工具使用规范与检索策略
+
+1. **精准查询优于全文搜索**:当你需要查询跨表关联关系时(例如:“寻找某个特定 Capability ID 被哪些 Requirement 关联了” 或者 “查某个 Tool 有没有被某个 Capability 包含”),**强烈推荐且务必优先使用 `relation_search` 工具**直接查询关系表(例如 `requirement_capability`, `capability_tool`, `tool_knowledge` 等)。
+2. 使用 `relation_search` 时,在 `filters` 中传入已知的 `_id` 即可迅速获得所有匹配的关联链路,进而拿到目标实体的 ID 再去定向获取详情,这比漫无目的地做大文本向量搜索 (`search` 结尾工具) 效率极速且精准得多!
+
 $user$
 

+ 163 - 0
knowhub/agents/research.py

@@ -0,0 +1,163 @@
+import asyncio
+import json
+import logging
+import sys
+from pathlib import Path
+from typing import Dict, Any, Optional
+
+# 确保项目路径可用
+sys.path.insert(0, str(Path(__file__).parent.parent.parent))
+
+from agent.core.runner import AgentRunner
+from agent.trace import FileSystemTraceStore, Trace, Message
+from agent.llm import create_qwen_llm_call
+from agent.llm.prompts import SimplePrompt
+
+logger = logging.getLogger(__name__)
+
+# 文件保存 trace 映射关系,持久化续跑
+TRACE_MAP_FILE = Path(".cache/research_trace_map.json")
+
+
+def _load_trace_map() -> Dict[str, str]:
+    if TRACE_MAP_FILE.exists():
+        return json.loads(TRACE_MAP_FILE.read_text(encoding="utf-8"))
+    return {}
+
+
+def _save_trace_map(mapping: Dict[str, str]):
+    TRACE_MAP_FILE.parent.mkdir(parents=True, exist_ok=True)
+    TRACE_MAP_FILE.write_text(json.dumps(mapping, indent=2, ensure_ascii=False), encoding="utf-8")
+
+
+def get_research_trace_id(caller_trace_id: str) -> Optional[str]:
+    """根据调用方 trace_id 查找对应的 Research trace_id"""
+    if not caller_trace_id:
+        return None
+    mapping = _load_trace_map()
+    return mapping.get(caller_trace_id)
+
+
+def set_research_trace_id(caller_trace_id: str, research_trace_id: str):
+    """记录映射"""
+    if not caller_trace_id:
+        return
+    mapping = _load_trace_map()
+    mapping[caller_trace_id] = research_trace_id
+    _save_trace_map(mapping)
+
+
+# ===== 单例 Runner =====
+
+_runner: Optional[AgentRunner] = None
+_prompt_messages = None
+_initialized = False
+
+
+def _ensure_initialized():
+    """延迟初始化 Runner 和 Prompt(首次调用时执行)"""
+    global _runner, _prompt_messages, _initialized
+    if _initialized:
+        return
+    _initialized = True
+
+    # 初始化 Runner。工具会自动从 __file__.parent.parent.parent / agent / tools 加载吗?
+    # 根据用户环境,内置通用工具大概是在 agent/tools,或者自动全局识别
+    # 在这里,我们将 skills_dir 也设为此处寻找特定技能,如果需要的话可以扩展。
+    skills_dir = Path(__file__).parent / "skills"
+    
+    _runner = AgentRunner(
+        trace_store=FileSystemTraceStore(base_path=".trace"),
+        llm_call=create_qwen_llm_call(model="qwen3.5-plus"),  # prompt使用sonnet,但如果想和系统对齐可保留qwen,按照之前的设定
+        skills_dir=str(skills_dir) if skills_dir.exists() else None,
+        debug=True,
+        logger_name="agents.research",
+    )
+
+    prompt_path = Path(__file__).parent / "research_agent.prompt"
+    if prompt_path.exists():
+        prompt = SimplePrompt(prompt_path)
+        _prompt_messages = prompt.build_messages()
+        
+        # 尝试通过 prompt meta 获取模型设置
+        if getattr(prompt, "meta", None) and prompt.meta.get("model"):
+            model_name = prompt.meta["model"]
+            _runner.llm_call = create_qwen_llm_call(model=model_name)
+    else:
+        _prompt_messages = []
+        logger.warning(f"Research prompt 文件不存在: {prompt_path}")
+
+    logger.info("✓ Research Agent 已初始化")
+
+
+# ===== 核心方法 =====
+
+async def research(query: str, caller_trace_id: str = "") -> Dict[str, Any]:
+    """
+    同步执行深度调研。运行 Research Agent,返回调查结果。
+
+    Args:
+        query: 用户设定的研究主题或查询
+        caller_trace_id: 调用方 trace_id,用于续跑
+
+    Returns:
+        {"response": str, "source_ids": [str], "sources": [dict]}
+    """
+    _ensure_initialized()
+
+    # 初始化云端无头浏览器(因为是部署在线上,必须防卡顿并自动分配独立环境)
+    try:
+        from agent.tools.builtin.browser import init_browser_session
+        await init_browser_session(browser_type="cloud")
+    except Exception as e:
+        logger.warning(f"Failed to init cloud browser: {e}")
+
+    # 查找或创建 trace
+    research_trace_id = get_research_trace_id(caller_trace_id)
+
+    from agent.core.runner import RunConfig
+    config = RunConfig(
+        model="qwen3.5-plus",
+        temperature=0.3,
+        max_iterations=200,
+        tool_groups=["core", "content", "browser"],
+        skills=["planning", "research", "browser"],
+    )
+    config.trace_id = research_trace_id  # None = 新建, 有值 = 续跑
+
+    # 构建消息
+    content = f"[RESEARCH TASK] {query}"
+    if research_trace_id is None:
+        messages = _prompt_messages + [{"role": "user", "content": content}]
+    else:
+        messages = [{"role": "user", "content": content}]
+
+    # 运行 Agent
+    response_text = ""
+    actual_trace_id = None
+
+    async for item in _runner.run(
+        messages=messages, 
+        config=config,
+    ):
+        if isinstance(item, Trace):
+            actual_trace_id = item.trace_id
+        elif isinstance(item, Message):
+            if item.role == "assistant":
+                msg_content = item.content
+                if isinstance(msg_content, dict):
+                    text = msg_content.get("text", "")
+                    if text:
+                        response_text = text
+                elif isinstance(msg_content, str) and msg_content:
+                    response_text = msg_content
+
+    # 记录 trace 映射
+    if actual_trace_id and caller_trace_id:
+        set_research_trace_id(caller_trace_id, actual_trace_id)
+
+    return {
+        "response": response_text,
+        "source_ids": [],
+        "sources": [], 
+    }

+ 141 - 0
knowhub/agents/research_agent.prompt

@@ -0,0 +1,141 @@
+---
+model: sonnet-4.6
+temperature: 0.3
+---
+
+$system$
+## 角色
+你是一个调研专家,负责根据指令搜索并如实记录调研发现。
+
+**你的边界**:只负责搜索和记录,不负责制定策略。发现的工序流程、方案、案例都要如实记录,但不要自己设计工序。
+**调研结果的形式可以多样**:单个工具、工序流程、真实案例都可以。但无论哪种形式,**必须落到具体工具**——每个步骤用什么工具来执行,需要明确。
+
+## 可用工具
+### 内容搜索工具
+- `search_posts(keyword, channel, cursor="0", max_count=20)`: 搜索帖子
+  - **channel 参数**:xhs(小红书), gzh(公众号), zhihu(知乎), bili(B站), douyin(抖音), toutiao(头条), weibo(微博)
+  - 示例:`search_posts("flux 2.0", channel="xhs", max_count=20)`
+- `select_post(index)`: 查看帖子详情(需先调用 search_posts)
+  - 示例:`select_post(index=1)`
+- `youtube_search(keyword)`: 搜索 YouTube 视频
+  - 示例:`youtube_search("flux 2.0 tutorial")`
+- `youtube_detail(content_id, include_captions=True)`: 获取 YouTube 视频详情和字幕
+  - 示例:`youtube_detail("视频ID", include_captions=True)`
+- `x_search(keyword)`: 搜索 X (Twitter) 内容
+  - 示例:`x_search("flux 2.0 max")`
+- `knowledge_search`: 搜索知识库
+- `browser-use`: 浏览器搜索(search_posts 不好用时使用)
+
+## 执行流程
+
+### 第一步:理解调研目标
+
+### 第二步:执行搜索
+
+**调研渠道策略**:
+1. **官网** - 获取官方介绍、技术规格、API 文档
+2. **内容平台** - 获取真实用例和使用经验
+   - 公众号:`search_posts(keyword="...", channel="gzh")`
+   - X:`x_search(keyword="...")`
+   - 知乎:`search_posts(keyword="...", channel="zhihu")`
+   - 小红书:`search_posts(keyword="...", channel="xhs")`
+3. **视频平台** - 获取用法教程和实操演示
+   - YouTube:`youtube_search(keyword="...")` → `youtube_detail(content_id="...")`
+   - B站:`search_posts(keyword="...", channel="bili")`
+
+**重要**:
+- **必须优先使用专用搜索工具**(search_posts、youtube_search、x_search)
+- **禁止使用 browser-use 搜索公众号、知乎、小红书、B站等已有专用工具的平台**
+- browser-use 仅用于搜索没有专用工具的平台或官网
+
+**Query 策略**(从以下角度搜索):
+1. **找官网** - "[工具名] 官网"、"[工具名] official website"
+2. **找用例** - "[工具名] 用例"、"[工具名] 使用案例"、"[工具名] tutorial"
+3. **找评测** - "[工具名] 评测"、"[工具名] review"、"[工具名] 测试"
+4. **找竞品讨论** - "[工具名] vs [竞品]"、"[工具名] 和 [竞品] 谁更强"
+5. **找排行** - "2026 年最强 [领域] 工具"、"[领域] 工具排行"
+
+**搜索优先级**:
+1. **知识库优先**:用 `knowledge_search` 按需求关键词搜索,查看已有策略经验、工具评估、工作流总结
+2. **线上调研**:知识库结果不充分时,进行线上搜索
+
+### 第三步:反思与调整
+
+在搜索过程中,你需要主动进行反思和调整:
+每完成 1-2 轮搜索后,在继续前先评估:
+- 当前方向是否有效?是否偏离需求?
+- 结果质量如何?下一轮应该调整 query 还是换角度?
+- 可选调用 `reflect` 工具辅助判断
+根据反思结果调整后续搜索策略,直到你认为信息充分或遇到明确的阻塞。
+
+### 第四步:结束与输出
+
+**何时结束**:
+- 信息已充分覆盖调研目标
+- 搜索结果开始重复,无新信息
+- 方向不明确,需要用户指导
+
+**如何结束**:
+1. **必须**使用 `write_file` 将调研结果按照下面的 JSON 格式写入到examples/tool_research/outputs/nanobanana_2
+2. 输出文件路径由调用方在 task 中指定,如未指定则输出为纯文本消息
+
+
+## 输出格式
+
+**Schema**:
+
+```jsonschema
+{
+  "搜索主题": "string — 本次搜索主题",
+  "搜索轨迹": "string — 搜索过程:尝试了哪些 query、如何调整方向等",
+  "调研发现": [
+    {
+      "名称": "string — 发现项名称(工具名/方案名/案例名)",
+      "类型": "tool | workflow | case — 单个工具 / 工序流程或整体方案 / 真实案例",
+      "来源": "string — 来源(knowledge_id / URL / 帖子链接)",
+      "核心描述": "string — 核心思路或能力描述",
+      "工序步骤": [
+        {
+          "步骤名称": "string — 步骤名称(如:生成线稿、角色一致性处理)",
+          "使用工具": "string — 该步骤使用的具体工具名称",
+          "说明": "string — 该步骤的操作说明"
+        }
+      ],
+      "工具信息": {
+        "工具名称": "string — 工具名称(类型为 tool 时必填)",
+        "仓库或链接": "string — 仓库或官网链接",
+        "输入格式": "string — 输入格式",
+        "输出格式": "string — 输出格式",
+        "最近更新": "string — 最近更新时间",
+        "能力": ["string — 工具能力"],
+        "限制": ["string — 工具限制"]
+      },
+      "外部评价": {
+        "专家或KOL推荐": ["string — 来源 + 评价摘要"],
+        "社区反馈": ["string — 来源 + 反馈摘要"],
+        "热度指标": "string — 提及次数、榜单排名、帖子热度等"
+      },
+      "使用案例": [
+        {
+          "描述": "string — 用例描述",
+          "来源链接": "string — 来源链接",
+          "相似度": "high | medium | low"
+        }
+      ],
+      "优点": ["string"],
+      "缺点": ["string"],
+      "风险": ["string"]
+    }
+  ]
+}
+```
+
+**字段说明**:
+- `工序步骤`:类型为 `workflow` 或 `case` 时填写,逐步骤记录用了什么工具
+- `工具信息`:类型为 `tool` 时必填;`workflow`/`case` 类型中,如果整体方案依赖某个核心工具(如 ComfyUI),也可填写
+- `外部评价`:尽量填写,是主 agent 选择工具时的重要参考;找不到可留空
+
+
+## 注意事项
+- `search_posts` 不好用时改用 `browser-use`
+- 如果调研过程中遇到不确定的问题,要停下来询问用户

+ 444 - 0
knowhub/docs/knowledge-management.md

@@ -0,0 +1,444 @@
+# KnowHub API 参考
+
+KnowHub 是一个 FastAPI 服务,提供知识图谱的存储、检索和管理能力。
+基础地址由环境变量 `KNOWHUB_API` 控制,默认 `http://localhost:8000`。
+
+FastAPI 自动生成的交互式文档:`{KNOWHUB_API}/docs`
+
+---
+
+## 数据模型
+
+### Knowledge(知识)
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `id` | string | 格式 `knowledge-{date}-{hash}`,自动生成 |
+| `task` | string | 任务描述(在什么情景下要完成什么目标) |
+| `content` | string | 核心内容 |
+| `types` | string[] | 类型标签:`tool` / `strategy` / `case` / `experience` |
+| `tags` | object | 业务标签,如 `{"project": "foo", "domain": "ai"}` |
+| `scopes` | string[] | 可见范围,默认 `["org:cybertogether"]` |
+| `owner` | string | 所有者,默认 `agent:{agent_id}` |
+| `source` | object | `{name, category, urls, agent_id, submitted_by, timestamp}` |
+| `eval` | object | `{score(1-5), helpful, harmful, confidence}` |
+| `capability_ids` | string[] | 关联的能力 ID |
+| `tool_ids` | string[] | 关联的工具 ID |
+| `resource_ids` | string[] | 关联的资源 ID |
+| `status` | string | `pending` / `processing` / `dedup_passed` / `analyzing` / `approved` / `checked` / `rejected` |
+
+### Tool(工具)
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `id` | string | 格式 `tools/{category}/{name}`,调用方指定 |
+| `name` | string | 工具名称 |
+| `version` | string | 版本号(可选) |
+| `introduction` | string | 简介 |
+| `tutorial` | string | 使用教程 |
+| `input` | object/string | 输入格式描述 |
+| `output` | object/string | 输出格式描述 |
+| `status` | string | 默认 `未接入` |
+| `capability_ids` | string[] | 关联的能力 ID |
+| `knowledge_ids` | string[] | 关联的知识 ID |
+| `provider_ids` | string[] | 关联的提供方 ID |
+
+### Capability(原子能力)
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `id` | string | 格式 `CAP-XXX`,调用方指定 |
+| `name` | string | 能力名称 |
+| `criterion` | string | 判断标准(何时认为该能力被满足) |
+| `description` | string | 详细描述 |
+| `requirement_ids` | string[] | 关联的需求 ID |
+| `tool_ids` | string[] | 实现该能力的工具 ID |
+| `implements` | object | `{tool_id: description}` 工具实现描述 |
+| `knowledge_ids` | string[] | 关联的知识 ID |
+
+### Requirement(需求)
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `id` | string | 格式 `REQ_XXX`,调用方指定 |
+| `description` | string | 需求描述 |
+| `capability_ids` | string[] | 满足该需求的能力 ID |
+| `knowledge_ids` | string[] | 关联的知识 ID |
+| `source_nodes` | object[] | 分类树节点 `[{id, score}]` |
+| `status` | string | 默认 `未满足` |
+| `match_result` | string | 匹配结果说明 |
+
+### Resource(资源)
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `id` | string | 路径格式,如 `docs/foo/bar` |
+| `title` | string | 标题 |
+| `body` | string | 正文内容 |
+| `secure_body` | string | 加密内容(AES256-GCM) |
+| `content_type` | string | 默认 `text` |
+| `metadata` | object | 自定义元数据 |
+
+---
+
+## Knowledge API
+
+### 智能查询(Librarian Agent)
+
+#### `POST /api/knowledge/ask`
+
+同步阻塞。触发 Librarian Agent 跨表检索 + LLM 整合,返回带引用的回答。
+同一 `trace_id` 的多次调用复用同一个 Librarian trace,积累上下文。
+
+```json
+// 请求
+{
+  "query": "有没有工具能做角色一致性生成?",
+  "trace_id": "trace-abc123"
+}
+
+// 响应
+{
+  "response": "根据知识库,ControlNet 可以...",
+  "source_ids": ["knowledge-20260101-abcd"],
+  "sources": []
+}
+```
+
+#### `POST /api/knowledge/upload` → 202
+
+异步。校验后立即返回,后台 Librarian Agent 处理去重、关联、写草稿池。
+
+```json
+// 请求
+{
+  "data": {
+    "knowledge": [...],
+    "tools": [...],
+    "resources": [...]
+  },
+  "trace_id": "trace-abc123",
+  "finalize": false
+}
+
+// 响应 202
+{
+  "message": "已接收 知识: 2 个,Librarian Agent 后台处理中",
+  "buffer_file": ".cache/.knowledge/buffer/upload_20260101_120000.json"
+}
+```
+
+#### `GET /api/knowledge/upload/pending`
+
+列出所有未处理或失败的 upload 任务。
+
+#### `POST /api/knowledge/upload/retry`
+
+重跑所有 `status=failed` 的 upload 任务。
+
+---
+
+### 向量检索
+
+#### `GET /api/knowledge/search`
+
+向量召回 + LLM 精排。
+
+| 参数 | 类型 | 默认 | 说明 |
+|------|------|------|------|
+| `q` | string | 必填 | 查询文本 |
+| `top_k` | int | 5 | 返回条数(1-20) |
+| `min_score` | int | 3 | 最低评分过滤(1-5) |
+| `types` | string | - | 逗号分隔,如 `tool,case` |
+| `owner` | string | - | 逗号分隔,过滤 owner |
+
+```json
+// 响应
+{
+  "results": [{"id": "...", "task": "...", "content": "...", "score": 0.92, ...}],
+  "count": 3
+}
+```
+
+---
+
+### CRUD
+
+#### `POST /api/knowledge` → 201
+
+创建知识,进入 `pending` 状态,自动触发去重流水线。
+
+```json
+{
+  "task": "使用 ControlNet 进行姿态控制",
+  "content": "...",
+  "types": ["tool"],
+  "tags": {"project": "image_gen"},
+  "source": {"agent_id": "research_agent", "category": "research"},
+  "eval": {"score": 4}
+}
+// 响应: {"status": "pending", "knowledge_id": "knowledge-20260101-abcd"}
+```
+
+#### `GET /api/knowledge`
+
+列出知识,支持分页和过滤。
+
+| 参数 | 说明 |
+|------|------|
+| `page` / `page_size` | 分页,默认 1/20 |
+| `types` | 逗号分隔类型过滤 |
+| `scopes` | 逗号分隔范围过滤 |
+| `owner` | owner 过滤 |
+| `tags` | JSON 字符串,如 `{"project":"foo"}` |
+| `status` | 状态过滤 |
+
+#### `GET /api/knowledge/{knowledge_id}`
+
+获取单条知识详情。
+
+#### `GET /api/knowledge/status/{knowledge_id}`
+
+查询知识处理状态(`pending` / `approved` 等)。
+
+#### `PUT /api/knowledge/{knowledge_id}`
+
+更新知识评估,支持知识进化(LLM 重写 content)。
+
+```json
+{
+  "update_score": 5,
+  "add_helpful_case": {"context": "...", "result": "success"},
+  "add_harmful_case": null,
+  "evolve_feedback": "内容需要补充 batch 处理的情况"
+}
+```
+
+#### `PATCH /api/knowledge/{knowledge_id}`
+
+直接编辑字段(task、content、types、tags、scopes、owner、capability_ids、tool_ids)。
+
+#### `DELETE /api/knowledge/{knowledge_id}`
+
+删除单条知识(级联清理关联表)。
+
+#### `POST /api/knowledge/batch_delete`
+
+批量删除。请求体为 `["knowledge-xxx", "knowledge-yyy"]`。
+
+---
+
+### 审核与验证
+
+#### `POST /api/knowledge/{knowledge_id}/verify`
+
+```json
+// approve:approved ↔ checked 切换;reject:→ rejected
+{"action": "approve", "verified_by": "howard"}
+```
+
+#### `POST /api/knowledge/batch_verify`
+
+```json
+{"knowledge_ids": ["knowledge-xxx"], "action": "approve", "verified_by": "howard"}
+```
+
+---
+
+### 运维工具
+
+#### `GET /api/knowledge/pending`
+
+查询处理队列(`pending` / `processing` / `dedup_passed` / `analyzing`)。
+
+#### `POST /api/knowledge/process`
+
+手动触发去重处理。`force=true` 时先回滚所有超时锁。
+
+#### `GET /api/knowledge/meta/tags`
+
+获取所有已使用的 tag keys。
+
+#### `POST /api/knowledge/slim`
+
+知识库瘦身:LLM 分析全库,合并语义相似条目。
+可选参数 `model`,默认 `google/gemini-2.5-flash-lite`。
+
+#### `POST /api/knowledge/batch_update`
+
+批量反馈知识有效性。
+
+#### `POST /api/extract`
+
+从消息历史中 LLM 提取知识并入库。
+
+```json
+{
+  "messages": [{"role": "user", "content": "..."}, {"role": "assistant", "content": "..."}],
+  "agent_id": "research_agent",
+  "submitted_by": "howard@example.com",
+  "session_key": ""
+}
+```
+
+---
+
+## Tool API
+
+#### `POST /api/tool` → 201
+
+创建或更新工具(id 相同则覆盖,同时全量替换关联表)。
+
+```json
+{
+  "id": "tools/image/controlnet",
+  "name": "ControlNet",
+  "introduction": "基于条件控制的图像生成工具",
+  "tutorial": "...",
+  "status": "已接入",
+  "capability_ids": ["CAP-001"],
+  "knowledge_ids": ["knowledge-20260101-abcd"]
+}
+```
+
+#### `GET /api/tool`
+
+列出工具。参数:`status`、`limit`(默认100)、`offset`。
+
+#### `GET /api/tool/search?q=...&top_k=5`
+
+向量检索工具。
+
+#### `GET /api/tool/{tool_id}`
+
+获取单个工具详情(含关联的 capability_ids、knowledge_ids)。
+
+#### `PATCH /api/tool/{tool_id}`
+
+部分更新工具字段。
+
+#### `DELETE /api/tool/{tool_id}`
+
+删除工具(级联清理关联表)。
+
+---
+
+## Capability API
+
+#### `POST /api/capability` → 201
+
+创建或更新原子能力。
+
+```json
+{
+  "id": "CAP-001",
+  "name": "人物姿态控制生成",
+  "criterion": "能根据骨骼图生成指定姿态的人物图像",
+  "description": "...",
+  "tool_ids": ["tools/image/controlnet"],
+  "implements": {"tools/image/controlnet": "使用 openpose 模型控制姿态"}
+}
+```
+
+#### `GET /api/capability`
+
+列出所有能力。参数:`limit`、`offset`。
+
+#### `GET /api/capability/search?q=...&top_k=5`
+
+向量检索能力。
+
+#### `GET /api/capability/{cap_id}`
+
+获取单个能力详情。
+
+#### `PATCH /api/capability/{cap_id}`
+
+部分更新能力字段。
+
+#### `DELETE /api/capability/{cap_id}`
+
+删除能力(级联清理关联表)。
+
+---
+
+## Requirement API
+
+#### `POST /api/requirement` → 201
+
+创建或更新需求。
+
+```json
+{
+  "id": "REQ_001",
+  "description": "需要能生成角色一致的多视角图像",
+  "capability_ids": ["CAP-001"],
+  "source_nodes": [{"id": "image_generation", "score": 0.9}],
+  "status": "已满足"
+}
+```
+
+#### `GET /api/requirement`
+
+列出需求。参数:`status`、`limit`、`offset`。
+
+#### `GET /api/requirement/search?q=...&top_k=5`
+
+向量检索需求。
+
+#### `GET /api/requirement/{req_id}`
+
+获取单个需求详情。
+
+#### `PATCH /api/requirement/{req_id}`
+
+部分更新需求字段。
+
+#### `DELETE /api/requirement/{req_id}`
+
+删除需求(级联清理关联表)。
+
+---
+
+## Resource API
+
+Resource 存储原始资料文档,通过 `knowledge_resource` 关联表与 Knowledge 关联。
+
+#### `POST /api/resource` → 201
+
+创建或更新资源。
+
+#### `GET /api/resource`
+
+列出资源(树形结构)。
+
+#### `GET /api/resource/{resource_id}`
+
+获取单个资源(含 toc、children、prev/next 导航)。
+
+#### `PATCH /api/resource/{resource_id}`
+
+部分更新资源字段。
+
+#### `DELETE /api/resource/{resource_id}`
+
+删除资源。
+
+---
+
+## 关联表说明
+
+所有实体间的关系通过 junction table 维护,写入时**全量替换**(DELETE + INSERT):
+
+| 关联表 | 连接 |
+|--------|------|
+| `capability_tool` | Capability ↔ Tool |
+| `capability_knowledge` | Capability ↔ Knowledge |
+| `requirement_capability` | Requirement ↔ Capability |
+| `requirement_knowledge` | Requirement ↔ Knowledge |
+| `tool_knowledge` | Tool ↔ Knowledge |
+| `tool_provider` | Tool ↔ Provider |
+| `knowledge_resource` | Knowledge ↔ Resource |
+| `knowledge_relation` | Knowledge → Knowledge(supplement/duplicate) |
+
+查询任意实体时,关联 ID 通过子查询自动聚合返回(`capability_ids`、`tool_ids` 等字段)。

+ 0 - 1
knowhub/internal_tools/__init__.py

@@ -10,7 +10,6 @@ KnowHub 内部工具
 # 重新导出原有工具供 Knowledge Manager 使用
 from agent.tools.builtin.knowledge import (
     knowledge_search,
-    knowledge_save,
     knowledge_list,
     knowledge_update,
     knowledge_batch_update,

+ 137 - 21
knowhub/internal_tools/cache_manager.py

@@ -29,13 +29,99 @@ def _ensure_dirs():
 
 @tool()
 async def organize_cached_data(merge: bool = True) -> ToolResult:
-    """为了兼容旧指令保留。现在实际上不再需要独立调用。"""
-    return ToolResult(title="ℹ️ 提示", output="请直接使用 read_file 和 write_file 编辑 pre_upload_list.json。")
+    """旧指令保留口。现在已废弃,无需调用。"""
+    return ToolResult(title="ℹ️ 提示", output="请直接使用 cache_research_data。")
 
 @tool()
-async def cache_research_data(data: str | Dict[str, Any], source: str = "unknown") -> ToolResult:
-    """为了兼容旧指令保留。现在实际上不再需要独立调用。"""
-    return ToolResult(title="ℹ️ 提示", output="请直接使用 read_file 和 write_file 编辑 pre_upload_list.json。")
+async def cache_research_data(entity_type: str, data: Dict[str, Any]) -> ToolResult:
+    """
+    【极端重要】由于通过文本级读写组装极庞大的嵌套 JSON 文件很容易导致大模型截断、忘闭合引发毁灭性覆盖或奔溃,
+    任何要在 JSON 缓存中「安全追加」一条信息的操作请仅限调用此工具!不要使用 write_file!
+    
+    Args:
+        entity_type: 所属的数据类别,仅能填入 "requirements", "capabilities", "tools" 或是 "knowledge"。
+        data: 具体那单独一条你想草拟或记录的数据实体结构(请直接传递 Json Object, 我们会在底层完成拼接保存)。
+        
+    Returns:
+        缓存操作的执行结果
+    """
+    _ensure_dirs()
+    if entity_type not in ("requirements", "capabilities", "tools", "knowledge"):
+        return ToolResult(
+            title="❌ 参数异常", 
+            output=f"传入的 entity_type = {entity_type} 不合法。必须是 requirements, capabilities, tools, knowledge。请重新确认参数类型!",
+            error="Invalid entity_type"
+        )
+        
+    # 0. 数据格式硬校验 (Schema Validation)
+    if entity_type == "knowledge":
+        if "task" not in data:
+            return ToolResult(
+                title="❌ 校验失败",
+                output="【严重错误】写入 knowledge 必须包含 'task' 字段!不能用 'title' 代替。请修正 JSON 结构后重新调用本工具。",
+                error="Missing 'task' field"
+            )
+        if "tags" in data and not isinstance(data["tags"], dict):
+            return ToolResult(
+                title="❌ 校验失败",
+                output="【严重错误】knowledge 的 'tags' 字段强制要求为字典格式 (如 `{\"标签名\": \"\"}`),绝对不能是数组(List)。请修正后重新调用本工具。",
+                error="Invalid 'tags' format"
+            )
+    elif entity_type == "capabilities":
+        if "name" not in data or "description" not in data:
+            return ToolResult(
+                title="❌ 校验失败",
+                output="写入 capabilities 必须包含 'name' 和 'description' 字段。请修正后再调用。",
+                error="Missing capability fields"
+            )
+    elif entity_type == "requirements":
+        if "description" not in data:
+            return ToolResult(
+                title="❌ 校验失败",
+                output="写入 requirements 必须包含 'description' 字段。请修正后再调用。",
+                error="Missing requirement fields"
+            )
+    
+    try:
+        # 1. 内存层安全读取
+        if PRE_UPLOAD_FILE.exists():
+            with open(PRE_UPLOAD_FILE, "r", encoding="utf-8") as f:
+                try:
+                    cache_dict = json.load(f)
+                except json.JSONDecodeError:
+                    # 如果原文件损坏,进行挽救性备份并重新初始化
+                    backup_file = CACHE_DIR / f"pre_upload_list_backup_{int(datetime.now().timestamp())}.json"
+                    os.rename(PRE_UPLOAD_FILE, backup_file)
+                    cache_dict = {"requirements": [], "capabilities": [], "tools": [], "knowledge": []}
+        else:
+            cache_dict = {"requirements": [], "capabilities": [], "tools": [], "knowledge": []}
+            
+        # 2. 追加
+        if entity_type not in cache_dict:
+            cache_dict[entity_type] = []
+        
+        # 去重更新与追加 (以 ID 为准)
+        data_id = data.get("id")
+        replaced = False
+        if data_id:
+            for idx, existing in enumerate(cache_dict[entity_type]):
+                if existing.get("id") == data_id:
+                    cache_dict[entity_type][idx] = data
+                    replaced = True
+                    break
+        if not replaced:
+            cache_dict[entity_type].append(data)
+            
+        # 3. 稳妥写盘
+        with open(PRE_UPLOAD_FILE, "w", encoding="utf-8") as f:
+            json.dump(cache_dict, f, ensure_ascii=False, indent=2)
+            
+        action = "更新" if replaced else "新建"
+        return ToolResult(title="✅ 存入草稿箱成功", output=f"成功将一条 {entity_type} {action}写入到了缓存文件!当前此类别规模: {len(cache_dict[entity_type])} 个。")
+        
+    except Exception as e:
+        logger.error(f"Cache save failed: {e}")
+        return ToolResult(title="❌ 系统异常", output=f"执行时发生底层错误: {str(e)}", error=str(e))
 
 @tool(
     description=(
@@ -103,12 +189,19 @@ async def commit_to_database() -> ToolResult:
         from agent.tools.builtin.knowledge import knowledge_save
         for k in knowledges:
             try:
+                raw_tags = k.get("tags", {})
+                if isinstance(raw_tags, list):
+                    raw_tags = {str(item): "" for item in raw_tags}
+                
                 await knowledge_save(
-                    task=k.get("task", "补充知识"),
+                    task=k.get("task", k.get("title", "补充知识")),
                     content=k.get("content", ""),
                     types=k.get("types", []),
                     score=k.get("score", 3),
-                    source_category=k.get("source", {}).get("category", "exp")
+                    source_category=k.get("source", {}).get("category", "exp"),
+                    capability_ids=k.get("capability_ids", []),
+                    tool_ids=k.get("tool_ids", []),
+                    tags=raw_tags
                 )
                 saved_knows += 1
             except Exception as e:
@@ -142,11 +235,13 @@ async def commit_to_database() -> ToolResult:
 
 
 @tool(
-    description="查看当前预整理草稿的统计信息。"
+    description="查看当前预整理草稿的统计明细,或者通过传入 entity_id 获取某条特定草稿的完整 JSON 详情。"
 )
-async def list_cache_status() -> ToolResult:
+async def list_cache_status(entity_id: Optional[str] = None) -> ToolResult:
     """
-    查看草稿状态(pre_upload_list.json)
+    查看草稿状态(pre_upload_list.json)或特定记录的详情。
+    Args:
+        entity_id: (可选) 如果传入实体 ID(如 'REQ_001'),将返回该条目的完整草稿详情。不传则仅列出概要。
     """
     _ensure_dirs()
     if not PRE_UPLOAD_FILE.exists():
@@ -155,17 +250,38 @@ async def list_cache_status() -> ToolResult:
     try:
         with open(PRE_UPLOAD_FILE, "r", encoding="utf-8") as f:
             data = json.load(f)
+            
+        if entity_id:
+            # 查找具体详情
+            for group in ("requirements", "capabilities", "tools", "knowledge"):
+                for item in data.get(group, []):
+                    if item.get("id") == entity_id:
+                        return ToolResult(
+                            title=f"📄 草稿详情: {entity_id}", 
+                            output=json.dumps(item, ensure_ascii=False, indent=2)
+                        )
+            return ToolResult(title="⚠️ 找不到对象", output=f"在草稿箱中未找到 ID 为 {entity_id} 的实体。")
         
-        reqs = len(data.get("requirements", []))
-        caps = len(data.get("capabilities", []))
-        tools = len(data.get("tools", []))
-        knows = len(data.get("knowledge", []))
-
-        output = (f"当前草稿数据({PRE_UPLOAD_FILE.name}):\n"
-                  f" - 需求: {reqs}\n"
-                  f" - 能力: {caps}\n"
-                  f" - 工具: {tools}\n"
-                  f" - 知识: {knows}\n")
-        return ToolResult(title="📁 草稿状态", output=output)
+        # 仅返回统计明细
+        reqs = data.get("requirements", [])
+        caps = data.get("capabilities", [])
+        tools = data.get("tools", [])
+        knows = data.get("knowledge", [])
+
+        output_lines = [f"当前草稿数据({PRE_UPLOAD_FILE.name}):(通过传参 entity_id 查看具体完整JSON)"]
+        
+        output_lines.append(f"\n- 需求 ({len(reqs)}条):")
+        for r in reqs: output_lines.append(f"  • [{r.get('id')}] {r.get('description', '')[:50]}...")
+            
+        output_lines.append(f"\n- 能力 ({len(caps)}条):")
+        for c in caps: output_lines.append(f"  • [{c.get('id')}] {c.get('name', '')}")
+            
+        output_lines.append(f"\n- 工具 ({len(tools)}条):")
+        for t in tools: output_lines.append(f"  • [{t.get('id')}] {t.get('name', '')}")
+            
+        output_lines.append(f"\n- 知识 ({len(knows)}条):")
+        for k in knows: output_lines.append(f"  • [{k.get('id')}] {k.get('title', '')[:50]}...")
+
+        return ToolResult(title="📁 草稿状态明细", output="\n".join(output_lines))
     except Exception as e:
         return ToolResult(title="❌ 读取状态失败", output=str(e), error=str(e))

+ 224 - 0
knowhub/internal_tools/capability_extractor.py

@@ -0,0 +1,224 @@
+import json
+import logging
+import uuid
+import os
+import asyncio
+from typing import List
+from agent.tools import tool, ToolResult
+from agent.llm.openrouter import openrouter_llm_call
+
+# 导入底层 Postgres 资产表依赖
+from tool_agent.tool.tool_store import PostgreSQLToolStore
+from tool_agent.tool.capability import PostgreSQLCapabilityStore
+
+logger = logging.getLogger(__name__)
+
+SYSTEM_PROMPT_CAPABILITY = """你是一个专业的能力分析师。
+你的任务是从给定的【待分析新工具】的使用介绍和它挂载的【相关背景知识文章】中,提取出它对整网【原子能力表】的贡献,并选择新建或是融合。
+
+## 定义与格式
+1. 原子能力是面向需求、跨工具的独立完整业务单元。
+2. 端到端型工具(如Midjourney)直接抽取能力;编排平台工具(如ComfyUI节点群)从实际搭建的工作流中提取能力视角(不要原子化平台或独立节点本身)。
+
+请输出严格的 JSON 数组结构:
+[
+  {
+    "action": "create",
+    "tool_id": "<当前待分析的工具ID>",
+    "knowledge_ids": ["<哪些传入的相关知识促成了这个能力,填入真实的知识ID>"],
+    "capability_id": "NEW_<任意数字字母临时ID>",
+    "name": "<总结提炼的新能力统称名>",
+    "criterion": "<客观统一的判定标准>",
+    "description": "<抽象的能力需求场景说明>",
+    "implement_description": "<在该特定工具里是如何实现该能力的(具体操作或调用链)>"
+  },
+  {
+    "action": "attach",
+    "tool_id": "<当前待分析的工具ID>",
+    "knowledge_ids": ["<关联的知识ID,可为空数组>"],
+    "capability_id": "<来自下面【已有全量能力库】字典中完全等价功能的真实ID>",
+    "implement_description": "<在该特定工具里实现此老能力的具体手法>"
+  },
+  {
+    "action": "update_and_attach",
+    "tool_id": "<当前待分析的工具ID>",
+    "knowledge_ids": ["<关联的知识ID>"],
+    "capability_id": "<来自【已有全量能力库】的真实ID>",
+    "name": "<更统整包容的全局更好命名(如果不改就留空不变)>",
+    "criterion": "<更新优化后的判定标准>",
+    "description": "<更新优化后的描述>",
+    "implement_description": "<在该特定工具中如何实现>"
+  }
+]
+
+请绝对不要输出 markdown 包装,仅输出原生的合法 JSON。如果一个工具覆盖了多个独立原子能力,请为每个能力出具一条动作操作。
+"""
+
+def _fetch_knowledge_map(cursor, k_ids: list):
+    if not k_ids: return {}
+    placeholders = ','.join(['%s'] * len(k_ids))
+    cursor.execute(f"SELECT row_to_json(knowledge) as data FROM knowledge WHERE id IN ({placeholders})", list(k_ids))
+    mapping = {}
+    for r in cursor.fetchall():
+        d = r['data']
+        text = str(d.get('content', d.get('markdown', d.get('description', ''))))
+        mapping[d['id']] = {"title": d.get('title', ''), "content": text[:4000]}
+    return mapping
+
+
+async def extract_capabilities_with_claude(existing_caps, tool_batch, knowledge_map):
+    cap_str = json.dumps([{"id": c["id"], "name": c["name"], "criterion": c.get("criterion", "")} for c in existing_caps], ensure_ascii=False)
+    tool_str = json.dumps([{"id": t["id"], "name": t["name"], "desc": t["introduction"], "docs": t["tutorial"], "associated_knowledge": t.get("knowledge_ids", [])} for t in tool_batch], ensure_ascii=False)
+    knowledge_str = json.dumps(knowledge_map, ensure_ascii=False)
+    
+    prompt = f"【现有全量原子能力库字典】:\n{cap_str}\n\n【相关背景知识文章】:\n{knowledge_str}\n\n【本次待分析抽取合并的工具列表】:\n{tool_str}\n\n请严格输出JSON操作数组:"
+    
+    messages = [
+        {"role": "system", "content": SYSTEM_PROMPT_CAPABILITY},
+        {"role": "user", "content": prompt}
+    ]
+    
+    result_text = ""
+    try:
+        result = await openrouter_llm_call(
+            messages=messages,
+            model="anthropic/claude-sonnet-4-5",
+            temperature=0.2
+        )
+        result_text = result.get("content", "")
+    except Exception as e:
+        logger.error(f"OpenRouter API failed: {e}")
+        return []
+
+    with open("raw_capability_responses.log", "a", encoding="utf-8") as f:
+        f.write(f"\n--- Synchronous Capability Batch Output ---\n{result_text}\n")
+
+    try:
+        clean_json = result_text.strip()
+        if clean_json.startswith("```json"): clean_json = clean_json[7:]
+        elif clean_json.startswith("```"): clean_json = clean_json[3:]
+        if clean_json.endswith("```"): clean_json = clean_json[:-3]
+        
+        data = json.loads(clean_json.strip())
+        if isinstance(data, dict):
+            if "action" in data: return [data]
+            return []
+        elif isinstance(data, list):
+            return [item for item in data if isinstance(item, dict) and "action" in item]
+        return []
+    except Exception as e:
+        logger.error(f"Failed to parse capability JSON: {e}")
+        return []
+
+
+@tool()
+async def sync_atomic_capabilities(target_tool_ids: List[str]) -> ToolResult:
+    """
+    一键式强同步工具(为Librarian等智能体量身打造)。
+    针对新发现的或发生变动的特定工具/知识源,它能在数十秒内完成关联获取、大模型分析并直接完成底层 PostgreSQL 更新操作。
+    直接返回应用成功后的增减战报。
+    
+    Args:
+        target_tool_ids: 必须提供。指定要被大模型执行能力审查提取的工具 ID 列表。建议每次传入数量极少(如 1-3个)以保证 15 秒内同步快速返回。
+    """
+    if not target_tool_ids:
+        return ToolResult(title="❌ 参数错误", output="必须提供 target_tool_ids,独立系统不再允许发起全量全局扫描避免阻塞。", error="Missing target_tool_ids")
+
+    logger.info(f"开启单通道同步能力萃取 (目标: {target_tool_ids})...")
+    
+    cap_store = PostgreSQLCapabilityStore()
+    tool_store = PostgreSQLToolStore()
+    k_cursor = cap_store._get_cursor()
+    stats = {"created": 0, "attached": 0, "updated": 0, "knowledge_inherited": 0}
+    
+    try:
+        existing_caps = cap_store.list_all(limit=5000)
+        all_tools = tool_store.list_all(limit=2000)
+        
+        target_tools = [t for t in all_tools if t.get("id") in target_tool_ids]
+        if not target_tools:
+            return ToolResult(title="❌ 未找到工具", output=f"找不到任何由 {target_tool_ids} 制定的接入工具")
+
+        # 拉取目标工具的强绑定相关知识
+        batch_k_ids = set([k for t in target_tools for k in t.get("knowledge_ids", [])])
+        k_map = _fetch_knowledge_map(k_cursor, list(batch_k_ids))
+        
+        # Claude 执行抽象推理构建矩阵
+        ops = await extract_capabilities_with_claude(existing_caps, target_tools, k_map)
+        
+        if not ops:
+            return ToolResult(title="ℹ️ 分析完成", output="大模型判定当前工具没有提取出任何有效或创新的功能资产。")
+            
+        temp_id_mapping = {}
+
+        # 落地阶段一:先创造出新的原子能力
+        for op in ops:
+            if op.get("action") == "create" and op.get("capability_id") and op.get("tool_id"):
+                real_id = f"cap-{uuid.uuid4().hex[:12]}"
+                temp_id_mapping[op.get("capability_id")] = real_id
+                
+                t_id = op.get("tool_id")
+                inherited_knowledge = op.get("knowledge_ids", [])
+                stats["knowledge_inherited"] += len(inherited_knowledge)
+                
+                cap_store.insert_or_update({
+                    "id": real_id,
+                    "name": op.get("name", ""),
+                    "criterion": op.get("criterion", ""),
+                    "description": op.get("description", ""),
+                    "tool_ids": [t_id],
+                    "implements": {t_id: op.get("implement_description", "")},
+                    "knowledge_ids": inherited_knowledge
+                })
+                stats["created"] += 1
+
+        # 落地阶段二:处理老能力的依附和扩展刷新
+        for op in ops:
+            action = op.get("action")
+            if action in ("attach", "update_and_attach") and op.get("capability_id") and op.get("tool_id"):
+                c_id = temp_id_mapping.get(op.get("capability_id"), op.get("capability_id"))
+                existing_cap = cap_store.get_by_id(c_id)
+                if not existing_cap: continue
+                
+                if action == "update_and_attach":
+                    existing_cap["name"] = op.get("name") or existing_cap.get("name")
+                    existing_cap["criterion"] = op.get("criterion") or existing_cap.get("criterion")
+                    existing_cap["description"] = op.get("description") or existing_cap.get("description")
+                    stats["updated"] += 1
+                
+                t_id = op.get("tool_id")
+                imp_desc = op.get("implement_description", "")
+                
+                tool_ids = existing_cap.get("tool_ids", [])
+                if t_id not in tool_ids: tool_ids.append(t_id)
+                existing_cap["tool_ids"] = tool_ids
+                
+                implements = existing_cap.get("implements", {})
+                implements[t_id] = imp_desc
+                existing_cap["implements"] = implements
+                
+                op_k_ids = op.get("knowledge_ids", [])
+                if op_k_ids:
+                    existing_k_ids = set(existing_cap.get("knowledge_ids", []))
+                    new_k_ids = [k for k in op_k_ids if k not in existing_k_ids]
+                    if new_k_ids:
+                        existing_k_ids.update(new_k_ids)
+                        existing_cap["knowledge_ids"] = list(existing_k_ids)
+                        stats["knowledge_inherited"] += len(new_k_ids)
+                
+                cap_store.insert_or_update(existing_cap)
+                stats["attached"] += 1
+
+        return ToolResult(
+            title="✅ 强同步萃取完成",
+            output=f"强同步萃取完毕并入库: 新生能力 {stats['created']}, 修缮扩写 {stats['updated']}, 同化挂载 {stats['attached']} (沿袭知识网脉络 {stats['knowledge_inherited']} 条).\n\n详情记录:\n" + json.dumps(ops, ensure_ascii=False, indent=2)
+        )
+
+    except Exception as e:
+        logger.error(f"Sync capability extraction failed: {e}")
+        cap_store.conn.rollback()
+        return ToolResult(title="❌ 系统异常", output=f"执行时发生错误: {str(e)}", error=str(e))
+    finally:
+        k_cursor.close()
+        cap_store.close()
+        tool_store.close()

+ 37 - 5
knowhub/knowhub_db/pg_store.py

@@ -133,11 +133,39 @@ class PostgreSQLStore:
         finally:
             cursor.close()
 
-    def search(self, query_embedding: List[float], filters: Optional[str] = None, limit: int = 10) -> List[Dict]:
+    def _apply_relation_filters(self, where_clause: str, relation_filters: Optional[Dict[str, str]], params: list) -> str:
+        if not relation_filters:
+            return where_clause
+            
+        rel_clauses = []
+        for k, v in relation_filters.items():
+            if not v: continue
+            if k == 'requirement_id':
+                rel_clauses.append("EXISTS (SELECT 1 FROM requirement_knowledge rk WHERE rk.knowledge_id = knowledge.id AND rk.requirement_id = %s)")
+                params.append(v)
+            elif k == 'capability_id':
+                rel_clauses.append("EXISTS (SELECT 1 FROM capability_knowledge ck WHERE ck.knowledge_id = knowledge.id AND ck.capability_id = %s)")
+                params.append(v)
+            elif k == 'tool_id':
+                rel_clauses.append("EXISTS (SELECT 1 FROM tool_knowledge tk WHERE tk.knowledge_id = knowledge.id AND tk.tool_id = %s)")
+                params.append(v)
+                
+        if not rel_clauses:
+            return where_clause
+            
+        rel_where = " AND ".join(rel_clauses)
+        if where_clause.strip():
+            return f"{where_clause} AND {rel_where}"
+        else:
+            return f"WHERE {rel_where}"
+
+    def search(self, query_embedding: List[float], filters: Optional[str] = None, limit: int = 10, relation_filters: Optional[Dict[str, str]] = None) -> List[Dict]:
         """向量检索(使用余弦相似度)"""
         cursor = self._get_cursor()
         try:
             where_clause = self._build_where_clause(filters) if filters else ""
+            params = []
+            where_clause = self._apply_relation_filters(where_clause, relation_filters, params)
             sql = f"""
                 SELECT {_SELECT_FIELDS},
                        1 - (task_embedding <=> %s::real[]) as score
@@ -146,24 +174,28 @@ class PostgreSQLStore:
                 ORDER BY task_embedding <=> %s::real[]
                 LIMIT %s
             """
-            cursor.execute(sql, (query_embedding, query_embedding, limit))
+            final_params = [query_embedding] + params + [query_embedding, limit]
+            cursor.execute(sql, tuple(final_params))
             results = cursor.fetchall()
             return [self._format_result(r) for r in results]
         finally:
             cursor.close()
 
-    def query(self, filters: str, limit: int = 100) -> List[Dict]:
+    def query(self, filters: str, limit: int = 100, relation_filters: Optional[Dict[str, str]] = None) -> List[Dict]:
         """纯标量查询"""
         cursor = self._get_cursor()
         try:
-            where_clause = self._build_where_clause(filters)
+            where_clause = self._build_where_clause(filters) if filters else ""
+            params = []
+            where_clause = self._apply_relation_filters(where_clause, relation_filters, params)
             sql = f"""
                 SELECT {_SELECT_FIELDS}
                 FROM knowledge
                 {where_clause}
                 LIMIT %s
             """
-            cursor.execute(sql, (limit,))
+            final_params = params + [limit]
+            cursor.execute(sql, tuple(final_params))
             results = cursor.fetchall()
             return [self._format_result(r) for r in results]
         finally:

+ 362 - 0
knowhub/research/outdoor_character_workflow_summary_20260409.md

@@ -0,0 +1,362 @@
+# 户外场景人物活动照片生成工作流合集
+
+**调研日期**: 2026-04-09  
+**调研渠道**: 小红书、X (Twitter)、YouTube  
+**提取成果**: 6种完整工作流方案,12个工具,7个教程资源
+
+---
+
+## 一、核心工作流方案
+
+### Workflow 1: OpenArt Character Builder + 多场景一致性工作流
+
+**适用场景**: 需要保持角色一致性,在多个户外场景中生成人物活动照片
+
+**工具链**: OpenArt AI Character Builder → Nano Banana 2 / Seedance 2.0 → Kling 3.0
+
+**步骤拆解**:
+
+#### Step 1: 创建角色基础
+- **输入**: 选择风格(Style)、性别(Gender)、种族(Ethnicity)、年龄(Age)
+- **工具**: OpenArt Character Builder
+- **输出**: 锁定角色DNA的基础图像(480种组合可选)
+- **说明**: 无需编写复杂提示词,通过UI选择锁定角色特征
+
+#### Step 2: 生成角色参考表 (Character Reference Sheet)
+- **输入**: 上传Step 1生成的角色图像
+- **工具**: Nano Banana 2 / Seedance 2.0
+- **提示词**:
+```
+Create a professional character reference sheet based strictly on the uploaded reference image. 
+Use a clean, neutral plain background. Arrange into two horizontal rows:
+- Top row: four full-body standing views – front, left profile, right profile, back
+- Bottom row: Four close-up portraits – front, left profile, right profile, back
+Maintain perfect identity consistency across every panel.
+```
+- **输出**: 多视角角色参考表(8个面板)
+
+#### Step 3: 场景生成
+- **输入**: 角色参考表 + 场景提示词
+- **工具**: Seedance 2.0 / Kling 3.0
+- **提示词结构**:
+```json
+{
+  "subject": "角色描述",
+  "environment": {
+    "setting": "户外场景 (如:spring alpine meadow, cherry blossom street)",
+    "time_of_day": "golden hour / soft daylight",
+    "lighting": "natural lighting, volumetric light shafts"
+  },
+  "camera": {
+    "lens": "85mm / wide-angle"
+  },
+  "lighting": {
+    "type": "soft diffused daylight",
+    "effect": "rim lighting, gentle shadows"
+  }
+}
+```
+- **输出**: 角色在户外场景中的图像/视频
+
+#### Step 4: 视频生成 (可选)
+- **输入**: 起始帧 + 结束帧
+- **工具**: Seedance 2.0
+- **提示词**: "Show me what happens in between. USE MULTIPLE CAMERA ANGLES"
+- **输出**: 多镜头角度的过渡视频
+
+**关键技术要点**:
+- 角色一致性保证:使用Character Builder锁定DNA + 生成多视角参考表 + 所有场景使用相同参考图
+- 户外场景提示词要素:golden hour, volumetric light shafts, rim lighting, subsurface scattering
+
+**来源**: 小红书/X/YouTube调研 (@thetripathi58, @heyDhavall, @MaAyyoub)
+
+---
+
+### Workflow 2: ComfyUI + Blender 户外场景生成工作流
+
+**适用场景**: 需要高度可控的3D场景建模和电影级渲染效果
+
+**工具链**: Blender → ComfyUI → Z-Image Turbo → LTX-2.3 → ComfyUI-VideoColorGrading
+
+**步骤拆解**:
+
+#### Step 1: 场景建模
+- **输入**: 文本提示词或概念图
+- **工具**: Blender (在ComfyUI内运行)
+- **输出**: 3D场景模型(支持视口预览)
+
+#### Step 2: 渲染控制
+- **输入**: 3D模型 + 相机路径
+- **工具**: ComfyUI + Blender集成节点
+- **输出**: 渲染帧序列
+
+#### Step 3: 图像生成增强
+- **输入**: 渲染帧
+- **工具**: Z-Image Turbo
+- **输出**: 增强细节的图像
+
+#### Step 4: 视频生成
+- **输入**: 增强后的图像序列
+- **工具**: LTX-2.3 + WanAnimate
+- **输出**: 最终视频
+
+#### Step 5: 颜色分级
+- **输入**: 生成的视频 + 参考图像
+- **工具**: ComfyUI-VideoColorGrading (Kijai)
+- **流程**: 从参考图像生成3D color LUT → 两阶段扩散过程对齐源视频帧与参考风格 → 应用时间一致性颜色分级
+- **输出**: 颜色分级后的最终视频
+
+**优势**:
+- 完全可控的3D场景布局
+- 支持精确的相机路径设计
+- 专业的颜色分级流程
+
+**难度等级**: ⭐⭐⭐⭐⭐(需要Blender和ComfyUI专业知识)
+
+**来源**: X/YouTube调研
+
+---
+
+### Workflow 3: ComfyUI + BrushNet 婚纱摄影外景工作流
+
+**适用场景**: 婚纱摄影、商业人像外景合成
+
+**工具链**: ComfyUI → BrushNet → IC-Light → Segment Anything → IPAdapter
+
+**核心流程**:
+1. **模型及原图加载** → 设置宽高比、缩放模式
+2. **遮罩生成** → Segment Anything 自动分割人物
+3. **BrushNet 局部重绘背景**
+   - 优势:对遮罩边界控制优秀,不破坏主体
+   - 透视关系协调
+4. **IC-Light 打光融合** → 使人物与背景光影统一
+5. **高清修复放大** → 恢复原图尺寸
+6. **PS 细节还原** → 图层和遮罩处理
+
+**关键节点**: `comfyui-brushnet`, `comfyui-inpaint-nodes`, `comfyui-ipadapter_plus`
+
+**来源**: 小红书 (帖子: 婚纱摄影外景自由)
+
+---
+
+### Workflow 4: ComfyUI 人像摄影工作流 (SD1.5)
+
+**适用场景**: 换装、换背景、保持面部特征的人像创作
+
+**工具链**: ComfyUI → SD1.5 → ControlNet (OpenPose) → IPAdapter → LoRA
+
+**核心思路**:
+1. **蒙版遮罩 + Latent 复合** → 重绘目标区域,保证光影效果
+2. **ControlNet 固定姿态** → OpenPose 控制人物姿势
+3. **IPAdapter + WD14 标签提取** → 风格迁移
+
+**工作流特点**:
+- 保持面部特征不变
+- 可换装、换背景
+- 支持多 ControlNet 叠加 (Depth, Canny, OpenPose)
+
+**来源**: 小红书 (帖子: comfyui 人像摄影工作流)
+
+---
+
+### Workflow 5: Kontext + MidJourney 场景及角色一致性工作流
+
+**适用场景**: 叙事动画、多镜头分镜、大场景多主体融合
+
+**工具链**: Kontext (Flux) → MidJourney → GPT
+
+**5步流程**:
+1. **准备三视图** → 角色/关键道具的正面、侧面、背面图
+2. **准备分镜草图** → 手绘构图规划
+3. **做主场景氛围空镜** → 确定整体色调和氛围
+4. **做分镜场景空镜** → MJ 生成,使用 `--sref` 引用氛围图
+5. **主体与场景融合** → Kontext 双图/多图模式融合
+
+**关键技巧**:
+- 大场景分区处理:先融合局部,再拼合到全景
+- 小角度机位切换可直接用 Kontext,大角度需单独生成
+- 建议使用 Kontext Pro/Max 版本,开源 dev 版本效果有限
+
+**来源**: 小红书 (帖子: 用Kontext + MJ 控制场景及角色一致性)
+
+---
+
+### Workflow 6: Nano Banana + Kling 户外人物行走场景
+
+**适用场景**: 街拍松弛感写真、人物行走视频
+
+**工具链**: Nano Banana Pro → Kling 3.0 → Topaz Gigapixel AI → Photoshop
+
+**街拍松弛感写真流程**:
+1. **上传参考照片** → 清晰半身照
+2. **使用详细提示词** (见下方模板)
+3. **生成多视角变体** → 选择最佳
+4. **Topaz 放大** → 4K 输出
+5. **PS 合成清理** → 去除瑕疵
+6. **Kling 动画** → 首尾帧控制生成行走视频
+
+**提示词模板** (精简版):
+```
+参考我上传的照片,输出高分辨率彩色时尚人像。
+主体:年轻女性,长发微卷,米色宽松衬衫外套,白色蕾丝短裙,黑色皮包,手持咖啡。
+姿态:三分之四侧身,左脚微前,右手轻抬整理发丝,眼神自信直视镜头。
+环境:高端城市街区咖啡店门口,暖色橱窗虚化,石材路面反光。
+光线:阴天自然柔光,侧后缘光勾勒发丝和包带。
+摄影:全画幅 85mm 人像镜头,f/1.8,浅景深,人物位于右侧三分之一处。
+细节:肌肤保留毛孔,发丝清晰,皮包纹理可见,轻微胶片颗粒。
+比例:4:5
+```
+
+**来源**: 小红书 (帖子: Nano Banana生成街拍松弛感写真) + YouTube (Uvq85gcMSE0, SeuI2zIWQJ0)
+
+---
+
+## 二、工具清单汇总
+
+| 工具名称 | 类别 | 核心用途 | 状态 |
+|---------|------|---------|------|
+| **OpenArt Character Builder** | 角色生成 | 4步锁定角色DNA (风格/性别/种族/年龄) | 已验证 |
+| **Nano Banana / Nano Banana Pro** | 图像生成 | 角色参考表、多视角生成、街拍写真 | 已验证 |
+| **Seedance 2.0** | 图像/视频生成 | 角色一致性、场景生成、视频过渡 | 已验证 |
+| **Kling 3.0** | 视频生成 | 高质量视频、首尾帧控制、多镜头角度 | 已验证 |
+| **ComfyUI** | 工作流编排 | 节点式工作流,集成多种模型 | 已验证 |
+| **BrushNet** | 局部重绘 | 背景替换,遮罩边界控制优秀 | 已验证 |
+| **IC-Light** | 光影融合 | 人物与背景打光统一 | 已验证 |
+| **ControlNet** | 姿态控制 | OpenPose/Depth/Canny 固定构图 | 已验证 |
+| **IPAdapter** | 风格迁移 | 参考图风格提取 | 已验证 |
+| **Kontext (Flux)** | 图像融合 | 多主体场景融合,角色一致性 | 已验证 |
+| **MidJourney** | 场景生成 | 氛围空镜、分镜场景生成 | 已验证 |
+| **Topaz Gigapixel AI** | 图像放大 | 4K 无损放大 | 已验证 |
+| **Photoshop** | 后期处理 | 遮罩清理、细节还原、图层合成 | 已验证 |
+| **Segment Anything** | 图像分割 | 自动人物抠图 | 已验证 |
+| **Blender** | 3D建模 | 场景建模,可与ComfyUI集成 | 已验证 |
+| **Z-Image Turbo** | 图像增强 | 提升渲染帧质感和细节 | 已验证 |
+| **LTX-2.3** | 视频生成 | 图像序列转视频 | 已验证 |
+| **WanAnimate** | 视频生成 | 视频生成和动画 | 已验证 |
+| **ComfyUI-VideoColorGrading** | 视频后期 | 基于参考图的颜色分级 | 已验证 |
+| **ChatGPT** | 提示词优化 | 构建光线控制等专业提示词 | 已验证 |
+| **PixPrettyAI** | 图像生成平台 | 托管Nano Banana 2等模型 | 已验证 |
+
+---
+
+## 三、关键提示词模板
+
+### 角色参考表生成 (Nano Banana 2)
+```
+Create a professional character reference sheet based strictly on the uploaded reference image. 
+Use a clean, neutral plain background. Arrange into two horizontal rows:
+- Top row: four full-body standing views – front, left profile, right profile, back
+- Bottom row: Four close-up portraits – front, left profile, right profile, back
+Maintain perfect identity consistency across every panel.
+```
+
+### 户外场景生成 (Seedance 2.0 / Kling)
+```json
+{
+  "subject": "年轻女性,长发微卷,米色衬衫外套,白色蕾丝裙",
+  "environment": {
+    "setting": "spring alpine meadow / cherry blossom street / urban cafe",
+    "time_of_day": "golden hour / soft daylight",
+    "lighting": "natural lighting, volumetric light shafts",
+    "elements": ["flowers", "trees", "mountains", "cobblestone street"]
+  },
+  "camera": {
+    "lens": "85mm portrait / wide-angle",
+    "movement": "slow tracking / pan"
+  },
+  "lighting": {
+    "type": "soft diffused daylight",
+    "effect": "rim lighting, gentle shadows"
+  }
+}
+```
+
+### 自然光线人像提示词结构
+```
+A high-contrast cinematic portrait with dramatic rim lighting from behind.
+Subject: [描述人物特征、服装、动作]
+Lighting: 
+  - Strong direct light illuminating one side of face
+  - Clear highlights and definition
+  - Softly shadowed opposite side
+  - Light source direction clearly visible
+Environment: Dark abstract bokeh background / outdoor park setting
+Camera: 85mm lens, shallow depth of field
+Style: Professional DSLR portrait, film grain, vintage feel
+```
+
+### 光线类型关键词对照表
+| 光线类型 | 提示词关键词 | 适用场景 |
+|---------|------------|---------|
+| Rim Lighting | dramatic rim lighting from behind | 人像特写、逆光场景 |
+| Golden Hour | golden hour lighting, warm tones | 温馨场景、日落场景 |
+| Volumetric Light | volumetric light shafts, god rays | 森林、有遮挡的场景 |
+| Soft Diffused | soft diffused daylight, overcast | 日常场景、阴天场景 |
+| Subsurface Scattering | subsurface scattering for skin | 人像特写、皮肤质感 |
+
+---
+
+## 四、调研经验总结
+
+### 经验 1: 如何构造有效的搜索关键词
+
+当调研AI生图工作流/工序时,应该将用户需求转换为偏向「教程」、「工作流」、「全套方案」的搜索词,而非单一软件名。
+
+**原因**: 单一软件名搜索结果多为工具介绍,而工作流关键词能直接找到完整的多步工序方案。
+
+**案例**: 本次调研「户外场景人物活动照片」需求,构造了5个关键词:
+1. AI生图 户外人物 工作流 教程
+2. Stable Diffusion 真实场景人物 完整流程
+3. AI绘画 自然光线 人物活动 步骤详解
+4. ComfyUI 户外场景 人物生成 workflow
+5. AI生图 真实背景人物合成 全套方案
+
+每个关键词都包含「工作流/流程/方案/教程」等工序导向词,成功从300条结果中提取19个完整Workflow。
+
+### 经验 2: 多渠道调研的并行执行策略
+
+当需要多渠道(小红书、X、YouTube)调研同一主题时,应该为每个渠道启动独立的research子agent并行执行。
+
+**原因**: 
+- 各渠道数据结构不同,独立agent可针对性处理
+- 并行执行大幅缩短调研时间
+
+**案例**: 本次调研同时启动3个agent,分别使用中文关键词(小红书)和英文关键词(X、YouTube),每个agent搜索5个关键词×20条结果,总计300条结果,在约15分钟内完成全部调研并提取19个Workflow。
+
+**关键技巧**:
+1. 为不同渠道准备适配的关键词(中文/英文)
+2. 任务描述中明确要求「只提取多步工序,严格拆解步骤」
+3. 使用deconstruct agent类型自动进行工序拆解
+
+---
+
+## 五、工作流选择建议
+
+| 需求场景 | 推荐工作流 | 理由 |
+|---------|-----------|------|
+| **角色一致性优先** | Workflow 1 (OpenArt + Seedance) | 4步锁定DNA,参考表机制成熟 |
+| **3D场景精确控制** | Workflow 2 (Blender + ComfyUI) | 完全可控的3D布局,专业颜色分级 |
+| **婚纱摄影/商业人像** | Workflow 3 (BrushNet) | 遮罩控制精准,光影融合自然 |
+| **换装/换背景灵活操作** | Workflow 4 (ComfyUI SD1.5) | ControlNet + IPAdapter 组合灵活 |
+| **叙事动画/多镜头** | Workflow 5 (Kontext + MJ) | 分镜规划完善,大场景分区处理 |
+| **街拍松弛感写真** | Workflow 6 (Nano Banana + Kling) | 提示词模板成熟,视频生成流畅 |
+
+---
+
+## 六、教程资源索引
+
+| 来源 | 标题 | 链接 |
+|------|------|------|
+| **小红书** | BrushNet婚纱摄影外景工作流 | https://www.xiaohongshu.com/explore/67b8726e0000000029032412 |
+| **小红书** | ComfyUI人像摄影工作流(SD1.5) | https://www.xiaohongshu.com/explore/66253296000000000302272a |
+| **小红书** | Kontext + MJ场景及角色一致性 | https://www.xiaohongshu.com/explore/687771cf0000000024008e4b |
+| **小红书** | Nano Banana街拍松弛感写真 | https://www.xiaohongshu.com/explore/68e11fa700000000070363c7 |
+| **YouTube** | Add AI People to Your Renders | https://www.youtube.com/watch?v=Uvq85gcMSE0 |
+| **YouTube** | Turn One Image into Multiple Camera Angles | https://www.youtube.com/watch?v=SeuI2zIWQJ0 |
+| **本地缓存** | AI户外工作流合集原始数据 | `/root/Agent/.cache/research/ai_outdoor_workflow_collection_20260409.json` |
+
+---
+
+**文档生成时间**: 2026-04-09 19:38  
+**调研执行Agent**: Librarian Agent  
+**数据状态**: 已整理归档,待入库

BIN
log.txt


+ 0 - 10
log2.txt

@@ -1,10 +0,0 @@
-Usage:
-  Upload: python upload.py <file_path>
-  Download: python upload.py download <url> <save_path>
-
---- Running Self Test ---
-Uploading img_1_gen.png to aigc-crawler/crawler/image...
-Upload SDK Response: {'oss_object_key': 'crawler/image/img_1_gen.png', 'save_oss_timestamp': 1775658352873}
-
-Extracted URL: https://res.cybertogether.net/crawler/image/img_1_gen.png
-Downloading from https://res.cybertogether.net/crawler/image/img_1_gen.png to downloaded_dummy.png...

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است