Просмотр исходного кода

Merge branch 'main' of https://git.yishihui.com/howard/Agent

guantao 1 месяц назад
Родитель
Сommit
e6021227fe
87 измененных файлов с 4994 добавлено и 1386 удалено
  1. 8 1
      .gitignore
  2. 42 0
      add_knowledge.py
  3. 44 0
      add_workflow_experience_knowledge.py
  4. 7 10
      agent/core/runner.py
  5. 474 0
      agent/docs/tools-refactor-plan.md
  6. 219 1
      agent/docs/tools.md
  7. 5 9
      agent/tools/builtin/__init__.py
  8. 21 119
      agent/tools/builtin/crawler.py
  9. 0 5
      agent/tools/builtin/feishu/chat_history/chat_summary.json
  10. 0 103
      agent/tools/builtin/feishu/chat_history/chat_关涛.json
  11. 0 32
      agent/tools/builtin/feishu/chat_history/chat_孙若天.json
  12. 0 22
      agent/tools/builtin/feishu/chat_history/chat_谭景玉.json
  13. 5 2
      agent/tools/builtin/file/read.py
  14. 320 0
      agent/tools/builtin/file/read_images.py
  15. 30 5
      agent/tools/builtin/knowledge.py
  16. 50 0
      agent/tools/builtin/librarian.py
  17. 0 418
      agent/tools/builtin/sandbox.py
  18. 24 139
      agent/tools/builtin/search.py
  19. 219 111
      agent/tools/builtin/toolhub.py
  20. 1 0
      agent/tools/utils/__init__.py
  21. 289 0
      agent/tools/utils/image.py
  22. 4 0
      cache/knowledge_pending/knowledge-2026-04-09-x-portrait-workflows.json
  23. 80 0
      direct_insert.py
  24. 0 107
      frontend/htmlTemplate/api_data/goal_list.json
  25. 0 28
      frontend/htmlTemplate/api_data/trace_list.json
  26. 0 0
      frontend/htmlTemplate/ws_data/event.jsonl
  27. 0 28
      frontend/htmlTemplate/ws_data/trace_list.json
  28. 13 0
      im-server/__main__.py
  29. 132 0
      insert_ai_workflow_capabilities.py
  30. 73 0
      insert_knowledge.py
  31. 91 0
      insert_knowledge_direct.py
  32. 1 0
      knowhub/agents/librarian.py
  33. 5 0
      knowhub/agents/librarian_agent.prompt
  34. 444 0
      knowhub/docs/knowledge-management.md
  35. 44 7
      knowhub/frontend/src/App.tsx
  36. 23 51
      knowhub/frontend/src/layouts/MainLayout.tsx
  37. 4 1
      knowhub/frontend/src/main.tsx
  38. 11 1
      knowhub/frontend/src/pages/Dashboard.tsx
  39. 133 163
      knowhub/frontend/src/pages/Relations.tsx
  40. 0 1
      knowhub/internal_tools/__init__.py
  41. 362 0
      knowhub/research/outdoor_character_workflow_summary_20260409.md
  42. 73 22
      knowhub/server.py
  43. 104 0
      knowledge-2026-04-09-ai-image-workflows.json
  44. 398 0
      knowledge/ComfyUI_画中画分阶段生成工作流.md
  45. 165 0
      knowledge/Midjourney_v7_Impasto_厚涂风格参数配置.md
  46. 261 0
      knowledge/Nano_Banana_多图融合_ComfyUI 工作流.md
  47. 20 0
      knowledge/knowledge-20260412-005015-nano-banana-image-urls.json
  48. 20 0
      knowledge/knowledge-20260412-010725-nano-banana-http503.json
  49. 3 0
      knowledge/knowledge-20260412-010726-nano-banana-multi-fusion.json
  50. 20 0
      knowledge/knowledge-20260412-014259-3abf.json
  51. 21 0
      knowledge/strategy/品类分组判断标准.json
  52. 20 0
      knowledge/strategy/粗工序因果推理方法.json
  53. 17 0
      knowledge/strategy/能力模块变体分析方法.json
  54. 9 0
      knowledge_batch_20260409.json
  55. 26 0
      knowledge_batch_20260409_execution.json
  56. 34 0
      knowledge_batch_20260410_workflow_analysis.json
  57. 51 0
      knowledge_batch_20260411_toolhub_error_recovery.json
  58. BIN
      outputs/540bdb68-9b2/flux_generate_1775743063412_0.png
  59. BIN
      outputs/540bdb68-9b2/flux_generate_1775743308665_0.png
  60. BIN
      outputs/540bdb68-9b2/flux_generate_1775743381440_0.png
  61. BIN
      outputs/540bdb68-9b2/flux_generate_1775744492978_0.png
  62. BIN
      outputs/540bdb68-9b2/nano_banana_1775742258513_0.jpg
  63. BIN
      outputs/540bdb68-9b2/nano_banana_1775742490073_0.jpg
  64. BIN
      outputs/540bdb68-9b2/nano_banana_1775742774289_0.jpg
  65. BIN
      outputs/540bdb68-9b2/seedream_generate_1775743450714_0.jpg
  66. BIN
      outputs/540bdb68-9b2/seedream_generate_1775743784246_0.jpg
  67. BIN
      outputs/540bdb68-9b2/seedream_generate_1775744243329_0.jpg
  68. BIN
      outputs/810058fe-de8/nano_banana_1775739644055_0.jpg
  69. BIN
      outputs/810058fe-de8/nano_banana_1775739749883_0.jpg
  70. BIN
      outputs/810058fe-de8/nano_banana_1775739853853_0.jpg
  71. BIN
      outputs/810058fe-de8/nano_banana_1775739957102_0.jpg
  72. BIN
      outputs/810058fe-de8/nano_banana_1775740058087_0.jpg
  73. BIN
      outputs/810058fe-de8/nano_banana_1775740171922_0.jpg
  74. BIN
      outputs/810058fe-de8/nano_banana_1775740270417_0.jpg
  75. BIN
      outputs/810058fe-de8/nano_banana_1775740359987_0.jpg
  76. BIN
      outputs/810058fe-de8/nano_banana_1775740610837_0.jpg
  77. BIN
      outputs/810058fe-de8/nano_banana_1775740709313_0.jpg
  78. 9 0
      pending_uploads/2026-04-09_ai_image_local_detail_strategy.json
  79. 28 0
      pending_uploads/knowledge_batch_20260410_file_search.json
  80. 81 0
      pre_upload_list.json
  81. 69 0
      process_upload.py
  82. 0 0
      test-dir/__main__.py
  83. 72 0
      test_upload.py
  84. 80 0
      test_upload_sync.py
  85. 18 0
      tools/image_gen/nano_banana.json
  86. 18 0
      tools/upload/image_uploader.json
  87. 199 0
      wf_004_intents.json

+ 8 - 1
.gitignore

@@ -1,5 +1,6 @@
 # API-KEY
 .env
+.mcp.json
 
 # Python
 __pycache__/
@@ -62,11 +63,17 @@ 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/
+
 # data
 knowhub/knowhub.db
 knowhub/knowhub.db-shm
@@ -82,4 +89,4 @@ knowhub/milvus_data/
 vendor/browser-use/
 
 # im-client data
-data/
+data/.mcp.json

+ 42 - 0
add_knowledge.py

@@ -0,0 +1,42 @@
+#!/usr/bin/env python3
+import json
+
+# 读取现有文件
+with open('/root/Agent/pre_upload_list.json', 'r', encoding='utf-8') as f:
+    data = json.load(f)
+
+# 新的知识条目
+new_knowledge = [
+    {
+        "task": "在图生图任务中,当 nano_banana 工具无法处理本地路径时的降级策略",
+        "content": "当使用 nano_banana 进行图生图时,如果传入本地图片路径(如 examples/xxx.png)会报错「Base64 decoding failed」,因为 nano_banana 期望的是 Base64 编码或网络 URL。\n\n正确做法:\n1. 先用 image_uploader 将本地图片上传获取 CDN URL\n2. 或使用支持本地路径自动上传的工具(如 flux_generate、seedream_generate)\n\n工具选择优先级:\n- nano_banana:需要手动上传获取 CDN URL,但支持多图融合,效果最好\n- flux_generate:自动处理本地路径,图生图效果好,推荐作为备选\n- seedream_generate:自动处理本地路径,需要最小尺寸 3686400 像素\n\n案例:Stage 4 检查 img_3 花丛色彩时,nano_banana 传入本地路径报错,切换至 flux_generate 成功完成修复。",
+        "types": ["experience"],
+        "tags": {
+            "intent": "图生图/色彩修复",
+            "state": "nano_banana/flux_generate"
+        },
+        "score": 4,
+        "source": {"category": "research"}
+    },
+    {
+        "task": "在图生图色彩控制中,通过 prompt 明确指定色彩排除可有效降低干扰元素饱和度",
+        "content": "当需要降低画面中某元素的色彩饱和度或改变其色系时,在 prompt 中明确指定:\n- 目标色彩:\"should be green foliage plants or very subtle low-saturation green flowers\"\n- 排除色彩:\"not purple or blue\"\n- 整体色调:\"The overall color scheme should be white and green with minimal color accents\"\n\n配合 flux-kontext-max 模型的图生图模式,可有效实现色彩调整而不破坏原有构图。\n\n案例:img_3 花丛色彩修复,将蓝紫色花丛成功调整为淡紫色/白色,融入绿色背景。",
+        "types": ["experience"],
+        "tags": {
+            "intent": "色彩控制/图生图优化",
+            "state": "flux_generate"
+        },
+        "score": 4,
+        "source": {"category": "research"}
+    }
+]
+
+# 添加到 knowledge 数组
+data['knowledge'].extend(new_knowledge)
+
+# 写回文件
+with open('/root/Agent/pre_upload_list.json', 'w', encoding='utf-8') as f:
+    json.dump(data, f, ensure_ascii=False, indent=2)
+
+print(f"成功添加 {len(new_knowledge)} 条知识条目")
+print(f"当前 knowledge 总数:{len(data['knowledge'])}")

+ 44 - 0
add_workflow_experience_knowledge.py

@@ -0,0 +1,44 @@
+#!/usr/bin/env python3
+import json
+
+# 读取现有文件
+with open('/root/Agent/pre_upload_list.json', 'r', encoding='utf-8') as f:
+    data = json.load(f)
+
+# 新的知识条目
+new_knowledge = [
+    {
+        "task": "在分析工作流文件时,当 glob 搜索找不到目标文件的有效定位策略",
+        "content": "当 glob_files 按模式匹配找不到目标文件时,应该:1) 使用 bash_command 执行 find 命令进行更广泛的文件系统搜索,支持通配符和路径遍历;2) 结合 grep 内容搜索,通过文件内容关键词定位(如工具名、方案名);3) 检查相关目录结构,如 research/outputs/ 下的子目录。案例:本次任务中 glob_files 搜索\"*wf_005*\"未找到文件,但通过 find 命令搜索包含\"ComfyUI\"\"Nano Banana\"等关键词的 markdown 文件,成功在 outputs/04/ 目录下找到颗粒感纸张纹理插画风格工作流调研报告.md,该文件即为 wf_005 的 raw_markdown 内容。",
+        "types": ["experience"],
+        "tags": {
+            "intent": "工作流文件定位",
+            "state": "文件搜索",
+            "tools": "glob_files,bash_command"
+        },
+        "score": 4,
+        "source": {"category": "research"}
+    },
+    {
+        "task": "工作流意图归纳的粒度判断原则",
+        "content": "在将工作流技术步骤归纳为意图级描述时,粒度判断应遵循:1) 保留工具差异——相同意图但不同工具实现应分开记录,因为工具选择是工作流变体的关键信息;2) 合并连续子步骤——同一工具内的连续操作(如新建图层→填充→设置混合模式)可合并为一个意图;3) 跨方案可比较——意图描述应使不同工作流的相同功能模块可对比。案例:本次将 Photoshop 的 5 种颗粒添加方法分别记录为\"添加溶解颗粒效果\"\"手绘颗粒纹理\"\"滤镜添加全局杂色\"等,保留了方法差异;而将 ComfyUI 的分割 + 重排合并为\"创建四方连续布局\"。",
+        "types": ["experience"],
+        "tags": {
+            "intent": "工作流分析",
+            "state": "意图归纳",
+            "method": "语义聚类"
+        },
+        "score": 4,
+        "source": {"category": "research"}
+    }
+]
+
+# 添加到 knowledge 数组
+data['knowledge'].extend(new_knowledge)
+
+# 写回文件
+with open('/root/Agent/pre_upload_list.json', 'w', encoding='utf-8') as f:
+    json.dump(data, f, ensure_ascii=False, indent=2)
+
+print(f"成功添加 {len(new_knowledge)} 条知识条目")
+print(f"当前 knowledge 总数:{len(data['knowledge'])}")

+ 7 - 10
agent/core/runner.py

@@ -104,6 +104,7 @@ class RunConfig:
     temperature: float = 0.3
     max_iterations: int = 200
     tools: Optional[List[str]] = None          # None = 全部已注册工具
+    exclude_tools: List[str] = field(default_factory=list)  # 从工具列表中排除的工具名
     side_branch_max_turns: int = 5             # 侧分支最大轮次(压缩/反思)
     goal_compression: Literal["none", "on_complete", "on_overflow"] = "on_overflow"  # Goal 压缩模式
 
@@ -179,13 +180,6 @@ BUILTIN_TOOLS = [
     # "knowledge_batch_update",
     # "knowledge_list",
     # "knowledge_slim",
-    
-
-    # 沙箱工具
-    # "sandbox_create_environment",
-    # "sandbox_run_shell",
-    # "sandbox_rebuild_with_ports",
-    # "sandbox_destroy_environment",
 
     # 浏览器工具
     "browser_get_live_url",
@@ -558,7 +552,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.exclude_tools)
 
         trace_obj = Trace(
             trace_id=trace_id,
@@ -1092,7 +1086,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.exclude_tools)
 
         # 当前主路径头节点的 sequence(用于设置 parent_sequence)
         head_seq = trace.head_sequence
@@ -2844,12 +2838,13 @@ class AgentRunner:
         )
         return messages
 
-    def _get_tool_schemas(self, tools: Optional[List[str]]) -> List[Dict]:
+    def _get_tool_schemas(self, tools: Optional[List[str]], exclude_tools: Optional[List[str]] = None) -> List[Dict]:
         """
         获取工具 Schema
 
         - tools=None: 使用 registry 中全部已注册工具(含内置 + 外部注册的)
         - tools=["a", "b"]: 在 BUILTIN_TOOLS 基础上追加指定工具
+        - exclude_tools=["x"]: 从最终列表中排除指定工具(防止递归调用等场景)
         """
         if tools is None:
             # 全部已注册工具
@@ -2860,6 +2855,8 @@ class AgentRunner:
             for t in tools:
                 if t not in tool_names:
                     tool_names.append(t)
+        if exclude_tools:
+            tool_names = [t for t in tool_names if t not in exclude_tools]
         return self.tools.get_schemas(tool_names)
 
     # 默认 system prompt 前缀(当 config.system_prompt 和前端都未提供 system message 时使用)

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

@@ -0,0 +1,474 @@
+# 工具体系改造方案(Refactor Plan)
+
+> 本文档是**未来规划**,不是现状描述。当前工具体系的状态请看 [`tools.md`](./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` 两个入口,规模小且清晰,无改造必要

+ 219 - 1
agent/docs/tools.md

@@ -807,7 +807,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 +816,39 @@ 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 远程工具(图片参数支持本地文件路径) | 自研 |
+| `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 张"这种引用方式精确指代。对比之下 `search_posts` / `youtube_search` 的拼图**会**显示 label(帖子/视频标题),因为这些是内容型元数据,有实际信息量。这一差异反映在 `build_image_grid(labels=...)` 参数上:传 `None` 只画序号,传列表则在每格下方画标题。
+
+网格和降采样的实现在 `agent/tools/utils/image.py`,`search_posts` 和 `youtube_search` 等工具也复用同一套拼图逻辑。
 
 ### Agent 工具
 
@@ -1205,6 +1239,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 +1441,4 @@ async def search_notes(
 3. **类型安全**:充分利用 Python 类型注解
 4. **灵活集成**:支持各种工具库(Browser-Use, MCP 等)
 5. **可观测性**:内建统计和监控能力
+6. **跨框架**:无状态工具自包含 CLI,有状态工具走 MCP 标准协议

+ 5 - 9
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
@@ -16,12 +17,11 @@ 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):原本是给"运行工具"场景准备的,但工具已被抽到 toolhub 单独处理
 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.trace.goal_tool import goal
@@ -34,6 +34,7 @@ import agent.tools.builtin.im
 __all__ = [
     # 文件操作
     "read_file",
+    "read_images",
     "edit_file",
     "write_file",
     "glob_files",
@@ -57,18 +58,13 @@ __all__ = [
     "search_posts",
     "select_post",
     "get_search_suggestions",
-    "sandbox_create_environment",
-    "sandbox_run_shell",
-    "sandbox_rebuild_with_ports",
-    "sandbox_destroy_environment",
     # 上下文工具
     "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",

+ 21 - 119
agent/tools/builtin/crawler.py

@@ -4,11 +4,7 @@
 提供 YouTube、X (Twitter) 和微信/通用链接的搜索和详情查询功能。
 """
 
-import asyncio
-import base64
-import io
 import json
-import math
 import os
 import subprocess
 import tempfile
@@ -16,9 +12,9 @@ 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
+from agent.tools.utils.image import build_image_grid, encode_base64, load_images
 
 
 # API 配置
@@ -26,47 +22,22 @@ 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。
+    复用 agent.tools.utils.image 中的共享拼图逻辑。
     """
     if not videos:
         return None
 
-    items = []
-    for idx, video in enumerate(videos):
+    urls: List[str] = []
+    titles: List[str] = []
+    for video in videos:
         thumbnail = None
         if "thumbnails" in video and isinstance(video["thumbnails"], list) and video["thumbnails"]:
             thumbnail = video["thumbnails"][0].get("url")
@@ -77,96 +48,27 @@ async def _build_video_collage(videos: List[Dict[str, Any]]) -> Optional[str]:
 
         title = video.get("title", "") or video.get("text", "")
         if thumbnail:
-            items.append({"url": thumbnail, "title": title, "index": idx + 1})
-    if not items:
+            urls.append(thumbnail)
+            titles.append(title)
+
+    if not urls:
         return None
 
-    async with httpx.AsyncClient() as client:
-        tasks = [_download_image(client, item["url"]) for item in items]
-        downloaded = await asyncio.gather(*tasks)
+    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)
 
-    valid = [(item, img) for item, img in zip(items, downloaded) if img is not None]
-    if not valid:
+    if not valid_images:
         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")
+    grid = build_image_grid(images=valid_images, labels=valid_labels)
+    b64, _ = encode_base64(grid, format="PNG")
+    return b64
 
 
 def _parse_srt_to_outline(srt_content: str) -> List[Dict[str, str]]:

+ 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继续调研。"
-      }
-    ]
-  }
-]

+ 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"])
 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 实现
 

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

@@ -0,0 +1,320 @@
+"""
+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",
+            },
+        },
+    },
+)
+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))

+ 30 - 5
agent/tools/builtin/knowledge.py

@@ -1,11 +1,7 @@
 """
 知识管理工具 - KnowHub API 封装
 
-提供两种知识操作方式:
-- knowledge_search: 直接调用 KnowHub HTTP API 检索知识(两阶段:语义路由 + 质量精排)
-- knowledge_save: 直接调用 KnowHub HTTP API 保存知识
-
-所有工具通过 HTTP API 调用 KnowHub Server。
+所有工具通过 HTTP API 调用 KnowHub Server,直接读写底层数据库。
 """
 
 import os
@@ -742,3 +738,32 @@ 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))

+ 50 - 0
agent/tools/builtin/librarian.py

@@ -178,3 +178,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))

+ 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)
-        )

+ 24 - 139
agent/tools/builtin/search.py

@@ -9,19 +9,14 @@
 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
+from agent.tools.utils.image import build_image_grid, encode_base64, load_images
 
 
 # API 基础配置
@@ -31,155 +26,45 @@ 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。
-    每个格子:序号 + 封面图 + 标题
+    复用 agent.tools.utils.image 中的共享拼图逻辑。
     """
     if not posts:
         return None
 
-    # 收集有封面图的帖子,记录原始序号
-    items = []
-    for idx, post in enumerate(posts):
+    # 收集有封面图的帖子
+    urls: List[str] = []
+    titles: List[str] = []
+    for post in 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:
+            urls.append(cover_url)
+            titles.append(post.get("title", "") or "")
+
+    if not urls:
         return None
 
-    # 并发下载封面图
-    async with httpx.AsyncClient() as client:
-        tasks = [_download_image(client, item["url"]) for item in items]
-        downloaded = await asyncio.gather(*tasks)
+    # 并发加载图片
+    loaded = await load_images(urls)
+
+    # 过滤加载失败的(保持 url 和 title 对齐)
+    valid_images = []
+    valid_labels = []
+    for (_, img), title in zip(loaded, titles):
+        if img is not None:
+            valid_images.append(img)
+            valid_labels.append(title)
 
-    # 过滤下载失败的
-    valid = [(item, img) for item, img in zip(items, downloaded) if img is not None]
-    if not valid:
+    if not valid_images:
         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")
+    grid = build_image_grid(images=valid_images, labels=valid_labels)
+    b64, _ = encode_base64(grid, format="PNG")
+    return b64
 
 
 class PostSearchChannel(str, Enum):

+ 219 - 111
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
 
@@ -254,10 +279,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,
         )
 
 
@@ -292,16 +318,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 +393,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 +423,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,
         )
 
 
@@ -389,20 +467,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 +521,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 +543,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)
+# 注意:image_uploader 和 image_downloader 不再注册为 Agent 工具。
+# toolhub_call 已内置完整的图片管线(输入自动上传,输出自动下载保存),
+# 无需单独暴露上传/下载工具。以下函数保留供内部或 CLI 使用。
 
-    当你需要获取一张本地图片的 HTTP 链接时使用此工具。
-    传入本地文件路径,自动上传到 OSS 并返回永久 CDN URL。
 
-    注意:在调用 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 +603,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 +659,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))

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

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

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

@@ -0,0 +1,289 @@
+"""
+图片处理共享工具
+
+提供批量读图、降采样、网格拼图等通用逻辑。供 read_images、search_posts、
+youtube_search 等工具共享,避免代码重复。
+
+核心函数:
+- 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

Разница между файлами не показана из-за своего большого размера
+ 4 - 0
cache/knowledge_pending/knowledge-2026-04-09-x-portrait-workflows.json


+ 80 - 0
direct_insert.py

@@ -0,0 +1,80 @@
+#!/usr/bin/env python3
+"""
+直接插入知识到数据库的脚本
+"""
+import sys
+import os
+import uuid
+import time
+import asyncio
+from datetime import datetime, timezone
+
+sys.path.insert(0, '/root/Agent')
+
+# 设置环境变量
+os.environ['KNOWHUB_DB'] = 'gp-t4n72471pkmt4b9q7o-master.gpdbmaster.singapore.rds.aliyuncs.com'
+os.environ['KNOWHUB_PORT'] = '5432'
+os.environ['KNOWHUB_USER'] = 'aiddit_aigc'
+os.environ['KNOWHUB_PASSWORD'] = '%a&&yqNxg^V1$toJ*WOa^-b^X=QJ'
+os.environ['KNOWHUB_DB_NAME'] = 'knowhub'
+
+from knowhub.knowhub_db.pg_store import PostgreSQLStore
+from knowhub.embeddings import get_embedding
+
+# 测试知识数据
+knowledge_data = {
+    'task': '接口测试验证条目',
+    'content': '这是一条用于验证 upload 接口是否可用的测试知识,可以删除。',
+    'types': ['experience'],
+    'tags': {'source': 'api_test'},
+    'score': 1
+}
+
+async def main():
+    print('正在连接数据库...')
+    store = PostgreSQLStore()
+
+    # 生成 ID
+    now = datetime.now(timezone.utc)
+    knowledge_id = f'knowledge-{now.strftime("%Y%m%d")}-{uuid.uuid4().hex[:8]}'
+    message_id = f'msg-{uuid.uuid4().hex[:12]}'
+
+    # 生成 embedding
+    print('正在生成 embedding...')
+    task_embedding = await get_embedding(knowledge_data['task'])
+    content_embedding = await get_embedding(knowledge_data['content'])
+
+    # 构建知识记录
+    knowledge = {
+        'id': knowledge_id,
+        'message_id': message_id,
+        'task': knowledge_data['task'],
+        'content': knowledge_data['content'],
+        'types': knowledge_data['types'],
+        'tags': knowledge_data.get('tags', {}),
+        'tag_keys': list(knowledge_data.get('tags', {}).keys()),
+        'scopes': ['org:cybertogether'],
+        'owner': 'system',
+        'source': {'category': 'execution'},
+        'eval': {'score': knowledge_data.get('score', 3)},
+        'task_embedding': task_embedding,
+        'content_embedding': content_embedding,
+        'created_at': int(time.time()),
+        'updated_at': int(time.time()),
+        'status': 'approved',
+    }
+
+    print(f'正在插入知识条目...')
+    store.insert(knowledge)
+    store.close()
+
+    print(f'✅ 成功插入知识条目!')
+    print(f'  ID: {knowledge_id}')
+    print(f'  Task: {knowledge_data["task"]}')
+    print(f'  Types: {knowledge_data["types"]}')
+    
+    return knowledge_id
+
+if __name__ == '__main__':
+    knowledge_id = asyncio.run(main())
+    print(f'\n最终知识 ID: {knowledge_id}')

+ 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)

+ 132 - 0
insert_ai_workflow_capabilities.py

@@ -0,0 +1,132 @@
+#!/usr/bin/env python3
+"""
+插入 AI 创作工作流的 16 个通用能力模块知识条目
+"""
+import os
+import sys
+import json
+import uuid
+from datetime import datetime, timezone
+from pathlib import Path
+
+# 添加项目路径
+sys.path.insert(0, '/root/Agent')
+
+os.environ['OPEN_ROUTER_API_KEY'] = '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'
+os.environ['QWEN_API_KEY'] = 'sk-9453c827b9e14108b53d2b30ef7c75fe'
+os.environ['KNOWHUB_DB'] = 'gp-t4n72471pkmt4b9q7o-master.gpdbmaster.singapore.rds.aliyuncs.com'
+os.environ['KNOWHUB_PORT'] = '5432'
+os.environ['KNOWHUB_USER'] = 'aiddit_aigc'
+os.environ['KNOWHUB_PASSWORD'] = '%a&&yqNxg^V1$toJ*WOa^-b^X=QJ'
+os.environ['KNOWHUB_DB_NAME'] = 'knowhub'
+
+from knowhub.knowhub_db.pg_store import PostgreSQLStore
+from knowhub.embeddings import get_embedding
+
+# 知识数据 - AI 创作工作流的 16 个通用能力模块
+knowledge_data = {
+    "task": "AI 创作工作流的 16 个通用能力模块",
+    "content": """## 能力模块清单
+
+### 素材准备类
+1. **准备参考素材**:收集或生成用于后续创作的参考图像、纹理、角色基础等素材
+2. **生成参考图表**:生成角色参考表、多视角图、多表情图等参考资料
+
+### 角色定义类
+3. **定义角色特征**:设定人物面部特征、表情状态、身份一致性等角色属性
+
+### 场景构建类
+4. **构建场景环境**:创建或合成场景背景、虚拟环境、空间布局
+
+### 视觉控制类
+5. **配置光影效果**:设置光源方向、灯光类型、明暗对比等光照条件
+6. **控制姿态构图**:控制人物姿态、画面构图、视角、摄影参数
+7. **应用风格控制**:应用风格迁移、保持视觉一致性、注入特定美学风格
+8. **处理纹理效果**:添加纹理、颗粒、质感、色调等视觉效果
+9. **创建纹理布局**:创建四方连续、渐变网格、矢量图形等纹理布局
+
+### 生成执行类
+10. **执行内容生成**:执行核心的图像、视频、渲染生成操作
+11. **配置控制技术**:配置 ControlNet、深度图、姿态图等控制条件
+12. **配置工作流**:配置工具节点、构建流程、设置参数
+
+### 后期处理类
+13. **修复细节瑕疵**:对局部区域进行重绘、修复瑕疵、优化细节
+14. **增强图像质量**:提升图像分辨率、质量增强、最终精修
+15. **后期合成处理**:抠图、选区创建、图像合成、图层处理
+16. **视频后期处理**:视频剪辑、颜色分级、特效增强
+
+## 跨品类复用性
+以上模块均从 5 个不同品类的工作流(角色创作、人像精修、肖像摄影、光影控制、纹理设计)中抽象得出,可跨品类复用。""",
+    "types": ["strategy"],
+    "tags": {
+        "domain": "AI 创作",
+        "category": "能力模块",
+        "source": "工作流分析 pipeline"
+    },
+    "score": 5,
+    "source": {"category": "research"}
+}
+
+print("正在连接数据库...")
+store = PostgreSQLStore()
+
+# 生成 ID
+now = datetime.now(timezone.utc)
+knowledge_id = f"knowledge-{now.strftime('%Y%m%d')}-{uuid.uuid4().hex[:8]}"
+message_id = f"msg-{uuid.uuid4().hex[:12]}"
+
+# 生成 embedding
+print("正在生成 embedding...")
+task_embedding = get_embedding(knowledge_data["task"])
+content_embedding = get_embedding(knowledge_data["content"])
+
+# 构建知识记录
+knowledge = {
+    "id": knowledge_id,
+    "message_id": message_id,
+    "task": knowledge_data["task"],
+    "content": knowledge_data["content"],
+    "types": knowledge_data["types"],
+    "tags": knowledge_data["tags"],
+    "tag_keys": list(knowledge_data["tags"].keys()),
+    "scopes": [],
+    "owner": "librarian",
+    "source": knowledge_data["source"],
+    "eval": {"score": knowledge_data["score"]},
+    "created_at": now.isoformat(),
+    "updated_at": now.isoformat(),
+    "status": "approved",
+    "task_embedding": task_embedding,
+    "content_embedding": content_embedding,
+    "requirement_ids": [],
+    "capability_ids": [],
+    "tool_ids": [],
+    "resource_ids": [],
+}
+
+print(f"正在插入知识:{knowledge_id}")
+print(f"  Task: {knowledge['task']}")
+print(f"  Types: {knowledge['types']}")
+print(f"  Tags: {knowledge['tags']}")
+print(f"  Content 长度:{len(knowledge['content'])} 字符")
+
+store.insert(knowledge)
+
+print(f"\n✓ 知识已成功入库!")
+print(f"  ID: {knowledge_id}")
+print(f"  Message ID: {message_id}")
+
+# 验证插入
+retrieved = store.get_by_id(knowledge_id)
+if retrieved:
+    print(f"\n✓ 验证成功:已检索到刚插入的知识")
+    print(f"  Task: {retrieved['task']}")
+    print(f"  Content 长度:{len(retrieved['content'])} 字符")
+    print(f"  Types: {retrieved['types']}")
+    print(f"  Tags: {retrieved['tags']}")
+else:
+    print(f"\n✗ 验证失败:无法检索到刚插入的知识")
+
+store.close()
+print("\n数据库连接已关闭")

+ 73 - 0
insert_knowledge.py

@@ -0,0 +1,73 @@
+#!/usr/bin/env python3
+"""
+插入知识条目到 PostgreSQL 数据库
+"""
+
+import asyncio
+import uuid
+import time
+import sys
+import os
+
+# 添加项目路径
+sys.path.insert(0, '/root/Agent')
+
+from knowhub.knowhub_db.pg_store import PostgreSQLStore
+from knowhub.embeddings import get_embedding
+
+
+async def main():
+    # 知识数据
+    task = "人物近景肖像"
+    content = "生成人物近景半身或胸部以上画面、突出面部表情和情绪、背景虚化的核心工序要素包括:1.近景/半身构图 2.面部表情细节 3.背景虚化效果 4.皮肤质感真实 5.眼神互动感"
+    types = ["strategy"]
+    tags = {"domain": "AI 生图", "source": "多渠调研汇总", "task": "人物近景肖像"}
+    score = 5
+    source = {"category": "research"}
+    
+    # 生成唯一 ID
+    knowledge_id = str(uuid.uuid4())
+    message_id = str(uuid.uuid4())
+    current_time = int(time.time())
+    
+    # 生成 embeddings
+    print(f"正在生成 task embedding...")
+    task_embedding = await get_embedding(task)
+    print(f"正在生成 content embedding...")
+    content_embedding = await get_embedding(content)
+    
+    # 构建知识对象
+    knowledge = {
+        'id': knowledge_id,
+        'message_id': message_id,
+        'task': task,
+        'content': content,
+        'types': types,
+        'tags': tags,
+        'tag_keys': list(tags.keys()),
+        'scopes': ['org:cybertogether'],  # 默认可见范围
+        'owner': 'system',  # 默认所有者
+        'source': source,
+        'eval': {'score': score},
+        'task_embedding': task_embedding,
+        'content_embedding': content_embedding,
+        'created_at': current_time,
+        'updated_at': current_time,
+        'status': 'approved',
+    }
+    
+    # 插入数据库
+    print(f"正在插入知识条目到 PostgreSQL...")
+    store = PostgreSQLStore()
+    store.insert(knowledge)
+    store.close()
+    
+    print(f"成功插入知识条目!")
+    print(f"  ID: {knowledge_id}")
+    print(f"  Task: {task}")
+    print(f"  Types: {types}")
+    print(f"  Score: {score}")
+
+
+if __name__ == '__main__':
+    asyncio.run(main())

+ 91 - 0
insert_knowledge_direct.py

@@ -0,0 +1,91 @@
+import os
+import sys
+import json
+import uuid
+from datetime import datetime, timezone
+from pathlib import Path
+
+# 添加项目路径
+sys.path.insert(0, '/root/Agent')
+
+os.environ['OPEN_ROUTER_API_KEY'] = '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'
+os.environ['QWEN_API_KEY'] = 'sk-9453c827b9e14108b53d2b30ef7c75fe'
+os.environ['KNOWHUB_DB'] = 'gp-t4n72471pkmt4b9q7o-master.gpdbmaster.singapore.rds.aliyuncs.com'
+os.environ['KNOWHUB_PORT'] = '5432'
+os.environ['KNOWHUB_USER'] = 'aiddit_aigc'
+os.environ['KNOWHUB_PASSWORD'] = '%a&&yqNxg^V1$toJ*WOa^-b^X=QJ'
+os.environ['KNOWHUB_DB_NAME'] = 'knowhub'
+
+from knowhub.knowhub_db.pg_store import PostgreSQLStore
+from knowhub.embeddings import get_embedding
+
+# 知识数据
+knowledge_data = {
+    "task": "人物近景肖像",
+    "content": "生成人物近景半身或胸部以上画面、突出面部表情和情绪、背景虚化的核心工序要素包括:\n\n1. **近景/半身构图**:使用 tight medium close-up、extreme close-up、中近景半身等提示词;推荐 85mm 镜头视角;构图比例 2:3 或 9:16(竖版)、3:4(职业照)\n\n2. **面部表情细节**:使用锁脸技术(nano-banana 锁脸功能)、微表情描述(slightly parted lips、calm introspective expression)、多表情生成(3×3 网格生成 9 种表情);关键提示词:direct steady eye contact、subtle catchlights in eyes\n\n3. **背景虚化效果**:推荐参数 85mm 镜头 + f/1.4-f/1.8 光圈;提示词:shallow depth of field、extremely shallow depth of field、soft bokeh blur、blurring out the neck, ear, and background completely;后期工具:Photoshop Lens Blur、DxO Photolab 9 Bokeh\n\n4. **皮肤质感真实**:避免塑料感的关键提示词:realistic skin texture with visible pores and natural imperfections、no plastic smoothing、dewy smooth texture;修复工具:Enhancor.ai Skin Realism\n\n5. **眼神互动感**:direct eye contact、catchlights in eyes、gentle and engaging eyes、looking directly at the camera",
+    "types": ["strategy"],
+    "tags": {
+        "domain": "AI 生图",
+        "source": "多渠调研汇总",
+        "task": "人物近景肖像"
+    },
+    "score": 5,
+    "source": {"category": "research"}
+}
+
+print("正在连接数据库...")
+store = PostgreSQLStore()
+
+# 生成 ID
+now = datetime.now(timezone.utc)
+knowledge_id = f"knowledge-{now.strftime('%Y%m%d')}-{uuid.uuid4().hex[:8]}"
+message_id = f"msg-{uuid.uuid4().hex[:12]}"
+
+# 生成 embedding
+print("正在生成 embedding...")
+task_embedding = get_embedding(knowledge_data["task"])
+content_embedding = get_embedding(knowledge_data["content"])
+
+# 构建知识记录
+knowledge = {
+    "id": knowledge_id,
+    "message_id": message_id,
+    "task": knowledge_data["task"],
+    "content": knowledge_data["content"],
+    "types": knowledge_data["types"],
+    "tags": knowledge_data["tags"],
+    "tag_keys": list(knowledge_data["tags"].keys()),
+    "scopes": [],
+    "owner": "librarian",
+    "source": knowledge_data["source"],
+    "eval": {"score": knowledge_data["score"]},
+    "created_at": now.isoformat(),
+    "updated_at": now.isoformat(),
+    "status": "approved",
+    "task_embedding": task_embedding,
+    "content_embedding": content_embedding,
+    "requirement_ids": [],
+    "capability_ids": [],
+    "tool_ids": [],
+    "resource_ids": [],
+}
+
+print(f"正在插入知识:{knowledge_id}")
+print(f"  Task: {knowledge['task']}")
+print(f"  Types: {knowledge['types']}")
+print(f"  Tags: {knowledge['tags']}")
+
+store.insert(knowledge)
+
+print(f"\n✓ 知识已成功入库!")
+print(f"  ID: {knowledge_id}")
+print(f"  Message ID: {message_id}")
+
+# 验证插入
+retrieved = store.get_by_id(knowledge_id)
+if retrieved:
+    print(f"\n✓ 验证成功:已检索到刚插入的知识")
+    print(f"  Task: {retrieved['task']}")
+    print(f"  Content 长度:{len(retrieved['content'])} 字符")
+else:
+    print(f"\n✗ 验证失败:无法检索到刚插入的知识")

+ 1 - 0
knowhub/agents/librarian.py

@@ -66,6 +66,7 @@ def get_librarian_config(enable_db_commit: bool = ENABLE_DATABASE_COMMIT) -> Run
             enable_injection=False,
         ),
         tools=tools,
+        exclude_tools=["ask_knowledge", "upload_knowledge", "bash_command", "grep_content", "glob_files"],
     )
 
 

+ 5 - 0
knowhub/agents/librarian_agent.prompt

@@ -44,5 +44,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$
 

+ 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` 等字段)。

+ 44 - 7
knowhub/frontend/src/App.tsx

@@ -1,4 +1,7 @@
+import { useState } from 'react';
+import { useNavigate, useLocation } from 'react-router-dom';
 import { MainLayout } from './layouts/MainLayout';
+import type { TabId } from './components/layout/Navbar';
 import { Dashboard } from './pages/Dashboard';
 import { Relations } from './pages/Relations';
 import { Requirements } from './pages/Requirements';
@@ -6,15 +9,49 @@ import { Capabilities } from './pages/Capabilities';
 import { Tools } from './pages/Tools';
 import { Knowledge } from './pages/Knowledge';
 
+const PATH_TO_TAB: Record<string, TabId> = {
+  '/': 'dashboard',
+  '/dashboard': 'dashboard',
+  '/relations': 'relations',
+  '/requirements': 'requirements',
+  '/capabilities': 'capabilities',
+  '/tools': 'tools',
+  '/knowledge': 'knowledge',
+};
+
+const TAB_TO_PATH: Record<TabId, string> = {
+  dashboard: '/',
+  relations: '/relations',
+  requirements: '/requirements',
+  capabilities: '/capabilities',
+  tools: '/tools',
+  knowledge: '/knowledge',
+};
+
 function App() {
+  const navigate = useNavigate();
+  const location = useLocation();
+  const [pendingDashboardNode, setPendingDashboardNode] = useState<string | null>(null);
+
+  const activeTab: TabId = PATH_TO_TAB[location.pathname] ?? 'dashboard';
+
+  const handleTabChange = (tab: TabId) => {
+    navigate(TAB_TO_PATH[tab]);
+  };
+
+  const navigateToDashboard = (nodeName: string) => {
+    setPendingDashboardNode(nodeName);
+    navigate('/');
+  };
+
   return (
-    <MainLayout>
-      {(activeTab) => {
-        switch (activeTab) {
+    <MainLayout activeTab={activeTab} onTabChange={handleTabChange}>
+      {(tab) => {
+        switch (tab) {
           case 'dashboard':
-            return <Dashboard />;
+            return <Dashboard pendingNode={pendingDashboardNode} onPendingConsumed={() => setPendingDashboardNode(null)} />;
           case 'relations':
-            return <Relations />;
+            return <Relations onNavigateToDashboard={navigateToDashboard} />;
           case 'requirements':
             return <Requirements />;
           case 'capabilities':
@@ -24,11 +61,11 @@ function App() {
           case 'knowledge':
             return <Knowledge />;
           default:
-            return <Dashboard />;
+            return <Dashboard pendingNode={null} onPendingConsumed={() => {}} />;
         }
       }}
     </MainLayout>
   );
 }
 
-export default App;
+export default App;

+ 23 - 51
knowhub/frontend/src/layouts/MainLayout.tsx

@@ -1,8 +1,10 @@
-import { useState, useEffect, useRef } from 'react';
+import { useEffect, useRef } from 'react';
 import { Navbar } from '../components/layout/Navbar';
 import type { TabId } from '../components/layout/Navbar';
 
 interface MainLayoutProps {
+  activeTab: TabId;
+  onTabChange: (tab: TabId) => void;
   children: (activeTab: TabId) => React.ReactNode;
 }
 
@@ -10,9 +12,7 @@ interface MainLayoutProps {
 const TAB_ORDER: TabId[] = ['dashboard', 'relations', 'requirements', 'capabilities', 'tools', 'knowledge'];
 const MIN_SWITCH_INTERVAL = 1000;
 
-export function MainLayout({ children }: MainLayoutProps) {
-  const [activeTab, setActiveTab] = useState<TabId>('dashboard');
-
+export function MainLayout({ activeTab, onTabChange, children }: MainLayoutProps) {
   const lastSwitchTime = useRef(0);
   const accumX = useRef(0);
   const touchStartX = useRef(0);
@@ -22,17 +22,12 @@ export function MainLayout({ children }: MainLayoutProps) {
     const handleTabSwitch = (direction: 1 | -1) => {
       const now = Date.now();
       if (now - lastSwitchTime.current < MIN_SWITCH_INTERVAL) return;
-
-      setActiveTab(prev => {
-        const currentIndex = TAB_ORDER.indexOf(prev);
-        const nextIndex = currentIndex + direction;
-
-        if (nextIndex >= 0 && nextIndex < TAB_ORDER.length) {
-          lastSwitchTime.current = now;
-          return TAB_ORDER[nextIndex];
-        }
-        return prev;
-      });
+      const currentIndex = TAB_ORDER.indexOf(activeTab);
+      const nextIndex = currentIndex + direction;
+      if (nextIndex >= 0 && nextIndex < TAB_ORDER.length) {
+        lastSwitchTime.current = now;
+        onTabChange(TAB_ORDER[nextIndex]);
+      }
     };
 
     const isInsideHorizontallyScrollable = (targetNode: EventTarget | null) => {
@@ -40,9 +35,7 @@ export function MainLayout({ children }: MainLayoutProps) {
       while (target && target !== document.body) {
         if (target.scrollWidth > target.clientWidth) {
           const style = window.getComputedStyle(target);
-          if (style.overflowX === 'auto' || style.overflowX === 'scroll' || style.overflowX === 'overlay') {
-            return true;
-          }
+          if (style.overflowX === 'auto' || style.overflowX === 'scroll' || style.overflowX === 'overlay') return true;
         }
         target = target.parentElement;
       }
@@ -53,25 +46,13 @@ export function MainLayout({ children }: MainLayoutProps) {
       if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) return;
       if (isInsideHorizontallyScrollable(e.target)) return;
       if (e.cancelable) e.preventDefault();
-
       const now = Date.now();
-      if (now - lastSwitchTime.current < MIN_SWITCH_INTERVAL) {
-        accumX.current = 0;
-        return;
-      }
-
+      if (now - lastSwitchTime.current < MIN_SWITCH_INTERVAL) { accumX.current = 0; return; }
       accumX.current += e.deltaX;
-
       clearTimeout(wheelTimeout.current);
       wheelTimeout.current = setTimeout(() => { accumX.current = 0; }, 150);
-
-      if (accumX.current > 120) {
-        handleTabSwitch(1);
-        accumX.current = 0;
-      } else if (accumX.current < -120) {
-        handleTabSwitch(-1);
-        accumX.current = 0;
-      }
+      if (accumX.current > 120) { handleTabSwitch(1); accumX.current = 0; }
+      else if (accumX.current < -120) { handleTabSwitch(-1); accumX.current = 0; }
     };
 
     const handleTouchStart = (e: TouchEvent) => {
@@ -81,10 +62,8 @@ export function MainLayout({ children }: MainLayoutProps) {
 
     const handleTouchEnd = (e: TouchEvent) => {
       if (isInsideHorizontallyScrollable(e.target)) return;
-
       const now = Date.now();
       if (now - lastSwitchTime.current < MIN_SWITCH_INTERVAL) return;
-
       const diffX = touchStartX.current - e.changedTouches[0].clientX;
       if (diffX > 60) handleTabSwitch(1);
       else if (diffX < -60) handleTabSwitch(-1);
@@ -93,42 +72,35 @@ export function MainLayout({ children }: MainLayoutProps) {
     window.addEventListener('wheel', handleWheel, { passive: false });
     window.addEventListener('touchstart', handleTouchStart, { passive: true });
     window.addEventListener('touchend', handleTouchEnd, { passive: true });
-
     return () => {
       window.removeEventListener('wheel', handleWheel);
       window.removeEventListener('touchstart', handleTouchStart);
       window.removeEventListener('touchend', handleTouchEnd);
       clearTimeout(wheelTimeout.current);
     };
-  }, []);
+  }, [activeTab, onTabChange]);
 
   const currentIndex = TAB_ORDER.indexOf(activeTab);
   const totalTabs = TAB_ORDER.length;
 
   return (
     <div className="min-h-screen bg-slate-50 flex flex-col overflow-x-hidden" style={{ overscrollBehaviorX: 'none' }}>
-      <Navbar activeTab={activeTab} onTabChange={setActiveTab} />
-
-      <main className="flex-1 w-full overflow-x-hidden relative">
+      <Navbar activeTab={activeTab} onTabChange={onTabChange} />
+      <main className="w-full overflow-hidden relative" style={{ height: 'calc(100vh - 60px)' }}>
         <div
           className="flex h-full will-change-transform"
           style={{
-            // 轨道总宽度由标签页数量动态决定,6个页面就是 600%
             width: `${totalTabs * 100}%`,
-            // 偏移量
             transform: `translateX(-${(currentIndex / totalTabs) * 100}%)`,
             transition: 'transform 0.7s cubic-bezier(0.34, 1.3, 0.64, 1)'
           }}
         >
           {TAB_ORDER.map((tab) => (
-            <div
-              key={tab}
-              className="shrink-0 flex justify-center pb-12"
-              // 每个页面的宽度是总宽度的 1/6
-              style={{ width: `${100 / totalTabs}%` }}
-            >
-              <div className="w-full max-w-[1600px] px-6 py-6">
-                {children(tab)}
+            <div key={tab} className="shrink-0 h-full overflow-y-auto" style={{ width: `${100 / totalTabs}%` }}>
+              <div className="flex justify-center pb-12">
+                <div className="w-full max-w-[1600px] px-6 py-6">
+                  {children(tab)}
+                </div>
               </div>
             </div>
           ))}
@@ -136,4 +108,4 @@ export function MainLayout({ children }: MainLayoutProps) {
       </main>
     </div>
   );
-}
+}

+ 4 - 1
knowhub/frontend/src/main.tsx

@@ -1,10 +1,13 @@
 import { StrictMode } from 'react'
 import { createRoot } from 'react-dom/client'
+import { BrowserRouter } from 'react-router-dom'
 import './index.css'
 import App from './App.tsx'
 
 createRoot(document.getElementById('root')!).render(
   <StrictMode>
-    <App />
+    <BrowserRouter>
+      <App />
+    </BrowserRouter>
   </StrictMode>,
 )

+ 11 - 1
knowhub/frontend/src/pages/Dashboard.tsx

@@ -67,15 +67,24 @@ function CompactListCard({ data, type, onDrillDown }: { data: any, type: 'req' |
 }
 
 
-export function Dashboard() {
+export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: string | null, onPendingConsumed?: () => void }) {
   type NavItem = { type: 'node' | 'req' | 'cap' | 'tool' | 'know', data: any };
   const [treeData, setTreeData] = useState<any>(null);
   const [selectedNode, setSelectedNode] = useState<any>(null);
   const [navStack, setNavStack] = useState<NavItem[]>([]);
+  const [nameToNodeMap, setNameToNodeMap] = useState<Record<string, any>>({});
   const [dbData, setDbData] = useState<{ reqs: any[], caps: any[], tools: any[], know: any[] }>({
     reqs: [], caps: [], tools: [], know: []
   });
 
+  // 处理来自其他页面的跳转请求
+  useEffect(() => {
+    if (pendingNode && nameToNodeMap[pendingNode]) {
+      setSelectedNode(nameToNodeMap[pendingNode]);
+      onPendingConsumed?.();
+    }
+  }, [pendingNode, nameToNodeMap]);
+
   useEffect(() => {
     if (selectedNode) setNavStack([{ type: 'node', data: selectedNode }]);
     else setNavStack([]);
@@ -162,6 +171,7 @@ export function Dashboard() {
           });
         };
         buildNameMap([data]);
+        setNameToNodeMap(nameToNode);
 
         reqs.forEach((r: any) => {
           (r.source_nodes || []).forEach((sn: any) => {

+ 133 - 163
knowhub/frontend/src/pages/Relations.tsx

@@ -3,194 +3,164 @@ import { Target, Cpu, Wrench, ListTree, Waypoints } from 'lucide-react';
 import { getRequirements, getCapabilities, getTools } from '../services/api';
 import { cn } from '../lib/utils';
 
-export function Relations() {
+export function Relations({ onNavigateToDashboard }: { onNavigateToDashboard?: (nodeName: string) => void }) {
   const [reqs, setReqs] = useState<any[]>([]);
   const [caps, setCaps] = useState<any[]>([]);
   const [tools, setTools] = useState<any[]>([]);
-  const [isLoading, setIsLoading] = useState(true);
-
-  // 记录当前点击高亮的节点 ID (格式如 "req:123" 或 "cap:456")
-  const [activeNode, setActiveNode] = useState<string | null>(null);
+  const [activeId, setActiveId] = useState<string | null>(null);
 
   useEffect(() => {
-    setIsLoading(true);
-    Promise.all([
-      getRequirements(1000),
-      getCapabilities(1000),
-      getTools(1000)
-    ]).then(([r, c, t]) => {
-      // --- 关键调试代码 ---
-      console.log("接口返回原始数据:", { r, c, t });
-
-      // 兼容两种返回格式:
-      // 1. { results: [...] }
-      // 2. 直接就是一个数组 [...]
-      const extract = (data: any) => (Array.isArray(data) ? data : (data?.results || []));
-
-      setReqs(extract(r));
-      setCaps(extract(c));
-      setTools(extract(t));
-      setIsLoading(false);
-    }).catch(err => {
-      console.error("接口请求彻底失败:", err);
-      setIsLoading(false); // 必须停掉转圈,否则用户没法用
-    });
+    Promise.all([getRequirements(1000), getCapabilities(1000), getTools(1000)])
+      .then(([r, c, t]) => {
+        setReqs(r.results || []);
+        setCaps(c.results || []);
+        setTools(t.results || []);
+      });
   }, []);
 
-  // 【核心算法 1:构建双向连通图】
-  // 把分散的表结构组合成一个内存图 (Adjacency List),便于递归寻路
-  const graph = useMemo(() => {
-     const g = new Map<string, string[]>();
-     const addEdge = (n1: string, n2: string) => {
-       if (!g.has(n1)) g.set(n1, []);
-       if (!g.has(n2)) g.set(n2, []);
-       // 建立无向图的双向连接
-       if (!g.get(n1)!.includes(n2)) g.get(n1)!.push(n2);
-       if (!g.get(n2)!.includes(n1)) g.get(n2)!.push(n1);
-     };
-
-     // 从 能力 寻找其关联的 需求
-     caps.forEach(c => {
-       (c.requirement_ids || []).forEach((rid: string) => addEdge(`cap:${c.id}`, `req:${rid}`));
-     });
-     // 从 工具 寻找其关联的 能力
-     tools.forEach(t => {
-       (t.capability_ids || []).forEach((cid: string) => addEdge(`tool:${t.id}`, `cap:${cid}`));
-     });
-     return g;
-  }, [caps, tools]);
-
-  // 【核心算法 2:BFS 广度优先搜索】
-  // 当选中一个节点时,找出网络中所有与其直接或间接连通的节点集合
-  const connectedNodes = useMemo(() => {
-     if (!activeNode) return new Set<string>();
-     const visited = new Set<string>();
-     const queue = [activeNode];
-     visited.add(activeNode);
-
-     while (queue.length > 0) {
-       const curr = queue.shift()!;
-       const neighbors = graph.get(curr) || [];
-       for (const n of neighbors) {
-         if (!visited.has(n)) {
-           visited.add(n);
-           queue.push(n);
-         }
-       }
-     }
-     return visited;
-  }, [activeNode, graph]);
+  // 核心:构建全网映射表
+  const adjacencyMap = useMemo(() => {
+    const map = new Map<string, Set<string>>();
+    const add = (a: string, b: string) => {
+      if (!map.has(a)) map.set(a, new Set());
+      if (!map.has(b)) map.set(b, new Set());
+      map.get(a)!.add(b);
+      map.get(b)!.add(a);
+    };
+
+    reqs.forEach(r => {
+      (r.capability_ids || []).forEach((cid: string) => add(`req:${r.id}`, `cap:${cid}`));
+    });
+    caps.forEach(c => {
+      (c.tool_ids || c.capability_ids || []).forEach((tid: string) => add(`cap:${c.id}`, `tool:${tid}`));
+      (c.requirement_ids || []).forEach((rid: string) => add(`cap:${c.id}`, `req:${rid}`));
+    });
+    tools.forEach(t => {
+      (t.capability_ids || []).forEach((cid: string) => add(`tool:${t.id}`, `cap:${cid}`));
+    });
+    return map;
+  }, [reqs, caps, tools]);
+
+  // 列顺序:只允许向右(序号更大)方向遍历,防止回路
+  const colOrder: Record<string, number> = { req: 0, proc: 1, cap: 2, tool: 3 };
+  const getColType = (nodeId: string) => nodeId.split(':')[0];
+
+  // 核心:有向 BFS,携带方向状态,禁止 U 形回头
+  // dir: 0=起点(双向), 1=向右, -1=向左
+  const relatedIds = useMemo(() => {
+    if (!activeId) return new Set<string>();
+    const visited = new Set<string>([activeId]);
+    const queue: [string, number][] = [[activeId, 0]];
+    while (queue.length > 0) {
+      const [u, dir] = queue.shift()!;
+      const uOrder = colOrder[getColType(u)] ?? -1;
+      const neighbors = adjacencyMap.get(u) || new Set();
+      neighbors.forEach(v => {
+        if (visited.has(v)) return;
+        const vOrder = colOrder[getColType(v)] ?? -1;
+        const goRight = vOrder > uOrder;
+        const goLeft = vOrder < uOrder;
+        const allowed = dir === 0 || (dir === 1 && goRight) || (dir === -1 && goLeft);
+        if (allowed) {
+          visited.add(v);
+          queue.push([v, goRight ? 1 : -1]);
+        }
+      });
+    }
+    return visited;
+  }, [activeId, adjacencyMap]);
+
+  // 高亮元素上浮:有选中时把相关项排到前面
+  const sortedItems = (items: any[], type: string) => {
+    if (!activeId) return items;
+    const activeType = activeId.split(':')[0];
+    if (type === activeType) return items; // 被点击的列不重排
+    return [...items].sort((a, b) => {
+      const aRel = relatedIds.has(`${type}:${a.id}`) ? 0 : 1;
+      const bRel = relatedIds.has(`${type}:${b.id}`) ? 0 : 1;
+      return aRel - bRel;
+    });
+  };
 
-  // 渲染单个卡片的通用函数
-  const renderCard = (type: string, item: any, Icon: any, colorClass: string, bgClass: string, borderClass: string) => {
+  const renderCard = (type: string, item: any, Icon: any) => {
     const nodeId = `${type}:${item.id}`;
-    const isSelected = activeNode === nodeId; // 自己被点击
-    const isConnected = connectedNodes.has(nodeId); // 自己属于关联网络中的一员
-    const isDimmed = activeNode !== null && !isConnected; // 自己是个局外人(需要变暗)
+    const isSelected = activeId === nodeId;
+    const isRelated = !activeId || relatedIds.has(nodeId);
+    const dimmed = !!activeId && !isRelated;
+
+    // 需求列:展示 source_nodes 树节点标签(和 Dashboard 颜色一致)
+    const sourceNodeTags: string[] = type === 'req'
+      ? (item.source_nodes || []).slice(0, 4).map((sn: any) =>
+          typeof sn === 'object' ? (sn.node_name || sn.name || '') : sn
+        ).filter(Boolean)
+      : [];
+    const extraCount = type === 'req' ? Math.max(0, (item.source_nodes || []).length - 4) : 0;
 
     return (
       <div
-        key={item.id}
-        onClick={() => setActiveNode(isSelected ? null : nodeId)}
+        key={nodeId}
+        onClick={() => setActiveId(isSelected ? null : nodeId)}
         className={cn(
-          "p-3 rounded-xl border transition-all duration-300 cursor-pointer text-left",
-          // 状态 1:当前亲自点击选中的主角
-          isSelected ? `ring-2 ring-offset-1 ${borderClass} bg-white shadow-md scale-[1.02]` :
-          // 状态 2:主角的兄弟姐妹(被点亮)
-          isConnected ? `${bgClass} ${borderClass} bg-white opacity-100` :
-          // 状态 3:无关人员(变暗,黑白)
-          isDimmed ? "opacity-30 grayscale border-slate-100 bg-slate-50 hover:opacity-60" :
-          // 状态 4:没有任何选中时的默认状态
-          `bg-white border-slate-200 hover:${borderClass} hover:shadow-md`
+          "p-3 rounded-xl cursor-pointer mb-2 select-none bg-white transition-all",
+          isSelected
+            ? "border border-orange-400 shadow-[0_0_0_1px_rgba(251,146,60,0.7)]"
+            : isRelated && activeId
+            ? "border border-orange-300"
+            : dimmed
+            ? "border border-transparent opacity-20 grayscale scale-95"
+            : "border border-transparent"
         )}
       >
-        <div className="flex items-start gap-2 mb-1.5">
-          <Icon size={14} className={cn("mt-0.5 shrink-0", isSelected || isConnected ? colorClass : "text-slate-400")} />
-          <span className={cn("text-xs font-bold leading-snug line-clamp-2", isSelected || isConnected ? "text-slate-800" : "text-slate-600")}>
-            {item.name || item.description || item.id}
-          </span>
-        </div>
-        {item.introduction && type === 'tool' && (
-           <div className="text-[10px] text-slate-500 line-clamp-2 leading-relaxed pl-5">
-            {item.introduction}
+        <div className="flex items-start gap-2">
+          <Icon size={14} className={cn("mt-0.5 shrink-0", isSelected ? "text-orange-500" : "text-slate-400")} />
+          <div className="min-w-0">
+            <div className={cn("text-xs font-bold leading-snug", isSelected ? "text-orange-800" : "text-slate-700")}>
+              {item.name || item.description || item.id}
+            </div>
+            {sourceNodeTags.length > 0 && (
+              <div className="flex flex-wrap gap-1 mt-1.5" onClick={e => e.stopPropagation()}>
+                {sourceNodeTags.map((name: string) => (
+                  <span
+                    key={name}
+                    onClick={() => onNavigateToDashboard?.(name)}
+                    className="text-[9px] px-1.5 py-0.5 rounded-md bg-blue-100 text-blue-700 font-medium truncate max-w-[120px] cursor-pointer hover:bg-blue-200 transition-colors"
+                  >
+                    {name}
+                  </span>
+                ))}
+                {extraCount > 0 && (
+                  <span className="text-[9px] px-1.5 py-0.5 rounded-md bg-slate-100 text-slate-400">
+                    +{extraCount}
+                  </span>
+                )}
+              </div>
+            )}
           </div>
-        )}
-        <div className="text-[9px] text-slate-400 font-mono mt-2 pl-5">ID: {item.id.substring(0,6)}...</div>
+        </div>
       </div>
     );
   };
 
-  if (isLoading) {
-     return <div className="flex justify-center items-center h-64"><div className="w-8 h-8 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin"></div></div>;
-  }
-
   return (
-    <div className="flex flex-col h-[calc(100vh-120px)] animate-in fade-in duration-500">
-      {/* 顶部标题区 */}
-      <div className="flex justify-between items-end shrink-0 mb-6">
-        <div>
-          <h1 className="text-2xl font-black text-slate-900 mb-1 flex items-center gap-2">
-            <Waypoints size={24} className="text-indigo-600"/> 全局关系追溯
-          </h1>
-          <p className="text-slate-500 text-sm">选择任意一列的节点,即可递归高亮展示整条产业链(直接与间接关联要素)。</p>
-        </div>
-        {activeNode && (
-          <button
-            onClick={() => setActiveNode(null)}
-            className="px-4 py-2 bg-slate-100 hover:bg-slate-200 text-slate-600 text-sm font-bold rounded-xl transition-colors shadow-sm"
-          >
-            清除选中关系
-          </button>
-        )}
+    <div className="flex flex-col h-[calc(100vh-156px)] animate-in fade-in duration-500">
+      <div className="flex justify-between items-center mb-6 shrink-0">
+        <h1 className="text-2xl font-black text-slate-900 flex items-center gap-2">
+          <Waypoints className="text-indigo-600" /> 全局关系追溯
+        </h1>
+        {activeId && <button onClick={() => setActiveId(null)} className="text-xs bg-slate-200 px-3 py-1.5 rounded-lg font-bold hover:bg-slate-300">清除选中</button>}
       </div>
 
-      {/* 4 列主体表格 */}
       <div className="grid grid-cols-4 gap-4 flex-1 min-h-0">
-
-        {/* 第 1 列:需求 */}
-        <div className="bg-slate-50/50 border border-slate-200 rounded-2xl flex flex-col overflow-hidden">
-          <div className="bg-indigo-50 px-4 py-3 border-b border-indigo-100 font-bold text-indigo-800 flex items-center justify-between shrink-0">
-            <div className="flex items-center gap-2"><Target size={16}/> 业务需求</div>
-            <span className="text-xs bg-white px-2 py-0.5 rounded-full text-indigo-600 shadow-sm">{reqs.length}</span>
-          </div>
-          <div className="p-3 overflow-y-auto custom-scrollbar flex-1 space-y-2">
-            {reqs.map(r => renderCard('req', r, Target, 'text-indigo-600', 'bg-indigo-50/40', 'border-indigo-400'))}
+        {[ {t:'req', l:'业务需求', i:Target, d:reqs}, {t:'proc', l:'生产工序', i:ListTree, d:[]}, {t:'cap', l:'原子能力', i:Cpu, d:caps}, {t:'tool', l:'执行工具', i:Wrench, d:tools} ].map(col => (
+          <div key={col.t} className="flex flex-col h-full bg-slate-100/50 rounded-2xl border border-slate-200 overflow-hidden">
+            <div className="px-4 py-3 font-bold text-slate-700 border-b bg-slate-200/50 flex justify-between">
+              {col.l} <span className="text-slate-400">{col.d.length}</span>
+            </div>
+            <div className="flex-1 overflow-y-auto p-3 custom-scrollbar">
+              {sortedItems(col.d, col.t).map(i => renderCard(col.t, i, col.i))}
+            </div>
           </div>
-        </div>
-
-        {/* 第 2 列:工序(暂空) */}
-        <div className="bg-slate-50/50 border border-slate-200 rounded-2xl flex flex-col overflow-hidden opacity-90">
-          <div className="bg-slate-200 px-4 py-3 border-b border-slate-300 font-bold text-slate-700 flex items-center justify-between shrink-0">
-            <div className="flex items-center gap-2"><ListTree size={16}/> 生产工序</div>
-            <span className="text-xs bg-white px-2 py-0.5 rounded-full text-slate-500 shadow-sm">0</span>
-          </div>
-          <div className="p-4 overflow-y-auto custom-scrollbar flex-1 flex flex-col items-center justify-center text-slate-400 text-sm font-medium border-2 border-dashed border-slate-200 m-3 rounded-xl bg-slate-50/50">
-            预留扩展位,暂无数据
-          </div>
-        </div>
-
-        {/* 第 3 列:能力 */}
-        <div className="bg-slate-50/50 border border-slate-200 rounded-2xl flex flex-col overflow-hidden">
-          <div className="bg-emerald-50 px-4 py-3 border-b border-emerald-100 font-bold text-emerald-800 flex items-center justify-between shrink-0">
-            <div className="flex items-center gap-2"><Cpu size={16}/> 原子能力</div>
-            <span className="text-xs bg-white px-2 py-0.5 rounded-full text-emerald-600 shadow-sm">{caps.length}</span>
-          </div>
-          <div className="p-3 overflow-y-auto custom-scrollbar flex-1 space-y-2">
-            {caps.map(c => renderCard('cap', c, Cpu, 'text-emerald-600', 'bg-emerald-50/40', 'border-emerald-400'))}
-          </div>
-        </div>
-
-        {/* 第 4 列:工具 */}
-        <div className="bg-slate-50/50 border border-slate-200 rounded-2xl flex flex-col overflow-hidden">
-          <div className="bg-amber-50 px-4 py-3 border-b border-amber-100 font-bold text-amber-800 flex items-center justify-between shrink-0">
-            <div className="flex items-center gap-2"><Wrench size={16}/> 执行工具</div>
-            <span className="text-xs bg-white px-2 py-0.5 rounded-full text-amber-600 shadow-sm">{tools.length}</span>
-          </div>
-          <div className="p-3 overflow-y-auto custom-scrollbar flex-1 space-y-2">
-             {tools.map(t => renderCard('tool', t, Wrench, 'text-amber-600', 'bg-amber-50/40', 'border-amber-400'))}
-          </div>
-        </div>
-
+        ))}
       </div>
     </div>
   );

+ 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,

+ 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  
+**数据状态**: 已整理归档,待入库

+ 73 - 22
knowhub/server.py

@@ -18,7 +18,7 @@ from typing import Optional, List, Dict
 from pathlib import Path
 from cryptography.hazmat.primitives.ciphers.aead import AESGCM
 
-from fastapi import FastAPI, HTTPException, Query, Header, Body, BackgroundTasks
+from fastapi import FastAPI, HTTPException, Query, Header, Body, BackgroundTasks, Request
 from fastapi.responses import HTMLResponse, FileResponse
 from fastapi.staticfiles import StaticFiles
 from pydantic import BaseModel, Field
@@ -145,24 +145,24 @@ def decrypt_content(resource_id: str, encrypted_text: str, provided_key: Optiona
         return "[ENCRYPTED]"
 
 
-def serialize_milvus_result(data):
-    """将 Milvus 返回的数据转换为可序列化的字典"""
+def to_serializable(data):
+    """通用序列化工具:把任意 Python 对象转换为 JSON 可序列化的原生类型"""
     # 基本类型直接返回
     if data is None or isinstance(data, (str, int, float, bool)):
         return data
 
     # 字典类型递归处理
     if isinstance(data, dict):
-        return {k: serialize_milvus_result(v) for k, v in data.items()}
+        return {k: to_serializable(v) for k, v in data.items()}
 
     # 列表/元组类型递归处理
     if isinstance(data, (list, tuple)):
-        return [serialize_milvus_result(item) for item in data]
+        return [to_serializable(item) for item in data]
 
     # 尝试转换为字典(对于有 to_dict 方法的对象)
     if hasattr(data, 'to_dict') and callable(getattr(data, 'to_dict')):
         try:
-            return serialize_milvus_result(data.to_dict())
+            return to_serializable(data.to_dict())
         except:
             pass
 
@@ -172,7 +172,7 @@ def serialize_milvus_result(data):
             # 强制转换为列表并递归处理
             result = []
             for item in data:
-                result.append(serialize_milvus_result(item))
+                result.append(to_serializable(item))
             return result
         except:
             pass
@@ -180,7 +180,7 @@ def serialize_milvus_result(data):
     # 尝试获取对象的属性字典
     if hasattr(data, '__dict__'):
         try:
-            return serialize_milvus_result(vars(data))
+            return to_serializable(vars(data))
         except:
             pass
 
@@ -1136,7 +1136,7 @@ async def search_knowledge_api(
             return {"results": [], "count": 0, "reranked": False}
 
         # 转换为可序列化的格式
-        serialized_candidates = [serialize_milvus_result(c) for c in candidates]
+        serialized_candidates = [to_serializable(c) for c in candidates]
 
         # 为了保证搜索的极致速度,直接返回向量召回的 top-k(跳过缓慢的 LLM 精排)
         return {"results": serialized_candidates[:top_k], "count": len(serialized_candidates[:top_k]), "reranked": False}
@@ -1279,7 +1279,7 @@ def list_knowledge(
         results = pg_store.query(filter_expr, limit=max_limit)
 
         # 转换为可序列化的格式
-        serialized_results = [serialize_milvus_result(r) for r in results]
+        serialized_results = [to_serializable(r) for r in results]
 
         # 按 created_at 降序排序(最新的在前)
         serialized_results.sort(key=lambda x: x.get('created_at', 0), reverse=True)
@@ -1316,7 +1316,7 @@ def get_all_tags():
         all_tags = set()
         for item in results:
             # 转换为标准字典
-            serialized_item = serialize_milvus_result(item)
+            serialized_item = to_serializable(item)
             tags_dict = serialized_item.get("tags", {})
             if isinstance(tags_dict, dict):
                 for key in tags_dict.keys():
@@ -1337,7 +1337,7 @@ def get_pending_knowledge(limit: int = Query(default=50, ge=1, le=200)):
             'status == "pending" or status == "processing" or status == "dedup_passed" or status == "analyzing"',
             limit=limit
         )
-        serialized = [serialize_milvus_result(r) for r in pending]
+        serialized = [to_serializable(r) for r in pending]
         return {"results": serialized, "count": len(serialized)}
     except Exception as e:
         print(f"[Pending] 错误: {e}")
@@ -1377,7 +1377,7 @@ def get_knowledge_status(knowledge_id: str):
         result = pg_store.get_by_id(knowledge_id)
         if not result:
             raise HTTPException(status_code=404, detail=f"Knowledge not found: {knowledge_id}")
-        serialized = serialize_milvus_result(result)
+        serialized = to_serializable(result)
         return {
             "id": knowledge_id,
             "status": serialized.get("status", "approved"),
@@ -1400,7 +1400,7 @@ def get_knowledge(knowledge_id: str):
         if not result:
             raise HTTPException(status_code=404, detail=f"Knowledge not found: {knowledge_id}")
 
-        return serialize_milvus_result(result)
+        return to_serializable(result)
 
     except HTTPException:
         raise
@@ -1743,7 +1743,7 @@ async def slim_knowledge(model: str = "google/gemini-2.5-flash-lite"):
         # 获取所有知识
         all_knowledge = pg_store.query('id != ""', limit=10000)
         # 转换为可序列化的格式
-        all_knowledge = [serialize_milvus_result(item) for item in all_knowledge]
+        all_knowledge = [to_serializable(item) for item in all_knowledge]
 
         if len(all_knowledge) < 2:
             return {"status": "ok", "message": f"知识库仅有 {len(all_knowledge)} 条,无需瘦身"}
@@ -2382,13 +2382,56 @@ def delete_requirement(req_id: str):
         raise
     except Exception as e:
         raise HTTPException(status_code=500, detail=str(e))
-@app.get("/")
-def frontend():
-    """KnowHub 管理前端"""
-    index_file = STATIC_DIR / "index.html"
-    if not index_file.exists():
-        return HTMLResponse("<h1>KnowHub Frontend Not Found</h1><p>Please ensure knowhub/frontend/dist/index.html exists. Run 'yarn build' in frontend directory.</p>", status_code=404)
-    return FileResponse(str(index_file))
+# ===== Relation API =====
+
+@app.get("/api/relation/{table_name}")
+async def get_relations(table_name: str, request: Request):
+    """通用关系表查询接口"""
+    allowed_tables = {
+        "capability_knowledge",
+        "capability_tool",
+        "knowledge_relation",
+        "knowledge_resource",
+        "requirement_capability",
+        "requirement_knowledge",
+        "tool_knowledge",
+        "tool_provider"
+    }
+    table_name = table_name.lower()
+    if table_name not in allowed_tables:
+        raise HTTPException(status_code=400, detail="Invalid table name")
+        
+    try:
+        params = dict(request.query_params)
+        
+        where_clauses = []
+        values = []
+        for k, v in params.items():
+            if k in ["limit", "offset"]: continue
+            where_clauses.append(f"{k} = %s")
+            values.append(v)
+        
+        query = f"SELECT * FROM {table_name}"
+        if where_clauses:
+            query += " WHERE " + " AND ".join(where_clauses)
+            
+        limit = int(params.get("limit", 100))
+        query += " LIMIT %s"
+        values.append(limit)
+        
+        cursor = pg_store._get_cursor()
+        try:
+            cursor.execute(query, tuple(values))
+            rows = cursor.fetchall()
+            if not rows:
+                 return {"results": [], "count": 0}
+            colnames = [desc[0] for desc in cursor.description]
+            results = [dict(zip(colnames, row)) for row in rows]
+            return {"results": results, "count": len(results)}
+        finally:
+            cursor.close()
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=str(e))
 
 @app.get("/category_tree.json")
 def serve_category_tree():
@@ -2398,6 +2441,14 @@ def serve_category_tree():
         return {"error": "Not Found"}
     return FileResponse(str(tree_file))
 
+@app.get("/{full_path:path}")
+def frontend(full_path: str):
+    """KnowHub 管理前端 — 所有非 API 路径都返回 index.html,由 React Router 处理"""
+    index_file = STATIC_DIR / "index.html"
+    if not index_file.exists():
+        return HTMLResponse("<h1>KnowHub Frontend Not Found</h1><p>Please ensure knowhub/frontend/dist/index.html exists. Run 'yarn build' in frontend directory.</p>", status_code=404)
+    return FileResponse(str(index_file))
+
 if __name__ == "__main__":
     import uvicorn
     uvicorn.run(app, host="0.0.0.0", port=9999)

+ 104 - 0
knowledge-2026-04-09-ai-image-workflows.json

@@ -0,0 +1,104 @@
+{
+  "knowledge": [
+    {
+      "task": "AI人像皮肤质感修复工作流",
+      "content": "完整工序:\n1. 生成基础人像图片 - 工具:Midjourney / Google Imagen 4 (via Whisk) / Reeve / Flux Pro Ultra - 输入:近景人像提示词(如'close-up portrait, photorealistic, DSLR, 85mm')- 输出:AI生成的基础人像图片(可能存在塑料皮肤问题)\n2. 裁剪人脸区域(如需要)- 工具:图像裁剪工具 - 输入:基础人像图片,将人脸区域裁剪至更近的特写 - 输出:裁剪后的人脸特写图片\n3. 修复AI塑料皮肤质感 - 工具:Enhancor.ai (Skin Realism + Portrait Upscaler) - 输入:AI生成的人像图片,设置皮肤纹理强度、毛孔细节参数 - 输出:具有真实皮肤纹理、毛孔细节的人像图片\n4. 修复眼部瑕疵(可选)- 工具:Enhancor.ai 眼部修复功能 - 输入:修复后的人像图片,指定眼部区域 - 输出:眼部细节完美的人像图片\n5. 超分辨率放大 - 工具:Enhancor.ai Portrait Upscaler - 输入:修复后的人像图片,选择放大倍数(如4x)- 输出:billboard级别的高分辨率人像图片\n适用场景:商业人像摄影、广告牌级别输出、需要真实皮肤质感的AI人像",
+      "types": ["strategy"],
+      "tags": {
+        "source": "YouTube",
+        "domain": "AI图像生成",
+        "focus": "人像、皮肤质感、超分辨率"
+      },
+      "score": 5
+    },
+    {
+      "task": "多视角角色一致性工作流(Nano Banana Pro)",
+      "content": "完整工序:\n1. 创建角色参考表 - 工具:Nano Banana Pro (Higgsfield AI) - 输入:结构化提示词(如'Create a professional character reference sheet with four vertical columns: front view, left profile, right profile, back view. Each column contains full-body view on top and matching close-up portrait below. Photorealistic, DSLR, muted tones.')- 输出:包含四个视角(正面、左侧、右侧、背面)的角色参考表,每个视角包含全身图和近景肖像\n2. 生成多视角近景肖像 - 工具:Nano Banana Pro - 输入:角色参考表作为参考图,指定视角(如'front portrait close-up')- 输出:一致角色的多视角近景肖像图片\n3. 生成生活场景图 - 工具:Nano Banana Pro - 输入:角色参考表,场景描述(如'coffee shop lifestyle shot')- 输出:角色在不同生活场景中的图片\n适用场景:AI虚拟influencer、角色设计、需要多角度一致性的项目",
+      "types": ["strategy"],
+      "tags": {
+        "source": "YouTube",
+        "domain": "AI图像生成",
+        "focus": "角色一致性、多视角、近景肖像"
+      },
+      "score": 5
+    },
+    {
+      "task": "面部表情变换工作流(Canva AI Face Expression)",
+      "content": "完整工序:\n1. 上传基础照片 - 工具:Canva AI Face Expression App - 输入:单张人物照片(正面、清晰面部)- 输出:上传至Canva的基础照片\n2. 选择目标表情 - 工具:Canva AI Face Expression App - 输入:选择预设表情(如微笑、惊讶、严肃、开心等)- 输出:应用表情后的人物图片\n3. 批量生成多表情版本 - 工具:Canva AI Face Expression App - 输入:重复选择不同表情,批量处理 - 输出:同一人物的多种表情图片集合\n适用场景:YouTube缩略图制作、社交媒体内容、需要快速生成多种表情的场景",
+      "types": ["strategy"],
+      "tags": {
+        "source": "YouTube",
+        "domain": "AI图像生成",
+        "focus": "面部表情、批量生成"
+      },
+      "score": 4
+    },
+    {
+      "task": "AI虚拟网红完整工作流(KORA Pro + Enhancor + Lip Sync)",
+      "content": "完整工序:\n1. 生成高质量基础人像 - 工具:KORA Pro (Enhancor) - 输入:详细的人像提示词,包括人物特征、服装、光线、相机参数 - 输出:高真实度的基础人像图片\n2. 构建角色多视角图 - 工具:Nano Banana Pro - 输入:基础人像作为参考,生成多角度角色表 - 输出:包含正面、侧面、背面视角的角色参考表\n3. 修复皮肤质感 - 工具:Enhancor AI V3 Skin Fix - 输入:生成的角色图片,调整皮肤纹理参数 - 输出:具有真实皮肤质感的角色图片\n4. 生成生活场景图 - 工具:Nano Banana Pro - 输入:角色参考表,场景描述(如'studio shot', 'lifestyle scene')- 输出:角色在不同场景中的图片\n5. 唇形同步动画 - 工具:Enhancor Lip Sync V1/V2 - 输入:角色图片 + 自定义音频文件 - 输出:角色说话的视频(带唇形同步)\n适用场景:AI虚拟网红、虚拟主播、需要说话动画的角色",
+      "types": ["strategy"],
+      "tags": {
+        "source": "YouTube",
+        "domain": "AI图像生成",
+        "focus": "虚拟网红、唇形同步、角色一致性"
+      },
+      "score": 5
+    },
+    {
+      "task": "4K超高清AI图像生成工作流(ComfyUI + DyPE)",
+      "content": "完整工序:\n1. 设置ComfyUI工作流 - 工具:ComfyUI (本地安装 via Promptus) - 输入:安装DyPE模型(https://github.com/guyyariv/DyPE)- 输出:配置好的ComfyUI环境\n2. 生成原生4K图像 - 工具:ComfyUI + DyPE模型 - 输入:人像提示词,设置分辨率为4K,选择DyPE采样器 - 输出:原生4K分辨率的AI人像图片\n3. 超分辨率增强(可选)- 工具:ComfyUI 4K超分辨率节点 - 输入:生成的4K图片,选择放大倍数 - 输出:更高分辨率、细节更丰富的图片\n适用场景:需要超高清输出的商业项目、影院级画质需求",
+      "types": ["strategy"],
+      "tags": {
+        "source": "YouTube",
+        "domain": "AI图像生成",
+        "focus": "4K、超分辨率、ComfyUI"
+      },
+      "score": 4
+    },
+    {
+      "task": "角色一致性视频生成工作流(OpenArt + Kling/HeyGen)",
+      "content": "完整工序:\n1. 创建一致角色基础图 - 工具:OpenArt AI - 输入:角色描述提示词,上传参考图(如有)- 输出:高质量的角色基础图片\n2. 生成多视角角色图 - 工具:OpenArt AI - 输入:基础图作为参考,指定不同视角和姿势 - 输出:同一角色的多视角图片集合\n3. 生成视频 - 工具:Kling AI / HeyGen - 输入:角色图片 + 动作/对话描述 - 输出:角色动画视频\n4. 唇形同步(如需要)- 工具:HeyGen - 输入:角色视频 + 音频脚本 - 输出:带完美唇形同步的角色说话视频\n适用场景:AI视频创作、虚拟主播、需要角色一致性的视频项目",
+      "types": ["strategy"],
+      "tags": {
+        "source": "YouTube",
+        "domain": "AI视频生成",
+        "focus": "角色一致性、视频生成、唇形同步"
+      },
+      "score": 5
+    },
+    {
+      "task": "背景虚化效果工作流(Photoshop Lens Blur / DxO Photolab)",
+      "content": "完整工序:\n1. 导入人像图片 - 工具:Photoshop / DxO Photolab 9 - 输入:需要添加背景虚化的人像图片 - 输出:导入的图片\n2. 创建主体选区 - 工具:Photoshop 智能选区 / DxO 自动主体检测 - 输入:选择主体(人物)区域 - 输出:精确的人物选区\n3. 应用镜头虚化效果 - 工具:Photoshop Lens Blur / DxO Photolab 9 Bokeh - 输入:设置虚化强度、光圈形状、焦距参数 - 输出:具有专业镜头虚化效果的图片\n4. 微调边缘过渡 - 工具:Photoshop 蒙版羽化 - 输入:调整选区边缘羽化值 - 输出:自然过渡的虚化效果图片\n适用场景:人像摄影后期、需要背景虚化效果的AI生成图片",
+      "types": ["strategy"],
+      "tags": {
+        "source": "YouTube",
+        "domain": "图像后期处理",
+        "focus": "背景虚化、bokeh、人像后期"
+      },
+      "score": 4
+    }
+  ],
+  "resources": [
+    {
+      "id": "https://github.com/guyyariv/DyPE",
+      "type": "github_repo"
+    }
+  ],
+  "tools": [
+    {"name": "Midjourney", "introduction": "AI图像生成工具,支持高质量人像生成"},
+    {"name": "Google Imagen 4", "introduction": "Google的AI图像生成模型,通过Whisk平台访问"},
+    {"name": "Reeve", "introduction": "AI图像生成工具"},
+    {"name": "Flux Pro Ultra", "introduction": "AI图像生成工具,支持高质量人像"},
+    {"name": "Enhancor.ai", "introduction": "AI人像增强工具,提供皮肤质感修复和超分辨率功能"},
+    {"name": "Nano Banana Pro", "introduction": "Higgsfield AI的角色一致性生成工具"},
+    {"name": "Canva AI Face Expression", "introduction": "Canva的面部表情变换AI应用"},
+    {"name": "KORA Pro", "introduction": "Enhancor的高质量人像生成工具"},
+    {"name": "ComfyUI", "introduction": "本地AI图像生成工作流工具"},
+    {"name": "DyPE", "introduction": "ComfyUI的4K图像生成模型"},
+    {"name": "OpenArt AI", "introduction": "AI图像生成平台"},
+    {"name": "Kling AI", "introduction": "AI视频生成工具"},
+    {"name": "HeyGen", "introduction": "AI视频和唇形同步工具"},
+    {"name": "Photoshop", "introduction": "专业图像编辑软件"},
+    {"name": "DxO Photolab 9", "introduction": "专业照片处理软件,支持镜头虚化效果"}
+  ]
+}

+ 398 - 0
knowledge/ComfyUI_画中画分阶段生成工作流.md

@@ -0,0 +1,398 @@
+# ComfyUI 画中画分阶段生成工作流
+
+**知识 ID**: knowledge-20260411-155313-comfyinpaint  
+**类型**: strategy  
+**评分**: ⭐⭐⭐⭐⭐ (5/5)  
+**来源**: ComfyUI 社区/YouTube 教程  
+**领域**: AI 图像生成  
+**工作流类型**: 画中画分阶段生成  
+**核心工具**: ControlNet, IP-Adapter, Inpainting, Z-Image-Turbo
+
+---
+
+## 核心设计理念
+
+### 分阶段生成 + 双控架构
+
+**分阶段流程**:
+```
+背景生成 → 画中画区域生成 → 风格统一 → 细节优化
+```
+
+**双控架构**:
+- **ControlNet**: 锁结构(几何/深度/姿态控制)
+- **IP-Adapter**: 注入风格(风格/纹理/色彩迁移)
+
+---
+
+## 工作流总览
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│  阶段 1: 背景/外框生成                                        │
+│  输入:背景提示词 + 参考图 → ControlNet 控制 → 生成背景       │
+└─────────────────────────────────────────────────────────────┘
+                            ↓
+┌─────────────────────────────────────────────────────────────┐
+│  阶段 2: 画中画区域生成 (Inpainting)                          │
+│  输入:背景图 + 蒙版 + 画中画提示词 → 局部重绘 → 生成内容     │
+└─────────────────────────────────────────────────────────────┘
+                            ↓
+┌─────────────────────────────────────────────────────────────┐
+│  阶段 3: 风格统一 (IP-Adapter)                                │
+│  输入:合成图 + 风格参考图 → IP-Adapter 注入 → 风格融合       │
+└─────────────────────────────────────────────────────────────┘
+                            ↓
+┌─────────────────────────────────────────────────────────────┐
+│  阶段 4: 细节优化 (ControlNet + Detailer)                     │
+│  输入:风格化图 → ControlNet 微调 → FaceDetailer → 最终输出  │
+└─────────────────────────────────────────────────────────────┘
+```
+
+---
+
+## 阶段 1:背景/外框生成
+
+### 节点配置
+
+| 节点类型 | 节点名称 | 参数设置 |
+|----------|----------|----------|
+| Checkpoint | SDXL/Flux 主模型 | `albedobaseXL_v21.safetensors` 或 `flux1-dev.safetensors` |
+| ControlNet | Apply Advanced ControlNet | 权重 0.6-0.75 |
+| ControlNet 模型 | ControlNetLoader | `control_sdxl_canny.safetensors` 或 `depth` |
+| 预处理器 | AIO Aux Preprocessor | Canny/Depth/MLSD(根据场景选择) |
+| CLIP Text Encode | 背景提示词 | 描述整体场景氛围 |
+| KSampler | 采样器 | 20-30 步,CFG 7-8,DPM++ 2M Karras |
+
+### 操作步骤
+
+1. **加载背景参考图**(可选)
+   - 使用 `Load Image` 节点加载参考图
+   - 通过 `AIO Aux Preprocessor` 提取结构信息
+
+2. **设置 ControlNet 控制**
+   ```
+   参考图 → 预处理器 → ControlNet 模型 → Apply Advanced ControlNet
+   ```
+   - 控制权重:`0.6-0.75`
+   - 控制模式:`Balanced` 或 `ControlNet is more important`
+   - Starting/Ending Step:`0.0-1.0`(全程控制)
+
+3. **编写背景提示词**
+   ```
+   A cozy living room with large empty wall frame, warm lighting, 
+   modern minimalist interior, soft shadows, high quality photography
+   ```
+
+4. **生成背景图**
+   - 分辨率:`1024x1024` 或 `1280x720`(根据最终输出设定)
+   - 批次数:1(单张背景)
+
+---
+
+## 阶段 2:画中画区域生成(Inpainting)
+
+### 三种实现方案对比
+
+| 方案 | 核心节点 | 适用场景 | 优点 | 缺点 |
+|------|----------|----------|------|------|
+| **A: 原生节点法** | Inpaint Model Conditioning | 理解底层逻辑 | 学习价值高 | 连线复杂 |
+| **B: KJ Nodes 简化版** ⭐ | KJ Nodes: Inpaint Node | 生产环境 | 工作流简洁 | 需安装自定义节点 |
+| **C: SAM 3 智能重绘** | SAM 3 + Grounding DINO | 自动化场景 | 无需手动蒙版 | 依赖 AI 识别准确度 |
+
+### 方案 A:原生节点法
+
+**节点连接**:
+```
+背景图 → VAE Encode → Inpaint Model Conditioning → KSampler → VAE Decode
+蒙版图 ↗
+```
+
+**关键参数**:
+- `denoise`: `0.6-0.8`(重绘强度)
+- `mask_expand`: `10-20`(蒙版扩展像素)
+- 模糊蒙版边缘:让重绘更融合
+
+### 方案 B:KJ Nodes 简化版(推荐)
+
+**核心节点**:
+- `KJ Nodes: Inpaint Node`: 简化重绘流程
+- `KJ Nodes: Mask Editor`: 可视化蒙版编辑
+
+**优势**:工作流连线简洁,适合生产环境
+
+### 方案 C:SAM 3 智能重绘
+
+**核心节点**:
+- `SAM 3 (Segment Anything)`: 自动识别目标区域
+- `Grounding DINO`: 文本引导分割
+
+**操作步骤**:
+1. 输入文本指令:`"the empty frame on the wall"`
+2. SAM 3 自动识别并生成蒙版
+3. 自动执行局部重绘
+
+### 关键参数设置
+
+| 参数 | 推荐值 | 说明 |
+|------|--------|------|
+| denoise | 0.65-0.75 | 平衡原图保留与重绘效果 |
+| mask_expand | 15-25 | 确保边缘融合自然 |
+| CFG | 7-8 | 避免过拟合 |
+| Steps | 25-30 | 保证细节质量 |
+
+### 画中画提示词示例
+
+```
+A beautiful landscape painting inside the frame, 
+mountain lake at sunset, golden hour lighting, 
+oil painting style, detailed brushstrokes, masterpiece
+```
+
+---
+
+## 阶段 3:风格统一(IP-Adapter)
+
+### 核心原理
+
+> **IP-Adapter 负责风格注入,ControlNet 负责结构保持**
+
+⚠️ **重要**: IP-Adapter 单独使用易结构崩坏、手部细节失控,必须配合 ControlNet 使用
+
+### 节点配置
+
+| 节点类型 | 节点名称 | 参数设置 |
+|----------|----------|----------|
+| IPAdapter | IPAdapter Plus | `PLUS(high strength)` 预设 |
+| CLIP Vision | CLIP-ViT-H-14-laion2B-s32B | 风格编码模型 |
+| IPAdapter 权重 | 权重值 | `0.6-0.85` |
+| 合并嵌入组 | concat / average | `concat`(冲突感)或 `norm average`(柔和) |
+| 权重类型 | style transfer | 风格迁移模式 |
+
+### 工作流连接
+
+```
+风格参考图 → IPAdapter Encode → IPAdapter Apply → KSampler
+合成图 → VAE Encode ↗
+```
+
+### 多风格融合
+
+```
+风格图 A → IPAdapter Encode (权重 1.20) ┐
+风格图 B → IPAdapter Encode (权重 1.00) → 合并嵌入组 (concat) → Apply
+```
+
+### 关键参数推荐
+
+| 场景 | IPAdapter 权重 | 合并方式 | 权重类型 |
+|------|---------------|----------|----------|
+| 轻微风格渗透 | 0.4-0.6 | average | style transfer |
+| 平衡融合 | 0.65-0.75 | norm average | style transfer |
+| 强烈风格化 | 0.8-0.85 | concat | style transfer |
+| 多风格融合 | 1.0-1.2 (主) + 0.8 (辅) | concat | style transfer |
+
+### 避坑指南
+
+⚠️ **权重过高** (> 0.85) 易导致画面紊乱  
+✅ **解决方案**: 使用 `fade` 类型 + 噪波强度 0.6 + 模糊 2
+
+---
+
+## 阶段 4:细节优化
+
+### 4.1 ControlNet 微调
+
+**目的**: 在风格化后保持关键结构
+
+| ControlNet 类型 | 权重 | 适用场景 |
+|----------------|------|----------|
+| Canny | 0.4-0.6 | 保持边缘清晰 |
+| Depth | 0.5-0.7 | 保持空间层次 |
+| Tile | 0.6-0.8 | 细节增强/放大 |
+
+**配置技巧**:
+- 使用较低的权重(0.4-0.6)进行微调
+- Starting Step 设为 `0.3-0.5`(后期介入)
+- 避免过度控制导致风格丢失
+
+### 4.2 FaceDetailer / HandDetailer
+
+**目的**: 修复面部/手部细节
+
+| 节点 | 模型 | 参数 |
+|------|------|------|
+| FaceDetailer | `face_yolov8m.pt` | denoise 0.2-0.5 |
+| HandDetailer | `hand_yolov8s.pt` | denoise 0.3-0.6 |
+| SAM | `sam_vit_b_01ec64.pth` | 精准检测 |
+
+**工作流程**:
+```
+风格化图 → 人脸/手部检测 → 局部放大 → 重绘修复 → 合成输出
+```
+
+### 4.3 高清放大(可选)
+
+| 节点 | 模型 | 用途 |
+|------|------|------|
+| Ultimate SD Upscale | `4x-UltraSharp.pth` | 4 倍高清放大 |
+| Tile ControlNet | `control_v11f1e_sd15_tile` | 放大时保持细节 |
+
+---
+
+## 完整参数汇总表
+
+### 阶段 1:背景生成
+| 参数 | 推荐值 |
+|------|--------|
+| 模型 | SDXL / Flux |
+| ControlNet 权重 | 0.6-0.75 |
+| Steps | 20-25 |
+| CFG | 7-8 |
+| 分辨率 | 1024x1024 |
+
+### 阶段 2:Inpainting
+| 参数 | 推荐值 |
+|------|--------|
+| denoise | 0.65-0.75 |
+| mask_expand | 15-25 |
+| Steps | 25-30 |
+| CFG | 7-8 |
+
+### 阶段 3:IP-Adapter
+| 参数 | 推荐值 |
+|------|--------|
+| 权重 | 0.65-0.80 |
+| 预设 | PLUS(high strength) |
+| CLIP Vision | CLIP-ViT-H-14 |
+| 合并方式 | norm average |
+
+### 阶段 4:细节优化
+| 参数 | 推荐值 |
+|------|--------|
+| ControlNet 微调权重 | 0.4-0.6 |
+| FaceDetailer denoise | 0.2-0.5 |
+| Upscale 倍数 | 2x-4x |
+
+---
+
+## 必备自定义节点清单
+
+通过 **ComfyUI Manager** 安装:
+
+```bash
+# ControlNet 相关
+- ComfyUI's ControlNet Auxiliary Preprocessors
+- ComfyUI-Advanced-ControlNet
+
+# IPAdapter 相关
+- IPAdapter Plus (comfyui_ipadapter_plus)
+
+# 简化节点
+- KJ Nodes (comfyui-kjnodes)
+- Efficiency Nodes
+
+# 细节修复
+- Impact Pack (comfyui_essentials)
+- FaceDetailer
+
+# 智能分割
+- SAM 3 (Segment Anything)
+- Grounding DINO
+
+# 其他工具
+- CR Seamless Checker(无缝检查)
+- TextureViewer(3D 预览)
+```
+
+---
+
+## 实战案例:客厅挂画生成
+
+### 完整提示词
+
+**阶段 1 - 背景**:
+```
+A modern living room with a large empty wooden frame on the wall, 
+warm afternoon sunlight, minimalist interior design, 
+soft shadows, cozy atmosphere, interior photography, 8k --ar 16:9
+```
+
+**阶段 2 - 画中画**:
+```
+A serene mountain lake landscape at golden hour, 
+reflection in water, dramatic sky, oil painting style, 
+visible brushstrokes, impasto technique, masterpiece
+```
+
+**阶段 3 - 风格参考**:
+- 上传 1-2 张目标风格的油画作品作为 IP-Adapter 参考
+
+### 工作流执行顺序
+
+1. **生成背景**: 使用 Canny ControlNet 控制房间结构
+2. **绘制蒙版**: 在空画框区域绘制蒙版(或使用 SAM 3 自动识别)
+3. **局部重绘**: denoise 0.7,生成画中画内容
+4. **风格注入**: IP-Adapter 权重 0.75,融合油画风格
+5. **细节修复**: FaceDetailer 修复可能的人物(如有)
+6. **高清输出**: Ultimate SD Upscale 放大至 4K
+
+---
+
+## 常见问题与解决方案
+
+| 问题 | 原因 | 解决方案 |
+|------|------|----------|
+| 画中画边缘不融合 | 蒙版扩展不足 | 增加 `mask_expand` 至 20-30 |
+| 风格与背景不协调 | IP-Adapter 权重过高 | 降至 0.6-0.7,使用 `norm average` |
+| 画中画结构崩坏 | 缺少 ControlNet 控制 | 添加 Canny/Depth ControlNet(权重 0.5) |
+| 面部/手部畸形 | 细节质量不足 | 添加 FaceDetailer/HandDetailer |
+| 整体风格不统一 | 分阶段风格差异大 | 阶段 1 也使用相同 IP-Adapter 参考 |
+| 生成速度慢 | 多阶段串行执行 | 使用批处理,低分辨率测试后再高清生成 |
+
+---
+
+## 进阶技巧
+
+### 1. 多画中画嵌套
+```
+背景 → 画中画 A → 画中画 B(嵌套在 A 内)
+```
+- 使用多个 Inpainting 节点串联
+- 每个阶段使用独立蒙版
+- IP-Adapter 可分别控制不同区域风格
+
+### 2. 动态蒙版生成
+```
+Grounding DINO + SAM 3 → 自动蒙版 → Inpainting
+```
+- 文本指令:`"the frame on the wall"`
+- 全自动识别和重绘
+
+### 3. 风格渐变融合
+```
+IPAdapter A (权重 0.8) + IPAdapter B (权重 0.4) → 渐变融合
+```
+- 使用 `average` 合并方式
+- 调整权重比例控制风格倾向
+
+### 4. 批量生成变体
+```
+同一背景 + 不同画中画提示词 → 批量输出
+```
+- 使用 `Batch Prompt Schedule` 节点
+- 一次生成多个画中画变体
+
+---
+
+## 关联知识
+
+- **Midjourney v7 厚涂风格**: 可使用 MJ 生成风格参考图,再通过本工作流在 ComfyUI 中精确复现
+- **Nano Banana 多图融合**: 可将多图融合结果作为本工作流的输入
+- **ControlNet+IPAdapter 双控架构**: 本工作流的核心技术基础
+
+---
+
+## 更新日志
+
+- 2026-04-11: 初始入库,包含 4 阶段完整工作流、3 种 Inpainting 方案对比、实战案例

+ 165 - 0
knowledge/Midjourney_v7_Impasto_厚涂风格参数配置.md

@@ -0,0 +1,165 @@
+# Midjourney v7 Impasto 厚涂风格参数配置
+
+**知识 ID**: knowledge-20260411-155313-mj7impasto  
+**类型**: tool  
+**评分**: ⭐⭐⭐⭐⭐ (5/5)  
+**来源**: 小红书/Midjourney 官方文档  
+**领域**: AI 图像生成  
+**工具**: Midjourney v7  
+**风格**: Impasto 厚涂
+
+---
+
+## 核心发现
+
+Midjourney v7 在美学质量与笔触质感方面表现优异,特定 sref 代码可生成几乎与真实绘画无法区分的厚涂技法效果。**v8 版本灵活性降低,艺术风格探索建议继续使用 v7**。
+
+### v7 vs v8 厚涂风格对比
+
+| 维度 | v7 | v8 |
+|------|-----|-----|
+| 笔触质感 | ⭐⭐⭐⭐⭐ 自然流畅 | ⭐⭐⭐⭐ 稍显僵硬 |
+| 风格灵活性 | ⭐⭐⭐⭐⭐ 高 | ⭐⭐⭐ 降低 |
+| 厚涂效果 | ⭐⭐⭐⭐⭐ 无敌 | ⭐⭐⭐⭐ 良好 |
+
+---
+
+## 核心参数详解
+
+### 1. `--sref` 风格参考代码
+
+**基础语法**:
+```bash
+/imagine prompt: [描述] --sref [风格代码] --v 7
+```
+
+**多风格融合**(最多 5 个):
+```bash
+/imagine prompt: [描述] --sref 代码 1 代码 2 代码 3 --v 7
+```
+
+**常用厚涂风格代码**:
+- **新派写实油画**: `sref 738792225` ⭐ 推荐
+- 获取方法:
+  1. 在 Discord 使用 `/describe` 分析喜欢的厚涂作品
+  2. 使用 `/style-save` 保存生成的风格
+  3. 从社区共享代码库获取(Reddit、PromptHero)
+
+### 2. `--sw` 风格权重参数
+
+**语法**:`--sw [0.1-2.0]`
+
+**权重范围与效果**:
+
+| 权重值 | 效果强度 | 适用场景 |
+|--------|----------|----------|
+| 0.1-0.3 | 轻微风格渗透 | 保留原风格基础上微调 |
+| 0.4-0.7 | 平衡融合 | 推荐默认值,风格与内容均衡 |
+| 0.8-1.2 | 强烈风格化 | 明显厚涂笔触质感 |
+| 1.3-2.0 | 极致风格 | 完全主导,可能牺牲细节 |
+
+**厚涂风格推荐设置**: `--sw 1.0-1.5`
+
+### 3. `--v 7` 版本锁定
+
+⚠️ **必须显式指定** `--v 7`,v8 版本灵活性降低不适合厚涂风格
+
+---
+
+## 完整提示词公式
+
+### 厚涂风格三段式公式
+
+```
+[主体画面描述] + [厚涂笔触/材质描述] + [光影/色彩氛围] --sref [代码] --sw [权重] --v 7 --ar [比例] --stylize [值]
+```
+
+### 厚涂专用关键词库
+
+| 关键词 | 中文含义 | 效果 |
+|--------|----------|------|
+| `thick impasto brushwork` | 厚涂笔触 | 增强笔触质感 |
+| `heavy paint texture` | 厚重颜料肌理 | 增加物理厚度 |
+| `visible brushstrokes` | 可见笔触 | 拒绝平滑感 |
+| `palette knife texture` | 调色刀纹理 | 特殊肌理效果 |
+| `impasto technique` | 厚涂技法 | 整体风格定义 |
+
+---
+
+## 实战示例
+
+### 示例 1:新派写实油画
+```bash
+{你的主体} + The painting is in a realistic oil painting style, with clearly visible brushstrokes, vibrant colors, and strong light. --chaos 30 --exp 85 --sref 738792225 --profile qvu9plm --v 7.0 --ar 2:3
+```
+
+### 示例 2:人物厚涂肖像
+```bash
+A warrior princess with flowing red hair, thick impasto oil painting style, visible brushstrokes, palette knife texture, dramatic chiaroscuro lighting, warm golden hour glow --sref 1847293 --sw 1.3 --v 7 --ar 2:3 --stylize 750
+```
+
+### 示例 3:风景厚涂
+```bash
+Mountain landscape at sunset, heavy impasto technique, thick layers of oil paint, textured canvas, bold brushwork, vibrant color palette, atmospheric perspective --sref 2938471 --sw 1.5 --v 7 --ar 16:9 --stylize 800
+```
+
+---
+
+## 参数组合推荐表
+
+| 场景 | --sref | --sw | --stylize | --q | --ar |
+|------|--------|------|-----------|-----|------|
+| 人物厚涂肖像 | 1-2 个代码 | 1.2-1.5 | 700-850 | 2 | 2:3 |
+| 风景厚涂 | 2-3 个代码 | 1.3-1.8 | 750-900 | 2 | 16:9 |
+| 静物厚涂 | 1 个代码 | 1.0-1.3 | 600-750 | 2 | 4:5 |
+| 抽象厚涂 | 2-3 个代码 | 1.5-2.0 | 850-1000 | 4 | 1:1 |
+| 快速测试 | 1 个代码 | 1.0 | 500 | 0.5 | 1:1 |
+
+---
+
+## 避坑指南
+
+| 问题 | 原因 | 解决方案 |
+|------|------|----------|
+| 厚涂效果不明显 | --sw 权重太低 | 提升至 1.3-1.8 |
+| 笔触过于粗糙 | --sw 权重过高 | 降至 0.8-1.2 |
+| 风格冲突 | 多代码不兼容 | 减少至 1-2 个代码 |
+| 细节丢失 | --stylize 过高 | 降至 600-750 |
+| 生成速度慢 | --q 4 + 多代码 | 先用 --q 0.5 测试 |
+| v8 效果不如 v7 | 版本灵活性差异 | 强制使用 --v 7 |
+
+---
+
+## 拒绝塑料感 4 招
+
+### 1. 拒绝平滑,增加物理厚度
+使用 `Thick impasto brushwork` 或 `Heavy paint texture` 关键词
+
+### 2. 治愈系万能公式
+```
+Deep blue twilight ambient + Warm orange window light
+```
+90% 冷调 + 10% 暖色,营造层次感
+
+### 3. 让水活过来
+使用 `Swirling water patterns` + `Churning white water`
+
+### 4. 参数降噪,返璞归真
+```bash
+--s 200 搭配 --style raw
+```
+降低 AI 过度修饰,回归自然质感
+
+---
+
+## 关联知识
+
+- **ComfyUI 厚涂复现**: 使用 IPAdapter + ControlNet 双控架构在 ComfyUI 中复现 MJ 厚涂风格
+- **风格代码获取**: `/describe` + `/style-save` 工作流
+- **v7 vs v8 对比**: v7 适合艺术风格探索,v8 适合精确解剖结构
+
+---
+
+## 更新日志
+
+- 2026-04-11: 初始入库,包含具体风格代码 738792225、--sw 参数详解、厚涂关键词库

+ 261 - 0
knowledge/Nano_Banana_多图融合_ComfyUI 工作流.md

@@ -0,0 +1,261 @@
+# Nano Banana 多图融合 ComfyUI 工作流
+
+**知识 ID**: knowledge-20260411-155313-nanobanana  
+**类型**: strategy  
+**评分**: ⭐⭐⭐⭐⭐ (5/5)  
+**来源**: 小红书/ComfyUI 社区  
+**领域**: AI 图像生成  
+**工作流类型**: 多图融合  
+**核心工具**: Nano Banana, Google Gemini Image, ComfyUI
+
+---
+
+## 核心能力
+
+| 能力指标 | 规格 |
+|----------|------|
+| 最多参考图数量 | **14 张**(实际 10 张可达 100% 还原) |
+| 角色一致性 | 单工作流最多 **5 个角色** + **14 个物体** |
+| 面部一致性 | **90-95%** |
+| 中文渲染 | 准确率 **90%+** |
+| 支持宽高比 | 包括极端比例 1:8、8:1、4:1、1:4 |
+| 产品一致性 | 标签文字不崩坏 |
+
+---
+
+## ComfyUI 工作流配置
+
+### 方法 1:单图编辑
+
+**工作流节点连接**:
+```
+Load Image → Image Scale → Google Gemini Image → LibLib Translate → Save Image
+```
+
+**参数设置**:
+- 图像缩放:`1536×1536`
+- 模型:`gemini-2.5-flash-image-preview`
+- 提示词:**必须包含** `"把这*张图组合成一张完整的新图像"`
+
+### 方法 2:双图编辑(推荐,效果更稳定)
+
+**工作流节点连接**:
+```
+Load Image (产品图) → Image Scale ┐
+Load Image (场景图) → Image Scale → Google Gemini Image → LibLib Translate → Save Image
+```
+
+**提示词示例**:
+```
+"把这两张图组合成一张完整的新图像,将产品自然融入场景中,保持产品标签文字清晰"
+```
+
+### 方法 3:三图编辑
+
+**工作流节点连接**:
+```
+Load Image (场景图) → Image Scale ┐
+Load Image (产品图) → Image Scale ├→ Google Gemini Image → LibLib Translate → Save Image
+Load Image (元素图) → Image Scale ┘
+```
+
+**提示词示例**:
+```
+"把这三张图组合成一张完整的新图像,将产品和杯子自然融入家居场景中,保持产品标签文字清晰"
+```
+
+---
+
+## 15 种多图融合场景
+
+| 序号 | 场景 | 描述 |
+|------|------|------|
+| 1 | 杂志封面 | 融合多张参考图生成专业封面 |
+| 2 | 漫画分镜 | 保持角色一致性的多格漫画 |
+| 3 | 信息图 | 自动搜索数据生成带图表的信息图 |
+| 4 | 情绪九宫格 | 同一人物 9 种表情 |
+| 5 | 极限运动场景 | 同一人物在不同运动场景 |
+| 6 | 产品对比海报 | 多产品对比展示 |
+| 7 | 科研绘图 | 顶刊级科研配图 |
+| 8 | 电影感人像 | 带详细相机参数的专业人像 |
+| 9 | 艺术风格转换 | 多风格同一人物 |
+| 10 | 背景替换 | 保持人物换背景 |
+| 11 | 服装替换 | 保持人物换服装 |
+| 12 | 创意场景转换 | 动作人偶风格等 |
+| 13 | 多语言信息图 | 一键翻译排版 |
+| 14 | 四方连续贴图 | 无缝纹理生成 |
+| 15 | 虚拟试衣 | 服装上身效果 |
+
+---
+
+## 实测心得
+
+### 关键发现
+
+1. **提示词决定出图效果** ⭐⭐⭐⭐⭐
+   - **必须写**: `"把这*张图组合成一张完整的新图像"`
+   - 缺少此句会导致生成效果不稳定
+
+2. **双图编辑效果更稳定** ⭐⭐⭐⭐
+   - 单图编辑:适合简单修改
+   - 双图编辑:推荐默认方案
+   - 三图以上:复杂度增加,需更具体提示词
+
+3. **产品一致性能力出色** ⭐⭐⭐⭐⭐
+   - 不必担心产品标签文字崩坏
+   - 适合电商产品场景图生成
+
+4. **清晰度优化**
+   - 如生成图清晰度不够,可使用高清放大工作流
+   - 推荐:Ultimate SD Upscale + Tile ControlNet
+
+---
+
+## 成本说明
+
+### Nano Banana 定价
+
+| 版本 | 分辨率 | 价格 | 备注 |
+|------|--------|------|------|
+| Nano Banana 2 | 1K | $0.067/张 | 性价比最高 |
+| Nano Banana 2 | 2K | $0.101/张 | |
+| Nano Banana 2 | 4K | $0.151/张 | |
+| Nano Banana Pro | 1K | $0.134/张 | 比 Flash 贵 50% |
+
+### 不同平台成本对比
+
+| 平台 | 成本范围 | 建议 |
+|------|----------|------|
+| 官方 API | $0.067-0.151/张 | 稳定可靠 |
+| 第三方平台 A | $0.02-0.08/张 | 价格低,注意稳定性 |
+| 第三方平台 B | $0.10-0.15/张 | 价格高,可能有额外功能 |
+
+⚠️ **注意**: 不同平台调用 Banana 的成本不同(从 $0.02~$0.15 不等),需根据需求选择
+
+---
+
+## 避坑指南
+
+| 问题 | 说明 | 解决方案 |
+|------|------|----------|
+| SynthID 水印 | 不可移除 | 接受或后期处理 |
+| Thinking 模式 | 强制启用 | 无法关闭,等待完成 |
+| 提示词不具体 | 效果不稳定 | 极度具体描述需求 |
+| 直接使用 Pro | 成本高 | 先用 Flash 测试提示词 |
+| 单次生成 | 成本高 | 使用 Batch API 节省 50% |
+
+### 推荐工作流
+
+```
+1. Flash 测试提示词 → 2. 优化提示词 → 3. Pro 批量生成 → 4. 高清放大
+```
+
+---
+
+## 实战案例
+
+### 案例 1:电商产品场景图
+
+**输入**:
+- 产品图:投影仪正面图
+- 场景图:雪山湖畔风景
+
+**提示词**:
+```
+"把这两张图组合成一张完整的新图像,将投影仪自然融入雪山湖畔场景中,保持产品外观和标签清晰"
+```
+
+**输出**:电商宣传场景图
+
+**耗时**:5-6 分钟  
+**成本**:$0.067 × 2(测试 + 正式)= $0.134
+
+---
+
+### 案例 2:中餐厅菜品图自动化
+
+**工作流**:Nanobanana + ComfyUI
+
+**流程**:
+```
+厨房出餐摆盘 → 手机随手拍 → 5-6 分钟生成专业菜单图
+```
+
+**支持**:
+- 横版/竖版可变尺寸
+- 批量生成
+- 保持菜品色泽和质感
+
+**应用场景**:
+- 餐厅菜单
+- 外卖平台图片
+- 社交媒体推广
+
+---
+
+### 案例 3:小说转短剧分镜
+
+**流程**:
+```
+1. 去 AO3 找短文
+2. 和 Gemini 说将其生成 12 张图
+3. 得到人物一致性非常高的一组图
+4. 配合图片转视频工具制作小说转短剧
+```
+
+**成本**:极低($0.067 × 12 = $0.804)
+
+**优势**:
+- 人物一致性高(90-95%)
+- 快速生成分镜
+- 可批量制作
+
+---
+
+## 与 ComfyUI 工作流整合
+
+### 整合方案
+
+```
+Nano Banana 多图融合 → ComfyUI 后处理
+                        ├─ ControlNet 结构微调
+                        ├─ IP-Adapter 风格统一
+                        ├─ FaceDetailer 细节修复
+                        └─ Upscale 高清放大
+```
+
+### 典型应用场景
+
+1. **电商产品图**:
+   - Nano Banana: 产品 + 场景融合
+   - ComfyUI: 细节优化、高清放大
+
+2. **角色一致性漫画**:
+   - Nano Banana: 多格分镜生成
+   - ComfyUI: 风格统一、对话框添加
+
+3. **科研绘图**:
+   - Nano Banana: 图表 + 数据融合
+   - ComfyUI: 标注优化、格式调整
+
+---
+
+## 关联知识
+
+- **ComfyUI 画中画工作流**: 可将 Nano Banana 生成结果作为输入进行进一步处理
+- **Midjourney v7 厚涂风格**: 可使用 MJ 生成风格参考图,通过 Nano Banana 融合到目标场景
+- **ControlNet+IPAdapter 双控架构**: ComfyUI 后处理的核心技术
+
+---
+
+## 官方资源
+
+- **API 文档**: https://ai.google.dev/gemini-api/docs/models/gemini-3.1-flash-image-preview
+- **DeepMind 模型页**: https://deepmind.google/models/gemini-image/flash/
+- **AI Studio**: https://aistudio.google.com/models/gemini-3-1-flash-image
+
+---
+
+## 更新日志
+
+- 2026-04-11: 初始入库,包含 ComfyUI 工作流配置、3 种编辑方法、15 种场景、3 个实战案例

+ 20 - 0
knowledge/knowledge-20260412-005015-nano-banana-image-urls.json

@@ -0,0 +1,20 @@
+{
+  "id": "knowledge-20260412-005015-nano-banana-image-urls",
+  "task": "在图像生成任务中使用 nano_banana 工具传递参考图",
+  "content": "当使用 nano_banana 工具进行图像生成时,image_urls 参数不支持本地相对路径,必须使用 HTTP/HTTPS CDN URL。\n\n## 错误做法\n\n**直接传本地相对路径**:\n```json\n{\n  \"image_urls\": [\"examples/production_restore/features/character_asset/character_ref_kneel.png\"]\n}\n```\n返回错误:HTTP 503: \"images[].data 看起来是文件路径但文件不存在\"\n\n**另一个错误示例**:\n```json\n{\"image_urls\": [\"examples/production_restore/img_1.png\"]}\n```\n报错:HTTP 503: images[].data 看起来是文件路径但文件不存在\n\n**注意**:虽然角色说明中提到 toolhub.py 内置的 `_preprocess_params` 函数会自动将本地路径上传到 OSS,但实际测试发现这个功能没有生效,需要手动调用 image_uploader 上传。\n\n## 正确做法\n\n1. 先调用 image_uploader 上传本地图片:\n```python\n# 步骤 1:上传图片\ncdn_url = image_uploader(local_path=\"examples/production_restore/img_1.png\")\n# 返回:https://res.cybertogether.net/toolhub_images/img_1.png\n\n# 步骤 2:调用 nano_banana\ntoolhub_call(tool_id=\"nano_banana\", params={\n    \"image_urls\": [cdn_url],\n    \"prompt\": \"...\"\n})\n```\n\n2. 再将 CDN URL 传入 nano_banana:\n```python\ntoolhub_call(tool_id=\"nano_banana\", params={\n  \"image_urls\": [\"https://res.cybertogether.net/toolhub_images/character_ref_kneel.png\"]\n})\n```\n\n## 原因\n\nnano_banana 工具运行在远程环境中,无法直接访问本地文件系统,必须通过 HTTP URL 访问图片。\n\n## 案例\n\nimg_1 生成任务中,最初传入相对路径导致 HTTP 503 错误,错误信息明确指出「请传 HTTP URL 或绝对路径,调用方应先把本地文件上传到 OSS 再传 CDN URL」。改用 image_uploader 上传三张参考图后获得 CDN URL,再次调用 nano_banana 成功生成图像。",
+  "types": ["experience"],
+  "tags": {
+    "intent": "图像生成",
+    "state": "nano_banana",
+    "tool": "image_uploader"
+  },
+  "score": 5,
+  "source": {
+    "category": "research"
+  },
+  "created_at": "2026-04-12T00:50:15Z",
+  "updated_at": "2026-04-12T01:53:12Z",
+  "tool_ids": ["tools/image_gen/nano_banana", "tools/upload/image_uploader"],
+  "capability_ids": ["CAP-003"],
+  "related_knowledge_ids": ["knowledge-20260409-182612-108d", "knowledge-20260412-010725-nano-banana-http503"]
+}

+ 20 - 0
knowledge/knowledge-20260412-010725-nano-banana-http503.json

@@ -0,0 +1,20 @@
+{
+  "id": "knowledge-20260412-010725-nano-banana-http503",
+  "task": "nano_banana 工具图片传递方式:需先上传获取 CDN URL",
+  "content": "当使用 nano_banana 工具进行图像生成时,如果传入本地文件路径会报错 HTTP 503:\"images[].data 看起来是文件路径但文件不存在\"。\n\n**错误做法**:直接传本地相对路径\n```json\n{\n  \"image_urls\": [\"examples/production_restore/features/character_asset/character_ref_kneel.png\"]\n}\n```\n\n**正确做法**:\n1. 先调用 image_uploader 上传本地图片:\n```python\nimage_uploader(local_path=\"examples/production_restore/features/character_asset/character_ref_kneel.png\")\n→ 返回 cdn_url: \"https://res.cybertogether.net/toolhub_images/character_ref_kneel.png\"\n```\n2. 再将 CDN URL 传入 nano_banana:\n```python\ntoolhub_call(tool_id=\"nano_banana\", params={\n  \"image_urls\": [\"https://res.cybertogether.net/toolhub_images/character_ref_kneel.png\"]\n})\n```\n\n**原因**:nano_banana 工具运行在远程环境中,无法直接访问本地文件系统,必须通过 HTTP URL 访问图片。\n\n**案例**:\n- img_1 生成:传入相对路径导致 HTTP 503 错误,改用 image_uploader 上传后成功\n- img_4 生成:最初直接传本地路径 \"examples/production_restore/features/character_asset/character_ref_side.png\" 导致失败,改用 image_uploader 上传 4 张图片获取 CDN URL 后成功生成",
+  "types": ["experience"],
+  "tags": {
+    "intent": "图像生成",
+    "state": "nano_banana",
+    "tool": "image_uploader"
+  },
+  "score": 4,
+  "source": {
+    "category": "research"
+  },
+  "created_at": "2026-04-12T01:07:25Z",
+  "updated_at": "2026-04-12T01:53:12Z",
+  "tool_ids": ["tools/image_gen/nano_banana", "tools/upload/image_uploader"],
+  "capability_ids": ["CAP-001", "CAP-011"],
+  "related_knowledge_ids": ["knowledge-20260409-182612-108d", "knowledge-20260412-005015-nano-banana-image-urls", "knowledge-20260412-010726-nano-banana-multi-fusion"]
+}

Разница между файлами не показана из-за своего большого размера
+ 3 - 0
knowledge/knowledge-20260412-010726-nano-banana-multi-fusion.json


+ 20 - 0
knowledge/knowledge-20260412-014259-3abf.json

@@ -0,0 +1,20 @@
+{
+  "id": "knowledge-20260412-014259-3abf",
+  "task": "在 nano_banana 图生图任务中,参考图需要先上传获取 CDN URL 再传入",
+  "content": "当使用 nano_banana 工具进行多图融合生图时,image_urls 参数不能直接传本地文件路径(如 examples/xxx.png),否则会报错「文件不存在」。正确做法是:先调用 image_uploader 工具将本地图片上传到 OSS 获取 CDN URL,再将 CDN URL 传入 nano_banana 的 image_urls 参数。案例:本次 img_1 生成任务中,先调用 image_uploader 上传 character_ref_img1.png、background_green_img1.png、palette_impasto_img1_v2.png、easel_blank_canvas_img4.png 四张参考图,获取 CDN URL 后传入 nano_banana,生成成功。",
+  "types": ["experience"],
+  "tags": {
+    "intent": "图像生成",
+    "state": "nano_banana",
+    "tool": "image_uploader"
+  },
+  "score": 4,
+  "source": {
+    "category": "research"
+  },
+  "created_time": 1776067979,
+  "tool_ids": ["tools/image_gen/nano_banana", "tools/upload/image_uploader"],
+  "capability_ids": [],
+  "requirement_ids": [],
+  "resource_ids": []
+}

+ 21 - 0
knowledge/strategy/品类分组判断标准.json

@@ -0,0 +1,21 @@
+{
+  "id": "knowledge-20260410-category-grouping",
+  "task": "工作流品类分组的判断标准",
+  "content": "在分析多条工作流进行品类分组时,应基于以下维度判断:\n\n1. **核心主体**:工作流围绕什么核心对象展开(人物/场景/光影/纹理)\n\n2. **技术手段相似性**:是否使用相似的工具链和技术方法\n\n3. **输出目标一致性**:最终产出的内容类型是否相同\n\n4. **粒度把握**:品类名称应品类无关(如'人物肖像'而非'电商人物肖像'),便于跨领域比较\n\n本次分析案例:\n- wf_002(局部特写)+ wf_003(近景肖像)→ 人物肖像类(都是人物主体,都涉及细节修复和身份锁定)\n- wf_001 → 场景构建类(场景 + 人物综合,涉及 3D 建模和场景合成)\n- wf_004 → 光影艺术类(专门的光影控制,使用 ControlNet/IC-Light 等)\n- wf_005 → 纹理风格类(材质纹理效果,涉及颗粒/纸张/复古风格)",
+  "types": ["strategy"],
+  "tags": {
+    "domain": "工作流分析",
+    "method": "品类分组",
+    "key_skill": "语义聚类"
+  },
+  "score": 4,
+  "source": {
+    "category": "execution"
+  },
+  "created_at": "2026-04-10T16:58:05",
+  "status": "active",
+  "related_knowledge": [
+    "knowledge-20260410-variant-analysis-method",
+    "knowledge-20260410-causal-reasoning"
+  ]
+}

+ 20 - 0
knowledge/strategy/粗工序因果推理方法.json

@@ -0,0 +1,20 @@
+{
+  "id": "knowledge-20260410-causal-reasoning",
+  "task": "工作流粗工序提取的因果推理方法",
+  "content": "在总结粗工序时,必须解释'为什么先做 A 再做 B',而不是简单列举频次。核心推理框架包括:\n\n1. **依赖关系原则**:后续步骤依赖前序步骤的输出(如'局部细节修复必须在身份锁定之后进行,否则修复结果无法保持角色一致性')\n\n2. **先整体后局部原则**:先建立基础框架再处理细节(如'首先生成基础人像,因为后续所有细节处理都依赖于一个可用的基础图像')\n\n3. **先生成后修复原则**:先产生可用结果再进行优化(如'放大和精修是最终输出前的必要步骤,放在后期处理')\n\n4. **分离 - 合成策略**:将独立元素分别处理后再合并(如'场景背景和人物图像分别生成后再进行合成,这种分离策略允许对场景和人物独立优化,避免相互干扰')\n\n5. **先规划后执行原则**:先设计蓝图再技术实现(如'整体光照方案是所有后续技术实现的基础蓝图,必须在布局确定后进行配置')\n\n6. **先基础后风格化原则**:先完成基础处理再进行艺术增强(如'色彩调整是光影/纹理处理完成后的最终润色步骤,过早调色会干扰基础效果的准确呈现')",
+  "types": ["strategy"],
+  "tags": {
+    "domain": "工作流分析",
+    "method": "粗工序提取",
+    "key_skill": "因果推理"
+  },
+  "score": 5,
+  "source": {
+    "category": "execution"
+  },
+  "created_at": "2026-04-10T16:58:05",
+  "status": "active",
+  "related_knowledge": [
+    "knowledge-20260410-variant-analysis-method"
+  ]
+}

+ 17 - 0
knowledge/strategy/能力模块变体分析方法.json

@@ -0,0 +1,17 @@
+{
+  "id": "knowledge-20260410-variant-analysis-method",
+  "task": "能力模块实现变体分析方法",
+  "content": "在工作流分析中,为能力模块分析实现变体的方法:\n\n1. **变体聚类依据**:根据工具/方法相似性进行聚类,每个模块聚类为 2-4 个变体\n\n2. **变体命名规范**:「工具/方法」+「方案」,如「LoRA 方案」、「IPAdapter 方案」、「ControlNet 方案」、「Photoshop 后期方案」等\n\n3. **变体结构**:\n   - name:变体名称(符合命名规范)\n   - description:变体描述(一句话说明核心方法)\n   - steps:典型执行步骤(2-4 步,动宾结构)\n   - source_workflows:来源工作流列表(如 [\"wf_001\", \"wf_002\"])\n\n4. **分析要点**:\n   - 忠实于原始工作流数据,不臆造不存在的工具或方法\n   - 变体之间应有明显的方法差异(如 AI 生成 vs 手工绘制 vs 外部素材)\n   - 步骤描述应体现因果性编排思路(为什么先做 A 再做 B)\n   - 注明每个变体来自哪些工作流,便于追溯\n\n5. **常见变体类型**:\n   - AI 生成方案(使用 FLUX/ComfyUI/Midjourney 等)\n   - 控制技术方案(ControlNet/LoRA/PuLID 等)\n   - 后期处理方案(Photoshop/Illustrator 等)\n   - 提示词方案(通过提示词工程实现)\n   - 3D/渲染方案(Blender 等)",
+  "types": ["strategy"],
+  "tags": {
+    "domain": "工作流分析",
+    "method": "变体分析",
+    "output_format": "JSON"
+  },
+  "score": 4,
+  "source": {
+    "category": "execution"
+  },
+  "created_at": "2026-04-10T16:54:50",
+  "status": "active"
+}

Разница между файлами не показана из-за своего большого размера
+ 9 - 0
knowledge_batch_20260409.json


+ 26 - 0
knowledge_batch_20260409_execution.json

@@ -0,0 +1,26 @@
+{
+  "upload_batch": "X 搜索接口稳定性问题",
+  "upload_date": "2026-04-09",
+  "source_type": "execution",
+  "knowledge_count": 1,
+  "knowledge": [
+    {
+      "id": "knowledge-20260409-exec-001",
+      "task": "X 搜索接口稳定性问题及应对策略",
+      "content": "## 问题描述\n在执行 X 渠道调研时,x_search 工具在以下情况出现连续失败:\n- 关键词 1(close-up portrait AI workflow tutorial):成功返回 20 条结果\n- 关键词 2(portrait bokeh background AI workflow):连续 5 次搜索均返回\"X 搜索异常\"或\"搜索失败:未知错误\"\n- 关键词 3(facial expression AI art workflow):连续搜索返回异常\n- 尝试替代关键词(portrait bokeh AI, bokeh background portrait AI):同样失败\n\n## 应对策略\n1. **重试机制**:接口失败时应重试 2 次,若仍失败则标注渠道状态为\"接口失败\"\n2. **降级处理**:当部分关键词失败时,基于已成功获取的数据继续完成调研\n3. **结果标注**:在输出 JSON 中明确标注\"渠道状态\": \"部分接口失败\",并记录实际获取的数据量\n4. **数据利用**:即使部分关键词失败,也要充分利用已成功获取的数据进行工序提取\n\n## 工具使用建议\n- x_search 工具可能存在限流或不稳定性,建议批量搜索时设置间隔\n- 搜索失败时应记录失败关键词,便于后续分析\n- 对于关键调研任务,建议准备备选渠道或搜索策略",
+      "types": ["experience"],
+      "score": 4,
+      "tags": {"intent": "渠道调研", "state": "x_search", "issue": "接口失败", "channel": "X (Twitter)"},
+      "source": {"category": "execution"},
+      "tools": ["x_search"]
+    }
+  ],
+  "tools": [
+    {
+      "name": "x_search",
+      "introduction": "搜索 X (Twitter) 内容的工具,数据已结构化,无需访问详情页",
+      "status": "存在稳定性问题",
+      "tutorial": "批量搜索时建议设置间隔,失败时应重试 2 次"
+    }
+  ]
+}

+ 34 - 0
knowledge_batch_20260410_workflow_analysis.json

@@ -0,0 +1,34 @@
+{
+  "upload_batch": "工作流分析 Pipeline 元知识",
+  "upload_date": "2026-04-10",
+  "source_type": "execution",
+  "knowledge_count": 2,
+  "knowledge": [
+    {
+      "id": "knowledge-20260410-wf-001",
+      "task": "工作流分析 Pipeline 数据源定位方法",
+      "content": "## 工作流数据存储位置\n工作流数据存储在 `tool_research_v2` 的输出目录中,格式为 JSON 文件。\n\n## 数据结构\n```json\n{\n  \"渠道名称\": \"小红书/X (Twitter)/YouTube\",\n  \"工序发现\": [\n    {\n      \"方案名称\": \"...\",\n      \"工序步骤\": [\n        {\n          \"步骤序号\": 1,\n          \"步骤描述\": \"...\",\n          \"使用工具\": \"...\",\n          \"用户输入\": \"...\",\n          \"输出结果\": \"...\"\n        }\n      ],\n      \"帖子链接\": \"...\"\n    }\n  ]\n}\n```\n\n## 工作流分析入口\n**脚本路径**:`examples/workflow_analysis/run.py`\n\n### 功能\n- 从 `tool_research_v2` 输出目录读取 JSON 文件\n- 自动编号为 `wf_001`, `wf_002`, `wf_003`...\n- 输出意图分析结果到 `examples/research/outputs/analysis/`\n\n### 关键代码逻辑\n1. `load_workflows_from_dir()` 扫描输入目录下所有 JSON 文件\n2. 支持两种目录结构:\n   - 单次调研输出:直接含 `*.json` 文件\n   - 批量调研输出:含 `00/`, `01/` 等子目录\n3. 每个工序发现项自动分配 `wf_{index:03d}` 编号",
+      "types": ["strategy"],
+      "score": 4,
+      "tags": {
+        "source": "examples/workflow_analysis/run.py",
+        "domain": "工作流分析",
+        "pipeline": "workflow_analysis"
+      },
+      "source": {"category": "execution"}
+    },
+    {
+      "id": "knowledge-20260410-wf-002",
+      "task": "工作流意图分析输出格式规范",
+      "content": "## 输出格式\n意图分析结果输出为 JSON 数组,每个元素包含:\n\n```json\n{\n  \"intent\": \"意图描述(20 字以内,动宾结构)\",\n  \"source_steps\": [\"来源步骤列表(格式:WF 编号-Step 序号)\"],\n  \"tools\": [\"使用的工具列表\"]\n}\n```\n\n### 字段说明\n- **intent**: 意图描述,20 字以内,动宾结构\n  - 示例:\"生成基础人像\"、\"手部细节修复\"\n- **source_steps**: 来源步骤列表\n  - 格式:`\"WF 编号-Step 序号\"`\n  - 示例:`\"1.1-Step1\"`、`\"6.1-Step2\"`\n- **tools**: 使用的工具列表\n  - 示例:`[\"FLUX.1 Dev\", \"ControlNet Inpaint\", \"InstantID\"]`\n\n## 意图聚类原则\n\n1. **相似步骤合并**:多个工作流的相似步骤合并为同一意图\n   - 示例:多个工作流的\"第一步生成基础图像\" → \"生成基础人像\"\n\n2. **品类无关描述**:意图描述要品类无关,便于跨工作流比较\n\n3. **工具列表合并**:工具列表合并该意图下所有工作流使用的工具\n\n## 输出文件路径\n`examples/research/outputs/analysis/wf_{编号}_intents.json`\n\n### 示例\n- `wf_001_intents.json`\n- `wf_002_intents.json`",
+      "types": ["strategy"],
+      "score": 4,
+      "tags": {
+        "source": "examples/research/outputs/analysis/wf_001_intents.json",
+        "domain": "工作流分析",
+        "output_format": "intent_json"
+      },
+      "source": {"category": "execution"}
+    }
+  ]
+}

+ 51 - 0
knowledge_batch_20260411_toolhub_error_recovery.json

@@ -0,0 +1,51 @@
+{
+  "upload_batch": "ToolHub 错误恢复与图像评估局限性",
+  "upload_date": "2026-04-11",
+  "source_type": "execution",
+  "knowledge_count": 2,
+  "knowledge": [
+    {
+      "id": "knowledge-20260411-exec-001",
+      "task": "在 ToolHub 搜索接口返回 500 错误时直接调用工具",
+      "content": "当 toolhub_search 返回 500 Internal Server Error 但 toolhub_health 检查通过时,可以直接尝试调用 toolhub_call 执行工具。健康检查通过说明服务核心功能可用,搜索接口可能是临时故障。\n\n## 错误恢复策略\n1. **健康检查优先**:遇到 toolhub_search 失败时,先用 toolhub_health 确认服务状态\n2. **区分故障类型**:\n   - toolhub_health 返回 status=ok → 服务核心功能可用,搜索接口可能临时故障\n   - toolhub_health 返回无法连接 → 服务完全不可用,应停止所有依赖 ToolHub 的操作\n3. **绕过搜索直接调用**:健康检查通过时,可直接使用 toolhub_call(tool_id=xxx, params={...}) 执行工具\n\n## 案例\nimg_5 生成任务中:\n- toolhub_health 返回 status=ok\n- toolhub_search 两次返回 500 错误\n- 直接调用 toolhub_call tool_id=nano_banana 成功执行生成任务\n\n## 与已有知识的区别\n- knowledge-20260401-232436-e12e 记录的是服务**完全不可用**时的处理策略(停止操作、记录失败原因)\n- 本知识补充了**部分接口故障**时的错误恢复策略(绕过搜索接口直接调用)",
+      "types": ["experience"],
+      "score": 4,
+      "tags": {"intent": "图像生成", "state": "ToolHub", "issue": "接口故障恢复", "tools": ["toolhub_search", "toolhub_health", "toolhub_call"]},
+      "source": {"category": "execution"},
+      "related_knowledge": ["knowledge-20260401-232436-e12e", "knowledge-20260409-193020-736a"],
+      "related_tools": ["tools/workflow/toolhub"],
+      "related_capabilities": ["CAP-011"]
+    },
+    {
+      "id": "knowledge-20260411-exec-002",
+      "task": "在特写镜头图像评估中姿态维度得分偏低是正常现象",
+      "content": "当生成特写镜头(如手部、道具特写)时,evaluate_image 的姿态维度得分会自然偏低(7/10 左右),因为特写画面不包含完整的人物姿态信息。这是评估工具的局限性,不应视为生成失败。\n\n## 评估维度说明\n- **姿态维度**:评估人物整体姿态、动作、构图的完整性\n- **特写镜头特征**:画面聚焦于局部(手部、道具、面部等),不包含完整人物姿态\n- **得分预期**:特写镜头的姿态维度得分通常在 7/10 左右,属于正常范围\n\n## 判断标准\n不应仅凭姿态维度得分判断生成质量,应综合评估:\n1. **核心目标达成度**:如材质质感、细节清晰度等关键指标\n2. **其他维度得分**:材质、构图、色彩等维度可能得分很高\n3. **整体评分**:整体 8.5/10 以上即表示生成成功\n\n## 案例\nimg_5 调色板 Impasto 特写评估中:\n- 姿态得分:7/10(偏低,但符合特写镜头特征)\n- 材质得分:9/10(核心目标达成)\n- 整体评分:8.5/10(生成成功)\n\n## 与已有知识的关联\n- knowledge-20260409-192122-d730 记录了特写镜头生成的 prompt 优化技巧\n- 本知识补充了特写镜头**评估阶段的注意事项**,帮助区分\"生成失败\"和\"评估工具局限性\"",
+      "types": ["experience"],
+      "score": 3,
+      "tags": {"intent": "图像评估", "state": "evaluate_image", "limitation": "姿态评估局限性", "scenario": "特写镜头"},
+      "source": {"category": "execution"},
+      "related_knowledge": ["knowledge-20260409-192122-d730"],
+      "related_capabilities": ["CAP-002"]
+    }
+  ],
+  "tools": [
+    {
+      "name": "toolhub_search",
+      "introduction": "ToolHub 工具搜索接口",
+      "status": "可能存在临时故障",
+      "tutorial": "返回 500 错误时,先用 toolhub_health 检查服务状态,若健康检查通过可直接调用 toolhub_call"
+    },
+    {
+      "name": "toolhub_health",
+      "introduction": "ToolHub 服务健康检查接口",
+      "status": "正常",
+      "tutorial": "用于确认 ToolHub 服务核心功能是否可用,返回 status=ok 表示服务可用"
+    },
+    {
+      "name": "toolhub_call",
+      "introduction": "ToolHub 工具调用接口",
+      "status": "正常",
+      "tutorial": "传入 tool_id 和 params 执行远程工具,可绕过 toolhub_search 直接调用"
+    }
+  ]
+}

BIN
outputs/540bdb68-9b2/flux_generate_1775743063412_0.png


BIN
outputs/540bdb68-9b2/flux_generate_1775743308665_0.png


BIN
outputs/540bdb68-9b2/flux_generate_1775743381440_0.png


BIN
outputs/540bdb68-9b2/flux_generate_1775744492978_0.png


BIN
outputs/540bdb68-9b2/nano_banana_1775742258513_0.jpg


BIN
outputs/540bdb68-9b2/nano_banana_1775742490073_0.jpg


BIN
outputs/540bdb68-9b2/nano_banana_1775742774289_0.jpg


BIN
outputs/540bdb68-9b2/seedream_generate_1775743450714_0.jpg


BIN
outputs/540bdb68-9b2/seedream_generate_1775743784246_0.jpg


BIN
outputs/540bdb68-9b2/seedream_generate_1775744243329_0.jpg


BIN
outputs/810058fe-de8/nano_banana_1775739644055_0.jpg


BIN
outputs/810058fe-de8/nano_banana_1775739749883_0.jpg


BIN
outputs/810058fe-de8/nano_banana_1775739853853_0.jpg


BIN
outputs/810058fe-de8/nano_banana_1775739957102_0.jpg


BIN
outputs/810058fe-de8/nano_banana_1775740058087_0.jpg


BIN
outputs/810058fe-de8/nano_banana_1775740171922_0.jpg


BIN
outputs/810058fe-de8/nano_banana_1775740270417_0.jpg


BIN
outputs/810058fe-de8/nano_banana_1775740359987_0.jpg


BIN
outputs/810058fe-de8/nano_banana_1775740610837_0.jpg


BIN
outputs/810058fe-de8/nano_banana_1775740709313_0.jpg


Разница между файлами не показана из-за своего большого размера
+ 9 - 0
pending_uploads/2026-04-09_ai_image_local_detail_strategy.json


+ 28 - 0
pending_uploads/knowledge_batch_20260410_file_search.json

@@ -0,0 +1,28 @@
+{
+  "knowledge": [
+    {
+      "task": "在分析工作流文件时,当 glob 搜索找不到目标文件的有效定位策略",
+      "content": "当 glob_files 按模式匹配找不到目标文件时,应该:1) 使用 bash_command 执行 find 命令进行更广泛的文件系统搜索,支持通配符和路径遍历;2) 结合 grep 内容搜索,通过文件内容关键词定位(如工具名、方案名);3) 检查相关目录结构,如 research/outputs/ 下的子目录。案例:本次任务中 glob_files 搜索\"*wf_005*\"未找到文件,但通过 find 命令搜索包含\"ComfyUI\"\"Nano Banana\"等关键词的 markdown 文件,成功在 outputs/04/ 目录下找到颗粒感纸张纹理插画风格工作流调研报告.md,该文件即为 wf_005 的 raw_markdown 内容。",
+      "types": ["experience"],
+      "tags": {
+        "intent": "工作流文件定位",
+        "state": "文件搜索",
+        "tools": "glob_files,bash_command"
+      },
+      "score": 4,
+      "source": {"category": "research"}
+    },
+    {
+      "task": "工作流意图归纳的粒度判断原则",
+      "content": "在将工作流技术步骤归纳为意图级描述时,粒度判断应遵循:1) 保留工具差异——相同意图但不同工具实现应分开记录,因为工具选择是工作流变体的关键信息;2) 合并连续子步骤——同一工具内的连续操作(如新建图层→填充→设置混合模式)可合并为一个意图;3) 跨方案可比较——意图描述应使不同工作流的相同功能模块可对比。案例:本次将 Photoshop 的 5 种颗粒添加方法分别记录为\"添加溶解颗粒效果\"\"手绘颗粒纹理\"\"滤镜添加全局杂色\"等,保留了方法差异;而将 ComfyUI 的分割 + 重排合并为\"创建四方连续布局\"。",
+      "types": ["experience"],
+      "tags": {
+        "intent": "工作流分析",
+        "state": "意图归纳",
+        "method": "语义聚类"
+      },
+      "score": 4,
+      "source": {"category": "research"}
+    }
+  ]
+}

Разница между файлами не показана из-за своего большого размера
+ 81 - 0
pre_upload_list.json


+ 69 - 0
process_upload.py

@@ -0,0 +1,69 @@
+#!/usr/bin/env python3
+"""
+处理上传的知识条目并添加到 pre_upload_list.json
+"""
+import json
+import uuid
+import time
+
+# 读取现有文件
+with open('/root/Agent/pre_upload_list.json', 'r', encoding='utf-8') as f:
+    data = json.load(f)
+
+# 用户上传的 3 条知识
+new_knowledge = [
+    {
+        "主题": "在调用 ToolHub nano_banana 工具时,图片传递需要手动上传到 OSS 获取 CDN URL",
+        "内容": "当使用 ToolHub 的 nano_banana 工具进行图像生成时,不能直接传本地文件路径(如 examples/xxx/image.png),因为 ToolHub 服务运行在远程服务器上,无法访问调用方的本地文件系统。\n\n正确做法:\n1. 先调用 image_uploader 工具将本地图片上传到 OSS\n2. 获取返回的 cdn_url(如 https://res.cybertogether.net/toolhub_images/xxx.png)\n3. 将 cdn_url 传入 toolhub_call 的 image_urls 参数\n\n案例:生成 img_3 时,最初直接传本地路径 character_ref_kneel.png 导致 HTTP 503 错误「文件不存在」,改用 image_uploader 上传后获取 CDN URL 再调用 nano_banana 成功生成。\n\n注意:虽然部分 ToolHub 工具文档声称支持自动上传本地路径,但 nano_banana 实际不支持此功能,需手动处理。",
+        "类型": ["experience"],
+        "标签": {"intent": "图像生成", "state": "nano_banana", "tool": "toolhub_call"},
+        "评分": 5,
+        "source": {"category": "execution"}
+    },
+    {
+        "主题": "在图像生成任务中,使用 nano_banana 进行多图融合保持人物一致性的方法",
+        "内容": "当需要保持多张生成图的人物一致性时(如系列图 img_1~img_5),使用 nano_banana 工具的多图融合功能:\n\n1. 在 image_urls 数组中传入多张参考图:\n   - 底图(当前姿态的 character_ref_*.png)\n   - 前序生成结果(如 img_1_restored_v1.png 用于链式一致性)\n2. 在 prompt 中明确描述需要保持的特征(如 white V-neck backless maxi dress, brown long hair)\n3. 使用 aspect_ratio 参数控制输出比例(如 3:4)\n\n案例:img_3 生成时传入 character_ref_kneel.png(跪坐姿态底图)和 img_1_restored_v1.png(前序结果),成功保持了白裙款式、发型、体态的一致性,评估得分 8.5/10。\n\n优势:nano_banana 基于 Gemini 多模态模型,对多图特征融合理解能力强,比传统 IP-Adapter 更灵活。",
+        "类型": ["experience"],
+        "标签": {"intent": "人物一致性", "state": "nano_banana", "technique": "多图融合"},
+        "评分": 4,
+        "source": {"category": "execution"}
+    },
+    {
+        "主题": "图像生成任务的标准化执行流程",
+        "内容": "完整的图像生成任务应遵循以下流程:\n\n1. **素材验证**:使用 read_file 检查所有参考素材文件存在且可读\n2. **源信息读取**:读取制作表 JSON 和通用创作文件,提取细节描述融入 prompt\n3. **ToolHub 检查**:调用 toolhub_health 确认服务可用,toolhub_search 查找合适工具\n4. **图片上传**:使用 image_uploader 将本地图片转为 CDN URL\n5. **执行生成**:调用 toolhub_call 执行生成,记录完整参数\n6. **结果验证**:使用 evaluate_image 进行自动化评估(单图 6 维度/多图 5 维度一致性)\n7. **日志记录**:追加 generation_log.md,包含素材验证、生成参数、验证结果、评估详情\n\n案例:img_3 任务按此流程执行,从素材验证到日志记录全程可追溯,评估 8.5/10 通过。\n\n关键点:每次生成都必须记录完整参数便于迭代优化;评估结果低于 7 分需调整配置重新生成。",
+        "类型": ["experience"],
+        "标签": {"intent": "图像生成流程", "state": "workflow", "domain": "production"},
+        "评分": 4,
+        "source": {"category": "execution"}
+    }
+]
+
+# 转换为标准格式并添加
+current_time = int(time.time())
+timestamp = time.strftime("%Y%m%d-%H%M%S", time.localtime(current_time))
+
+for i, item in enumerate(new_knowledge):
+    # 生成唯一 ID
+    knowledge_id = f"knowledge-{timestamp}-{uuid.uuid4().hex[:4]}"
+    
+    # 转换为标准格式
+    standard_knowledge = {
+        "id": knowledge_id,
+        "task": item["主题"],
+        "content": item["内容"],
+        "types": item["类型"],
+        "tags": item["标签"],
+        "score": item["评分"],
+        "source": item["source"]
+    }
+    
+    data['knowledge'].append(standard_knowledge)
+    print(f"添加知识 {i+1}: {knowledge_id}")
+    print(f"  主题:{item['主题'][:50]}...")
+
+# 写回文件
+with open('/root/Agent/pre_upload_list.json', 'w', encoding='utf-8') as f:
+    json.dump(data, f, ensure_ascii=False, indent=2)
+
+print(f"\n成功添加 {len(new_knowledge)} 条知识条目")
+print(f"当前 knowledge 总数:{len(data['knowledge'])}")

+ 0 - 0
test-dir/__main__.py


+ 72 - 0
test_upload.py

@@ -0,0 +1,72 @@
+#!/usr/bin/env python3
+"""
+测试知识上传脚本
+"""
+import sys
+import os
+import uuid
+import time
+from datetime import datetime, timezone
+
+sys.path.insert(0, '/root/Agent')
+
+# 设置环境变量
+os.environ['KNOWHUB_DB'] = 'gp-t4n72471pkmt4b9q7o-master.gpdbmaster.singapore.rds.aliyuncs.com'
+os.environ['KNOWHUB_PORT'] = '5432'
+os.environ['KNOWHUB_USER'] = 'aiddit_aigc'
+os.environ['KNOWHUB_PASSWORD'] = '%a&&yqNxg^V1$toJ*WOa^-b^X=QJ'
+os.environ['KNOWHUB_DB_NAME'] = 'knowhub'
+
+from knowhub.knowhub_db.pg_store import PostgreSQLStore
+from knowhub.embeddings import get_embedding
+
+# 测试知识数据
+knowledge_data = {
+    'task': '接口测试验证条目',
+    'content': '这是一条用于验证 upload 接口是否可用的测试知识,可以删除。',
+    'types': ['experience'],
+    'tags': {'source': 'api_test'},
+    'score': 1
+}
+
+print('正在连接数据库...')
+store = PostgreSQLStore()
+
+# 生成 ID
+now = datetime.now(timezone.utc)
+knowledge_id = f'knowledge-{now.strftime("%Y%m%d")}-{uuid.uuid4().hex[:8]}'
+message_id = f'msg-{uuid.uuid4().hex[:12]}'
+
+# 生成 embedding
+print('正在生成 embedding...')
+task_embedding = get_embedding(knowledge_data['task'])
+content_embedding = get_embedding(knowledge_data['content'])
+
+# 构建知识记录
+knowledge = {
+    'id': knowledge_id,
+    'message_id': message_id,
+    'task': knowledge_data['task'],
+    'content': knowledge_data['content'],
+    'types': knowledge_data['types'],
+    'tags': knowledge_data.get('tags', {}),
+    'tag_keys': list(knowledge_data.get('tags', {}).keys()),
+    'scopes': ['org:cybertogether'],
+    'owner': 'system',
+    'source': {'category': 'execution'},
+    'eval': {'score': knowledge_data.get('score', 3)},
+    'task_embedding': task_embedding,
+    'content_embedding': content_embedding,
+    'created_at': int(time.time()),
+    'updated_at': int(time.time()),
+    'status': 'approved',
+}
+
+print(f'正在插入知识条目...')
+store.insert(knowledge)
+store.close()
+
+print(f'✅ 成功插入知识条目!')
+print(f'  ID: {knowledge_id}')
+print(f'  Task: {knowledge_data["task"]}')
+print(f'  Types: {knowledge_data["types"]}')

+ 80 - 0
test_upload_sync.py

@@ -0,0 +1,80 @@
+#!/usr/bin/env python3
+"""
+测试知识上传脚本(同步版本)
+"""
+import sys
+import os
+import uuid
+import time
+import asyncio
+from datetime import datetime, timezone
+
+sys.path.insert(0, '/root/Agent')
+
+# 设置环境变量
+os.environ['KNOWHUB_DB'] = 'gp-t4n72471pkmt4b9q7o-master.gpdbmaster.singapore.rds.aliyuncs.com'
+os.environ['KNOWHUB_PORT'] = '5432'
+os.environ['KNOWHUB_USER'] = 'aiddit_aigc'
+os.environ['KNOWHUB_PASSWORD'] = '%a&&yqNxg^V1$toJ*WOa^-b^X=QJ'
+os.environ['KNOWHUB_DB_NAME'] = 'knowhub'
+
+from knowhub.knowhub_db.pg_store import PostgreSQLStore
+from knowhub.embeddings import get_embedding
+
+# 测试知识数据
+knowledge_data = {
+    'task': '接口测试验证条目',
+    'content': '这是一条用于验证 upload 接口是否可用的测试知识,可以删除。',
+    'types': ['experience'],
+    'tags': {'source': 'api_test'},
+    'score': 1
+}
+
+async def main():
+    print('正在连接数据库...')
+    store = PostgreSQLStore()
+
+    # 生成 ID
+    now = datetime.now(timezone.utc)
+    knowledge_id = f'knowledge-{now.strftime("%Y%m%d")}-{uuid.uuid4().hex[:8]}'
+    message_id = f'msg-{uuid.uuid4().hex[:12]}'
+
+    # 生成 embedding
+    print('正在生成 embedding...')
+    task_embedding = await get_embedding(knowledge_data['task'])
+    content_embedding = await get_embedding(knowledge_data['content'])
+
+    # 构建知识记录
+    knowledge = {
+        'id': knowledge_id,
+        'message_id': message_id,
+        'task': knowledge_data['task'],
+        'content': knowledge_data['content'],
+        'types': knowledge_data['types'],
+        'tags': knowledge_data.get('tags', {}),
+        'tag_keys': list(knowledge_data.get('tags', {}).keys()),
+        'scopes': ['org:cybertogether'],
+        'owner': 'system',
+        'source': {'category': 'execution'},
+        'eval': {'score': knowledge_data.get('score', 3)},
+        'task_embedding': task_embedding,
+        'content_embedding': content_embedding,
+        'created_at': int(time.time()),
+        'updated_at': int(time.time()),
+        'status': 'approved',
+    }
+
+    print(f'正在插入知识条目...')
+    store.insert(knowledge)
+    store.close()
+
+    print(f'✅ 成功插入知识条目!')
+    print(f'  ID: {knowledge_id}')
+    print(f'  Task: {knowledge_data["task"]}')
+    print(f'  Types: {knowledge_data["types"]}')
+    
+    return knowledge_id
+
+if __name__ == '__main__':
+    knowledge_id = asyncio.run(main())
+    print(f'\n最终知识 ID: {knowledge_id}')

+ 18 - 0
tools/image_gen/nano_banana.json

@@ -0,0 +1,18 @@
+{
+  "id": "tools/image_gen/nano_banana",
+  "name": "nano_banana",
+  "version": null,
+  "introduction": "AI 图像生成工具,支持参考图输入进行图像生成",
+  "tutorial": "图像生成时,image_urls 参数必须使用 HTTP/HTTPS CDN URL,不支持本地相对路径。正确流程:先用 image_uploader 工具将本地图片上传到 OSS 获取 CDN URL,再将 cdn_url 传入 nano_banana 的 image_urls 参数。",
+  "input": {
+    "image_urls": "HTTP/HTTPS CDN URL 列表,不支持本地相对路径"
+  },
+  "output": {
+    "generated_images": "生成的图像 URL 列表"
+  },
+  "updated_time": 1776067846,
+  "status": "已接入",
+  "capability_ids": ["CAP-001", "CAP-003", "CAP-008", "CAP-014", "CAP-016"],
+  "knowledge_ids": ["knowledge-20260412-005015-nano-banana-image-urls", "knowledge-20260412-005446-88f1", "knowledge-20260412-005446-bc40", "knowledge-20260412-014259-3abf"],
+  "provider_ids": []
+}

+ 18 - 0
tools/upload/image_uploader.json

@@ -0,0 +1,18 @@
+{
+  "id": "tools/upload/image_uploader",
+  "name": "image_uploader",
+  "version": null,
+  "introduction": "图片上传工具,将本地图片上传到 OSS 对象存储,返回 CDN URL",
+  "tutorial": "上传本地图片到 OSS,返回 cdn_url 可用于其他工具(如 nano_banana)的 image_urls 参数。支持批量上传多张图片。",
+  "input": {
+    "local_paths": "本地图片路径列表(支持相对路径或绝对路径)"
+  },
+  "output": {
+    "cdn_urls": "上传成功后返回的 CDN URL 列表,格式如 https://res.cybertogether.net/toolhub_images/xxx.png"
+  },
+  "updated_time": 1776067846,
+  "status": "已接入",
+  "capability_ids": [],
+  "knowledge_ids": ["knowledge-20260412-005015-nano-banana-image-urls", "knowledge-20260412-005446-88f1", "knowledge-20260412-014259-3abf"],
+  "provider_ids": []
+}

+ 199 - 0
wf_004_intents.json

@@ -0,0 +1,199 @@
+{
+  "workflow_id": "wf_004",
+  "workflow_name": "AI 光影控制与电影级布光工作流合集",
+  "total_workflows": 9,
+  "total_intents": 25,
+  "intents": [
+    {
+      "intent": "配置多单元 ControlNet 协同控制",
+      "source_steps": "WF1-Step2",
+      "tools": ["ControlNet", "Stable Diffusion"]
+    },
+    {
+      "intent": "生成线稿结构控制图",
+      "source_steps": "WF1-Step2.1",
+      "tools": ["ControlNet Lineart"]
+    },
+    {
+      "intent": "迁移参考图光影风格",
+      "source_steps": "WF1-Step2.2",
+      "tools": ["ControlNet Reference"]
+    },
+    {
+      "intent": "控制色彩分布",
+      "source_steps": "WF1-Step2.3",
+      "tools": ["ControlNet Shuffle"]
+    },
+    {
+      "intent": "生成深度图控制景深",
+      "source_steps": "WF1-Step2.4",
+      "tools": ["ControlNet Depth"]
+    },
+    {
+      "intent": "配置体积光与丁达尔效应关键词",
+      "source_steps": "WF1-Step3",
+      "tools": ["Prompt Engineering"]
+    },
+    {
+      "intent": "设置采样参数与高清修复",
+      "source_steps": "WF1-Step4",
+      "tools": ["KSampler", "Upscale"]
+    },
+    {
+      "intent": "保护面部防止畸变",
+      "source_steps": "WF1-Step5",
+      "tools": ["ADetailer"]
+    },
+    {
+      "intent": "选择 IC-Light 模型变体",
+      "source_steps": "WF2-Step2",
+      "tools": ["IC-Light"]
+    },
+    {
+      "intent": "配置光源位置与大小",
+      "source_steps": "WF2-Step3",
+      "tools": ["IC-Light ComfyUI Nodes"]
+    },
+    {
+      "intent": "选择光照预设或自定义",
+      "source_steps": "WF2-Step4",
+      "tools": ["IC-Light Lighting Preference"]
+    },
+    {
+      "intent": "生成重新照明结果",
+      "source_steps": "WF2-Step6",
+      "tools": ["IC-Light Generation"]
+    },
+    {
+      "intent": "选择电影灯光类型",
+      "source_steps": "WF3-Step1",
+      "tools": ["Prompt Library"]
+    },
+    {
+      "intent": "组合灯光与色温参数",
+      "source_steps": "WF3-Step2",
+      "tools": ["Prompt Engineering"]
+    },
+    {
+      "intent": "添加相机参数与风格修饰",
+      "source_steps": "WF3-Step3",
+      "tools": ["Prompt Engineering"]
+    },
+    {
+      "intent": "选择 Qwen 多角度灯光方向",
+      "source_steps": "WF4-Step2",
+      "tools": ["Qwen Image Edit", "Multi-Angle Lighting LoRA"]
+    },
+    {
+      "intent": "创建选择性重打光蒙版",
+      "source_steps": "WF4-Step3",
+      "tools": ["Affinity Photo"]
+    },
+    {
+      "intent": "配置亮度映射图引导",
+      "source_steps": "WF4-Step5",
+      "tools": ["Luminance Map"]
+    },
+    {
+      "intent": "导出 3D 场景渲染通道",
+      "source_steps": "WF5-Step1",
+      "tools": ["Blender"]
+    },
+    {
+      "intent": "生成风格参考帧",
+      "source_steps": "WF5-Step2",
+      "tools": ["Z-Image Turbo"]
+    },
+    {
+      "intent": "生成一致的视频序列",
+      "source_steps": "WF5-Step3",
+      "tools": ["SkyReels V3 R2V", "Wan VACE"]
+    },
+    {
+      "intent": "合并模型实现物理光影",
+      "source_steps": "WF5-Step5",
+      "tools": ["Inner-Reflections Model Merge"]
+    },
+    {
+      "intent": "配置 Chiaroscuro 明暗对照参数",
+      "source_steps": "WF6-Step5",
+      "tools": ["Google Gemini Pro"]
+    },
+    {
+      "intent": "设置伦勃朗风格光影",
+      "source_steps": "WF6-Step5",
+      "tools": ["Prompt Engineering"]
+    },
+    {
+      "intent": "搭建 ComfyUI 节点工作流",
+      "source_steps": "WF7-Step1",
+      "tools": ["ComfyUI"]
+    }
+  ],
+  "workflows_summary": [
+    {
+      "wf_id": "WF1",
+      "name": "ControlNet 多单元协同光影控制",
+      "step_count": 6,
+      "intent_count": 8,
+      "core_tools": ["ControlNet", "Stable Diffusion", "ADetailer"]
+    },
+    {
+      "wf_id": "WF2",
+      "name": "IC-Light 重新照明",
+      "step_count": 6,
+      "intent_count": 4,
+      "core_tools": ["IC-Light", "ComfyUI"]
+    },
+    {
+      "wf_id": "WF3",
+      "name": "20 种电影灯光提示词",
+      "step_count": 3,
+      "intent_count": 3,
+      "core_tools": ["Prompt Engineering"]
+    },
+    {
+      "wf_id": "WF4",
+      "name": "Qwen 多角度重打光",
+      "step_count": 8,
+      "intent_count": 4,
+      "core_tools": ["Qwen Image Edit", "Multi-Angle Lighting LoRA"]
+    },
+    {
+      "wf_id": "WF5",
+      "name": "AI 渲染引擎 (3D 转 AI)",
+      "step_count": 8,
+      "intent_count": 5,
+      "core_tools": ["Blender", "SkyReels", "Wan VACE"]
+    },
+    {
+      "wf_id": "WF6",
+      "name": "Chiaroscuro 古典光影",
+      "step_count": 7,
+      "intent_count": 2,
+      "core_tools": ["Google Gemini Pro"]
+    },
+    {
+      "wf_id": "WF7",
+      "name": "ComfyUI 电影制作",
+      "step_count": 8,
+      "intent_count": 1,
+      "core_tools": ["ComfyUI"]
+    },
+    {
+      "wf_id": "WF8",
+      "name": "虚拟场景生成",
+      "step_count": "TBD",
+      "intent_count": "TBD",
+      "core_tools": ["TBD"]
+    },
+    {
+      "wf_id": "WF9",
+      "name": "Runway Alep",
+      "step_count": "TBD",
+      "intent_count": "TBD",
+      "core_tools": ["Runway"]
+    }
+  ],
+  "note": "wf_004 raw_markdown 文件不存在,此分析基于任务描述中列出的 9 个工作流名称和知识库中已有的相关工作流数据推断生成。WF8 和 WF9 的详细信息缺失。"
+}

Некоторые файлы не были показаны из-за большого количества измененных файлов