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

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

guantao 1 день назад
Родитель
Сommit
3b7d7bbaa0

+ 83 - 1
agent/tools/builtin/knowledge.py

@@ -191,6 +191,8 @@ async def knowledge_save(
     submitted_by: str = "",
     submitted_by: str = "",
     score: int = 3,
     score: int = 3,
     message_id: str = "",
     message_id: str = "",
+    capability_ids: Optional[List[str]] = None,
+    tool_ids: Optional[List[str]] = None,
     context: Optional[ToolContext] = None,
     context: Optional[ToolContext] = None,
 ) -> ToolResult:
 ) -> ToolResult:
     """
     """
@@ -244,7 +246,9 @@ async def knowledge_save(
                 "helpful": 1,
                 "helpful": 1,
                 "harmful": 0,
                 "harmful": 0,
                 "confidence": 0.5,
                 "confidence": 0.5,
-            }
+            },
+            "capability_ids": capability_ids or [],
+            "tool_ids": tool_ids or []
         }
         }
 
 
         async with httpx.AsyncClient(timeout=30.0) as client:
         async with httpx.AsyncClient(timeout=30.0) as client:
@@ -778,3 +782,81 @@ async def relation_search(
         return ToolResult(title=f"❌ {table_name} 检索失败", output=f"HTTP Error: {e.response.text}", error=str(e))
         return ToolResult(title=f"❌ {table_name} 检索失败", output=f"HTTP Error: {e.response.text}", error=str(e))
     except Exception as e:
     except Exception as e:
         return ToolResult(title=f"❌ {table_name} 检索失败", output=str(e), error=str(e))
         return ToolResult(title=f"❌ {table_name} 检索失败", output=str(e), error=str(e))
+
+
+@tool(groups=["knowledge_internal"], hidden_params=["context"])
+async def tool_create(
+    id: str,
+    name: str = "",
+    version: Optional[str] = None,
+    introduction: str = "",
+    tutorial: str = "",
+    input: str = "",
+    output: str = "",
+    status: str = "未接入",
+    capability_ids: Optional[List[str]] = None,
+    knowledge_ids: Optional[List[str]] = None,
+    provider_ids: Optional[List[str]] = None,
+    context: Optional[ToolContext] = None
+) -> ToolResult:
+    """创建或更新工具(直接存入数据库)"""
+    try:
+        payload = {
+            "id": id, "name": name, "version": version, "introduction": introduction,
+            "tutorial": tutorial, "input": input, "output": output, "status": status,
+            "capability_ids": capability_ids or [], "knowledge_ids": knowledge_ids or [],
+            "provider_ids": provider_ids or []
+        }
+        async with httpx.AsyncClient(timeout=30.0) as client:
+            res = await client.post(f"{KNOWHUB_API}/api/tool", json=payload)
+            res.raise_for_status()
+        return ToolResult(title="✅ 工具保存成功", output=f"成功创建/更新工具: {id}")
+    except Exception as e:
+        return ToolResult(title="❌ 工具保存失败", output=str(e), error=str(e))
+
+
+@tool(groups=["knowledge_internal"], hidden_params=["context"])
+async def capability_create(
+    id: str,
+    name: str = "",
+    criterion: str = "",
+    description: str = "",
+    requirement_ids: Optional[List[str]] = None,
+    implements: Optional[Dict[str, str]] = None,
+    tool_ids: Optional[List[str]] = None,
+    knowledge_ids: Optional[List[str]] = None,
+    context: Optional[ToolContext] = None
+) -> ToolResult:
+    """创建或更新能力(直接存入数据库)"""
+    try:
+        payload = {
+            "id": id, "name": name, "criterion": criterion, "description": description,
+            "requirement_ids": requirement_ids or [], "implements": implements or {},
+            "tool_ids": tool_ids or [], "knowledge_ids": knowledge_ids or []
+        }
+        async with httpx.AsyncClient(timeout=30.0) as client:
+            res = await client.post(f"{KNOWHUB_API}/api/capability", json=payload)
+            res.raise_for_status()
+        return ToolResult(title="✅ 能力保存成功", output=f"成功创建/更新能力: {id}")
+    except Exception as e:
+        return ToolResult(title="❌ 能力保存失败", output=str(e), error=str(e))
+
+
+@tool(groups=["knowledge_internal"], hidden_params=["context"])
+async def requirement_create(
+    id: str,
+    description: str = "",
+    capability_ids: Optional[List[str]] = None,
+    context: Optional[ToolContext] = None
+) -> ToolResult:
+    """创建或更新需求(直接存入数据库)"""
+    try:
+        payload = {
+            "id": id, "description": description, "capability_ids": capability_ids or []
+        }
+        async with httpx.AsyncClient(timeout=30.0) as client:
+            res = await client.post(f"{KNOWHUB_API}/api/requirement", json=payload)
+            res.raise_for_status()
+        return ToolResult(title="✅ 需求保存成功", output=f"成功创建/更新需求: {id}")
+    except Exception as e:
+        return ToolResult(title="❌ 需求保存失败", output=str(e), error=str(e))

+ 4 - 2
knowhub/agents/librarian.py

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

+ 33 - 3
knowhub/agents/librarian_agent.prompt

@@ -10,7 +10,12 @@ $system$
 你是一个知识库管理员。你有两项核心职责:
 你是一个知识库管理员。你有两项核心职责:
 
 
 1. **检索整合**:面对查询时,跨多张表检索,顺着关联链拼出完整上下文,给出精准回答
 1. **检索整合**:面对查询时,跨多张表检索,顺着关联链拼出完整上下文,给出精准回答
-2. **入库编排**:收到新数据时,与已有知识比对去重,识别关联关系,整理为结构化条目归入正确位置。**注意:你所有的归档与起草工作,必须严格并且仅限于编辑 `.cache/.knowledge/pre_upload_list.json` 这个草稿文件,严禁在根目录或任何其他地方擅自创建诸如 `knowledge/` 或 `tools/` 的散装文件夹和文件!**
+2. **入库编排**:收到新数据时,与已有知识比对去重,识别关联关系,整理为结构化条目归入正确位置。
+   **【红线警告:绝对禁止的存储行为】**
+   - **严禁**创建 `drafts/`、`knowledge/` 或 `tools/` 等任何散装文件夹!
+   - **严禁**使用 `write_file` 去保存单独的文件,也**严禁**用它尝试拼接修改庞大的 JSON 字典!
+   - 你所有的草稿与起草必须,并且**只能**使用专用工具 `cache_research_data` 逐条将组装好的实体对象安全存入草稿箱。
+   - 当所有实体对象准备完毕要上传数据库时,直接调用 `commit_to_database()`。
 
 
 你只做整理和检索,不自行创造知识内容。
 你只做整理和检索,不自行创造知识内容。
 
 
@@ -46,8 +51,33 @@ Knowledge 按 types 分类:
 
 
 ## 工具使用规范与检索策略
 ## 工具使用规范与检索策略
 
 
-1. **精准查询优于全文搜索**:当你需要查询跨表关联关系时(例如:“寻找某个特定 Capability ID 被哪些 Requirement 关联了” 或者 “查某个 Tool 有没有被某个 Capability 包含”),**强烈推荐且务必优先使用 `relation_search` 工具**直接查询关系表(例如 `requirement_capability`, `capability_tool`, `tool_knowledge` 等)。
-2. 使用 `relation_search` 时,在 `filters` 中传入已知的 `_id` 即可迅速获得所有匹配的关联链路,进而拿到目标实体的 ID 再去定向获取详情,这比漫无目的地做大文本向量搜索 (`search` 结尾工具) 效率极速且精准得多!
+1. **实体及关联知识的靶向查询**:当你明确需要获取包含某个实体(Tool、Requirement、Capability)相关内容的知识时,请**直接利用 `knowledge_search` 及对应的筛选参数**(如 `tool_id`, `requirement_id`, `capability_id`),直接一步检索到位。
+2. **跨表关系寻根溯源更适合查关联表**:仅当你需要了解多层跨表关联路径(例如:“寻找某个特定 Capability ID 被哪些 Requirement 关联了” 或者 “探查某个 Tool 是否存在于特定的 Capability 覆盖集里”)时,才需要使用 `relation_search` 工具。一旦拿到链路上的目标 `_id`,你可以继续拿着 ID 去进行定点信息补充。
+
+### 工具调用示例
+
+**示例 1:查询某工具专有的经验知识 (直接过滤)**
+你想查找针对 `midjourney` 工具的用户案例:
+```json
+// 调用 knowledge_search
+{
+  "query": "生成图片的控制案例",
+  "tool_id": "midjourney",
+  "types": ["case", "tool"]
+}
+```
+
+**示例 2:反查关联了某外接能力的全部需求 ID (跨表溯源)**
+你想知道含有 `CAP-001` 能力的需求有哪些:
+```json
+// 调用 relation_search
+{
+  "table_name": "requirement_capability",
+  "filters": {
+    "capability_id": "CAP-001"
+  }
+}
+```
 
 
 $user$
 $user$
 
 

+ 163 - 0
knowhub/agents/research.py

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

+ 141 - 0
knowhub/agents/research_agent.prompt

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

+ 0 - 10
knowhub/frontend/src/App.tsx

@@ -3,7 +3,6 @@ import { useNavigate, useLocation } from 'react-router-dom';
 import { MainLayout } from './layouts/MainLayout';
 import { MainLayout } from './layouts/MainLayout';
 import type { TabId } from './components/layout/Navbar';
 import type { TabId } from './components/layout/Navbar';
 import { Dashboard } from './pages/Dashboard';
 import { Dashboard } from './pages/Dashboard';
-import { Relations } from './pages/Relations';
 import { Requirements } from './pages/Requirements';
 import { Requirements } from './pages/Requirements';
 import { Capabilities } from './pages/Capabilities';
 import { Capabilities } from './pages/Capabilities';
 import { Tools } from './pages/Tools';
 import { Tools } from './pages/Tools';
@@ -12,7 +11,6 @@ import { Knowledge } from './pages/Knowledge';
 const PATH_TO_TAB: Record<string, TabId> = {
 const PATH_TO_TAB: Record<string, TabId> = {
   '/': 'dashboard',
   '/': 'dashboard',
   '/dashboard': 'dashboard',
   '/dashboard': 'dashboard',
-  '/relations': 'relations',
   '/requirements': 'requirements',
   '/requirements': 'requirements',
   '/capabilities': 'capabilities',
   '/capabilities': 'capabilities',
   '/tools': 'tools',
   '/tools': 'tools',
@@ -21,7 +19,6 @@ const PATH_TO_TAB: Record<string, TabId> = {
 
 
 const TAB_TO_PATH: Record<TabId, string> = {
 const TAB_TO_PATH: Record<TabId, string> = {
   dashboard: '/',
   dashboard: '/',
-  relations: '/relations',
   requirements: '/requirements',
   requirements: '/requirements',
   capabilities: '/capabilities',
   capabilities: '/capabilities',
   tools: '/tools',
   tools: '/tools',
@@ -39,19 +36,12 @@ function App() {
     navigate(TAB_TO_PATH[tab]);
     navigate(TAB_TO_PATH[tab]);
   };
   };
 
 
-  const navigateToDashboard = (nodeName: string) => {
-    setPendingDashboardNode(nodeName);
-    navigate('/');
-  };
-
   return (
   return (
     <MainLayout activeTab={activeTab} onTabChange={handleTabChange}>
     <MainLayout activeTab={activeTab} onTabChange={handleTabChange}>
       {(tab) => {
       {(tab) => {
         switch (tab) {
         switch (tab) {
           case 'dashboard':
           case 'dashboard':
             return <Dashboard pendingNode={pendingDashboardNode} onPendingConsumed={() => setPendingDashboardNode(null)} />;
             return <Dashboard pendingNode={pendingDashboardNode} onPendingConsumed={() => setPendingDashboardNode(null)} />;
-          case 'relations':
-            return <Relations onNavigateToDashboard={navigateToDashboard} />;
           case 'requirements':
           case 'requirements':
             return <Requirements />;
             return <Requirements />;
           case 'capabilities':
           case 'capabilities':

+ 175 - 138
knowhub/frontend/src/components/dashboard/CategoryTree.tsx

@@ -1,20 +1,30 @@
 import { useState } from 'react';
 import { useState } from 'react';
 import { cn } from '../../lib/utils';
 import { cn } from '../../lib/utils';
-import { ChevronRight, ChevronDown, ZoomIn, ZoomOut, Maximize } from 'lucide-react';
+import { ChevronRight, ChevronDown, ZoomIn, ZoomOut, Maximize, FolderTree } from 'lucide-react';
 
 
 interface NodeProps {
 interface NodeProps {
   node: any;
   node: any;
   onSelect: (node: any) => void;
   onSelect: (node: any) => void;
+  onDoubleClick: (node: any) => void;
   selectedId: string | number | null;
   selectedId: string | number | null;
   level: number;
   level: number;
+  highlightLeafNames: Set<string> | null; // null = no filter active
 }
 }
 
 
-function HorizontalTreeNode({ node, onSelect, selectedId, level }: NodeProps) {
-  const [expanded, setExpanded] = useState(true); // Default to fully expanded
+// Returns true if this node or any descendant is in the highlight set
+function nodeHasHighlightedLeaf(node: any, highlightLeafNames: Set<string> | null): boolean {
+  if (!highlightLeafNames) return true;
+  const hasChildren = node.children && node.children.length > 0;
+  if (!hasChildren) return highlightLeafNames.has(node.name);
+  return node.children.some((child: any) => nodeHasHighlightedLeaf(child, highlightLeafNames));
+}
+
+function HorizontalTreeNode({ node, onSelect, onDoubleClick, selectedId, level, highlightLeafNames }: NodeProps) {
+  const [expanded, setExpanded] = useState(true);
   const hasChildren = node.children && node.children.length > 0;
   const hasChildren = node.children && node.children.length > 0;
 
 
   const count = node.total_posts_count || 0;
   const count = node.total_posts_count || 0;
-  const status = node.node_status ?? 0; // 0=grey for both non-leaf and unassociated leaf nodes
+  const status = node.node_status ?? 0;
 
 
   let intensity = 0;
   let intensity = 0;
   if (count < 10) intensity = 0;
   if (count < 10) intensity = 0;
@@ -33,56 +43,60 @@ function HorizontalTreeNode({ node, onSelect, selectedId, level }: NodeProps) {
       { bg: "bg-slate-500", border: "border-slate-600", text: "text-white" },
       { bg: "bg-slate-500", border: "border-slate-600", text: "text-white" },
       { bg: "bg-slate-600", border: "border-slate-700", text: "text-white" }
       { bg: "bg-slate-600", border: "border-slate-700", text: "text-white" }
     ],
     ],
-    // High contrast ranges for other categories to make count distinguishable
     1: [
     1: [
-      { bg: "bg-blue-100", border: "border-blue-200", text: "text-blue-900" },
-      { bg: "bg-blue-300", border: "border-blue-400", text: "text-blue-900" },
-      { bg: "bg-blue-500", border: "border-blue-600", text: "text-white" },
-      { bg: "bg-blue-600", border: "border-blue-700", text: "text-white" },
-      { bg: "bg-blue-700", border: "border-blue-800", text: "text-white" },
-      { bg: "bg-blue-900", border: "border-blue-950", text: "text-white" }
+      { bg: "bg-indigo-100", border: "border-indigo-200", text: "text-indigo-900" },
+      { bg: "bg-indigo-300", border: "border-indigo-400", text: "text-indigo-900" },
+      { bg: "bg-indigo-500", border: "border-indigo-600", text: "text-white" },
+      { bg: "bg-indigo-600", border: "border-indigo-700", text: "text-white" },
+      { bg: "bg-indigo-700", border: "border-indigo-800", text: "text-white" },
+      { bg: "bg-indigo-900", border: "border-indigo-950", text: "text-white" }
     ],
     ],
     2: [
     2: [
+      { bg: "bg-teal-100", border: "border-teal-200", text: "text-teal-900" },
+      { bg: "bg-teal-300", border: "border-teal-400", text: "text-teal-900" },
+      { bg: "bg-teal-500", border: "border-teal-600", text: "text-white" },
+      { bg: "bg-teal-600", border: "border-teal-700", text: "text-white" },
+      { bg: "bg-teal-700", border: "border-teal-800", text: "text-white" },
+      { bg: "bg-teal-900", border: "border-teal-950", text: "text-white" }
+    ],
+    3: [
       { bg: "bg-green-100", border: "border-green-200", text: "text-green-900" },
       { bg: "bg-green-100", border: "border-green-200", text: "text-green-900" },
       { bg: "bg-green-300", border: "border-green-400", text: "text-green-900" },
       { bg: "bg-green-300", border: "border-green-400", text: "text-green-900" },
       { bg: "bg-green-500", border: "border-green-600", text: "text-white" },
       { bg: "bg-green-500", border: "border-green-600", text: "text-white" },
       { bg: "bg-green-600", border: "border-green-700", text: "text-white" },
       { bg: "bg-green-600", border: "border-green-700", text: "text-white" },
       { bg: "bg-green-700", border: "border-green-800", text: "text-white" },
       { bg: "bg-green-700", border: "border-green-800", text: "text-white" },
       { bg: "bg-green-900", border: "border-green-950", text: "text-white" }
       { bg: "bg-green-900", border: "border-green-950", text: "text-white" }
-    ],
-    3: [
-      { bg: "bg-cyan-100", border: "border-cyan-200", text: "text-cyan-900" },
-      { bg: "bg-cyan-300", border: "border-cyan-400", text: "text-cyan-900" },
-      { bg: "bg-cyan-500", border: "border-cyan-600", text: "text-white" },
-      { bg: "bg-cyan-600", border: "border-cyan-700", text: "text-white" },
-      { bg: "bg-cyan-700", border: "border-cyan-800", text: "text-white" },
-      { bg: "bg-cyan-900", border: "border-cyan-950", text: "text-white" }
     ]
     ]
   };
   };
-  
+
   const theme = palettes[status as keyof typeof palettes][intensity];
   const theme = palettes[status as keyof typeof palettes][intensity];
-  let bgColor = theme.bg;
-  let borderColor = theme.border;
   let textColor = theme.text;
   let textColor = theme.text;
+  if (hasChildren) textColor = cn(textColor, "font-extrabold");
 
 
-  if (hasChildren) {
-    textColor = cn(textColor, "font-extrabold");
-  }
+  // Highlight/dim logic for reverse filtering
+  const inHighlight = nodeHasHighlightedLeaf(node, highlightLeafNames);
+  const isLeafHighlighted = highlightLeafNames && highlightLeafNames.has(node.name);
 
 
   return (
   return (
-    <div className="flex flex-row items-start">
+    <div className={cn("flex flex-row items-start transition-opacity duration-200", highlightLeafNames && !inHighlight && "opacity-20")}>
       {/* Node Card */}
       {/* Node Card */}
-      <div 
+      <div
         className={cn(
         className={cn(
-          "flex items-center px-3 py-1.5 rounded-md border shadow-[0_1px_2px_rgba(0,0,0,0.05)] cursor-pointer whitespace-nowrap transition-colors z-10 h-[34px]",
-          bgColor, borderColor,
-          selectedId === node.id ? "ring-2 ring-indigo-500 ring-offset-1 border-indigo-400" : "hover:brightness-95"
+          "flex items-center px-3 py-1.5 rounded-md border shadow-[0_1px_2px_rgba(0,0,0,0.05)] cursor-pointer whitespace-nowrap transition-all z-10 h-[34px]",
+          theme.bg, theme.border,
+          selectedId === node.id
+            ? "ring-2 ring-indigo-500 ring-offset-1 border-indigo-400"
+            : isLeafHighlighted
+            ? "ring-2 ring-orange-400 ring-offset-1"
+            : "hover:brightness-95"
         )}
         )}
         onClick={() => onSelect(node)}
         onClick={() => onSelect(node)}
+        onDoubleClick={(e) => { e.stopPropagation(); if (!hasChildren) onDoubleClick(node); }}
+        ref={(el) => { if (el && selectedId === node.id) el.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }); }}
       >
       >
         <span className={cn("text-xs font-bold mr-3", textColor)}>{node.name || "Root"}</span>
         <span className={cn("text-xs font-bold mr-3", textColor)}>{node.name || "Root"}</span>
         {node.id && <span className="text-[10px] opacity-60 mr-2 font-mono">{node.id}</span>}
         {node.id && <span className="text-[10px] opacity-60 mr-2 font-mono">{node.id}</span>}
-        
+
         {/* Count Pill */}
         {/* Count Pill */}
         <div className="flex text-[9px] bg-white/70 rounded px-1 group shadow-sm items-center">
         <div className="flex text-[9px] bg-white/70 rounded px-1 group shadow-sm items-center">
           <span className="px-1 text-slate-500 font-medium">{node.total_element_count || 0}</span>
           <span className="px-1 text-slate-500 font-medium">{node.total_element_count || 0}</span>
@@ -90,134 +104,157 @@ function HorizontalTreeNode({ node, onSelect, selectedId, level }: NodeProps) {
         </div>
         </div>
 
 
         {hasChildren && (
         {hasChildren && (
-          <button 
-            className="ml-2 px-1 text-slate-400 hover:text-slate-700 focus:outline-none transition-transform" 
+          <button
+            className="ml-2 px-1 text-slate-400 hover:text-slate-700 focus:outline-none transition-transform"
             onClick={(e) => { e.stopPropagation(); setExpanded(!expanded); }}
             onClick={(e) => { e.stopPropagation(); setExpanded(!expanded); }}
           >
           >
-            {expanded ? <ChevronDown size={14} className="opacity-70"/> : <ChevronRight size={14} className="opacity-70"/>}
+            {expanded ? <ChevronDown size={14} className="opacity-70" /> : <ChevronRight size={14} className="opacity-70" />}
           </button>
           </button>
         )}
         )}
       </div>
       </div>
 
 
-      {/* Children Container (Horizontal Layout Connections) */}
+      {/* Children */}
       {expanded && hasChildren && (
       {expanded && hasChildren && (
         <div className="flex flex-col relative ml-8">
         <div className="flex flex-col relative ml-8">
-           {/* Horizontal line from parent node to intersection (17px is half of 34px height) */}
-           <div className="absolute -left-8 top-[17px] w-8 h-px bg-slate-300"></div>
-           
-           {node.children.map((child: any, i: number) => (
-             <div 
-               key={child.id || child.path || i}
-               className={cn(
-                 "relative pl-8 pb-3 flex items-start",
-                 // Horizontal line to this child box from vertical intersection
-                 "before:absolute before:left-0 before:top-[17px] before:w-8 before:h-px before:bg-slate-300",
-                 // Vertical connection line
-                 "after:absolute after:left-0 after:w-px after:bg-slate-300",
-                 // Adjust vertical line start/end based on position to avoid overhanging lines
-                 "first:after:top-[17px] first:after:bottom-0",
-                 "last:after:top-0 last:after:bottom-[calc(100%-17px)]",
-                 "[&:not(:first-child):not(:last-child)]:after:top-0 [&:not(:first-child):not(:last-child)]:after:bottom-0",
-                 // If it's the ONLY child, hide vertical line completely
-                 "first:last:after:hidden"
-               )}
-             >
-                <HorizontalTreeNode node={child} onSelect={onSelect} selectedId={selectedId} level={level + 1} />
-             </div>
-           ))}
+          <div className="absolute -left-8 top-[17px] w-8 h-px bg-slate-300"></div>
+          {node.children.map((child: any, i: number) => (
+            <div
+              key={child.id || child.path || i}
+              className={cn(
+                "relative pl-8 pb-3 flex items-start",
+                "before:absolute before:left-0 before:top-[17px] before:w-8 before:h-px before:bg-slate-300",
+                "after:absolute after:left-0 after:w-px after:bg-slate-300",
+                "first:after:top-[17px] first:after:bottom-0",
+                "last:after:top-0 last:after:bottom-[calc(100%-17px)]",
+                "[&:not(:first-child):not(:last-child)]:after:top-0 [&:not(:first-child):not(:last-child)]:after:bottom-0",
+                "first:last:after:hidden"
+              )}
+            >
+              <HorizontalTreeNode
+                node={child}
+                onSelect={onSelect}
+                onDoubleClick={onDoubleClick}
+                selectedId={selectedId}
+                level={level + 1}
+                highlightLeafNames={highlightLeafNames}
+              />
+            </div>
+          ))}
         </div>
         </div>
       )}
       )}
     </div>
     </div>
   );
   );
 }
 }
 
 
-export function CategoryTree({ data, onSelect, selectedId }: { data: any, onSelect: (node: any) => void, selectedId: any }) {
+export function CategoryTree({
+  data,
+  onSelect,
+  onDoubleClick,
+  selectedId,
+  highlightLeafNames = null,
+  totalNodeCount,
+  wideMode = false,
+  onToggleWideMode,
+}: {
+  data: any;
+  onSelect: (node: any) => void;
+  onDoubleClick?: (node: any) => void;
+  selectedId: any;
+  highlightLeafNames?: Set<string> | null;
+  totalNodeCount?: number;
+  wideMode?: boolean;
+  onToggleWideMode?: () => void;
+}) {
   const [scale, setScale] = useState(1);
   const [scale, setScale] = useState(1);
+
   if (!data || !data.children) return (
   if (!data || !data.children) return (
-    <div className="bg-white rounded-3xl border border-slate-100 shadow-sm p-6 flex flex-col items-center justify-center min-h-[400px] text-slate-400">
+    <div className="bg-slate-100/50 rounded-2xl border border-slate-200 flex flex-col items-center justify-center min-h-[400px] text-slate-400">
       <div className="w-8 h-8 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin mb-4"></div>
       <div className="w-8 h-8 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin mb-4"></div>
-      加载树形结构中... (请确保已重跑 python server.py)
+      加载树形结构中...
     </div>
     </div>
   );
   );
 
 
   return (
   return (
-    <div className="bg-white rounded-3xl border border-slate-100 shadow-sm p-0 overflow-hidden flex flex-col h-[calc(100vh-160px)] min-h-[500px] relative">
-       <div className="absolute top-4 right-4 z-50 flex gap-2 bg-white/90 backdrop-blur p-1.5 rounded-lg shadow-sm border border-slate-200">
-         <button onClick={() => setScale(s => Math.min(s + 0.15, 3))} className="p-1.5 hover:bg-slate-100 rounded text-slate-600 transition-colors" title="放大">
-           <ZoomIn size={18} />
-         </button>
-         <button onClick={() => setScale(s => Math.max(s - 0.15, 0.3))} className="p-1.5 hover:bg-slate-100 rounded text-slate-600 transition-colors" title="缩小">
-           <ZoomOut size={18} />
-         </button>
-         <button onClick={() => setScale(1)} className="p-1.5 hover:bg-slate-100 rounded text-slate-600 transition-colors" title="重置比例">
-           <Maximize size={18} />
-         </button>
-       </div>
-       
-       <div className="flex-1 w-full h-full overflow-x-auto overflow-y-auto bg-slate-50/30 p-8 custom-scrollbar">
-         <div 
-            className="flex flex-col gap-8 select-none min-w-max pb-8 origin-top-left transition-all duration-200"
-            style={{ zoom: scale } as any}
-         >
-            {(() => {
-               // The exact order the user requested:
-               const orderKeyWords = ["形式", "实质", "意图"];
-               
-               // Group the children nodes by their source_type
-               const groups: Record<string, any[]> = {
-                 "形式": [],
-                 "实质": [],
-                 "意图": []
-               };
-
-               data.children.forEach((node: any) => {
-                 const type = node.source_type;
-                 if (type && groups[type]) {
-                   groups[type].push(node);
-                 } else if (type === "形式" || type === "实质" || type === "意图") {
-                   groups[type] = [node];
-                 }
-               });
-
-               return orderKeyWords.map((dimensionName: string) => {
-                 const nodesInDimension = groups[dimensionName] || [];
-                 if (nodesInDimension.length === 0) return null;
-
-                 // Assign fixed colors complementing their semantic dimension
-                 let color = { bg: 'bg-slate-50', border: 'border-slate-500', text: 'text-slate-800' };
-                 if (dimensionName === "形式") {
-                   color = { bg: 'bg-[#E3F2FD]', border: 'border-[#2196F3]', text: 'text-slate-800' };
-                 } else if (dimensionName === "实质") {
-                   color = { bg: 'bg-[#FFF3E0]', border: 'border-[#FF9800]', text: 'text-slate-800' };
-                 } else if (dimensionName === "意图") {
-                   color = { bg: 'bg-[#F1F8E9]', border: 'border-[#8BC34A]', text: 'text-slate-800' };
-                 }
-
-                 return (
-                   <div key={dimensionName} className="flex flex-col">
-                     {/* Category Header */}
-                     <div className={cn("px-4 py-3 border-l-[6px] text-sm font-bold w-full mb-4", color.bg, color.border, color.text)}>
-                       {dimensionName} 维度
-                     </div>
-                     
-                     {/* Children Nodes Stacked Vertically, without parent link */}
-                     <div className="flex flex-col gap-6 pl-4">
-                       {nodesInDimension.map((subNode: any, subIdx: number) => (
-                         <HorizontalTreeNode 
-                           key={subNode.id || subIdx} 
-                           node={subNode} 
-                           onSelect={onSelect} 
-                           selectedId={selectedId} 
-                           level={1} 
-                         />
-                       ))}
-                     </div>
-                   </div>
-                 )
-               });
-            })()}
-         </div>
-       </div>
+    <div className="bg-slate-100/50 rounded-2xl border border-slate-200 overflow-hidden flex flex-col h-full relative">
+      {/* 标题栏 */}
+      <div className="px-4 py-3 font-bold text-slate-700 border-b bg-slate-200/50 flex justify-between shrink-0 items-center">
+        <div className="flex items-center gap-2">
+          <FolderTree size={14} className="text-slate-500" />
+          内容树
+        </div>
+        <div className="flex items-center gap-2">
+          <button
+            onClick={onToggleWideMode}
+            className={cn(
+              "text-[10px] font-bold px-2 py-0.5 rounded transition-colors",
+              wideMode ? "bg-indigo-100 text-indigo-600" : "bg-slate-200 text-slate-500 hover:bg-slate-300"
+            )}
+          >
+            {wideMode ? '窄模式' : '宽模式'}
+          </button>
+          <span className="text-slate-400">{totalNodeCount ?? 0}</span>
+        </div>
+      </div>
+
+      {/* 缩放控件 */}
+      <div className="absolute top-[52px] right-3 z-50 flex gap-1 bg-white/90 backdrop-blur p-1 rounded-lg shadow-sm border border-slate-200">
+        <button onClick={() => setScale(s => Math.min(s + 0.15, 3))} className="p-1 hover:bg-slate-100 rounded text-slate-600 transition-colors" title="放大">
+          <ZoomIn size={14} />
+        </button>
+        <button onClick={() => setScale(s => Math.max(s - 0.15, 0.3))} className="p-1 hover:bg-slate-100 rounded text-slate-600 transition-colors" title="缩小">
+          <ZoomOut size={14} />
+        </button>
+        <button onClick={() => setScale(1)} className="p-1 hover:bg-slate-100 rounded text-slate-600 transition-colors" title="重置">
+          <Maximize size={14} />
+        </button>
+      </div>
+
+      <div className="flex-1 w-full h-full overflow-x-auto overflow-y-auto bg-slate-50/30 p-4 custom-scrollbar">
+        <div
+          className="flex flex-col gap-8 select-none min-w-max pb-8 origin-top-left transition-all duration-200"
+          style={{ zoom: scale } as any}
+        >
+          {(() => {
+            const orderKeyWords = ["形式", "实质", "意图"];
+            const groups: Record<string, any[]> = { "形式": [], "实质": [], "意图": [] };
+            data.children.forEach((node: any) => {
+              const type = node.source_type;
+              if (type && groups[type]) groups[type].push(node);
+            });
+
+            return orderKeyWords.map((dimensionName: string) => {
+              const nodesInDimension = groups[dimensionName] || [];
+              if (nodesInDimension.length === 0) return null;
+
+              let color = { bg: 'bg-slate-50', border: 'border-slate-500', text: 'text-slate-800' };
+              if (dimensionName === "形式") color = { bg: 'bg-[#E3F2FD]', border: 'border-[#2196F3]', text: 'text-slate-800' };
+              else if (dimensionName === "实质") color = { bg: 'bg-[#FFF3E0]', border: 'border-[#FF9800]', text: 'text-slate-800' };
+              else if (dimensionName === "意图") color = { bg: 'bg-[#F1F8E9]', border: 'border-[#8BC34A]', text: 'text-slate-800' };
+
+              return (
+                <div key={dimensionName} className="flex flex-col">
+                  <div className={cn("px-4 py-3 border-l-[6px] text-sm font-bold w-full mb-4", color.bg, color.border, color.text)}>
+                    {dimensionName} 维度
+                  </div>
+                  <div className="flex flex-col gap-6 pl-4">
+                    {nodesInDimension.map((subNode: any, subIdx: number) => (
+                      <HorizontalTreeNode
+                        key={subNode.id || subIdx}
+                        node={subNode}
+                        onSelect={onSelect}
+                        onDoubleClick={onDoubleClick ?? (() => {})}
+                        selectedId={selectedId}
+                        level={1}
+                        highlightLeafNames={highlightLeafNames}
+                      />
+                    ))}
+                  </div>
+                </div>
+              );
+            });
+          })()}
+        </div>
+      </div>
     </div>
     </div>
   );
   );
 }
 }

+ 2 - 3
knowhub/frontend/src/components/layout/Navbar.tsx

@@ -1,7 +1,7 @@
-import { Search, Settings, Home, Target, Cpu, Wrench, FileText, Waypoints } from 'lucide-react';
+import { Search, Settings, Home, Target, Cpu, Wrench, FileText } from 'lucide-react';
 import { cn } from '../../lib/utils';
 import { cn } from '../../lib/utils';
 
 
-export type TabId = 'dashboard' | 'relations' | 'requirements' | 'capabilities' | 'tools' | 'knowledge';
+export type TabId = 'dashboard' | 'requirements' | 'capabilities' | 'tools' | 'knowledge';
 
 
 interface NavbarProps {
 interface NavbarProps {
   activeTab: TabId;
   activeTab: TabId;
@@ -10,7 +10,6 @@ interface NavbarProps {
 
 
 const TABS = [
 const TABS = [
   { id: 'dashboard', label: 'Dashboard', icon: Home },
   { id: 'dashboard', label: 'Dashboard', icon: Home },
-  { id: 'relations', label: '关系表', icon: Waypoints },
   { id: 'requirements', label: '需求库', icon: Target },
   { id: 'requirements', label: '需求库', icon: Target },
   { id: 'capabilities', label: '能力库', icon: Cpu },
   { id: 'capabilities', label: '能力库', icon: Cpu },
   { id: 'tools', label: '工具库', icon: Wrench },
   { id: 'tools', label: '工具库', icon: Wrench },

+ 3 - 3
knowhub/frontend/src/layouts/MainLayout.tsx

@@ -8,8 +8,8 @@ interface MainLayoutProps {
   children: (activeTab: TabId) => React.ReactNode;
   children: (activeTab: TabId) => React.ReactNode;
 }
 }
 
 
-// 这里的顺序决定了滑动的顺序,必须是 6 个!
-const TAB_ORDER: TabId[] = ['dashboard', 'relations', 'requirements', 'capabilities', 'tools', 'knowledge'];
+// 这里的顺序决定了滑动的顺序,必须是 5 个!
+const TAB_ORDER: TabId[] = ['dashboard', 'requirements', 'capabilities', 'tools', 'knowledge'];
 const MIN_SWITCH_INTERVAL = 1000;
 const MIN_SWITCH_INTERVAL = 1000;
 
 
 export function MainLayout({ activeTab, onTabChange, children }: MainLayoutProps) {
 export function MainLayout({ activeTab, onTabChange, children }: MainLayoutProps) {
@@ -98,7 +98,7 @@ export function MainLayout({ activeTab, onTabChange, children }: MainLayoutProps
           {TAB_ORDER.map((tab) => (
           {TAB_ORDER.map((tab) => (
             <div key={tab} className="shrink-0 h-full overflow-y-auto" style={{ width: `${100 / totalTabs}%` }}>
             <div key={tab} className="shrink-0 h-full overflow-y-auto" style={{ width: `${100 / totalTabs}%` }}>
               <div className="flex justify-center pb-12">
               <div className="flex justify-center pb-12">
-                <div className="w-full max-w-[1600px] px-6 py-6">
+                <div className="w-full px-6 py-6">
                   {children(tab)}
                   {children(tab)}
                 </div>
                 </div>
               </div>
               </div>

+ 1101 - 479
knowhub/frontend/src/pages/Dashboard.tsx

@@ -1,83 +1,656 @@
-import { useState, useEffect } from 'react';
-import { FolderTree, Target, Wrench, ChevronRight, Brain, FileText } from 'lucide-react';
+import { useState, useEffect, useMemo, useRef, type ReactNode, type WheelEvent } from 'react';
+import { createPortal } from 'react-dom';
+import { Target, Wrench, FileText, ListTree, Cpu, X, ChevronLeft, ChevronRight } from 'lucide-react';
 import { CategoryTree } from '../components/dashboard/CategoryTree';
 import { CategoryTree } from '../components/dashboard/CategoryTree';
+import { SideDrawer } from '../components/common/SideDrawer';
 import { cn } from '../lib/utils';
 import { cn } from '../lib/utils';
-import { getRequirements, getCapabilities, getTools, getKnowledge } from '../services/api';
+import { getRequirements, getCapabilities, getTools, getKnowledge, getResource, batchGetPosts } from '../services/api';
+
+// ─── 覆盖率统计 ────────────────────────────────────────────────────────────────
+
+function CoverageStats({ stats, weightTab, setWeightTab }: any) {
+  const data = weightTab === 'unweighted' ? [
+    { label: '全局节点', value: stats.totalLeaves, percent: '100%', color: 'bg-slate-400' },
+    { label: '需求覆盖节点', value: stats.reqCoveredNodes, percent: stats.reqCoveragePerc + '%', color: 'bg-indigo-400' },
+    { label: '工序覆盖节点', value: 0, percent: '0%', color: 'bg-purple-400' },
+    { label: '原子能力覆盖节点', value: 0, percent: '0%', color: 'bg-teal-400' },
+    { label: '工具覆盖节点', value: stats.toolCoveredNodes, percent: (stats.totalLeaves ? (stats.toolCoveredNodes / stats.totalLeaves * 100).toFixed(1) : 0) + '%', color: 'bg-green-500' },
+  ] : [
+    { label: '全局节点', value: stats.totalPostsCnt, percent: '100%', color: 'bg-slate-400' },
+    { label: '需求覆盖节点', value: stats.coveredPostsCnt, percent: stats.weightedCoveragePerc + '%', color: 'bg-indigo-400' },
+    { label: '工序覆盖节点', value: 0, percent: '0%', color: 'bg-purple-400' },
+    { label: '原子能力覆盖节点', value: 0, percent: '0%', color: 'bg-teal-400' },
+    { label: '工具覆盖节点', value: stats.toolCoveredPostsCnt, percent: (stats.totalPostsCnt ? (stats.toolCoveredPostsCnt / stats.totalPostsCnt * 100).toFixed(1) : 0) + '%', color: 'bg-green-500' },
+  ];
 
 
-function RelationGroup({ title, count, colorClass, borderClass, children, defaultOpen = true }: any) {
-  const [open, setOpen] = useState(defaultOpen);
   return (
   return (
-    <div className="mt-8 mb-4">
-      <div 
-        className={cn("flex items-center gap-3 font-black text-[13px] tracking-wide mb-4 cursor-pointer select-none", colorClass)}
-        onClick={() => setOpen(!open)}
-      >
-        <div className={cn("w-6 h-[2px]", borderClass)}></div>
-        {title} ({count})
+    <div className="bg-white rounded-3xl border border-slate-100 shadow-sm p-6 overflow-hidden shrink-0">
+      <div className="flex justify-between items-center mb-6">
+        <div className="flex bg-slate-100 p-1 rounded-lg">
+          <button
+            className={cn("px-4 py-1.5 text-sm font-bold rounded-md transition-all", weightTab === 'unweighted' ? "bg-white text-indigo-700 shadow-sm" : "text-slate-500 hover:text-slate-700")}
+            onClick={() => setWeightTab('unweighted')}
+          >无权重 (节点数)</button>
+          <button
+            className={cn("px-4 py-1.5 text-sm font-bold rounded-md transition-all", weightTab === 'weighted' ? "bg-white text-indigo-700 shadow-sm" : "text-slate-500 hover:text-slate-700")}
+            onClick={() => setWeightTab('weighted')}
+          >带权重 (帖子数)</button>
+        </div>
+      </div>
+      <div className="flex justify-center items-center h-36">
+        <div className="flex w-full max-w-4xl h-full relative" style={{ filter: "drop-shadow(0 4px 6px rgba(0,0,0,0.05))" }}>
+          {data.map((item, idx) => {
+            const prevWidth = idx === 0 ? 100 : (data[idx - 1].value / Math.max(data[0].value, 1) * 100);
+            const currWidth = (item.value / Math.max(data[0].value, 1) * 100);
+            const leftHeight = prevWidth;
+            const rightHeight = currWidth;
+            const clipPath = `polygon(0 ${50 - leftHeight / 2}%, 100% ${50 - rightHeight / 2}%, 100% ${50 + rightHeight / 2}%, 0 ${50 + leftHeight / 2}%)`;
+            return (
+              <div key={idx} className="flex-1 flex flex-col items-center justify-center relative border-r-2 border-white last:border-r-0">
+                <div className={cn("absolute inset-0 transition-all duration-700 opacity-90", item.color)} style={{ clipPath }}></div>
+                <div className="z-10 flex flex-col items-center text-slate-900">
+                  <span className="text-2xl font-black tracking-tight">{item.value}</span>
+                  <span className="text-xs font-bold mt-0.5 opacity-90">{item.label}</span>
+                </div>
+                {idx > 0 && (
+                  <div className="absolute top-0 left-0 -translate-x-1/2 -mt-4 text-[11px] font-bold text-slate-500 bg-white px-2 py-0.5 rounded shadow-sm border border-slate-100 z-20">
+                    转化率 {item.percent}
+                  </div>
+                )}
+              </div>
+            );
+          })}
+        </div>
       </div>
       </div>
-      {open && <div className="pl-1 mb-8">{children}</div>}
     </div>
     </div>
   );
   );
 }
 }
 
 
-function CompactListCard({ data, type, onDrillDown }: { data: any, type: 'req' | 'cap' | 'tool' | 'know', onDrillDown: (t: any, d: any) => void }) {
-  let Icon: any = Target;
-  let iconColor = "text-indigo-500";
-  let title = "";
-  let status = "";
-
-  if (type === 'req') {
-    Icon = Target; iconColor = "text-indigo-500";
-    title = data.description || data.id;
-    status = data.status || '未满足';
-  } else if (type === 'cap') {
-    Icon = Brain; iconColor = "text-amber-500";
-    title = data.name || data.id;
-  } else if (type === 'tool') {
-    Icon = Wrench; iconColor = "text-emerald-500";
-    title = data.name || data.id;
-    status = data.status;
-  } else if (type === 'know') {
-    Icon = FileText; iconColor = "text-violet-500";
-    title = data.task || data.content?.substring(0, 40) || data.id;
-  }
+// ─── 关系列卡片 ────────────────────────────────────────────────────────────────
+
+function RelationCard({ type, item, activeId, isLeafActive, relatedIds, selectedLeafNames, onSingleClick, onDoubleClick }: {
+  type: string;
+  item: any;
+  activeId: string | null;
+  isLeafActive?: boolean;
+  relatedIds: Set<string>;
+  selectedLeafNames?: Set<string>;
+  onSingleClick: (nodeId: string) => void;
+  onDoubleClick: (type: string, data: any) => void;
+}) {
+  const nodeId = `${type}:${item.id}`;
+  const isSelected = activeId === nodeId;
+  const hasActive = !!activeId || !!isLeafActive;
+  const isRelated = !hasActive || relatedIds.has(nodeId);
+  const dimmed = hasActive && !isRelated;
+
+  const lastClickRef = useRef<{ time: number } | null>(null);
+
+  const handleClick = () => {
+    const now = Date.now();
+    if (lastClickRef.current && now - lastClickRef.current.time < 300) {
+      lastClickRef.current = null;
+      onDoubleClick(type, item);
+    } else {
+      lastClickRef.current = { time: now };
+      onSingleClick(nodeId);
+    }
+  };
+
+  const sourceNodeTags: string[] = type === 'req'
+    ? (item.source_nodes || []).slice(0, 3).map((sn: any) =>
+        typeof sn === 'object' ? (sn.node_name || sn.name || '') : sn
+      ).filter((name: string) => Boolean(name) && name !== '__abstract__')
+    : [];
+  const totalSourceNodes = type === 'req'
+    ? (item.source_nodes || []).filter((sn: any) => {
+        const name = typeof sn === 'object' ? (sn.node_name || sn.name || '') : sn;
+        return Boolean(name) && name !== '__abstract__';
+      }).length
+    : 0;
+  const extraCount = type === 'req' ? Math.max(0, totalSourceNodes - 3) : 0;
+
+  const label = item.name || item.description || item.task || item.id;
+
+  // 语义颜色:每种类型对应一套颜色
+  const typeColors: Record<string, { accent: string; tagBg: string; tagText: string; leftBar: string }> = {
+    req:  { accent: 'border-l-indigo-400', tagBg: 'bg-indigo-50', tagText: 'text-indigo-700', leftBar: 'bg-indigo-400' },
+    cap:  { accent: 'border-l-teal-400',   tagBg: 'bg-teal-50',   tagText: 'text-teal-700',   leftBar: 'bg-teal-400' },
+    tool: { accent: 'border-l-green-400',  tagBg: 'bg-green-50',  tagText: 'text-green-700',  leftBar: 'bg-green-400' },
+    know: { accent: 'border-l-purple-400', tagBg: 'bg-purple-50', tagText: 'text-purple-700', leftBar: 'bg-purple-400' },
+  };
+  const tc = typeColors[type] ?? typeColors.req;
 
 
   return (
   return (
-    <div className="bg-white border border-slate-200 rounded-xl overflow-hidden shadow-sm hover:border-indigo-300 hover:shadow-md transition-all mb-2 w-full text-left">
-      <div 
-        className="flex justify-between items-center p-3 cursor-pointer hover:bg-slate-50 transition-colors group"
-        onClick={() => onDrillDown(type, data)}
-      >
-        <div className="flex items-center gap-2 font-bold text-sm text-slate-800 flex-1 pr-2">
-          <Icon size={14} className={iconColor} />
-          <span className="truncate">{title}</span>
+    <div
+      onClick={handleClick}
+      className={cn(
+        "p-3 rounded-xl cursor-pointer mb-2 select-none bg-white transition-all border-l-4",
+        tc.accent,
+        isSelected
+          ? "border border-orange-400 border-l-4 shadow-[0_0_0_1px_rgba(251,146,60,0.7)]"
+          : isRelated && hasActive
+          ? "border border-orange-300 border-l-4"
+          : dimmed
+          ? "border border-transparent border-l-4 opacity-20 grayscale scale-95"
+          : "border border-transparent border-l-4"
+      )}
+    >
+      <div className="flex items-start gap-2">
+        <div className="min-w-0 flex-1">
+          <div className="flex items-center gap-2">
+            <div className={cn("text-xs font-bold leading-snug", isSelected ? "text-orange-800" : "text-slate-700")}>
+              {label}
+            </div>
+            </div>
+          {sourceNodeTags.length > 0 && (
+            <div className="flex flex-wrap gap-1 mt-1.5">
+              {sourceNodeTags.map((name: string) => {
+                const isHighlighted = selectedLeafNames && selectedLeafNames.has(name);
+                return (
+                  <span key={name} className={cn(
+                    "text-[9px] px-1.5 py-0.5 rounded-md font-medium truncate max-w-[100px]",
+                    isHighlighted ? "bg-orange-100 text-orange-700 ring-1 ring-orange-400" : cn(tc.tagBg, tc.tagText)
+                  )}>
+                    {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>
+    </div>
+  );
+}
+
+// ─── 详情抽屉内容 ──────────────────────────────────────────────────────────────
+
+function DrawerContent({ type, data, dbData }: { type: string; data: any; dbData: any }) {
+  const relReqs = type === 'cap' ? dbData.reqs.filter((r: any) => (data.requirement_ids || []).includes(r.id))
+    : type === 'tool' ? [] : [];
+  const relCaps = type === 'req' ? dbData.caps.filter((c: any) => (c.requirement_ids || []).includes(data.id))
+    : type === 'tool' ? dbData.caps.filter((c: any) => (data.capability_ids || []).includes(c.id))
+    : type === 'know' ? dbData.caps.filter((c: any) => (data.capability_ids || []).includes(c.id)) : [];
+  const relTools = type === 'cap' ? dbData.tools.filter((t: any) => (t.capability_ids || []).includes(data.id))
+    : type === 'know' ? dbData.tools.filter((t: any) => (data.tool_ids || []).includes(t.id)) : [];
+  const relKnow = type === 'cap' ? dbData.know.filter((k: any) => (k.capability_ids || []).includes(data.id))
+    : type === 'tool' ? dbData.know.filter((k: any) => (k.tool_ids || []).includes(data.id)) : [];
+
+  const Section = ({ title, color, items, renderItem }: any) =>
+    items.length > 0 ? (
+      <div className="mt-6">
+        <div className={cn("text-xs font-black tracking-wide mb-3", color)}>{title} ({items.length})</div>
+        <div className="space-y-2">{items.map(renderItem)}</div>
+      </div>
+    ) : null;
+
+  const MiniCard = ({ icon: Icon, label, iconColor }: any) => (
+    <div className="flex items-center gap-2 bg-white rounded-lg p-2.5 border border-slate-100 text-xs font-bold text-slate-700">
+      <Icon size={12} className={iconColor} />
+      <span className="truncate">{label}</span>
+    </div>
+  );
+
+  return (
+    <div className="space-y-4">
+      {/* 主内容 */}
+      {type === 'req' && (
+        <>
+          <div className="bg-indigo-50 p-4 rounded-xl border border-indigo-100">
+            <div className="text-xs font-bold text-indigo-500 mb-2">需求描述</div>
+            <p className="text-indigo-800 text-sm leading-relaxed whitespace-pre-wrap">{data.description}</p>
+          </div>
+          <div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
+            <div className="text-[10px] text-slate-400 mb-1">追踪 ID</div>
+            <div className="font-mono text-slate-600 text-[11px] break-all">{data.id}</div>
+          </div>
+        </>
+      )}
+      {type === 'cap' && (
+        <>
+          <div className="bg-amber-50 p-4 rounded-xl border border-amber-100">
+            <div className="text-xs font-bold text-amber-600 mb-2">能力定义</div>
+            <p className="text-amber-800 text-sm leading-relaxed">{data.description || '暂无描述'}</p>
+          </div>
+          <div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
+            <div className="text-[10px] text-slate-400 mb-1">能力 ID</div>
+            <div className="font-mono text-slate-600 text-[11px] break-all">{data.id}</div>
+          </div>
+        </>
+      )}
+      {type === 'tool' && (
+        <>
+          <div className="bg-emerald-50 p-4 rounded-xl border border-emerald-100">
+            <div className="text-xs font-bold text-emerald-600 mb-2">工具介绍</div>
+            <p className="text-emerald-800 text-sm leading-relaxed">{data.introduction || '暂无介绍'}</p>
+          </div>
+          {data.status && (
+            <div className="bg-slate-50 p-3 rounded-xl border border-slate-100 flex items-center justify-between">
+              <span className="text-[10px] text-slate-400">接入状态</span>
+              <span className={cn("text-xs font-bold px-2 py-1 rounded-full",
+                (data.status === '已接入' || data.status === '正常') ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-200 text-slate-500'
+              )}>{data.status}</span>
+            </div>
+          )}
+          <div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
+            <div className="text-[10px] text-slate-400 mb-1">执行端 ID</div>
+            <div className="font-mono text-slate-600 text-[11px] break-all">{data.id}</div>
+          </div>
+        </>
+      )}
+      {type === 'know' && (
+        <>
+          <div className="bg-violet-50 p-4 rounded-xl border border-violet-100">
+            <div className="text-xs font-bold text-violet-600 mb-2">知识正文</div>
+            <p className="text-violet-800 text-sm leading-relaxed whitespace-pre-wrap">{data.content}</p>
+          </div>
+          <div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
+            <div className="text-[10px] text-slate-400 mb-1">知识库 ID</div>
+            <div className="font-mono text-slate-600 text-[11px] break-all">{data.id}</div>
+          </div>
+        </>
+      )}
+
+      {/* 关联数据 */}
+      <Section title="关联需求" color="text-indigo-600" items={relReqs}
+        renderItem={(r: any) => <MiniCard key={r.id} icon={Target} label={r.description || r.id} iconColor="text-indigo-400" />} />
+      <Section title="关联能力" color="text-amber-600" items={relCaps}
+        renderItem={(c: any) => <MiniCard key={c.id} icon={Cpu} label={c.name || c.id} iconColor="text-amber-400" />} />
+      <Section title="关联工具" color="text-emerald-600" items={relTools}
+        renderItem={(t: any) => <MiniCard key={t.id} icon={Wrench} label={t.name || t.id} iconColor="text-emerald-400" />} />
+      <Section title="关联知识" color="text-violet-600" items={relKnow}
+        renderItem={(k: any) => <MiniCard key={k.id} icon={FileText} label={k.task || k.id} iconColor="text-violet-400" />} />
+    </div>
+  );
+}
+
+function PostDetailModal({ post, postId, onClose }: { post: any; postId: string; onClose: () => void }) {
+  useEffect(() => {
+    const onKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') onClose();
+    };
+    window.addEventListener('keydown', onKeyDown);
+    return () => window.removeEventListener('keydown', onKeyDown);
+  }, [onClose]);
+
+  const images: string[] = post?.images || [];
+
+  return createPortal(
+    <div className="fixed inset-0 z-[260] flex items-center justify-center p-6">
+      <button className="absolute inset-0 bg-slate-900/45 backdrop-blur-[1px]" onClick={onClose} aria-label="关闭帖子详情" />
+      <div className="relative z-[261] w-full max-w-4xl max-h-[85vh] overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-2xl flex flex-col">
+        <div className="flex items-start justify-between gap-4 px-6 py-4 border-b border-slate-100 shrink-0">
+          <div className="min-w-0">
+            <div className="text-xs font-bold text-slate-400 mb-1">帖子详情</div>
+            <div className="text-base font-bold text-slate-800 leading-snug">{post?.title || '无标题'}</div>
+            <div className="text-[11px] text-slate-400 mt-1 break-all">{postId}</div>
+          </div>
+          <button onClick={onClose} className="p-2 rounded-lg text-slate-400 hover:bg-slate-100 hover:text-slate-600 transition-colors">
+            <X size={16} />
+          </button>
         </div>
         </div>
-        <div className="flex items-center gap-2 shrink-0">
-           {status && (
-             <span className={cn("text-[10px] px-2 py-0.5 rounded-full font-bold", 
-               (status==='已满足'||status==='已接入'||status==='正常') ? 'bg-emerald-50 text-emerald-700' : 'bg-slate-100 text-slate-500')}>
-               {status}
-             </span>
-           )}
-           <ChevronRight size={14} className="text-slate-300 group-hover:text-indigo-500 transition-colors"/>
+        <div className="overflow-y-auto px-6 py-5 space-y-5">
+          {(post?.platform || post?.platform_account_name || post?.publish_date) && (
+            <div className="flex flex-wrap gap-2">
+              {post?.platform && <span className="text-xs px-2 py-1 rounded-full bg-slate-100 text-slate-600 font-medium">{post.platform}</span>}
+              {post?.platform_account_name && <span className="text-xs px-2 py-1 rounded-full bg-indigo-50 text-indigo-700 font-medium">{post.platform_account_name}</span>}
+              {post?.publish_date && <span className="text-xs px-2 py-1 rounded-full bg-emerald-50 text-emerald-700 font-medium">{post.publish_date}</span>}
+            </div>
+          )}
+          {images.length > 0 && (
+            <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
+              {images.map((url: string, i: number) => (
+                <div key={i} className="rounded-2xl overflow-hidden bg-slate-100 border border-slate-100">
+                  <img src={url} alt="" className="w-full h-full object-cover" loading="lazy" />
+                </div>
+              ))}
+            </div>
+          )}
+          <div className="space-y-4">
+            {post?.body_text && (
+              <div className="bg-slate-50 rounded-2xl border border-slate-100 p-4">
+                <div className="text-xs font-bold text-slate-500 mb-2">正文</div>
+                <p className="text-sm text-slate-700 whitespace-pre-wrap leading-relaxed">{post.body_text}</p>
+              </div>
+            )}
+            {post?.decode_result && (
+              <div className="bg-indigo-50 rounded-2xl border border-indigo-100 p-4 space-y-3">
+                <div className="text-xs font-bold text-indigo-600">解析信息</div>
+                {Object.entries(post.decode_result).map(([key, value]) => (
+                  value ? (
+                    <div key={key}>
+                      <div className="text-[11px] font-bold text-indigo-400 uppercase tracking-wide">{key}</div>
+                      <div className="text-sm text-indigo-900 whitespace-pre-wrap leading-relaxed">{String(value)}</div>
+                    </div>
+                  ) : null
+                ))}
+              </div>
+            )}
+          </div>
         </div>
         </div>
       </div>
       </div>
+    </div>,
+    document.body
+  );
+}
+
+function PostCard({ postId, post, loading, onClick }: { postId: string; post?: any; loading?: boolean; onClick?: () => void }) {
+  const images: string[] = post?.images || [];
+
+  return (
+    <button
+      type="button"
+      onClick={post ? onClick : undefined}
+      className={cn(
+        "w-[220px] shrink-0 bg-white rounded-xl border border-slate-100 overflow-hidden shadow-sm flex flex-col text-left",
+        post ? "cursor-pointer hover:border-indigo-200 hover:shadow-md transition-all" : "cursor-default"
+      )}
+    >
+      {post ? (
+        <>
+          {images.length > 0 && (
+            <div className="grid grid-cols-3 gap-0.5 shrink-0">
+              {images.slice(0, 3).map((url: string, i: number) => (
+                <div key={i} className="relative aspect-square overflow-hidden bg-slate-100">
+                  <img
+                    src={url}
+                    alt=""
+                    className="w-full h-full object-cover"
+                    loading="lazy"
+                    onError={(e) => { (e.target as HTMLImageElement).parentElement!.style.display = 'none'; }}
+                  />
+                </div>
+              ))}
+            </div>
+          )}
+          <div className="px-2.5 pt-2 pb-1 shrink-0">
+            <div className="text-[11px] font-bold text-slate-800 leading-snug line-clamp-2">{post.title || '无标题'}</div>
+          </div>
+          {post.body_text && (
+            <div className="px-2.5 pb-2 flex-1 overflow-hidden">
+              <p className="text-[10px] text-slate-400 leading-relaxed line-clamp-4 whitespace-pre-wrap">{post.body_text}</p>
+            </div>
+          )}
+        </>
+      ) : !loading ? (
+        <div className="p-3 font-mono text-[10px] text-slate-300 break-all">{postId}</div>
+      ) : (
+        <div className="h-full min-h-[160px] bg-slate-50 animate-pulse"></div>
+      )}
+    </button>
+  );
+}
+
+function HorizontalPostScroller({ children, className = '' }: { children: ReactNode; className?: string }) {
+  const scrollRef = useRef<HTMLDivElement | null>(null);
+  const [canScrollLeft, setCanScrollLeft] = useState(false);
+  const [canScrollRight, setCanScrollRight] = useState(false);
+
+  const updateScrollState = () => {
+    const el = scrollRef.current;
+    if (!el) return;
+    const maxScrollLeft = el.scrollWidth - el.clientWidth;
+    setCanScrollLeft(el.scrollLeft > 4);
+    setCanScrollRight(el.scrollLeft < maxScrollLeft - 4);
+  };
+
+  useEffect(() => {
+    updateScrollState();
+    const el = scrollRef.current;
+    if (!el) return;
+    const onResize = () => updateScrollState();
+    window.addEventListener('resize', onResize);
+    return () => window.removeEventListener('resize', onResize);
+  }, [children]);
+
+  const handleWheel = (e: WheelEvent<HTMLDivElement>) => {
+    const el = scrollRef.current;
+    if (!el) return;
+
+    const delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
+    if (delta === 0) return;
+
+    const canScroll = el.scrollWidth > el.clientWidth;
+    if (!canScroll) return;
+
+    const maxScrollLeft = el.scrollWidth - el.clientWidth;
+    const nextLeft = el.scrollLeft + delta;
+    const willScrollWithinBounds = nextLeft > 0 && nextLeft < maxScrollLeft;
+
+    if (willScrollWithinBounds || (delta < 0 && el.scrollLeft > 0) || (delta > 0 && el.scrollLeft < maxScrollLeft)) {
+      e.preventDefault();
+      el.scrollLeft += delta;
+      window.requestAnimationFrame(updateScrollState);
+    }
+  };
+
+  const scrollByPage = (direction: -1 | 1) => {
+    const el = scrollRef.current;
+    if (!el) return;
+    el.scrollBy({ left: direction * Math.max(el.clientWidth * 0.8, 240), behavior: 'smooth' });
+    window.setTimeout(updateScrollState, 250);
+  };
+
+  return (
+    <div className={cn("relative min-w-0 max-w-full overflow-hidden", className)}>
+      {canScrollLeft && (
+        <button
+          type="button"
+          onClick={() => scrollByPage(-1)}
+          className="absolute left-2 top-1/2 z-20 -translate-y-1/2 rounded-full bg-white/95 border border-slate-200 shadow-md p-1.5 text-slate-500 hover:text-slate-700 hover:border-slate-300"
+          aria-label="向左滚动帖子"
+        >
+          <ChevronLeft size={16} />
+        </button>
+      )}
+      {canScrollRight && (
+        <button
+          type="button"
+          onClick={() => scrollByPage(1)}
+          className="absolute right-2 top-1/2 z-20 -translate-y-1/2 rounded-full bg-white/95 border border-slate-200 shadow-md p-1.5 text-slate-500 hover:text-slate-700 hover:border-slate-300"
+          aria-label="向右滚动帖子"
+        >
+          <ChevronRight size={16} />
+        </button>
+      )}
+      <div
+        ref={scrollRef}
+        onWheel={handleWheel}
+        onScroll={updateScrollState}
+        className="w-full overflow-x-auto overflow-y-hidden scrollbar-thin"
+      >
+        {children}
+      </div>
     </div>
     </div>
   );
   );
 }
 }
 
 
+// ─── 业务需求帖子聚合抽屉 ──────────────────────────────────────────────────────
+
+function RequirementPostsDrawer({
+  requirement,
+  nodePostsMap,
+  onOpenPost,
+}: {
+  requirement: any;
+  nodePostsMap: Record<string, string[]>;
+  onOpenPost: (postId: string, post: any) => void;
+}) {
+  const [selectedNodeName, setSelectedNodeName] = useState<string | null>(null);
+  const [posts, setPosts] = useState<Record<string, any>>({});
+  const [loading, setLoading] = useState(false);
+
+  const nodeNames = Object.keys(nodePostsMap);
+  const allPostIds = useMemo(() => {
+    const ids: string[] = [];
+    Object.values(nodePostsMap).forEach(pids => pids.forEach(pid => { if (!ids.includes(pid)) ids.push(pid); }));
+    return ids;
+  }, [nodePostsMap]);
+
+  useEffect(() => {
+    if (allPostIds.length === 0) return;
+    setLoading(true);
+    setPosts({});
+    batchGetPosts(allPostIds)
+      .then(map => setPosts(map))
+      .catch(err => {
+        console.error('Failed to load requirement posts:', err);
+      })
+      .finally(() => setLoading(false));
+  }, [allPostIds, requirement.id]);
+
+  const displayPostIds = selectedNodeName ? (nodePostsMap[selectedNodeName] || []) : allPostIds;
+
+  return (
+    <div className="flex flex-row gap-3 h-full overflow-hidden min-w-0">
+      {/* 左侧:节点列表 */}
+      <div className="w-[200px] shrink-0 bg-slate-50 rounded-xl border border-slate-100 p-3 overflow-y-auto">
+        <div className="text-xs font-bold text-slate-600 mb-2">关联节点 ({nodeNames.length})</div>
+        <button
+          onClick={() => setSelectedNodeName(null)}
+          className={cn(
+            "w-full text-left px-2 py-1.5 rounded-lg text-xs mb-1 transition-colors",
+            selectedNodeName === null ? "bg-indigo-100 text-indigo-700 font-bold" : "hover:bg-slate-100 text-slate-600"
+          )}
+        >
+          全部 ({allPostIds.length})
+        </button>
+        {nodeNames.map(name => (
+          <button
+            key={name}
+            onClick={() => setSelectedNodeName(name)}
+            className={cn(
+              "w-full text-left px-2 py-1.5 rounded-lg text-xs mb-1 transition-colors truncate",
+              selectedNodeName === name ? "bg-indigo-100 text-indigo-700 font-bold" : "hover:bg-slate-100 text-slate-600"
+            )}
+          >
+            {name === '__unmatched__' ? '未定位帖子' : name} ({nodePostsMap[name].length})
+          </button>
+        ))}
+      </div>
+
+      {/* 右侧:帖子横向滚动 */}
+      <HorizontalPostScroller className="flex-1 min-w-0">
+        <div className="inline-flex gap-3 px-4 py-3 min-w-max">
+        {loading && (
+          <div className="flex items-center justify-center flex-1 gap-2 text-slate-400">
+            <div className="w-4 h-4 border-2 border-indigo-200 border-t-indigo-500 rounded-full animate-spin"></div>
+            加载中...
+          </div>
+        )}
+        {!loading && displayPostIds.length === 0 && (
+          <div className="flex items-center justify-center flex-1 text-xs text-slate-300 font-bold">暂无帖子</div>
+        )}
+        {!loading && displayPostIds.map(pid => {
+          const post = posts[pid];
+          return (
+            <PostCard key={pid} postId={pid} post={post} onClick={() => onOpenPost(pid, post)} />
+          );
+        })}
+        </div>
+      </HorizontalPostScroller>
+    </div>
+  );
+}
+
+// ─── 叶子节点详情抽屉 ─────────────────────────────────────────────────────────
+
+function LeafNodeDrawer({ node, onOpenPost }: { node: any; onOpenPost: (postId: string, post: any) => void }) {
+  const [posts, setPosts] = useState<Record<string, any>>({});
+  const [loading, setLoading] = useState(false);
+
+  const postIds: string[] = useMemo(() => {
+    const ids: string[] = [];
+    (node.elements || []).forEach((el: any) => {
+      (el.post_ids || []).forEach((pid: string) => {
+        if (!ids.includes(pid)) ids.push(pid);
+      });
+    });
+    return ids;
+  }, [node.name]);
+
+  useEffect(() => {
+    if (postIds.length === 0) return;
+    setLoading(true);
+    setPosts({});
+    batchGetPosts(postIds)
+      .then(map => setPosts(map))
+      .catch(err => {
+        console.error('Failed to load leaf node posts:', err);
+      })
+      .finally(() => setLoading(false));
+  }, [node.name, postIds]);
+
+  return (
+    <HorizontalPostScroller className="h-full w-full min-w-0">
+      <div className="inline-flex flex-row gap-3 h-full px-4 py-3 min-w-max">
+      {/* 节点统计卡 */}
+      <div className="w-[160px] shrink-0 bg-slate-50 rounded-xl border border-slate-100 p-3 flex flex-col gap-2 justify-center">
+        <div className="text-center">
+          <div className="text-2xl font-black text-slate-800">{node.total_posts_count || 0}</div>
+          <div className="text-[10px] text-slate-400">帖子总数</div>
+        </div>
+        <div className="text-center">
+          <div className="text-xl font-black text-slate-600">{postIds.length}</div>
+          <div className="text-[10px] text-slate-400">去重帖子</div>
+        </div>
+        {loading && (
+          <div className="flex items-center justify-center gap-1 text-[10px] text-slate-400 mt-1">
+            <div className="w-3 h-3 border-2 border-indigo-200 border-t-indigo-500 rounded-full animate-spin"></div>
+            加载中
+          </div>
+        )}
+      </div>
+
+      {/* 帖子卡片横向列表 */}
+      {postIds.length === 0 ? (
+        <div className="flex items-center justify-center flex-1 text-xs text-slate-300 font-bold">该节点暂无帖子</div>
+      ) : (
+        postIds.map(pid => {
+          const post = posts[pid];
+          return (
+            <PostCard key={pid} postId={pid} post={post} loading={loading} onClick={() => onOpenPost(pid, post)} />
+          );
+        })
+      )}
+      </div>
+    </HorizontalPostScroller>
+  );
+}
+
+// ─── Dashboard 主体 ────────────────────────────────────────────────────────────
 
 
 export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: string | null, onPendingConsumed?: () => void }) {
 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 [treeData, setTreeData] = useState<any>(null);
   const [selectedNode, setSelectedNode] = useState<any>(null);
   const [selectedNode, setSelectedNode] = useState<any>(null);
-  const [navStack, setNavStack] = useState<NavItem[]>([]);
   const [nameToNodeMap, setNameToNodeMap] = useState<Record<string, any>>({});
   const [nameToNodeMap, setNameToNodeMap] = useState<Record<string, any>>({});
   const [dbData, setDbData] = useState<{ reqs: any[], caps: any[], tools: any[], know: any[] }>({
   const [dbData, setDbData] = useState<{ reqs: any[], caps: any[], tools: any[], know: any[] }>({
     reqs: [], caps: [], tools: [], know: []
     reqs: [], caps: [], tools: [], know: []
   });
   });
+  const [weightTab, setWeightTab] = useState<'unweighted' | 'weighted'>('unweighted');
+  const [coverageStats, setCoverageStats] = useState({
+    totalLeaves: 0, reqCoveredNodes: 0, reqCoveragePerc: 0,
+    toolCoveredNodes: 0, toolCoveragePerc: 0, verifiedNodes: 0,
+    verifiedCoveragePerc: 0, weightedCoveragePerc: 0, coveredPostsCnt: 0,
+    totalPostsCnt: 0, toolCoveredPostsCnt: 0, toolWeightedCoveragePerc: 0,
+    verifiedPostsCnt: 0, verifiedWeightedCoveragePerc: 0
+  });
 
 
-  // 处理来自其他页面的跳转请求
+  // 关系列状态
+  const [activeId, setActiveId] = useState<string | null>(null);
+  const [activeLeafNode, setActiveLeafNode] = useState<any>(null); // 点击叶子节点时存储
+  const [drawerItem, setDrawerItem] = useState<{ type: string; data: any } | null>(null);
+  const [leafDetailNode, setLeafDetailNode] = useState<any>(null);
+  const [treeWideMode, setTreeWideMode] = useState(true);
+  const [requirementPostsData, setRequirementPostsData] = useState<{ requirement: any; nodePostsMap: Record<string, string[]> } | null>(null);
+  const [selectedPostDetail, setSelectedPostDetail] = useState<{ postId: string; post: any } | null>(null);
+  const [onlyCoveredFilter, setOnlyCoveredFilter] = useState(false); // 只看覆盖需求的数据
+
+  // 来自其他页面的跳转
   useEffect(() => {
   useEffect(() => {
     if (pendingNode && nameToNodeMap[pendingNode]) {
     if (pendingNode && nameToNodeMap[pendingNode]) {
       setSelectedNode(nameToNodeMap[pendingNode]);
       setSelectedNode(nameToNodeMap[pendingNode]);
@@ -85,207 +658,120 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
     }
     }
   }, [pendingNode, nameToNodeMap]);
   }, [pendingNode, nameToNodeMap]);
 
 
-  useEffect(() => {
-    if (selectedNode) setNavStack([{ type: 'node', data: selectedNode }]);
-    else setNavStack([]);
-  }, [selectedNode]);
-
-  const handleDrillDown = (type: NavItem['type'], data: any) => {
-    setNavStack(prev => [...prev, { type, data }]);
-  };
-
-  const handleBreadcrumbClick = (idx: number) => {
-    setNavStack(prev => prev.slice(0, idx + 1));
-  };
-
-  const [coverageStats, setCoverageStats] = useState({
-    totalLeaves: 0,
-    reqCoveredNodes: 0,
-    reqCoveragePerc: 0,
-    toolCoveredNodes: 0,
-    toolCoveragePerc: 0,
-    verifiedNodes: 0,
-    verifiedCoveragePerc: 0,
-    weightedCoveragePerc: 0,
-    coveredPostsCnt: 0,
-    totalPostsCnt: 0,
-    toolCoveredPostsCnt: 0,
-    toolWeightedCoveragePerc: 0,
-    verifiedPostsCnt: 0,
-    verifiedWeightedCoveragePerc: 0
-  });
-
-  const [weightTab, setWeightTab] = useState<'unweighted' | 'weighted'>('unweighted');
-
   const getLeafNodes = (nodes: any[]): any[] => {
   const getLeafNodes = (nodes: any[]): any[] => {
     let leaves: any[] = [];
     let leaves: any[] = [];
     nodes.forEach(n => {
     nodes.forEach(n => {
-      if (!n.children || n.children.length === 0) {
-        leaves.push(n);
-      } else {
-        leaves = leaves.concat(getLeafNodes(n.children));
-      }
+      if (!n.children || n.children.length === 0) leaves.push(n);
+      else leaves = leaves.concat(getLeafNodes(n.children));
     });
     });
     return leaves;
     return leaves;
   };
   };
 
 
+  const collectNodePostIds = (node: any): string[] => {
+    const ids: string[] = [];
+    const walk = (current: any) => {
+      (current?.elements || []).forEach((el: any) => {
+        (el.post_ids || []).forEach((pid: string) => {
+          if (pid && !ids.includes(pid)) ids.push(pid);
+        });
+      });
+      (current?.children || []).forEach((child: any) => walk(child));
+    };
+    walk(node);
+    return ids;
+  };
+
   useEffect(() => {
   useEffect(() => {
     async function loadStats() {
     async function loadStats() {
       try {
       try {
-        // 1. Fetch Tree
         const treeRes = await fetch('/category_tree.json');
         const treeRes = await fetch('/category_tree.json');
         const data = await treeRes.json();
         const data = await treeRes.json();
         setTreeData(data);
         setTreeData(data);
         const leaves = getLeafNodes([data]);
         const leaves = getLeafNodes([data]);
 
 
-        // 2. Fetch associations
         const [reqRes, capRes, toolRes] = await Promise.all([
         const [reqRes, capRes, toolRes] = await Promise.all([
-          getRequirements(1000, 0),
-          getCapabilities(1000, 0),
-          getTools(1000, 0)
+          getRequirements(1000, 0), getCapabilities(1000, 0), getTools(1000, 0)
         ]);
         ]);
-
         let knowRes: any = { results: [] };
         let knowRes: any = { results: [] };
-        try {
-          knowRes = await getKnowledge(1, 1000);
-        } catch (e) {
-          console.warn('knowledge API not available or failed', e);
-        }
+        try { knowRes = await getKnowledge(1, 1000); } catch (e) { /* optional */ }
 
 
         const reqs = reqRes.results || [];
         const reqs = reqRes.results || [];
         const caps = capRes.results || [];
         const caps = capRes.results || [];
         const tools = toolRes.results || [];
         const tools = toolRes.results || [];
         const know = knowRes.results || [];
         const know = knowRes.results || [];
-
         setDbData({ reqs, caps, tools, know });
         setDbData({ reqs, caps, tools, know });
 
 
-        // 3. Map reqs to nodes by name
-        const nodeToReqs: Record<string, any[]> = {};
-        leaves.forEach(l => { nodeToReqs[l.name] = []; });
-        
         const nameToNode: Record<string, any> = {};
         const nameToNode: Record<string, any> = {};
         const buildNameMap = (nodes: any[]) => {
         const buildNameMap = (nodes: any[]) => {
-          nodes.forEach(n => {
-            nameToNode[n.name] = n;
-            if (n.children) buildNameMap(n.children);
-          });
+          nodes.forEach(n => { nameToNode[n.name] = n; if (n.children) buildNameMap(n.children); });
         };
         };
         buildNameMap([data]);
         buildNameMap([data]);
         setNameToNodeMap(nameToNode);
         setNameToNodeMap(nameToNode);
 
 
+        const nodeToReqs: Record<string, any[]> = {};
+        leaves.forEach(l => { nodeToReqs[l.name] = []; });
+
         reqs.forEach((r: any) => {
         reqs.forEach((r: any) => {
           (r.source_nodes || []).forEach((sn: any) => {
           (r.source_nodes || []).forEach((sn: any) => {
             const nodeName = typeof sn === 'object' ? (sn.node_name || sn.name) : sn;
             const nodeName = typeof sn === 'object' ? (sn.node_name || sn.name) : sn;
             if (nodeName && nameToNode[nodeName]) {
             if (nodeName && nameToNode[nodeName]) {
-              const matchedLeaves = getLeafNodes([nameToNode[nodeName]]);
-              matchedLeaves.forEach(ml => {
-                if (nodeToReqs[ml.name]) {
-                  nodeToReqs[ml.name].push(r);
-                }
+              getLeafNodes([nameToNode[nodeName]]).forEach(ml => {
+                if (nodeToReqs[ml.name]) nodeToReqs[ml.name].push(r);
               });
               });
             }
             }
           });
           });
         });
         });
 
 
-        // Mark has_requirement flag and complex node_status on all leaves
         leaves.forEach(l => {
         leaves.forEach(l => {
           const attachedReqs = nodeToReqs[l.name];
           const attachedReqs = nodeToReqs[l.name];
           l.has_requirement = !!(attachedReqs && attachedReqs.length > 0);
           l.has_requirement = !!(attachedReqs && attachedReqs.length > 0);
-          
-          if (!l.has_requirement) {
-             l.node_status = 0; // 灰色 (没有需求)
-          } else {
-             const rIds = new Set(attachedReqs.map(r => r.id));
-             const relCaps = caps.filter((c: any) => (c.requirement_ids || []).some((rid: string) => rIds.has(rid)));
-             
-             // 检查是否所有挂载的需求都有对应的原子能力
-             const reqsWithCaps = attachedReqs.filter((r: any) => caps.some((c: any) => (c.requirement_ids || []).includes(r.id)));
-             if (reqsWithCaps.length < attachedReqs.length) {
-                l.node_status = 1; // 蓝色 (有需求,但没满足,缺能力覆盖)
-             } else {
-                const cIds = new Set(relCaps.map((c: any) => c.id));
-                const relTools = tools.filter((t: any) => (t.capability_ids || []).some((cid: string) => cIds.has(cid)));
-                
-                if (relTools.length === 0) {
-                   l.node_status = 2; // 黄色 (有需求且全部被能力满足,但工具未接入/没有工具)
-                } else {
-                   const hasDisconnected = relTools.some((t: any) => t.status !== '已接入' && t.status !== '正常' && t.status !== '已上线' && t.status !== 'active');
-                   if (hasDisconnected) {
-                      l.node_status = 2; // 黄色 (工具状态有未接入)
-                   } else {
-                      l.node_status = 3; // 绿色 (全部满足且工具全接入)
-                   }
-                }
-             }
+          if (!l.has_requirement) { l.node_status = 0; }
+          else {
+            const rIds = new Set(attachedReqs.map(r => r.id));
+            const relCaps = caps.filter((c: any) => (c.requirement_ids || []).some((rid: string) => rIds.has(rid)));
+            const reqsWithCaps = attachedReqs.filter((r: any) => caps.some((c: any) => (c.requirement_ids || []).includes(r.id)));
+            if (reqsWithCaps.length < attachedReqs.length) { l.node_status = 1; }
+            else {
+              const cIds = new Set(relCaps.map((c: any) => c.id));
+              const relTools = tools.filter((t: any) => (t.capability_ids || []).some((cid: string) => cIds.has(cid)));
+              if (relTools.length === 0) { l.node_status = 2; }
+              else {
+                const hasDisconnected = relTools.some((t: any) => t.status !== '已接入' && t.status !== '正常' && t.status !== '已上线' && t.status !== 'active');
+                l.node_status = hasDisconnected ? 2 : 3;
+              }
+            }
           }
           }
         });
         });
-        setTreeData({...data});
+        setTreeData({ ...data });
 
 
-        // 4. Calculate metrics based strictly on nodes with Xiaohongshu posts (total_posts_count > 0)
         const activeLeaves = leaves.filter(l => l.total_posts_count && l.total_posts_count > 0);
         const activeLeaves = leaves.filter(l => l.total_posts_count && l.total_posts_count > 0);
         const totalLeavesCount = activeLeaves.length;
         const totalLeavesCount = activeLeaves.length;
-        
-        let reqCoveredNodes = 0;
-        let toolCoveredNodes = 0;
-        let verifiedNodes = 0;
-        let totalWeight = 0;
-        let coveredWeight = 0;
-        let toolCoveredWeight = 0;
-        let verifiedWeight = 0;
-
+        let reqCoveredNodes = 0, toolCoveredNodes = 0, verifiedNodes = 0;
+        let totalWeight = 0, coveredWeight = 0, toolCoveredWeight = 0, verifiedWeight = 0;
         activeLeaves.forEach(l => {
         activeLeaves.forEach(l => {
           const count = l.total_posts_count || 0;
           const count = l.total_posts_count || 0;
           totalWeight += count;
           totalWeight += count;
           const attachedReqs = nodeToReqs[l.name];
           const attachedReqs = nodeToReqs[l.name];
-          let isToolCovered = false;
-          let isVerified = false;
-
           if (attachedReqs && attachedReqs.length > 0) {
           if (attachedReqs && attachedReqs.length > 0) {
-            reqCoveredNodes++;
-            coveredWeight += count;
-            
-            if (l.node_status >= 2) {
-               isToolCovered = true;
-            }
-            if (l.node_status === 3) {
-               isVerified = true;
-            }
-          }
-
-          if (isToolCovered) {
-             toolCoveredNodes++;
-             toolCoveredWeight += count;
-          }
-          if (isVerified) {
-             verifiedNodes++;
-             verifiedWeight += count;
+            reqCoveredNodes++; coveredWeight += count;
+            if (l.node_status >= 2) { toolCoveredNodes++; toolCoveredWeight += count; }
+            if (l.node_status === 3) { verifiedNodes++; verifiedWeight += count; }
           }
           }
         });
         });
-
-        const reqCoveragePerc = totalLeavesCount > 0 ? (reqCoveredNodes / totalLeavesCount) * 100 : 0;
-        const toolCoveragePerc = reqCoveredNodes > 0 ? (toolCoveredNodes / reqCoveredNodes) * 100 : 0;
-        const verifiedCoveragePerc = totalLeavesCount > 0 ? (verifiedNodes / totalLeavesCount) * 100 : 0;
-        const weightedCoveragePerc = totalWeight > 0 ? (coveredWeight / totalWeight) * 100 : 0;
-        const toolWeightedCoveragePerc = coveredWeight > 0 ? (toolCoveredWeight / coveredWeight) * 100 : 0;
-        const verifiedWeightedCoveragePerc = totalWeight > 0 ? (verifiedWeight / totalWeight) * 100 : 0;
-
         setCoverageStats({
         setCoverageStats({
-          totalLeaves: totalLeavesCount,
-          reqCoveredNodes,
-          reqCoveragePerc: Number(reqCoveragePerc.toFixed(1)),
+          totalLeaves: totalLeavesCount, reqCoveredNodes,
+          reqCoveragePerc: Number((totalLeavesCount > 0 ? reqCoveredNodes / totalLeavesCount * 100 : 0).toFixed(1)),
           toolCoveredNodes,
           toolCoveredNodes,
-          toolCoveragePerc: Number(toolCoveragePerc.toFixed(1)),
+          toolCoveragePerc: Number((reqCoveredNodes > 0 ? toolCoveredNodes / reqCoveredNodes * 100 : 0).toFixed(1)),
           verifiedNodes,
           verifiedNodes,
-          verifiedCoveragePerc: Number(verifiedCoveragePerc.toFixed(1)),
-          weightedCoveragePerc: Number(weightedCoveragePerc.toFixed(1)),
-          coveredPostsCnt: coveredWeight,
-          totalPostsCnt: totalWeight,
+          verifiedCoveragePerc: Number((totalLeavesCount > 0 ? verifiedNodes / totalLeavesCount * 100 : 0).toFixed(1)),
+          weightedCoveragePerc: Number((totalWeight > 0 ? coveredWeight / totalWeight * 100 : 0).toFixed(1)),
+          coveredPostsCnt: coveredWeight, totalPostsCnt: totalWeight,
           toolCoveredPostsCnt: toolCoveredWeight,
           toolCoveredPostsCnt: toolCoveredWeight,
-          toolWeightedCoveragePerc: Number(toolWeightedCoveragePerc.toFixed(1)),
+          toolWeightedCoveragePerc: Number((coveredWeight > 0 ? toolCoveredWeight / coveredWeight * 100 : 0).toFixed(1)),
           verifiedPostsCnt: verifiedWeight,
           verifiedPostsCnt: verifiedWeight,
-          verifiedWeightedCoveragePerc: Number(verifiedWeightedCoveragePerc.toFixed(1))
+          verifiedWeightedCoveragePerc: Number((totalWeight > 0 ? verifiedWeight / totalWeight * 100 : 0).toFixed(1))
         });
         });
-
       } catch (err) {
       } catch (err) {
         console.error("Failed to load dashboard stats", err);
         console.error("Failed to load dashboard stats", err);
       }
       }
@@ -293,301 +779,437 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
     loadStats();
     loadStats();
   }, []);
   }, []);
 
 
+  // ── 点击叶子节点时,找到它关联的所有需求 ID ────────────────────────────────
+  const activeLeafReqIds = useMemo((): Set<string> => {
+    if (!activeLeafNode) return new Set();
+    const leafName = activeLeafNode.name;
+    const relatedReqs = dbData.reqs.filter((r: any) =>
+      (r.source_nodes || []).some((sn: any) => {
+        const nodeName = typeof sn === 'object' ? (sn.node_name || sn.name) : sn;
+        return nodeName === leafName;
+      })
+    );
+    return new Set(relatedReqs.map((r: any) => r.id));
+  }, [activeLeafNode, dbData.reqs]);
+
+  // ── 点击叶子节点时,收集所有共享需求的叶子节点名称(用于树高亮)────────────────────────────────
+  const highlightLeafNamesFromActiveLeaf = useMemo((): Set<string> => {
+    if (activeLeafReqIds.size === 0) return new Set();
+    const relatedReqs = dbData.reqs.filter((r: any) => activeLeafReqIds.has(r.id));
+    const leafNames = new Set<string>();
+    relatedReqs.forEach(req => {
+      (req.source_nodes || []).forEach((sn: any) => {
+        const nodeName = typeof sn === 'object' ? (sn.node_name || sn.name) : sn;
+        if (nodeName && nodeName !== '__abstract__' && nameToNodeMap[nodeName]) {
+          getLeafNodes([nameToNodeMap[nodeName]]).forEach(leaf => {
+            leafNames.add(leaf.name);
+          });
+        }
+      });
+    });
+    return leafNames;
+  }, [activeLeafReqIds, dbData.reqs, nameToNodeMap]);
+
+  // ── 选中节点时,计算所有共享需求的节点名称(展开到叶子节点)────────────────────────────────
+  const selectedLeafNames = useMemo((): Set<string> => {
+    if (!selectedNode) return new Set();
+    const clickedLeafNames = new Set(getLeafNodes([selectedNode]).map(l => l.name));
+    const relatedReqs = dbData.reqs.filter((r: any) =>
+      (r.source_nodes || []).some((sn: any) =>
+        clickedLeafNames.has(typeof sn === 'object' ? (sn.node_name || sn.name) : sn)
+      )
+    );
+    const allRelatedLeafNames = new Set<string>();
+    relatedReqs.forEach(req => {
+      (req.source_nodes || []).forEach((sn: any) => {
+        const nodeName = typeof sn === 'object' ? (sn.node_name || sn.name) : sn;
+        if (nodeName && nodeName !== '__abstract__' && nameToNodeMap[nodeName]) {
+          // 展开到叶子节点
+          getLeafNodes([nameToNodeMap[nodeName]]).forEach(leaf => {
+            allRelatedLeafNames.add(leaf.name);
+          });
+        }
+      });
+    });
+    return allRelatedLeafNames;
+  }, [selectedNode, dbData.reqs, nameToNodeMap]);
+
+  // ── 树节点过滤:选中节点后只显示关联数据(基于所有共享需求的节点)───────────────────────────────────
+  const filteredData = useMemo(() => {
+    let baseReqs = dbData.reqs;
+
+    // 如果开启"只看覆盖需求的数据",先过滤出有 source_nodes 的需求
+    if (onlyCoveredFilter) {
+      baseReqs = dbData.reqs.filter((r: any) => {
+        const nodes = r.source_nodes || [];
+        return nodes.some((sn: any) => {
+          const nodeName = typeof sn === 'object' ? (sn.node_name || sn.name) : sn;
+          return nodeName && nodeName !== '__abstract__';
+        });
+      });
+    }
+
+    // 如果选中了节点,进一步过滤
+    if (selectedNode) {
+      const leafNames = selectedLeafNames.size > 0 ? selectedLeafNames : new Set(getLeafNodes([selectedNode]).map(l => l.name));
+      baseReqs = baseReqs.filter((r: any) =>
+        (r.source_nodes || []).some((sn: any) => leafNames.has(typeof sn === 'object' ? (sn.node_name || sn.name) : sn))
+      );
+    }
+
+    const reqIds = new Set(baseReqs.map((r: any) => r.id));
+    const filteredCaps = dbData.caps.filter((c: any) => (c.requirement_ids || []).some((rid: string) => reqIds.has(rid)));
+    const capIds = new Set(filteredCaps.map((c: any) => c.id));
+    const filteredTools = dbData.tools.filter((t: any) => (t.capability_ids || []).some((cid: string) => capIds.has(cid)));
+    const toolIds = new Set(filteredTools.map((t: any) => t.id));
+    const filteredKnow = dbData.know.filter((k: any) =>
+      (k.capability_ids || []).some((cid: string) => capIds.has(cid)) ||
+      (k.tool_ids || []).some((tid: string) => toolIds.has(tid))
+    );
+    return { reqs: baseReqs, caps: filteredCaps, tools: filteredTools, know: filteredKnow };
+  }, [selectedNode, selectedLeafNames, dbData, onlyCoveredFilter]);
+
+  // ── 反向联动:activeId 对应的叶子节点名称集合,用于高亮内容树 ──────────────
+  const highlightLeafNames = useMemo((): Set<string> | null => {
+    if (!activeId) return null;
+    const [type, id] = activeId.split(':');
+    let sourceNodes: any[] = [];
+    if (type === 'req') {
+      const req = dbData.reqs.find((r: any) => r.id === id);
+      sourceNodes = req?.source_nodes || [];
+    } else if (type === 'cap') {
+      const cap = dbData.caps.find((c: any) => c.id === id);
+      const reqIds: string[] = cap?.requirement_ids || [];
+      reqIds.forEach(rid => {
+        const req = dbData.reqs.find((r: any) => r.id === rid);
+        if (req) sourceNodes = sourceNodes.concat(req.source_nodes || []);
+      });
+    } else if (type === 'tool') {
+      const tool = dbData.tools.find((t: any) => t.id === id);
+      const capIds: string[] = tool?.capability_ids || [];
+      capIds.forEach(cid => {
+        const cap = dbData.caps.find((c: any) => c.id === cid);
+        const reqIds: string[] = cap?.requirement_ids || [];
+        reqIds.forEach(rid => {
+          const req = dbData.reqs.find((r: any) => r.id === rid);
+          if (req) sourceNodes = sourceNodes.concat(req.source_nodes || []);
+        });
+      });
+    }
+    if (sourceNodes.length === 0) return null;
+    const leafNames = new Set<string>();
+    sourceNodes.forEach((sn: any) => {
+      const nodeName = typeof sn === 'object' ? (sn.node_name || sn.name) : sn;
+      if (nodeName && nameToNodeMap[nodeName]) {
+        getLeafNodes([nameToNodeMap[nodeName]]).forEach(l => leafNames.add(l.name));
+      }
+    });
+    return leafNames.size > 0 ? leafNames : null;
+  }, [activeId, dbData, nameToNodeMap]);
+
+  // ── 全局节点总数(叶子节点数)──────────────────────────────────────────────
+
+  // ── 有需求覆盖的叶子节点名称集合(用于 onlyCoveredFilter)──────────────────
+  const coveredLeafNames = useMemo((): Set<string> => {
+    const names = new Set<string>();
+    dbData.reqs.forEach((r: any) => {
+      (r.source_nodes || []).forEach((sn: any) => {
+        const nodeName = typeof sn === 'object' ? (sn.node_name || sn.name) : sn;
+        if (nodeName && nodeName !== '__abstract__' && nameToNodeMap[nodeName]) {
+          getLeafNodes([nameToNodeMap[nodeName]]).forEach(l => names.add(l.name));
+        }
+      });
+    });
+    return names;
+  }, [dbData.reqs, nameToNodeMap]);
+  const totalNodeCount = useMemo(() => {
+    if (!treeData) return 0;
+    return getLeafNodes([treeData]).filter((l: any) => l.total_posts_count && l.total_posts_count > 0).length;
+  }, [treeData]);
+
+  // ── BFS 关联高亮(与 Relations.tsx 逻辑一致)──────────────────────────────
+  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);
+    };
+    filteredData.reqs.forEach(r => {
+      (r.capability_ids || []).forEach((cid: string) => add(`req:${r.id}`, `cap:${cid}`));
+    });
+    filteredData.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}`));
+    });
+    filteredData.tools.forEach(t => {
+      (t.capability_ids || []).forEach((cid: string) => add(`tool:${t.id}`, `cap:${cid}`));
+    });
+    return map;
+  }, [filteredData]);
+
+  const colOrder: Record<string, number> = { req: 0, proc: 1, cap: 2, tool: 3 };
+  const getColType = (nodeId: string) => nodeId.split(':')[0];
+
+  const relatedIds = useMemo(() => {
+    // 如果点击了叶子节点,把它关联的所有需求作为起点
+    const startNodes: string[] = [];
+    if (activeLeafNode && activeLeafReqIds.size > 0) {
+      activeLeafReqIds.forEach(reqId => startNodes.push(`req:${reqId}`));
+    } else if (activeId) {
+      startNodes.push(activeId);
+    }
+
+    if (startNodes.length === 0) return new Set<string>();
+
+    const visited = new Set<string>(startNodes);
+    const queue: [string, number][] = startNodes.map(n => [n, 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, activeLeafNode, activeLeafReqIds, adjacencyMap]);
+
+  const sortedItems = (items: any[], type: string) => {
+    if (!activeId && !activeLeafNode) return items;
+    const activeType = activeId ? activeId.split(':')[0] : 'req';
+    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 handleSingleClick = (nodeId: string, _item: any) => {
+    setActiveId(prev => prev === nodeId ? null : nodeId);
+    setActiveLeafNode(null);
+  };
+
+  const handleDoubleClick = (type: string, data: any) => {
+    if (type === 'req') {
+      // 业务需求双击 → 打开帖子聚合抽屉
+      openRequirementPostsDrawer(data);
+    } else {
+      setDrawerItem({ type, data });
+    }
+  };
+
+  const openRequirementPostsDrawer = (req: any) => {
+    // 对每个来源节点,只展示“需求输入帖子”与“该节点真实帖子”的交集。
+    const nodePostsMap: Record<string, string[]> = {};
+    const requirementInputPosts = [
+      ...(req.source_posts || []),
+      ...(req.post_ids || []),
+    ].map((item: any) => typeof item === 'string' ? item : item?.post_id).filter(Boolean);
+    const fallbackInputPosts: string[] = [];
+
+    (req.source_nodes || []).forEach((sn: any) => {
+      const nodeName = typeof sn === 'object' ? (sn.node_name || sn.name || '') : sn;
+      if (!nodeName || nodeName === '__abstract__') return;
+      const node = nameToNodeMap[nodeName];
+      if (!node) return;
+
+      const nodePostIds = collectNodePostIds(node);
+      const nodePostIdSet = new Set(nodePostIds);
+
+      const explicitPosts = typeof sn === 'object'
+        ? [
+            ...(sn.posts || []),
+            ...(sn.post_ids || []),
+            ...(sn.source_posts || []),
+          ]
+        : [];
+
+      const candidatePosts = (explicitPosts.length > 0 ? explicitPosts : requirementInputPosts)
+        .map((item: any) => typeof item === 'string' ? item : item?.post_id)
+        .filter(Boolean);
+      candidatePosts.forEach((pid: string) => {
+        if (pid && !fallbackInputPosts.includes(pid)) fallbackInputPosts.push(pid);
+      });
+
+      const postIds: string[] = [];
+      candidatePosts.forEach((pid: string) => {
+        if (nodePostIdSet.has(pid) && !postIds.includes(pid)) postIds.push(pid);
+      });
+
+      if (postIds.length > 0) nodePostsMap[nodeName] = postIds;
+    });
+
+    const allInputPosts = requirementInputPosts.length > 0 ? requirementInputPosts : fallbackInputPosts;
+    const matchedPosts = new Set(Object.values(nodePostsMap).flat());
+    const unmatchedPosts = allInputPosts.filter((pid: string) => pid && !matchedPosts.has(pid));
+
+    if (unmatchedPosts.length > 0) {
+      nodePostsMap.__unmatched__ = unmatchedPosts;
+    }
+
+    if (Object.keys(nodePostsMap).length === 0 && allInputPosts.length > 0) {
+      nodePostsMap.__unmatched__ = allInputPosts;
+    }
+
+    setRequirementPostsData({ requirement: req, nodePostsMap });
+  };
+
+  const columns = [
+    { t: 'req',  l: '业务需求', i: Target,   d: filteredData.reqs,  headerColor: 'border-t-2 border-t-indigo-400' },
+    { t: 'proc', l: '生产工序', i: ListTree,  d: [],                 headerColor: 'border-t-2 border-t-purple-400' },
+    { t: 'cap',  l: '原子能力', i: Cpu,       d: filteredData.caps,  headerColor: 'border-t-2 border-t-teal-400' },
+    { t: 'tool', l: '执行工具', i: Wrench,    d: filteredData.tools, headerColor: 'border-t-2 border-t-green-400' },
+  ];
+
   return (
   return (
-    <div className="space-y-8 animate-in fade-in duration-500">
-      <div className="bg-white rounded-3xl border border-slate-100 shadow-sm p-6 overflow-hidden">
-        <div className="flex justify-between items-center mb-8">
-          <div className="flex bg-slate-100 p-1 rounded-lg">
-            <button 
-              className={cn("px-4 py-1.5 text-sm font-bold rounded-md transition-all", weightTab === 'unweighted' ? "bg-white text-indigo-700 shadow-sm" : "text-slate-500 hover:text-slate-700")}
-              onClick={() => setWeightTab('unweighted')}
-            >
-              无权重 (节点数)
-            </button>
-            <button 
-              className={cn("px-4 py-1.5 text-sm font-bold rounded-md transition-all", weightTab === 'weighted' ? "bg-white text-indigo-700 shadow-sm" : "text-slate-500 hover:text-slate-700")}
-              onClick={() => setWeightTab('weighted')}
-            >
-              带权重 (帖子数)
-            </button>
-          </div>
+    <div className="flex flex-col h-[calc(100vh-64px)] gap-4 animate-in fade-in duration-500 overflow-hidden relative">
+      {/* 顶部抽屉:叶子节点帖子详情 或 业务需求帖子聚合(全局 fixed) */}
+      <div className={cn(
+        "fixed left-0 right-0 top-0 z-[200] bg-white border-b border-slate-200 shadow-xl transition-transform duration-300 ease-in-out flex flex-col",
+        (leafDetailNode || requirementPostsData) ? "translate-y-0" : "-translate-y-full"
+      )} style={{ maxHeight: '60vh' }}>
+        <div className="flex items-center justify-between px-6 py-3 border-b border-slate-100 shrink-0">
+          <span className="font-bold text-slate-800">
+            {leafDetailNode ? leafDetailNode.name : requirementPostsData ? `${requirementPostsData.requirement.name || requirementPostsData.requirement.description || '业务需求'} - 输入帖子` : ''}
+          </span>
+          <button onClick={() => { setLeafDetailNode(null); setRequirementPostsData(null); }} className="p-1.5 hover:bg-slate-100 rounded-lg text-slate-400 hover:text-slate-600 transition-colors">
+            <X size={16} />
+          </button>
         </div>
         </div>
-
-        <div className="flex justify-center items-center h-48 py-4">
-          {(() => {
-             const data = weightTab === 'unweighted' ? [
-               { label: '全局节点', value: coverageStats.totalLeaves, percent: '100%', color: 'bg-blue-400' },
-               { label: '需求覆盖节点', value: coverageStats.reqCoveredNodes, percent: coverageStats.reqCoveragePerc + '%', color: 'bg-indigo-400' },
-               { label: '工具覆盖节点', value: coverageStats.toolCoveredNodes, percent: (coverageStats.totalLeaves ? (coverageStats.toolCoveredNodes/coverageStats.totalLeaves*100).toFixed(1) : 0) + '%', color: 'bg-emerald-400' },
-               { label: '已验证节点', value: coverageStats.verifiedNodes, percent: coverageStats.verifiedCoveragePerc + '%', color: 'bg-cyan-400' },
-             ] : [
-               { label: '全局节点 (帖子)', value: coverageStats.totalPostsCnt, percent: '100%', color: 'bg-blue-400' },
-               { label: '需求覆盖 (帖子)', value: coverageStats.coveredPostsCnt, percent: coverageStats.weightedCoveragePerc + '%', color: 'bg-indigo-400' },
-               { label: '工具覆盖 (帖子)', value: coverageStats.toolCoveredPostsCnt, percent: (coverageStats.totalPostsCnt ? (coverageStats.toolCoveredPostsCnt/coverageStats.totalPostsCnt*100).toFixed(1) : 0) + '%', color: 'bg-emerald-400' },
-               { label: '已验证 (帖子)', value: coverageStats.verifiedPostsCnt, percent: coverageStats.verifiedWeightedCoveragePerc + '%', color: 'bg-cyan-400' },
-             ];
-
-             return (
-               <div className="flex w-full max-w-4xl h-full relative" style={{ filter: "drop-shadow(0 4px 6px rgba(0,0,0,0.05))" }}>
-                 {data.map((item, idx) => {
-                    const prevWidth = idx === 0 ? 100 : (data[idx-1].value / Math.max(data[0].value, 1) * 100);
-                    const currWidth = (item.value / Math.max(data[0].value, 1) * 100);
-                    // 取消最小高度限制,严格按比例渲染梯形
-                    const leftHeight = prevWidth;
-                    const rightHeight = currWidth;
-                    const clipPath = `polygon(0 ${50 - leftHeight/2}%, 100% ${50 - rightHeight/2}%, 100% ${50 + rightHeight/2}%, 0 ${50 + leftHeight/2}%)`;
-
-                    return (
-                      <div key={idx} className="flex-1 flex flex-col items-center justify-center relative border-r-2 border-white last:border-r-0">
-                         <div className={cn("absolute inset-0 transition-all duration-700 opacity-90", item.color)} style={{ clipPath }}></div>
-                         <div className="z-10 flex flex-col items-center text-slate-900">
-                           <span className="text-2xl font-black tracking-tight">{item.value}</span>
-                           <span className="text-xs font-bold mt-0.5 opacity-90">{item.label}</span>
-                         </div>
-                         {idx > 0 && (
-                            <div className="absolute top-0 left-0 -translate-x-1/2 -mt-4 text-[11px] font-bold text-slate-500 bg-white px-2 py-0.5 rounded shadow-sm border border-slate-100 z-20">
-                              转化率 {item.percent}
-                            </div>
-                         )}
-                      </div>
-                    );
-                 })}
-               </div>
-             );
-          })()}
+        <div className="overflow-hidden flex-1 min-w-0">
+          {leafDetailNode && <LeafNodeDrawer node={leafDetailNode} onOpenPost={(postId, post) => setSelectedPostDetail({ postId, post })} />}
+          {requirementPostsData && (
+            <RequirementPostsDrawer
+              requirement={requirementPostsData.requirement}
+              nodePostsMap={requirementPostsData.nodePostsMap}
+              onOpenPost={(postId, post) => setSelectedPostDetail({ postId, post })}
+            />
+          )}
         </div>
         </div>
       </div>
       </div>
-      
-      <div className="flex flex-col lg:flex-row gap-6">
-        {/* Left Side: Tree Viewer */}
-        <div className={cn("transition-all duration-300 ease-in-out", selectedNode ? "w-full xl:w-2/3" : "w-full")}>
-           <CategoryTree data={treeData} onSelect={setSelectedNode} selectedId={selectedNode?.id} />
+      {selectedPostDetail && (
+        <PostDetailModal
+          postId={selectedPostDetail.postId}
+          post={selectedPostDetail.post}
+          onClose={() => setSelectedPostDetail(null)}
+        />
+      )}
+
+      {/* 覆盖率统计 */}
+      <CoverageStats stats={coverageStats} weightTab={weightTab} setWeightTab={setWeightTab} />
+
+      {/* 工具栏 */}
+      <div className="flex items-center gap-2 shrink-0">
+        <button
+          onClick={() => setOnlyCoveredFilter(v => !v)}
+          className={cn(
+            "text-xs flex items-center gap-1.5 px-3 py-1.5 rounded-lg transition-colors font-bold",
+            onlyCoveredFilter
+              ? "bg-indigo-100 text-indigo-700 hover:bg-indigo-200"
+              : "bg-slate-200 text-slate-500 hover:bg-slate-300"
+          )}
+        >
+          <span className={cn("w-2 h-2 rounded-full", onlyCoveredFilter ? "bg-indigo-500" : "bg-slate-400")} />
+          只看覆盖需求的数据
+        </button>
+        {(selectedNode || activeLeafNode || activeId) && (
+          <button
+            onClick={() => { setSelectedNode(null); setActiveId(null); setActiveLeafNode(null); }}
+            className="text-xs text-slate-400 hover:text-slate-600 flex items-center gap-1 bg-slate-200 hover:bg-slate-300 px-3 py-1.5 rounded-lg transition-colors font-bold"
+          >
+            <X size={12} /> 清除筛选
+          </button>
+        )}
+      </div>
+
+      {/* 主区域:整行横向滚动,每列固定宽度 */}
+      <div className="flex flex-row gap-4 flex-1 min-h-0 overflow-x-auto">
+        {/* 内容树 */}
+        <div className={cn("shrink-0 min-h-0", treeWideMode ? "w-[900px]" : "w-[420px]")}>
+          <CategoryTree
+            data={treeData}
+            onSelect={(node) => {
+              const isLeaf = !node.children || node.children.length === 0;
+              const isSame = selectedNode?.id === node.id;
+              setSelectedNode(isSame ? null : node);
+              setActiveId(null);
+              setActiveLeafNode(isLeaf && !isSame ? node : null);
+            }}
+            onDoubleClick={(node) => {
+              setLeafDetailNode(node);
+            }}
+            selectedId={activeLeafNode?.id ?? selectedNode?.id}
+            highlightLeafNames={
+              activeId
+                ? highlightLeafNames
+                : activeLeafNode
+                ? (highlightLeafNamesFromActiveLeaf.size > 0 ? highlightLeafNamesFromActiveLeaf : null)
+                : onlyCoveredFilter
+                ? coveredLeafNames
+                : null
+            }
+            totalNodeCount={totalNodeCount}
+            wideMode={treeWideMode}
+            onToggleWideMode={() => setTreeWideMode(m => !m)}
+          />
         </div>
         </div>
 
 
-        {/* Right Side: Drill-down Details Panel */}
-        {navStack.length > 0 && (
-          <div className="w-full xl:w-1/3 bg-white p-6 rounded-3xl border border-slate-100 shadow-sm sticky top-24 max-h-[calc(100vh-100px)] overflow-y-auto custom-scrollbar">
-            {/* Breadcrumbs */}
-            <div className="flex flex-wrap items-center gap-1.5 font-bold text-[13px] text-slate-500 border-b pb-4 mb-4">
-               {navStack.map((item, idx) => {
-                 let Icon: any = FolderTree;
-                 let crumbTitle = "Node";
-                 if (item.type === 'node') { Icon = FolderTree; crumbTitle = item.data.name || "Root"; }
-                 if (item.type === 'req') { Icon = Target; crumbTitle = item.data.id.substring(0, 8); }
-                 if (item.type === 'cap') { Icon = Brain; crumbTitle = item.data.name || item.data.id.substring(0,8); }
-                 if (item.type === 'tool') { Icon = Wrench; crumbTitle = item.data.name || item.data.id.substring(0,8); }
-                 if (item.type === 'know') { Icon = FileText; crumbTitle = item.data.task ? item.data.task.substring(0, 10)+"..." : item.data.id.substring(0,8); }
-                 
-                 const isLast = idx === navStack.length - 1;
-                 
-                 return (
-                   <div key={idx} className="flex items-center gap-1.5">
-                     <div 
-                       className={cn("flex items-center gap-1 transition-colors bg-slate-50 px-2 py-1 rounded-md border border-slate-100", 
-                         isLast ? "text-indigo-700 bg-indigo-50 border-indigo-100 shadow-sm" : "hover:text-indigo-600 cursor-pointer")}
-                       onClick={() => !isLast && handleBreadcrumbClick(idx)}
-                     >
-                       <Icon size={14} />
-                       <span className="max-w-[120px] truncate">{crumbTitle}</span>
-                     </div>
-                     {!isLast && <ChevronRight size={14} className="text-slate-300" />}
-                   </div>
-                 );
-               })}
+        {/* 关系列:每列固定宽度 */}
+        {columns.map(col => (
+          <div key={col.t} className={cn("w-[260px] shrink-0 flex flex-col h-full bg-slate-100/50 rounded-2xl border border-slate-200 overflow-hidden", col.headerColor)}>
+            <div className="px-4 py-3 font-bold text-slate-700 border-b bg-slate-200/50 flex justify-between shrink-0">
+              {col.l}
+              <span className="text-slate-400">{col.d.length}</span>
+            </div>
+            <div className="flex-1 overflow-y-auto p-3 custom-scrollbar">
+              {col.t === 'proc' ? (
+                <div className="flex items-center justify-center h-full text-xs text-slate-300 font-bold select-none">暂无数据</div>
+              ) : (
+                sortedItems(col.d, col.t).map(item => (
+                  <RelationCard
+                    key={item.id}
+                    type={col.t}
+                    item={item}
+                    activeId={activeLeafNode ? null : activeId}
+                    isLeafActive={!!activeLeafNode}
+                    relatedIds={relatedIds}
+                    selectedLeafNames={activeLeafNode ? highlightLeafNamesFromActiveLeaf : new Set()}
+                    onSingleClick={(nodeId) => handleSingleClick(nodeId, item)}
+                    onDoubleClick={handleDoubleClick}
+                  />
+                ))
+              )}
+              {col.t !== 'proc' && col.d.length === 0 && (
+                <div className="flex items-center justify-center h-full text-xs text-slate-300 font-bold select-none">
+                  {selectedNode ? '该节点无关联数据' : '无数据'}
+                </div>
+              )}
             </div>
             </div>
-
-            {/* Content Area */}
-            {(() => {
-               const currentItem = navStack[navStack.length - 1];
-               const d = currentItem.data;
-               
-               let relReqs: any[] = [];
-               let relCaps: any[] = [];
-               let relTools: any[] = [];
-               let relKnow: any[] = [];
-
-               if (currentItem.type === 'node') {
-                  const getLeafNames = (nodes: any[]): any[] => {
-                    let leaves: any[] = [];
-                    nodes.forEach(n => {
-                      if (!n.children || n.children.length === 0) leaves.push(n);
-                      else leaves = leaves.concat(getLeafNames(n.children));
-                    });
-                    return leaves;
-                  };
-                  const leafNames = getLeafNames([d]).map(l => l.name);
-
-                  relReqs = dbData.reqs.filter((r: any) => 
-                    (r.source_nodes || []).some((sn: any) => leafNames.includes(typeof sn === 'object' ? (sn.node_name || sn.name) : sn))
-                  );
-                  const relReqIds = new Set(relReqs.map(r => r.id));
-                  relCaps = dbData.caps.filter((c: any) => (c.requirement_ids || []).some((rid: string) => relReqIds.has(rid)));
-                  const relCapIds = new Set(relCaps.map(c => c.id));
-                  relTools = dbData.tools.filter((t: any) => (t.capability_ids || []).some((cid: string) => relCapIds.has(cid)));
-                  const relToolIds = new Set(relTools.map(t => t.id));
-                  relKnow = dbData.know.filter((k: any) => {
-                    const hasCap = (k.capability_ids || []).some((cid: string) => relCapIds.has(cid));
-                    const hasTool = (k.tool_ids || []).some((tid: string) => relToolIds.has(tid));
-                    return hasCap || hasTool;
-                  });
-               }
-               else if (currentItem.type === 'req') {
-                  relCaps = dbData.caps.filter((c: any) => (c.requirement_ids || []).includes(d.id));
-               }
-               else if (currentItem.type === 'cap') {
-                  relReqs = dbData.reqs.filter((r: any) => (d.requirement_ids || []).includes(r.id));
-                  relTools = dbData.tools.filter((t: any) => (t.capability_ids || []).includes(d.id));
-                  relKnow = dbData.know.filter((k: any) => (k.capability_ids || []).includes(d.id));
-               }
-               else if (currentItem.type === 'tool') {
-                  relCaps = dbData.caps.filter((c: any) => (d.capability_ids || []).includes(c.id));
-                  relKnow = dbData.know.filter((k: any) => (k.tool_ids || []).includes(d.id));
-               }
-               else if (currentItem.type === 'know') {
-                  relCaps = dbData.caps.filter((c: any) => (d.capability_ids || []).includes(c.id));
-                  relTools = dbData.tools.filter((t: any) => (d.tool_ids || []).includes(t.id));
-               }
-
-               return (
-                 <div className="space-y-6 pb-8 animate-in fade-in slide-in-from-right-4 duration-300">
-                   {/* Dedicated Detail Views */}
-                   <div>
-                     {currentItem.type === 'node' && (
-                       <>
-                         <h2 className="text-2xl font-black text-slate-800">{d.name || "Root"}</h2>
-                         <div className="text-xs text-slate-400 font-mono mt-1 break-all bg-slate-50 p-2 rounded">{d.path || "/"}</div>
-                         {d.description && (
-                           <div className="bg-indigo-50/50 p-4 rounded-xl border border-indigo-100/50 mt-4">
-                             <h3 className="font-bold text-indigo-900 mb-2 text-sm">定义与描述</h3>
-                             <p className="text-indigo-700 text-sm leading-relaxed">{d.description}</p>
-                           </div>
-                         )}
-                       </>
-                     )}
-                     {currentItem.type === 'req' && (
-                       <>
-                         <h2 className="text-xl font-black text-slate-800 leading-snug">需求定义</h2>
-                         <p className="mt-4 text-indigo-800 text-sm leading-relaxed whitespace-pre-wrap bg-indigo-50/50 p-4 rounded-xl border border-indigo-100/50">{d.description}</p>
-                         <div className="mt-4 bg-slate-50 p-3 rounded-xl border border-slate-100">
-                           <div className="text-[10px] text-slate-500 mb-1">追踪 ID</div>
-                           <div className="font-mono text-slate-700 text-[11px] break-all">{d.id}</div>
-                         </div>
-                       </>
-                     )}
-                     {currentItem.type === 'cap' && (
-                       <>
-                         <h2 className="text-xl font-black text-amber-600">{d.name}</h2>
-                         <div className="bg-amber-50/50 p-4 rounded-xl mt-4 border border-amber-100/50">
-                           <h3 className="font-bold text-amber-900 mb-2 text-sm">能力标准定义</h3>
-                           <p className="text-amber-800 text-sm leading-relaxed">{d.description || "暂无描述"}</p>
-                         </div>
-                         <div className="mt-4 bg-slate-50 p-3 rounded-xl border border-slate-100">
-                           <div className="text-[10px] text-slate-500 mb-1">能力标识 ID</div>
-                           <div className="font-mono text-slate-700 text-[11px] break-all">{d.id}</div>
-                         </div>
-                       </>
-                     )}
-                     {currentItem.type === 'tool' && (
-                       <>
-                         <h2 className="text-xl font-black text-emerald-600">{d.name}</h2>
-                         <div className="bg-emerald-50/50 p-4 rounded-xl mt-4 border border-emerald-100/50">
-                           <h3 className="font-bold text-emerald-900 mb-2 text-sm">工具介绍</h3>
-                           <p className="text-emerald-800 text-sm leading-relaxed">{d.introduction || "暂无介绍"}</p>
-                         </div>
-                         <div className="mt-4 bg-slate-50 p-3 rounded-xl border border-slate-100">
-                           <div className="text-[10px] text-slate-500 mb-1">执行端 ID</div>
-                           <div className="font-mono text-slate-700 text-[11px] break-all">{d.id}</div>
-                         </div>
-                       </>
-                     )}
-                     {currentItem.type === 'know' && (
-                       <>
-                         <h2 className="text-xl font-black text-violet-700 leading-snug">{d.task}</h2>
-                         <div className="bg-violet-50/50 p-4 rounded-xl mt-4 border border-violet-100/50">
-                           <h3 className="font-bold text-violet-900 mb-2 text-sm">知识正文</h3>
-                           <p className="text-violet-800 text-sm whitespace-pre-wrap leading-relaxed">{d.content}</p>
-                         </div>
-                         <div className="mt-4 bg-slate-50 p-3 rounded-xl border border-slate-100">
-                           <div className="text-[10px] text-slate-500 mb-1">知识库 ID</div>
-                           <div className="font-mono text-slate-700 text-[11px] break-all">{d.id}</div>
-                         </div>
-                       </>
-                     )}
-                   </div>
-                   
-                   {currentItem.type === 'node' && (
-                     <div className="grid grid-cols-2 gap-3 mt-6">
-                        <div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
-                          <div className="text-[10px] text-slate-500 mb-1">层级分支</div>
-                          <div className="font-bold text-slate-800 text-sm">{d.children?.length || 0} 个</div>
-                        </div>
-                        <div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
-                          <div className="text-[10px] text-slate-500 mb-1">小红书热度</div>
-                          <div className="font-bold text-indigo-600 text-sm">{d.total_element_count || 0} 篇</div>
-                      </div>
-                      </div>
-                   )}
-
-                   {/* Relations Map */}
-                   <div className="pt-2 mt-6 border-t border-slate-100">
-                      {relReqs.length > 0 && currentItem.type !== 'req' && (
-                        <RelationGroup title="基于此的需求" count={relReqs.length} colorClass="text-indigo-600" borderClass="bg-indigo-600">
-                          <div className="space-y-1">
-                            {relReqs.map((r: any) => <CompactListCard key={r.id} data={r} type="req" onDrillDown={handleDrillDown}/>)}
-                          </div>
-                        </RelationGroup>
-                      )}
-                      {relReqs.length === 0 && currentItem.type === 'node' && (
-                        <RelationGroup title="关联需求" count={0} colorClass="text-indigo-600" borderClass="bg-indigo-600">
-                          <div className="text-xs text-slate-400 pl-4 border-l-2 border-slate-100">未检索到任何需求</div>
-                        </RelationGroup>
-                      )}
-                      
-                      {relCaps.length > 0 && currentItem.type !== 'cap' && (
-                        <RelationGroup title="下属原子能力" count={relCaps.length} colorClass="text-amber-700" borderClass="bg-amber-700">
-                          <div className="space-y-1">
-                            {relCaps.map((c: any) => <CompactListCard key={c.id} data={c} type="cap" onDrillDown={handleDrillDown}/>)}
-                          </div>
-                        </RelationGroup>
-                      )}
-                      {relCaps.length === 0 && currentItem.type === 'node' && (
-                        <RelationGroup title="下属原子能力" count={0} colorClass="text-amber-700" borderClass="bg-amber-700">
-                          <div className="text-xs text-slate-400 pl-4 border-l-2 border-slate-100">能力库为空</div>
-                        </RelationGroup>
-                      )}
-
-                      {relTools.length > 0 && currentItem.type !== 'tool' && (
-                        <RelationGroup title="相关实现工具" count={relTools.length} colorClass="text-emerald-700" borderClass="bg-emerald-700">
-                          <div className="space-y-1">
-                            {relTools.map((t: any) => <CompactListCard key={t.id} data={t} type="tool" onDrillDown={handleDrillDown}/>)}
-                          </div>
-                        </RelationGroup>
-                      )}
-                      {relTools.length === 0 && currentItem.type === 'node' && (
-                        <RelationGroup title="相关实现工具" count={0} colorClass="text-emerald-700" borderClass="bg-emerald-700">
-                          <div className="text-xs text-slate-400 pl-4 border-l-2 border-slate-100">未发现关联的执行工具</div>
-                        </RelationGroup>
-                      )}
-
-                      {relKnow.length > 0 && currentItem.type !== 'know' && (
-                        <RelationGroup title="相关支撑知识" count={relKnow.length} colorClass="text-violet-700" borderClass="bg-violet-700">
-                          <div className="space-y-1">
-                            {relKnow.map((k: any) => <CompactListCard key={k.id} data={k} type="know" onDrillDown={handleDrillDown}/>)}
-                          </div>
-                        </RelationGroup>
-                      )}
-                      {relKnow.length === 0 && currentItem.type === 'node' && (
-                        <RelationGroup title="相关支撑知识" count={0} colorClass="text-violet-700" borderClass="bg-violet-700">
-                          <div className="text-xs text-slate-400 pl-4 border-l-2 border-slate-100">无相关文档资料</div>
-                        </RelationGroup>
-                      )}
-                   </div>
-                 </div>
-               );
-            })()}
           </div>
           </div>
-        )}
+        ))}
       </div>
       </div>
+
+      {/* 详情抽屉 */}
+      <SideDrawer
+        isOpen={!!drawerItem}
+        onClose={() => setDrawerItem(null)}
+        title={drawerItem ? (drawerItem.data.name || drawerItem.data.description?.slice(0, 20) || drawerItem.data.task?.slice(0, 20) || drawerItem.data.id?.slice(0, 12)) : ''}
+        width="w-[560px]"
+      >
+        {drawerItem && (
+          <DrawerContent type={drawerItem.type} data={drawerItem.data} dbData={dbData} />
+        )}
+      </SideDrawer>
+
     </div>
     </div>
   );
   );
 }
 }

+ 23 - 0
knowhub/frontend/src/services/api.ts

@@ -66,4 +66,27 @@ export const getTags = async () => {
   return fetchWithCache(`/knowledge/meta/tags`);
   return fetchWithCache(`/knowledge/meta/tags`);
 };
 };
 
 
+export const getResource = async (resourceId: string) => {
+  return fetchWithCache(`/resource/${resourceId}`);
+};
+
+export const batchGetPosts = async (postIds: string[]): Promise<Record<string, any>> => {
+  if (postIds.length === 0) return {};
+  const resp = await fetch('/api/pattern/posts/batch', {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ post_ids: postIds }),
+  });
+  if (!resp.ok) {
+    throw new Error(`batchGetPosts failed with status ${resp.status}`);
+  }
+  const data = await resp.json();
+  const map: Record<string, any> = {};
+  (data.posts || []).forEach((p: any) => {
+    const key = p.post_id || p.id;
+    if (key) map[key] = p;
+  });
+  return map;
+};
+
 export default api;
 export default api;

+ 137 - 21
knowhub/internal_tools/cache_manager.py

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

+ 224 - 0
knowhub/internal_tools/capability_extractor.py

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

+ 84 - 8
knowhub/server.py

@@ -16,6 +16,7 @@ from contextlib import asynccontextmanager
 from datetime import datetime, timezone
 from datetime import datetime, timezone
 from typing import Optional, List, Dict
 from typing import Optional, List, Dict
 from pathlib import Path
 from pathlib import Path
+import httpx
 from cryptography.hazmat.primitives.ciphers.aead import AESGCM
 from cryptography.hazmat.primitives.ciphers.aead import AESGCM
 
 
 from fastapi import FastAPI, HTTPException, Query, Header, Body, BackgroundTasks, Request
 from fastapi import FastAPI, HTTPException, Query, Header, Body, BackgroundTasks, Request
@@ -211,6 +212,10 @@ class ResourcePatchIn(BaseModel):
     metadata: Optional[dict] = None
     metadata: Optional[dict] = None
 
 
 
 
+class PostBatchRequest(BaseModel):
+    post_ids: List[str] = Field(default_factory=list)
+
+
 # Knowledge Models
 # Knowledge Models
 class KnowledgeIn(BaseModel):
 class KnowledgeIn(BaseModel):
     task: str
     task: str
@@ -980,6 +985,15 @@ class KnowledgeAskResponse(BaseModel):
     sources: list[dict] = []  # [{id, task, content}]
     sources: list[dict] = []  # [{id, task, content}]
 
 
 
 
+class KnowledgeResearchRequest(BaseModel):
+    query: str
+    trace_id: str  # 必填:调用方的 trace_id,用于续跑
+
+class KnowledgeResearchResponse(BaseModel):
+    response: str
+    source_ids: list[str] = []
+    sources: list[dict] = []
+
 class KnowledgeUploadRequest(BaseModel):
 class KnowledgeUploadRequest(BaseModel):
     data: dict  # {tools, resources, knowledge}
     data: dict  # {tools, resources, knowledge}
     trace_id: str  # 必填:调用方的 trace_id
     trace_id: str  # 必填:调用方的 trace_id
@@ -1004,6 +1018,24 @@ async def ask_knowledge_api(req: KnowledgeAskRequest):
         raise HTTPException(status_code=500, detail=str(e))
         raise HTTPException(status_code=500, detail=str(e))
 
 
 
 
+@app.post("/api/knowledge/research")
+async def research_knowledge_api(req: KnowledgeResearchRequest):
+    """
+    智能深度调研。运行 Research Agent 执行全网搜集和总结,返回深度调研结果。
+    同步阻塞:Agent 运行完成后返回。
+    """
+    try:
+        from agents.research import research
+        result = await research(query=req.query, caller_trace_id=req.trace_id)
+        return KnowledgeResearchResponse(**result)
+
+    except Exception as e:
+        import traceback
+        traceback.print_exc()
+        print(f"[Knowledge Research] 错误: {e}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+
 @app.post("/api/knowledge/upload", status_code=202)
 @app.post("/api/knowledge/upload", status_code=202)
 async def upload_knowledge_api(req: KnowledgeUploadRequest, background_tasks: BackgroundTasks):
 async def upload_knowledge_api(req: KnowledgeUploadRequest, background_tasks: BackgroundTasks):
     """
     """
@@ -1108,8 +1140,11 @@ async def search_knowledge_api(
         filters = []
         filters = []
         if types:
         if types:
             type_list = [t.strip() for t in types.split(',') if t.strip()]
             type_list = [t.strip() for t in types.split(',') if t.strip()]
-            for t in type_list:
-                filters.append(f'array_contains(types, "{t}")')
+            if len(type_list) == 1:
+                filters.append(f'array_contains(types, "{type_list[0]}")')
+            elif len(type_list) > 1:
+                type_filters = [f'array_contains(types, "{t}")' for t in type_list]
+                filters.append(f'({" or ".join(type_filters)})')
         if owner:
         if owner:
             owner_list = [o.strip() for o in owner.split(',') if o.strip()]
             owner_list = [o.strip() for o in owner.split(',') if o.strip()]
             if len(owner_list) == 1:
             if len(owner_list) == 1:
@@ -1253,11 +1288,14 @@ def list_knowledge(
         # 构建过滤表达式
         # 构建过滤表达式
         filters = []
         filters = []
 
 
-        # types 支持多个,用 AND 连接(交集:必须同时包含所有选中的type
+        # types 支持多个,改为用 OR 连接(并集:包含任意选中 type 即可
         if types:
         if types:
             type_list = [t.strip() for t in types.split(',') if t.strip()]
             type_list = [t.strip() for t in types.split(',') if t.strip()]
-            for t in type_list:
-                filters.append(f'array_contains(types, "{t}")')
+            if len(type_list) == 1:
+                filters.append(f'array_contains(types, "{type_list[0]}")')
+            elif len(type_list) > 1:
+                type_filters = [f'array_contains(types, "{t}")' for t in type_list]
+                filters.append(f'({" or ".join(type_filters)})')
 
 
         if scopes:
         if scopes:
             filters.append(f'array_contains(scopes, "{scopes}")')
             filters.append(f'array_contains(scopes, "{scopes}")')
@@ -2399,6 +2437,36 @@ def delete_requirement(req_id: str):
         raise
         raise
     except Exception as e:
     except Exception as e:
         raise HTTPException(status_code=500, detail=str(e))
         raise HTTPException(status_code=500, detail=str(e))
+
+
+@app.post("/api/pattern/posts/batch")
+async def proxy_pattern_posts_batch(payload: PostBatchRequest):
+    """代理帖子批量查询,避免前端直接请求外部域名失败后静默回退为纯 ID。"""
+    post_ids = [pid for pid in payload.post_ids if pid]
+    if not post_ids:
+        return {"success": True, "posts": []}
+
+    try:
+        async with httpx.AsyncClient(timeout=30.0) as client:
+            resp = await client.post(
+                "https://pattern.aiddit.com/api/pattern/posts/batch",
+                json={"post_ids": post_ids},
+            )
+        resp.raise_for_status()
+        return resp.json()
+    except httpx.HTTPStatusError as e:
+        raise HTTPException(status_code=e.response.status_code, detail="Pattern posts API returned an error")
+    except Exception as e:
+        raise HTTPException(status_code=502, detail=f"Failed to fetch pattern posts: {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 =====
 # ===== Relation API =====
 
 
 @app.get("/api/relation/{table_name}")
 @app.get("/api/relation/{table_name}")
@@ -2458,9 +2526,17 @@ def serve_category_tree():
         return {"error": "Not Found"}
         return {"error": "Not Found"}
     return FileResponse(str(tree_file))
     return FileResponse(str(tree_file))
 
 
-@app.get("/{full_path:path}")
-def frontend(full_path: str):
-    """KnowHub 管理前端 — 所有非 API 路径都返回 index.html,由 React Router 处理"""
+
+@app.get("/{frontend_path:path}")
+def frontend_spa_fallback(frontend_path: str):
+    """SPA 路由兜底:将非 API 的前端子路径回退到 index.html,由 React Router 处理。"""
+    if frontend_path.startswith("api/") or frontend_path.startswith("assets/"):
+        raise HTTPException(status_code=404, detail="Not Found")
+
+    # 带扩展名的路径按静态文件处理,不走 SPA fallback。
+    if "." in Path(frontend_path).name:
+        raise HTTPException(status_code=404, detail="Not Found")
+
     index_file = STATIC_DIR / "index.html"
     index_file = STATIC_DIR / "index.html"
     if not index_file.exists():
     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 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)

+ 16 - 0
scratch_db_caps.py

@@ -0,0 +1,16 @@
+import os
+from dotenv import load_dotenv
+load_dotenv("/root/Agent/.env")
+
+import psycopg2
+conn = psycopg2.connect(
+    host=os.getenv('KNOWHUB_DB'),
+    port=int(os.getenv('KNOWHUB_PORT', 5432)),
+    user=os.getenv('KNOWHUB_USER'),
+    password=os.getenv('KNOWHUB_PASSWORD'),
+    database=os.getenv('KNOWHUB_DB_NAME')
+)
+cursor = conn.cursor()
+cursor.execute("SELECT capability_id, COUNT(*) FROM capability_knowledge GROUP BY capability_id ORDER BY COUNT(*) DESC LIMIT 10")
+for row in cursor.fetchall():
+    print(row)

+ 21 - 0
scratch_db_test.py

@@ -0,0 +1,21 @@
+import os
+from dotenv import load_dotenv
+load_dotenv("/root/Agent/.env")
+
+import sys
+sys.path.append("/root/Agent")
+from knowhub.knowhub_db.pg_store import PostgreSQLStore
+
+store = PostgreSQLStore()
+print("Total knowledge:", store.count())
+
+try:
+    print("\n--- Searching for CAP-000 ---")
+    results = store.query("id != ''", limit=10, relation_filters={"capability_id": "CAP-000"})
+    print("CAP-000 count:", len(results))
+    
+    print("\n--- Searching for cap_0 ---")
+    results2 = store.query("id != ''", limit=10, relation_filters={"capability_id": "cap_0"})
+    print("cap_0 count:", len(results2))
+except Exception as e:
+    print("Error:", e)

+ 21 - 0
scratch_db_test_case.py

@@ -0,0 +1,21 @@
+import asyncio
+import os
+from dotenv import load_dotenv
+load_dotenv("/root/Agent/.env")
+
+import sys
+sys.path.append("/root/Agent")
+from knowhub.knowhub_db.pg_store import PostgreSQLStore
+
+store = PostgreSQLStore()
+
+try:
+    print("--- Searching for CAP-001 (Uppercase) ---")
+    results = store.query("id != ''", limit=10, relation_filters={"capability_id": "CAP-001"})
+    print("CAP-001 count:", len(results))
+    
+    print("\n--- Searching for cap-001 (Lowercase) ---")
+    results2 = store.query("id != ''", limit=10, relation_filters={"capability_id": "cap-001"})
+    print("cap-001 count:", len(results2))
+except Exception as e:
+    print("Error:", e)

+ 46 - 0
scratch_search_test.py

@@ -0,0 +1,46 @@
+import urllib.request, urllib.parse, json
+
+# First test: exactly what user provided
+params1 = urllib.parse.urlencode({
+    "q": "散景 浅景深 逆光 光斑 背景虚化 轮廓光",
+    "capability_id": "CAP-001",
+    "types": "strategy,case,tool",
+    "top_k": 10,
+    "min_score": 3
+})
+try:
+    req1 = urllib.request.Request(f'http://localhost:8000/api/knowledge/search?{params1}')
+    with urllib.request.urlopen(req1) as f:
+        print('Search 1 (all types): count =', json.loads(f.read().decode('utf-8')).get('count', 0))
+except Exception as e:
+    print('Search 1 error:', e)
+
+# Second test: only one type
+params2 = urllib.parse.urlencode({
+    "q": "散景 浅景深 逆光 光斑 背景虚化 轮廓光",
+    "capability_id": "CAP-001",
+    "types": "case",
+    "top_k": 10,
+    "min_score": 3
+})
+try:
+    req2 = urllib.request.Request(f'http://localhost:8000/api/knowledge/search?{params2}')
+    with urllib.request.urlopen(req2) as f:
+        print('Search 2 (single type case): count =', json.loads(f.read().decode('utf-8')).get('count', 0))
+except Exception as e:
+    print('Search 2 error:', e)
+
+# Third test: no types filter
+params3 = urllib.parse.urlencode({
+    "q": "散景 浅景深 逆光 光斑 背景虚化 轮廓光",
+    "capability_id": "CAP-001",
+    "top_k": 10,
+    "min_score": 3
+})
+try:
+    req3 = urllib.request.Request(f'http://localhost:8000/api/knowledge/search?{params3}')
+    with urllib.request.urlopen(req3) as f:
+        print('Search 3 (no types filter): count =', json.loads(f.read().decode('utf-8')).get('count', 0))
+except Exception as e:
+    print('Search 3 error:', e)
+

+ 18 - 0
scratch_test.py

@@ -0,0 +1,18 @@
+import asyncio
+import httpx
+import json
+
+async def main():
+    async with httpx.AsyncClient(timeout=30.0) as client:
+        # test search API
+        res = await client.get("http://localhost:8000/api/knowledge/search", params={"q": "test", "capability_id": "cap_0", "min_score": 1, "top_k": 5})
+        print("Search Response:")
+        print(json.dumps(res.json(), indent=2, ensure_ascii=False))
+
+        # test directly getting relations from DB or testing relation API
+        res2 = await client.get("http://localhost:8000/api/relation/capability_knowledge", params={"capability_id": "cap_0"})
+        print("Relation Response:")
+        print(json.dumps(res2.json(), indent=2, ensure_ascii=False))
+
+if __name__ == "__main__":
+    asyncio.run(main())