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

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

guantao 16 часов назад
Родитель
Сommit
3b7d7bbaa0

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

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

+ 4 - 2
knowhub/agents/librarian.py

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

+ 33 - 3
knowhub/agents/librarian_agent.prompt

@@ -10,7 +10,12 @@ $system$
 你是一个知识库管理员。你有两项核心职责:
 
 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$
 

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

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

@@ -1,20 +1,30 @@
 import { useState } from 'react';
 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 {
   node: any;
   onSelect: (node: any) => void;
+  onDoubleClick: (node: any) => void;
   selectedId: string | number | null;
   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 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;
   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-600", border: "border-slate-700", text: "text-white" }
     ],
-    // High contrast ranges for other categories to make count distinguishable
     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: [
+      { 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-300", border: "border-green-400", text: "text-green-900" },
       { 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-700", border: "border-green-800", 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];
-  let bgColor = theme.bg;
-  let borderColor = theme.border;
   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 (
-    <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 */}
-      <div 
+      <div
         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)}
+        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>
         {node.id && <span className="text-[10px] opacity-60 mr-2 font-mono">{node.id}</span>}
-        
+
         {/* Count Pill */}
         <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>
@@ -90,134 +104,157 @@ function HorizontalTreeNode({ node, onSelect, selectedId, level }: NodeProps) {
         </div>
 
         {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); }}
           >
-            {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>
         )}
       </div>
 
-      {/* Children Container (Horizontal Layout Connections) */}
+      {/* Children */}
       {expanded && hasChildren && (
         <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>
   );
 }
 
-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);
+
   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>
-      加载树形结构中... (请确保已重跑 python server.py)
+      加载树形结构中...
     </div>
   );
 
   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>
   );
 }

+ 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';
 
-export type TabId = 'dashboard' | 'relations' | 'requirements' | 'capabilities' | 'tools' | 'knowledge';
+export type TabId = 'dashboard' | 'requirements' | 'capabilities' | 'tools' | 'knowledge';
 
 interface NavbarProps {
   activeTab: TabId;
@@ -10,7 +10,6 @@ interface NavbarProps {
 
 const TABS = [
   { id: 'dashboard', label: 'Dashboard', icon: Home },
-  { id: 'relations', label: '关系表', icon: Waypoints },
   { id: 'requirements', label: '需求库', icon: Target },
   { id: 'capabilities', label: '能力库', icon: Cpu },
   { id: 'tools', label: '工具库', icon: Wrench },

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

@@ -8,8 +8,8 @@ interface MainLayoutProps {
   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;
 
 export function MainLayout({ activeTab, onTabChange, children }: MainLayoutProps) {
@@ -98,7 +98,7 @@ export function MainLayout({ activeTab, onTabChange, children }: MainLayoutProps
           {TAB_ORDER.map((tab) => (
             <div key={tab} className="shrink-0 h-full overflow-y-auto" style={{ width: `${100 / totalTabs}%` }}>
               <div className="flex justify-center pb-12">
-                <div className="w-full max-w-[1600px] px-6 py-6">
+                <div className="w-full px-6 py-6">
                   {children(tab)}
                 </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 { SideDrawer } from '../components/common/SideDrawer';
 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 (
-    <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>
-      {open && <div className="pl-1 mb-8">{children}</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 (
-    <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 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>,
+    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>
   );
 }
 
+// ─── 业务需求帖子聚合抽屉 ──────────────────────────────────────────────────────
+
+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 }) {
-  type NavItem = { type: 'node' | 'req' | 'cap' | 'tool' | 'know', data: any };
   const [treeData, setTreeData] = useState<any>(null);
   const [selectedNode, setSelectedNode] = useState<any>(null);
-  const [navStack, setNavStack] = useState<NavItem[]>([]);
   const [nameToNodeMap, setNameToNodeMap] = useState<Record<string, any>>({});
   const [dbData, setDbData] = useState<{ reqs: any[], caps: any[], tools: any[], know: any[] }>({
     reqs: [], caps: [], tools: [], know: []
   });
+  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(() => {
     if (pendingNode && nameToNodeMap[pendingNode]) {
       setSelectedNode(nameToNodeMap[pendingNode]);
@@ -85,207 +658,120 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
     }
   }, [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[] => {
     let leaves: any[] = [];
     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;
   };
 
+  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(() => {
     async function loadStats() {
       try {
-        // 1. Fetch Tree
         const treeRes = await fetch('/category_tree.json');
         const data = await treeRes.json();
         setTreeData(data);
         const leaves = getLeafNodes([data]);
 
-        // 2. Fetch associations
         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: [] };
-        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 caps = capRes.results || [];
         const tools = toolRes.results || [];
         const know = knowRes.results || [];
-
         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 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]);
         setNameToNodeMap(nameToNode);
 
+        const nodeToReqs: Record<string, any[]> = {};
+        leaves.forEach(l => { nodeToReqs[l.name] = []; });
+
         reqs.forEach((r: any) => {
           (r.source_nodes || []).forEach((sn: any) => {
             const nodeName = typeof sn === 'object' ? (sn.node_name || sn.name) : sn;
             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 => {
           const attachedReqs = nodeToReqs[l.name];
           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 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 => {
           const count = l.total_posts_count || 0;
           totalWeight += count;
           const attachedReqs = nodeToReqs[l.name];
-          let isToolCovered = false;
-          let isVerified = false;
-
           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({
-          totalLeaves: totalLeavesCount,
-          reqCoveredNodes,
-          reqCoveragePerc: Number(reqCoveragePerc.toFixed(1)),
+          totalLeaves: totalLeavesCount, reqCoveredNodes,
+          reqCoveragePerc: Number((totalLeavesCount > 0 ? reqCoveredNodes / totalLeavesCount * 100 : 0).toFixed(1)),
           toolCoveredNodes,
-          toolCoveragePerc: Number(toolCoveragePerc.toFixed(1)),
+          toolCoveragePerc: Number((reqCoveredNodes > 0 ? toolCoveredNodes / reqCoveredNodes * 100 : 0).toFixed(1)),
           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,
-          toolWeightedCoveragePerc: Number(toolWeightedCoveragePerc.toFixed(1)),
+          toolWeightedCoveragePerc: Number((coveredWeight > 0 ? toolCoveredWeight / coveredWeight * 100 : 0).toFixed(1)),
           verifiedPostsCnt: verifiedWeight,
-          verifiedWeightedCoveragePerc: Number(verifiedWeightedCoveragePerc.toFixed(1))
+          verifiedWeightedCoveragePerc: Number((totalWeight > 0 ? verifiedWeight / totalWeight * 100 : 0).toFixed(1))
         });
-
       } catch (err) {
         console.error("Failed to load dashboard stats", err);
       }
@@ -293,301 +779,437 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
     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 (
-    <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 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 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>
 
-        {/* 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>
-
-            {/* 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>
+
+      {/* 详情抽屉 */}
+      <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>
   );
 }

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

@@ -66,4 +66,27 @@ export const getTags = async () => {
   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;

+ 137 - 21
knowhub/internal_tools/cache_manager.py

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

+ 224 - 0
knowhub/internal_tools/capability_extractor.py

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

+ 84 - 8
knowhub/server.py

@@ -16,6 +16,7 @@ from contextlib import asynccontextmanager
 from datetime import datetime, timezone
 from typing import Optional, List, Dict
 from pathlib import Path
+import httpx
 from cryptography.hazmat.primitives.ciphers.aead import AESGCM
 
 from fastapi import FastAPI, HTTPException, Query, Header, Body, BackgroundTasks, Request
@@ -211,6 +212,10 @@ class ResourcePatchIn(BaseModel):
     metadata: Optional[dict] = None
 
 
+class PostBatchRequest(BaseModel):
+    post_ids: List[str] = Field(default_factory=list)
+
+
 # Knowledge Models
 class KnowledgeIn(BaseModel):
     task: str
@@ -980,6 +985,15 @@ class KnowledgeAskResponse(BaseModel):
     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):
     data: dict  # {tools, resources, knowledge}
     trace_id: str  # 必填:调用方的 trace_id
@@ -1004,6 +1018,24 @@ async def ask_knowledge_api(req: KnowledgeAskRequest):
         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)
 async def upload_knowledge_api(req: KnowledgeUploadRequest, background_tasks: BackgroundTasks):
     """
@@ -1108,8 +1140,11 @@ async def search_knowledge_api(
         filters = []
         if types:
             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:
             owner_list = [o.strip() for o in owner.split(',') if o.strip()]
             if len(owner_list) == 1:
@@ -1253,11 +1288,14 @@ def list_knowledge(
         # 构建过滤表达式
         filters = []
 
-        # types 支持多个,用 AND 连接(交集:必须同时包含所有选中的type
+        # types 支持多个,改为用 OR 连接(并集:包含任意选中 type 即可
         if types:
             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:
             filters.append(f'array_contains(scopes, "{scopes}")')
@@ -2399,6 +2437,36 @@ def delete_requirement(req_id: str):
         raise
     except Exception as 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 =====
 
 @app.get("/api/relation/{table_name}")
@@ -2458,9 +2526,17 @@ def serve_category_tree():
         return {"error": "Not Found"}
     return FileResponse(str(tree_file))
 
-@app.get("/{full_path:path}")
-def frontend(full_path: str):
-    """KnowHub 管理前端 — 所有非 API 路径都返回 index.html,由 React Router 处理"""
+
+@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"
     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)

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