Procházet zdrojové kódy

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

guantao před 12 hodinami
rodič
revize
5df7e9f1ae
28 změnil soubory, kde provedl 2862 přidání a 17 odebrání
  1. 10 0
      agent/tools/builtin/feishu/chat_history/chat_谭景玉.json
  2. 160 0
      agent/trace/api.py
  3. 27 0
      agent/trace/store.py
  4. 2 0
      api_server.py
  5. 50 0
      examples/content_tree_analyst/analyst.prompt
  6. 34 0
      examples/content_tree_analyst/config.py
  7. 39 0
      examples/content_tree_analyst/docs/requirements.md
  8. 740 0
      examples/content_tree_analyst/docs/内容树API.md
  9. 15 0
      examples/content_tree_analyst/docs/频繁项集API.md
  10. 9 0
      examples/content_tree_analyst/presets.json
  11. 221 0
      examples/content_tree_analyst/run.py
  12. 1 0
      examples/content_tree_analyst/tools/__init__.py
  13. 167 0
      examples/content_tree_analyst/tools/content_tree.py
  14. 99 0
      examples/content_tree_analyst/tools/frequent_itemsets.py
  15. 1 1
      frontend/react-template/package.json
  16. 20 0
      frontend/react-template/src/App.tsx
  17. 29 0
      frontend/react-template/src/api/traceApi.ts
  18. 138 0
      frontend/react-template/src/components/CreateKnowledgeModal/CreateKnowledgeModal.module.css
  19. 153 0
      frontend/react-template/src/components/CreateKnowledgeModal/CreateKnowledgeModal.tsx
  20. 24 0
      frontend/react-template/src/components/DetailPanel/DetailPanel.module.css
  21. 65 0
      frontend/react-template/src/components/DetailPanel/DetailPanel.tsx
  22. 28 4
      frontend/react-template/src/components/FlowChart/FlowChart.tsx
  23. 255 0
      frontend/react-template/src/components/KnowledgeFeedbackModal/KnowledgeFeedbackModal.module.css
  24. 305 0
      frontend/react-template/src/components/KnowledgeFeedbackModal/KnowledgeFeedbackModal.tsx
  25. 5 2
      frontend/react-template/src/components/MainContent/MainContent.tsx
  26. 54 0
      frontend/react-template/src/components/TopBar/TopBar.tsx
  27. 33 0
      frontend/react-template/src/types/goal.ts
  28. 178 10
      knowhub/server.py

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

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

+ 160 - 0
agent/trace/api.py

@@ -4,9 +4,14 @@ Trace RESTful API
 提供 Trace、GoalTree、Message 的查询接口
 """
 
+import os
+import json
+import httpx
+from datetime import datetime, timezone
 from typing import List, Optional, Dict, Any
 from fastapi import APIRouter, HTTPException, Query
 from pydantic import BaseModel
+from agent.llm.openrouter import openrouter_llm_call
 
 from .protocols import TraceStore
 
@@ -171,3 +176,158 @@ async def get_messages(
     return MessagesResponse(
         messages=[m.to_dict() for m in messages]
     )
+
+
+# ===== 知识反馈 =====
+
+
+class KnowledgeFeedbackItem(BaseModel):
+    knowledge_id: str
+    action: str  # "confirm" | "override" | "skip"
+    eval_status: Optional[str] = None  # helpful | harmful | unused | irrelevant | neutral
+    feedback_text: Optional[str] = None
+    source: Dict[str, Any] = {}  # {trace_id, goal_id, sequence, feedback_by, feedback_at}
+
+
+class KnowledgeFeedbackRequest(BaseModel):
+    feedback_list: List[KnowledgeFeedbackItem]
+
+
+@router.get("/{trace_id}/knowledge_log")
+async def get_knowledge_log(trace_id: str):
+    """获取 Trace 的知识注入日志"""
+    store = get_trace_store()
+    trace = await store.get_trace(trace_id)
+    if not trace:
+        raise HTTPException(status_code=404, detail="Trace not found")
+    return await store.get_knowledge_log(trace_id)
+
+
+@router.post("/{trace_id}/knowledge_feedback")
+async def submit_knowledge_feedback(trace_id: str, req: KnowledgeFeedbackRequest):
+    """提交知识使用反馈,同步更新 knowledge_log.json 并转发到 KnowHub"""
+    store = get_trace_store()
+    trace = await store.get_trace(trace_id)
+    if not trace:
+        raise HTTPException(status_code=404, detail="Trace not found")
+
+    knowhub_url = os.getenv("KNOWHUB_API") or os.getenv("KNOWHUB_URL", "http://localhost:9999")
+    updated_count = 0
+    now_iso = datetime.now(timezone.utc).isoformat()
+
+    async with httpx.AsyncClient(timeout=10.0) as client:
+        for item in req.feedback_list:
+            if item.action == "skip":
+                continue
+
+            # 1. 记录到 knowledge_log.json
+            feedback_record = {
+                "action": item.action,
+                "eval_status": item.eval_status,
+                "feedback_text": item.feedback_text,
+                "feedback_by": item.source.get("feedback_by", "user"),
+                "feedback_at": item.source.get("feedback_at", now_iso),
+            }
+            await store.update_user_feedback(trace_id, item.knowledge_id, feedback_record)
+
+            # 2. 构建 history 条目(含完整溯源信息)
+            history_entry = {
+                "source": "user",
+                "action": item.action,
+                "eval_status": item.eval_status,
+                "feedback_by": item.source.get("feedback_by", "user"),
+                "feedback_at": now_iso,
+                "trace_id": trace_id,
+                "goal_id": item.source.get("goal_id"),
+                "sequence": item.source.get("sequence"),
+                "feedback_text": item.feedback_text,
+            }
+
+            # 3. 根据 action 和 eval_status 决定调用 KnowHub 的哪个字段
+            if item.action == "confirm":
+                payload = {"add_helpful_case": history_entry}
+            elif item.action == "override":
+                if item.eval_status == "harmful":
+                    payload = {"add_harmful_case": history_entry}
+                else:
+                    # helpful / unused / irrelevant / neutral → 记为 helpful_case,history 内保留完整 eval_status
+                    payload = {"add_helpful_case": history_entry}
+            else:
+                continue
+
+            try:
+                await client.put(
+                    f"{knowhub_url}/api/knowledge/{item.knowledge_id}",
+                    json=payload
+                )
+                updated_count += 1
+            except Exception as e:
+                # 记录警告但不中断整体提交
+                print(f"[KnowledgeFeedback] KnowHub 更新失败 {item.knowledge_id}: {e}")
+
+    return {"status": "ok", "updated": updated_count}
+
+
+@router.post("/extract_comment", status_code=201)
+async def extract_comment_proxy(req: Dict[str, Any]):
+    """调用 LLM 从评论提取结构化知识,再 POST 到远端 KnowHub /api/knowledge"""
+    comment = (req.get("comment") or "").strip()
+    if not comment:
+        raise HTTPException(status_code=400, detail="comment is required")
+
+    context = req.get("context") or ""
+    prompt = f"""你是知识提取专家。根据用户的评论和 Agent 执行上下文,提取一条结构化知识。
+
+【上下文(Agent 执行内容)】:
+{context or "(无上下文)"}
+
+【用户评论】:
+{comment}
+
+【输出格式】(严格 JSON,不要其他内容):
+{{
+  "task": "任务场景描述(一句话,描述在什么情况下要完成什么目标)",
+  "content": "核心知识内容(具体可操作的方法、注意事项)"
+}}"""
+
+    try:
+        response = await openrouter_llm_call(
+            messages=[{"role": "user", "content": prompt}],
+            model="google/gemini-2.5-flash-lite",
+        )
+        raw = response.get("content", "").strip()
+        if "```" in raw:
+            for part in raw.split("```"):
+                part = part.strip().lstrip("json").strip()
+                try:
+                    parsed = json.loads(part)
+                    if "task" in parsed and "content" in parsed:
+                        raw = part
+                        break
+                except Exception:
+                    continue
+        extracted = json.loads(raw)
+        task = extracted.get("task", "").strip()
+        content = extracted.get("content", "").strip()
+        if not task or not content:
+            raise ValueError("missing task or content")
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=f"LLM 提取失败: {e}")
+
+    knowhub_url = os.getenv("KNOWHUB_API") or os.getenv("KNOWHUB_URL", "http://localhost:9999")
+    payload = {
+        "task": task,
+        "content": content,
+        "types": req.get("types", ["strategy"]),
+        "scopes": req.get("scopes", ["org:cybertogether"]),
+        "owner": req.get("owner", "user"),
+        "source": req.get("source", {}),
+    }
+    async with httpx.AsyncClient(timeout=15.0) as client:
+        try:
+            resp = await client.post(f"{knowhub_url}/api/knowledge", json=payload)
+            resp.raise_for_status()
+            data = resp.json()
+            return {"status": "pending", "knowledge_id": data.get("id", ""), "task": task, "content": content}
+        except Exception as e:
+            raise HTTPException(status_code=502, detail=f"KnowHub 写入失败: {e}")

+ 27 - 0
agent/trace/store.py

@@ -839,3 +839,30 @@ class FileSystemTraceStore:
         """获取所有待评估的知识条目"""
         log = await self.get_knowledge_log(trace_id)
         return [e for e in log["entries"] if e["eval_result"] is None]
+
+    async def update_user_feedback(
+        self,
+        trace_id: str,
+        knowledge_id: str,
+        user_feedback: Dict[str, Any]
+    ) -> None:
+        """记录用户对知识的反馈(confirm/override),不覆盖 agent 的 eval_result
+
+        当同一个 knowledge_id 被多次注入时,更新最近一次注入的条目。
+        """
+        log = await self.get_knowledge_log(trace_id)
+
+        # 找到所有匹配的条目(不限 eval_result 是否为 None)
+        matching_entries = [
+            (i, entry) for i, entry in enumerate(log["entries"])
+            if entry["knowledge_id"] == knowledge_id
+        ]
+
+        if matching_entries:
+            # 按 injected_at_sequence 倒序,取最近一次注入的条目
+            matching_entries.sort(key=lambda x: x[1]["injected_at_sequence"], reverse=True)
+            idx, entry = matching_entries[0]
+            entry["user_feedback"] = user_feedback
+
+        log_file = self._get_knowledge_log_file(trace_id)
+        log_file.write_text(json.dumps(log, indent=2, ensure_ascii=False), encoding="utf-8")

+ 2 - 0
api_server.py

@@ -11,6 +11,8 @@ API Server - FastAPI 应用入口
 import logging
 import json
 import os
+from dotenv import load_dotenv
+load_dotenv()
 from fastapi import FastAPI, Request, WebSocket
 from fastapi.middleware.cors import CORSMiddleware
 import uvicorn

+ 50 - 0
examples/content_tree_analyst/analyst.prompt

@@ -0,0 +1,50 @@
+---
+model: qwen3.5-plus
+temperature: 0.3
+---
+
+$system$
+
+## 角色
+你是内容制作需求分析专家,擅长从内容树节点结构中归纳出有价值的图文内容制作需求。
+
+## 工作流程
+
+### 第一步:获取节点局部结构
+给定一个内容树节点 id(category 或 element),调用 `search_content_tree` 或 `get_category_tree` 获取:
+- **祖先路径**(`include_ancestors=true`):了解该节点的上下文和所属维度
+- **同级节点**:搜索同名关键词或父节点的直接子节点,了解同类
+- **子孙节点**(`descendant_depth=2`):了解该节点的细分方向
+
+### 第二步:判断与图文制作的相关性
+结合节点的名称、描述、所属维度(实质/形式/意图),判断哪些节点与**图文内容制作**直接相关:
+- **保留**:与视觉呈现、角色设计、场景构图、风格表达、情感传达等制作行为直接相关的节点
+- **过滤**:纯语义/主题分类节点(如"节日"、"品牌"等不涉及制作手法的节点)
+
+### 第三步:获取关联频繁项集
+对筛选出的重要节点,调用 `get_frequent_itemsets` 获取关联要素:
+- 传入节点的 `entity_id`(搜索接口返回的 `entity_id` 字段)
+- 频繁项集揭示了在优质内容中经常与该节点共同出现的要素
+- 用这些关联要素扩展需求的覆盖范围(如"动作姿态"→"夸张"、"运动"等)
+
+### 第四步:归纳制作需求
+对每组相关节点,归纳出若干条制作能力或工具需求:
+- **粒度适中**:不能太细("生成猫咪"),也不能太粗("生成图像")
+- **正确示例**:"需要能够生成保持角色一致性的人物图像的能力"
+- **同批需求不重叠**:不同需求应覆盖不同的制作维度,而且最好是对应到不同的工具
+
+### 第五步:输出结构化需求
+将归纳结果写入 `%output_dir%/requirements.md`,每条需求包含:
+- 需求描述(自然语言)
+- 来源节点 id 列表
+- 相关频繁项集 id(若有)
+- 所属维度(实质/形式/意图)
+
+$user$
+请对以下内容树节点进行制作需求归纳分析:
+
+stable_id:334
+source_type:形式
+
+请按照工作流程,逐步分析该节点及其周边结构,最终将结构化的制作需求列表输出到 %output_dir%/requirements.md。
+注意分析出来的需求不可以彼此之间有显著重叠;最好是有所区分的不同能力、需要不同工具支撑的能力。

+ 34 - 0
examples/content_tree_analyst/config.py

@@ -0,0 +1,34 @@
+"""
+项目配置
+"""
+
+from agent.core.runner import RunConfig
+from agent.tools.builtin.knowledge import KnowledgeConfig
+
+
+RUN_CONFIG = RunConfig(
+    model="qwen3.5-plus",
+    temperature=0.3,
+    max_iterations=500,
+    extra_llm_params={"extra_body": {"enable_thinking": True}},
+    agent_type="analyst",
+    name="内容树需求归纳",
+
+    knowledge=KnowledgeConfig(
+        enable_extraction=False,
+        enable_completion_extraction=False,
+        enable_injection=False,
+        owner="sunlit.howard@gmail.com",
+        default_tags={"project": "gen_query_from_content_tree"},
+        default_scopes=["org:cybertogether"],
+        # default_search_types=["strategy"],
+        default_search_owner="sunlit.howard@gmail.com",
+    ),
+)
+
+OUTPUT_DIR = "examples/content_tree_analyst/output"
+SKILLS_DIR = "./skills"
+TRACE_STORE_PATH = ".trace"
+DEBUG = True
+LOG_LEVEL = "INFO"
+LOG_FILE = None

+ 39 - 0
examples/content_tree_analyst/docs/requirements.md

@@ -0,0 +1,39 @@
+任务1:从内容树节点归纳制作需求                                                                      
+                                                                                               
+  - 输入:内容树中某个节点的 id(category 或 element)
+  - 过程:                                
+    a. 调用搜索 API
+  获取该节点的局部结构:祖先路径(了解上下文)、同级节点(了解同类)、子孙节点(了解细分)             
+    b. 结合节点的名称、描述、所属维度(实质/形式/意图),判断哪些节点与图文内容制作直接相关(过滤掉与制
+  作无关的纯语义/主题类节点)                                                                          
+    c. 对筛选出的重要节点,进一步获取其关联的频繁项集(通过get_frequent_itemsets接口,每次传入一个category_ids;频繁项集中可以看到经常与指定节点在优质内容中共同出现的要素,比如能给“动作姿态”节点扩展到“夸张”“运动”等关联要素,从而能提出更准的需求)
+    d. 对每一组节点,归纳出一个制作能力或工具需求(如"需要能够生成保持角色一致性的人物图像的能力")
+  - 输出:若干条结构化的制作需求,每条包含:  
+    - 需求描述(自然语言)                
+    - 来源节点 id 列表(支撑这条需求的节点)
+    - 相关频繁项集id(若有)
+    - 所属维度(实质/形式/意图)                                                                       
+  - 约束:                                
+    - 需求粒度不能太细(不是"生成猫咪"),也不能太粗(不是"生成图像")                                 
+    - 同一批输出的需求之间应尽量不重叠                                                                 
+                                          
+  ---                                                                                                  
+  任务2:从制作能力/工具关联内容树节点                                                                 
+   
+  - 输入:一条制作能力或工具知识(包含名称、描述、适用场景)                                           
+  - 过程:                                                        
+    a. 从知识的名称和适用场景中提取关键词,调用搜索 API 在内容树中检索相关节点
+    b. 对返回的节点按相关度评分,过滤掉低相关节点
+    c. 对保留的节点,标注关联类型:
+        - 直接关联:该能力/工具直接用于制作包含此节点的内容
+        - 间接关联:该能力/工具是制作此类内容的前置步骤或辅助手段
+    d. 将 节点 id - 知识 id - 关联类型 - 置信度 写入映射表
+  - 输出:一批映射记录,每条包含:
+    - 节点 id + 节点名称                      
+    - 知识 id                             
+    - 关联类型(直接/间接)
+    - 置信度(高/中/低)                                                                               
+  - 约束:
+    - 低置信度的映射需标记,待后续实验验证后升级或删除                                                 
+    - 同一个节点可以关联多条知识,需支持后续按置信度排序检索      
+                                          

+ 740 - 0
examples/content_tree_analyst/docs/内容树API.md

@@ -0,0 +1,740 @@
+# 搜索 API 文档
+
+本文档包含三个搜索相关的 API 接口:
+
+1. **关键词搜索** - 根据关键词搜索分类和元素
+2. **获取分类树** - 获取指定分类的完整路径和子树
+3. **获取全量元素** - 分页浏览元素,支持排序和筛选
+
+---
+
+## 接口 1:关键词搜索
+
+### 接口地址
+
+```
+GET http://8.147.104.190:8001/api/search
+```
+
+### 功能说明
+
+搜索全局分类库中的分类(Category)和元素(Element),支持文本匹配、上下文扩展、平台筛选等功能。
+
+## 请求参数
+
+| 参数名 | 类型 | 必填 | 默认值 | 说明 |
+|--------|------|------|--------|------|
+| q | string | ✓ | - | 搜索关键词 |
+| source_type | string | ✓ | - | 元素类型:`实质` / `形式` / `意图` |
+| entity_type | string | ✗ | all | 搜索对象类型:`category`(分类)/ `element`(元素)/ `all`(全部) |
+| top_k | integer | ✗ | 20 | 返回结果数量,范围 1-100 |
+| use_description | boolean | ✗ | false | 是否在描述字段中搜索(true=搜索名称+描述,false=仅搜索名称) |
+| mode | string | ✗ | text | 搜索模式:`text`(文本匹配)/ `vector`(向量搜索)/ `hybrid`(混合),当前仅支持 text |
+| include_ancestors | boolean | ✗ | false | 是否返回祖先路径(从根节点到父节点的完整路径) |
+| descendant_depth | integer | ✗ | 0 | 返回 N 代以内的子孙节点,0=不返回,1=直接子节点,2=子节点+孙节点... |
+| platform | string | ✗ | null | 按平台筛选,仅对元素有效(如:`小红书`、`抖音`、`微博` 等) |
+
+## 返回格式
+
+```json
+{
+  "success": true,
+  "query": "搜索关键词",
+  "source_type": "实质",
+  "entity_type": "all",
+  "count": 3,
+  "results": [
+    {
+      "entity_type": "category",
+      "entity_id": 123,
+      "stable_id": 456,
+      "name": "分类名称",
+      "description": "分类描述",
+      "path": "/一级分类/二级分类",
+      "category_nature": "领域层级",
+      "level": 2,
+      "score": 0.95,
+      "scores": {
+        "text": 0.95,
+        "vector": 0.0
+      },
+      "ancestors": [...],
+      "descendants": [...]
+    },
+    {
+      "entity_type": "element",
+      "entity_id": 789,
+      "name": "元素名称",
+      "description": "元素描述",
+      "belong_category_stable_id": 456,
+      "occurrence_count": 25,
+      "score": 0.88,
+      "scores": {
+        "text": 0.88,
+        "vector": 0.0
+      }
+    }
+  ]
+}
+```
+
+### 返回字段说明
+
+#### 通用字段
+- `success`: 请求是否成功
+- `query`: 搜索关键词
+- `source_type`: 元素类型
+- `entity_type`: 搜索对象类型
+- `count`: 返回结果数量
+- `results`: 结果列表
+
+#### 结果对象字段(分类 Category)
+- `entity_type`: 固定为 "category"
+- `entity_id`: 分类数据库 ID
+- `stable_id`: 分类稳定 ID(用于跨版本引用)
+- `name`: 分类名称
+- `description`: 分类描述
+- `path`: 分类路径(如 `/主体/角色类型/人物角色`)
+- `category_nature`: 分类性质(领域层级/元描述层级)
+- `level`: 层级深度(1=根节点)
+- `score`: 综合相似度分数(0-1)
+- `scores`: 各维度分数
+- `ancestors`: 祖先路径(当 `include_ancestors=true` 时返回)
+- `descendants`: 子孙节点(当 `descendant_depth>0` 时返回)
+
+#### 结果对象字段(元素 Element)
+- `entity_type`: 固定为 "element"
+- `entity_id`: 元素数据库 ID
+- `name`: 元素名称
+- `description`: 元素描述
+- `belong_category_stable_id`: 所属分类的 stable_id
+- `occurrence_count`: 出现次数
+- `score`: 综合相似度分数(0-1)
+- `scores`: 各维度分数
+
+#### ancestors 字段结构
+```json
+[
+  {
+    "stable_id": 1,
+    "name": "主体",
+    "level": 1
+  },
+  {
+    "stable_id": 10,
+    "name": "角色类型",
+    "level": 2
+  }
+]
+```
+
+#### descendants 字段结构
+```json
+[
+  {
+    "stable_id": 500,
+    "name": "人物角色",
+    "level": 3,
+    "depth_from_parent": 1,
+    "is_leaf": false
+  },
+  {
+    "stable_id": 501,
+    "name": "动物角色",
+    "level": 3,
+    "depth_from_parent": 1,
+    "is_leaf": true
+  }
+]
+```
+
+---
+
+## 使用示例
+
+### 1. 基础搜索
+
+#### 搜索所有(分类+元素)
+```bash
+curl -G "http://8.147.104.190:8001/api/search" \
+  --data-urlencode "q=角色" \
+  --data-urlencode "source_type=实质"
+```
+
+#### 只搜索分类
+```bash
+curl -G "http://8.147.104.190:8001/api/search" \
+  --data-urlencode "q=主体" \
+  --data-urlencode "source_type=实质" \
+  --data-urlencode "entity_type=category"
+```
+
+#### 只搜索元素
+```bash
+curl -G "http://8.147.104.190:8001/api/search" \
+  --data-urlencode "q=猫咪" \
+  --data-urlencode "source_type=形式" \
+  --data-urlencode "entity_type=element"
+```
+
+#### 限制返回数量
+```bash
+curl -G "http://8.147.104.190:8001/api/search" \
+  --data-urlencode "q=角色" \
+  --data-urlencode "source_type=实质" \
+  --data-urlencode "top_k=5"
+```
+
+### 2. 扩展搜索范围
+
+#### 搜索名称+描述
+```bash
+curl -G "http://8.147.104.190:8001/api/search" \
+  --data-urlencode "q=拟人" \
+  --data-urlencode "source_type=形式" \
+  --data-urlencode "use_description=true"
+```
+
+### 3. 获取上下文信息
+
+#### 返回祖先路径
+```bash
+curl -G "http://8.147.104.190:8001/api/search" \
+  --data-urlencode "q=人物角色" \
+  --data-urlencode "source_type=实质" \
+  --data-urlencode "entity_type=category" \
+  --data-urlencode "include_ancestors=true"
+```
+
+#### 返回1代子孙节点
+```bash
+curl -G "http://8.147.104.190:8001/api/search" \
+  --data-urlencode "q=主体" \
+  --data-urlencode "source_type=实质" \
+  --data-urlencode "entity_type=category" \
+  --data-urlencode "descendant_depth=1"
+```
+
+#### 返回2代子孙节点
+```bash
+curl -G "http://8.147.104.190:8001/api/search" \
+  --data-urlencode "q=主体" \
+  --data-urlencode "source_type=实质" \
+  --data-urlencode "entity_type=category" \
+  --data-urlencode "descendant_depth=2"
+```
+
+#### 同时返回祖先+子孙
+```bash
+curl -G "http://8.147.104.190:8001/api/search" \
+  --data-urlencode "q=角色类型" \
+  --data-urlencode "source_type=实质" \
+  --data-urlencode "entity_type=category" \
+  --data-urlencode "include_ancestors=true" \
+  --data-urlencode "descendant_depth=2"
+```
+
+### 4. 平台筛选
+
+#### 只搜索小红书平台的元素
+```bash
+curl -G "http://8.147.104.190:8001/api/search" \
+  --data-urlencode "q=角色" \
+  --data-urlencode "source_type=实质" \
+  --data-urlencode "entity_type=element" \
+  --data-urlencode "platform=小红书"
+```
+
+#### 搜索抖音平台的元素
+```bash
+curl -G "http://8.147.104.190:8001/api/search" \
+  --data-urlencode "q=主体" \
+  --data-urlencode "source_type=形式" \
+  --data-urlencode "platform=抖音"
+```
+
+#### 平台筛选+描述搜索
+```bash
+curl -G "http://8.147.104.190:8001/api/search" \
+  --data-urlencode "q=拟人" \
+  --data-urlencode "source_type=形式" \
+  --data-urlencode "platform=小红书" \
+  --data-urlencode "use_description=true"
+```
+
+### 5. 组合查询
+
+#### 全功能组合
+```bash
+curl -G "http://8.147.104.190:8001/api/search" \
+  --data-urlencode "q=角色" \
+  --data-urlencode "source_type=实质" \
+  --data-urlencode "entity_type=all" \
+  --data-urlencode "top_k=10" \
+  --data-urlencode "use_description=true" \
+  --data-urlencode "include_ancestors=true" \
+  --data-urlencode "descendant_depth=1" \
+  --data-urlencode "platform=小红书"
+```
+
+#### 不同 source_type 的搜索
+```bash
+# 搜索意图维度
+curl -G "http://8.147.104.190:8001/api/search" \
+  --data-urlencode "q=情感" \
+  --data-urlencode "source_type=意图"
+
+# 搜索形式维度
+curl -G "http://8.147.104.190:8001/api/search" \
+  --data-urlencode "q=视觉" \
+  --data-urlencode "source_type=形式"
+```
+
+### 6. 浏览器直接访问
+
+```
+http://8.147.104.190:8001/api/search?q=角色&source_type=实质
+http://8.147.104.190:8001/api/search?q=主体&source_type=实质&entity_type=category&include_ancestors=true&descendant_depth=2
+http://8.147.104.190:8001/api/search?q=猫咪&source_type=形式&platform=小红书
+```
+
+### 7. FastAPI 交互式文档
+
+访问以下地址可以在网页上直接测试 API:
+
+```
+http://8.147.104.190:8001/docs
+```
+
+在文档页面找到 `/api/search` 接口,点击 "Try it out" 按钮,填写参数后点击 "Execute" 即可测试。
+
+---
+
+## 返回示例
+
+### 示例 1:基础搜索
+
+**请求:**
+```bash
+curl -G "http://8.147.104.190:8001/api/search" \
+  --data-urlencode "q=角色" \
+  --data-urlencode "source_type=实质"
+```
+
+**响应:**
+```json
+{
+  "success": true,
+  "query": "角色",
+  "source_type": "实质",
+  "entity_type": "all",
+  "count": 2,
+  "results": [
+    {
+      "entity_type": "category",
+      "entity_id": 123,
+      "stable_id": 456,
+      "name": "角色类型",
+      "description": "内容主体的角色分类",
+      "path": "/主体/角色类型",
+      "category_nature": "领域层级",
+      "level": 2,
+      "score": 0.95,
+      "scores": {
+        "text": 0.95,
+        "vector": 0.0
+      }
+    },
+    {
+      "entity_type": "element",
+      "entity_id": 789,
+      "name": "人物角色",
+      "description": "真实或虚拟的人物形象",
+      "belong_category_stable_id": 456,
+      "occurrence_count": 25,
+      "score": 0.88,
+      "scores": {
+        "text": 0.88,
+        "vector": 0.0
+      }
+    }
+  ]
+}
+```
+
+### 示例 2:带祖先和子孙的搜索
+
+**请求:**
+```bash
+curl -G "http://8.147.104.190:8001/api/search" \
+  --data-urlencode "q=角色类型" \
+  --data-urlencode "source_type=实质" \
+  --data-urlencode "entity_type=category" \
+  --data-urlencode "include_ancestors=true" \
+  --data-urlencode "descendant_depth=2"
+```
+
+**响应:**
+```json
+{
+  "success": true,
+  "query": "角色类型",
+  "source_type": "实质",
+  "entity_type": "category",
+  "count": 1,
+  "results": [
+    {
+      "entity_type": "category",
+      "entity_id": 123,
+      "stable_id": 456,
+      "name": "角色类型",
+      "description": "内容主体的角色分类",
+      "path": "/主体/角色类型",
+      "category_nature": "领域层级",
+      "level": 2,
+      "score": 1.0,
+      "scores": {
+        "text": 1.0,
+        "vector": 0.0
+      },
+      "ancestors": [
+        {
+          "stable_id": 1,
+          "name": "主体",
+          "level": 1
+        }
+      ],
+      "descendants": [
+        {
+          "stable_id": 500,
+          "name": "人物角色",
+          "level": 3,
+          "depth_from_parent": 1,
+          "is_leaf": false
+        },
+        {
+          "stable_id": 501,
+          "name": "动物角色",
+          "level": 3,
+          "depth_from_parent": 1,
+          "is_leaf": false
+        },
+        {
+          "stable_id": 600,
+          "name": "真人角色",
+          "level": 4,
+          "depth_from_parent": 2,
+          "is_leaf": true
+        },
+        {
+          "stable_id": 601,
+          "name": "虚拟角色",
+          "level": 4,
+          "depth_from_parent": 2,
+          "is_leaf": true
+        }
+      ]
+    }
+  ]
+}
+```
+
+### 示例 3:平台筛选
+
+**请求:**
+```bash
+curl -G "http://8.147.104.190:8001/api/search" \
+  --data-urlencode "q=猫咪" \
+  --data-urlencode "source_type=形式" \
+  --data-urlencode "entity_type=element" \
+  --data-urlencode "platform=小红书"
+```
+
+**响应:**
+```json
+{
+  "success": true,
+  "query": "猫咪",
+  "source_type": "形式",
+  "entity_type": "element",
+  "count": 3,
+  "results": [
+    {
+      "entity_type": "element",
+      "entity_id": 1001,
+      "name": "猫咪",
+      "description": "可爱的猫咪形象",
+      "belong_category_stable_id": 200,
+      "occurrence_count": 15,
+      "score": 1.0,
+      "scores": {
+        "text": 1.0,
+        "vector": 0.0
+      }
+    },
+    {
+      "entity_type": "element",
+      "entity_id": 1002,
+      "name": "猫咪拟人",
+      "description": "拟人化的猫咪角色",
+      "belong_category_stable_id": 201,
+      "occurrence_count": 8,
+      "score": 0.85,
+      "scores": {
+        "text": 0.85,
+        "vector": 0.0
+      }
+    }
+  ]
+}
+```
+
+---
+
+## 错误处理
+
+### 错误响应格式
+
+```json
+{
+  "detail": "错误信息描述"
+}
+```
+
+### 常见错误
+
+| HTTP 状态码 | 错误原因 | 解决方法 |
+|------------|---------|---------|
+| 400 | 缺少必填参数(q 或 source_type) | 检查请求参数 |
+| 400 | source_type 值不合法 | 使用 `实质`、`形式` 或 `意图` |
+| 400 | entity_type 值不合法 | 使用 `category`、`element` 或 `all` |
+| 400 | mode 不支持 | 当前仅支持 `text` 模式 |
+| 500 | 服务器内部错误 | 联系技术支持 |
+
+---
+
+## 注意事项
+
+1. **中文参数编码**:使用 curl 时,中文参数需要用 `--data-urlencode` 进行 URL 编码
+2. **平台筛选限制**:`platform` 参数仅对元素(element)有效,对分类(category)无效
+3. **性能考虑**:
+   - `use_description=true` 会增加搜索范围,可能降低精确度
+   - `descendant_depth` 越大,返回数据越多,响应时间越长
+   - 建议 `top_k` 不超过 50
+4. **搜索模式**:当前仅支持 `text` 模式,`vector` 和 `hybrid` 模式将在后续版本中支持
+5. **平台名称**:`platform` 参数值需要与数据库中的平台名称完全一致(区分大小写)
+
+---
+
+## 接口 2:获取分类树
+
+### 接口地址
+
+```
+GET http://8.147.104.190:8001/api/search/category/{stable_id}
+```
+
+### 功能说明
+
+获取指定分类的完整路径和子树结构,用于导航和展示分类层级关系。
+
+### 请求参数
+
+| 参数名 | 类型 | 必填 | 默认值 | 说明 |
+|--------|------|------|--------|------|
+| stable_id | integer | ✓ | - | 分类的 stable_id(路径参数) |
+| source_type | string | ✓ | - | 元素类型:`实质` / `形式` / `意图` |
+| include_ancestors | boolean | ✗ | true | 是否返回祖先路径(从根节点到当前节点) |
+| descendant_depth | integer | ✗ | -1 | 返回子孙深度,-1=全部,0=仅当前节点,1=子节点,2=子+孙... |
+
+### 返回格式
+
+```json
+{
+  "success": true,
+  "current": {
+    "stable_id": 125,
+    "name": "吉祥用语",
+    "description": "通用的吉祥话语和祝福词句",
+    "path": "/表象/符号/表达符号/祝福语/吉祥用语",
+    "level": 5
+  },
+  "ancestors": [
+    {
+      "stable_id": 38,
+      "name": "表象",
+      "path": "/表象",
+      "level": 1
+    },
+    {
+      "stable_id": 40,
+      "name": "符号",
+      "path": "/表象/符号",
+      "level": 2
+    }
+  ],
+  "descendants": [
+    {
+      "stable_id": 126,
+      "name": "新年祝福",
+      "description": "新年相关的祝福语",
+      "path": "/表象/符号/表达符号/祝福语/吉祥用语/新年祝福",
+      "level": 6,
+      "children": []
+    }
+  ]
+}
+```
+
+### 使用示例
+
+#### 获取分类的完整路径和所有子孙
+```bash
+curl -G "http://8.147.104.190:8001/api/search/category/125" \
+  --data-urlencode "source_type=实质" \
+  --data-urlencode "include_ancestors=true" \
+  --data-urlencode "descendant_depth=-1"
+```
+
+#### 只获取当前节点和祖先路径
+```bash
+curl -G "http://8.147.104.190:8001/api/search/category/125" \
+  --data-urlencode "source_type=实质" \
+  --data-urlencode "include_ancestors=true" \
+  --data-urlencode "descendant_depth=0"
+```
+
+#### 获取2代子孙节点
+```bash
+curl -G "http://8.147.104.190:8001/api/search/category/125" \
+  --data-urlencode "source_type=实质" \
+  --data-urlencode "descendant_depth=2"
+```
+
+---
+
+## 接口 3:获取全量元素数据
+
+### 接口地址
+
+```
+GET http://8.147.104.190:8001/api/search/elements
+```
+
+### 功能说明
+
+获取全量元素数据,支持分页、排序、筛选。主要用于获取高频元素、按分类浏览元素等场景。
+
+### 请求参数
+
+| 参数名 | 类型 | 必填 | 默认值 | 说明 |
+|--------|------|------|--------|------|
+| source_type | string | ✓ | - | 元素类型:`实质` / `形式` / `意图` |
+| page | integer | ✗ | 1 | 页码(从1开始) |
+| page_size | integer | ✗ | 50 | 每页数量(1-200) |
+| sort_by | string | ✗ | occurrence_count | 排序字段:`occurrence_count` / `name` / `id` |
+| order | string | ✗ | desc | 排序方向:`asc` / `desc` |
+| category_stable_id | integer | ✗ | null | 按分类筛选(可选) |
+| platform | string | ✗ | null | 按平台筛选(可选) |
+| min_occurrence | integer | ✗ | null | 最小出现次数(可选) |
+
+### 返回格式
+
+```json
+{
+  "success": true,
+  "source_type": "实质",
+  "page": 1,
+  "page_size": 50,
+  "total": 4082,
+  "total_pages": 82,
+  "results": [
+    {
+      "id": 45,
+      "name": "祝福语",
+      "description": "'吉祥如意'、'幸福安康'等文字内容",
+      "occurrence_count": 974,
+      "element_sub_type": "具象概念",
+      "category": {
+        "stable_id": 125,
+        "name": "吉祥用语",
+        "path": "/表象/符号/表达符号/祝福语/吉祥用语"
+      }
+    }
+  ]
+}
+```
+
+### 使用示例
+
+#### 获取高频元素前50(默认)
+```bash
+curl -G "http://8.147.104.190:8001/api/search/elements" \
+  --data-urlencode "source_type=实质"
+```
+
+#### 获取高频元素前10
+```bash
+curl -G "http://8.147.104.190:8001/api/search/elements" \
+  --data-urlencode "source_type=实质" \
+  --data-urlencode "page=1" \
+  --data-urlencode "page_size=10" \
+  --data-urlencode "sort_by=occurrence_count" \
+  --data-urlencode "order=desc"
+```
+
+#### 按名称排序
+```bash
+curl -G "http://8.147.104.190:8001/api/search/elements" \
+  --data-urlencode "source_type=形式" \
+  --data-urlencode "sort_by=name" \
+  --data-urlencode "order=asc"
+```
+
+#### 筛选指定分类下的元素
+```bash
+curl -G "http://8.147.104.190:8001/api/search/elements" \
+  --data-urlencode "source_type=实质" \
+  --data-urlencode "category_stable_id=125"
+```
+
+#### 筛选出现次数>=10的元素
+```bash
+curl -G "http://8.147.104.190:8001/api/search/elements" \
+  --data-urlencode "source_type=实质" \
+  --data-urlencode "min_occurrence=10"
+```
+
+#### 按平台筛选元素
+```bash
+curl -G "http://8.147.104.190:8001/api/search/elements" \
+  --data-urlencode "source_type=形式" \
+  --data-urlencode "platform=小红书"
+```
+
+#### 组合筛选:指定分类+最小出现次数
+```bash
+curl -G "http://8.147.104.190:8001/api/search/elements" \
+  --data-urlencode "source_type=实质" \
+  --data-urlencode "category_stable_id=125" \
+  --data-urlencode "min_occurrence=50" \
+  --data-urlencode "page_size=20"
+```
+
+#### 分页浏览
+```bash
+# 第1页
+curl -G "http://8.147.104.190:8001/api/search/elements" \
+  --data-urlencode "source_type=实质" \
+  --data-urlencode "page=1" \
+  --data-urlencode "page_size=50"
+
+# 第2页
+curl -G "http://8.147.104.190:8001/api/search/elements" \
+  --data-urlencode "source_type=实质" \
+  --data-urlencode "page=2" \
+  --data-urlencode "page_size=50"
+```
+
+---
+

+ 15 - 0
examples/content_tree_analyst/docs/频繁项集API.md

@@ -0,0 +1,15 @@
+  curl 'https://pattern.aiddit.com/api/pattern/tools/get_frequent_itemsets/execute' \
+  -H 'Accept: */*' \
+  -H 'Accept-Language: zh-CN,zh;q=0.9' \
+  -H 'Connection: keep-alive' \
+  -H 'Content-Type: application/json' \
+  -H 'Origin: https://pattern.aiddit.com' \
+  -H 'Referer: https://pattern.aiddit.com/execution/33' \
+  -H 'Sec-Fetch-Dest: empty' \
+  -H 'Sec-Fetch-Mode: cors' \
+  -H 'Sec-Fetch-Site: same-origin' \
+  -H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36' \
+  -H 'sec-ch-ua: "Not:A-Brand";v="99", "Google Chrome";v="145", "Chromium";v="145"' \
+  -H 'sec-ch-ua-mobile: ?0' \
+  -H 'sec-ch-ua-platform: "macOS"' \
+  --data-raw '{"execution_id":33,"args":{"top_n":20,"category_ids":[378],"sort_by":"absolute_support"}}'

+ 9 - 0
examples/content_tree_analyst/presets.json

@@ -0,0 +1,9 @@
+{
+  "analyst": {
+    "system_prompt_file": "analyst.prompt",
+    "max_iterations": 200,
+    "temperature": 0.3,
+    "skills": ["planning"],
+    "description": "内容树需求归纳 Agent"
+  }
+}

+ 221 - 0
examples/content_tree_analyst/run.py

@@ -0,0 +1,221 @@
+"""
+内容树需求归纳 Agent
+
+从内容树节点归纳制作需求(任务1)。
+
+用法:
+  python run.py                    # 直接运行(任务在 analyst.prompt 中配置)
+  python run.py --trace <TRACE_ID> # 恢复已有 trace
+"""
+
+import argparse
+import os
+import sys
+import asyncio
+from pathlib import Path
+
+os.environ.setdefault("no_proxy", "*")
+
+sys.path.insert(0, str(Path(__file__).parent.parent.parent))
+
+from dotenv import load_dotenv
+load_dotenv()
+
+from agent.llm.prompts import SimplePrompt
+from agent.core.runner import AgentRunner, RunConfig
+from agent.trace import FileSystemTraceStore, Trace, Message
+from agent.llm import create_qwen_llm_call
+from agent.cli import InteractiveController
+from agent.utils import setup_logging
+
+# 注册自定义工具
+from tools.content_tree import search_content_tree, get_category_tree
+from tools.frequent_itemsets import get_frequent_itemsets
+
+from config import RUN_CONFIG, SKILLS_DIR, TRACE_STORE_PATH, DEBUG, LOG_LEVEL, LOG_FILE, OUTPUT_DIR
+
+
+async def main():
+    parser = argparse.ArgumentParser(description="内容树需求归纳 Agent")
+    parser.add_argument("--trace", type=str, default=None, help="已有 Trace ID,用于恢复继续执行")
+    args = parser.parse_args()
+
+    base_dir = Path(__file__).parent
+    project_root = base_dir.parent.parent
+    output_dir = project_root / OUTPUT_DIR
+    output_dir.mkdir(parents=True, exist_ok=True)
+
+    setup_logging(level=LOG_LEVEL, file=LOG_FILE)
+
+    # 加载 presets
+    presets_path = base_dir / "presets.json"
+    if presets_path.exists():
+        from agent.core.presets import load_presets_from_json
+        load_presets_from_json(str(presets_path))
+        print("已加载 presets")
+
+    # 构建任务消息(直接从 analyst.prompt 加载,无变量替换)
+    if not args.trace:
+        prompt = SimplePrompt(base_dir / "analyst.prompt")
+        messages = prompt.build_messages(output_dir=OUTPUT_DIR)
+    else:
+        messages = None
+
+    # 创建 Runner
+    store = FileSystemTraceStore(base_path=TRACE_STORE_PATH)
+    runner = AgentRunner(
+        trace_store=store,
+        llm_call=create_qwen_llm_call(model=RUN_CONFIG.model),
+        skills_dir=SKILLS_DIR,
+        debug=DEBUG,
+    )
+
+    interactive = InteractiveController(runner=runner, store=store, enable_stdin_check=True)
+    runner.stdin_check = interactive.check_stdin
+
+    print("=" * 60)
+    print("内容树需求归纳 Agent")
+    print("=" * 60)
+    print("💡 输入 'p' 暂停,'q' 退出")
+    print("=" * 60)
+
+    run_config = RUN_CONFIG
+    resume_trace_id = args.trace
+    current_trace_id = resume_trace_id
+    current_sequence = 0
+    should_exit = False
+    final_response = ""
+
+    try:
+        if resume_trace_id:
+            existing = await store.get_trace(resume_trace_id)
+            if not existing:
+                print(f"错误: Trace 不存在: {resume_trace_id}")
+                sys.exit(1)
+            run_config.trace_id = resume_trace_id
+            print(f"恢复 Trace: {resume_trace_id[:8]}...")
+
+        while not should_exit:
+            if current_trace_id:
+                run_config.trace_id = current_trace_id
+
+            # 恢复模式:先进交互菜单
+            if current_trace_id and messages is None:
+                check_trace = await store.get_trace(current_trace_id)
+                if check_trace:
+                    current_sequence = check_trace.head_sequence
+                    menu_result = await interactive.show_menu(current_trace_id, current_sequence)
+                    if menu_result["action"] == "stop":
+                        break
+                    elif menu_result["action"] == "continue":
+                        new_msgs = menu_result.get("messages", [])
+                        messages = new_msgs if new_msgs else []
+                        run_config.after_sequence = menu_result.get("after_sequence")
+                        continue
+                break
+
+            if messages is None:
+                messages = []
+
+            print("▶️ 开始执行...")
+            paused = False
+
+            try:
+                async for item in runner.run(messages=messages, config=run_config):
+                    cmd = interactive.check_stdin()
+                    if cmd == "pause":
+                        print("\n⏸️ 暂停中...")
+                        if current_trace_id:
+                            await runner.stop(current_trace_id)
+                        await asyncio.sleep(0.5)
+                        menu_result = await interactive.show_menu(current_trace_id, current_sequence)
+                        if menu_result["action"] == "stop":
+                            should_exit = True
+                            paused = True
+                            break
+                        elif menu_result["action"] == "continue":
+                            new_msgs = menu_result.get("messages", [])
+                            messages = new_msgs if new_msgs else []
+                            run_config.after_sequence = menu_result.get("after_sequence")
+                            paused = True
+                            break
+                    elif cmd == "quit":
+                        print("\n🛑 停止...")
+                        if current_trace_id:
+                            await runner.stop(current_trace_id)
+                        should_exit = True
+                        break
+
+                    if isinstance(item, Trace):
+                        current_trace_id = item.trace_id
+                        if item.status == "running":
+                            print(f"[Trace] 开始: {item.trace_id[:8]}...")
+                        elif item.status == "completed":
+                            print(f"\n[Trace] ✅ 完成 | messages={item.total_messages} | cost=${item.total_cost:.4f}")
+                        elif item.status == "failed":
+                            print(f"\n[Trace] ❌ 失败: {item.error_message}")
+
+                    elif isinstance(item, Message):
+                        current_sequence = item.sequence
+                        if item.role == "assistant":
+                            content = item.content
+                            if isinstance(content, dict):
+                                text = content.get("text", "")
+                                tool_calls = content.get("tool_calls")
+                                if text and not tool_calls:
+                                    final_response = text
+                                    print(f"\n[Response]\n{text}")
+                                elif text:
+                                    preview = text[:150] + "..." if len(text) > 150 else text
+                                    print(f"[Assistant] {preview}")
+                        elif item.role == "tool":
+                            content = item.content
+                            tool_name = content.get("tool_name", "unknown") if isinstance(content, dict) else "unknown"
+                            desc = item.description or ""
+                            if desc and desc != tool_name:
+                                print(f"[Tool] ✅ {tool_name}: {desc[:80]}")
+                            else:
+                                print(f"[Tool] ✅ {tool_name}")
+
+            except Exception as e:
+                print(f"\n执行出错: {e}")
+                import traceback
+                traceback.print_exc()
+
+            if paused:
+                if should_exit:
+                    break
+                continue
+
+            if should_exit:
+                break
+
+            if current_trace_id:
+                menu_result = await interactive.show_menu(current_trace_id, current_sequence)
+                if menu_result["action"] == "stop":
+                    break
+                elif menu_result["action"] == "continue":
+                    new_msgs = menu_result.get("messages", [])
+                    messages = new_msgs if new_msgs else []
+                    run_config.after_sequence = menu_result.get("after_sequence")
+                    continue
+            break
+
+    except KeyboardInterrupt:
+        print("\n用户中断 (Ctrl+C)")
+        if current_trace_id:
+            await runner.stop(current_trace_id)
+
+    # 保存最终结果
+    if final_response:
+        result_file = output_dir / "result.txt"
+        result_file.write_text(final_response, encoding="utf-8")
+        print(f"\n✓ 结果已保存: {result_file}")
+
+    if current_trace_id:
+        print(f"\nTrace ID: {current_trace_id}")
+        print("可视化: python3 api_server.py → http://localhost:8000/api/traces")
+
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 1 - 0
examples/content_tree_analyst/tools/__init__.py

@@ -0,0 +1 @@
+# tools/__init__.py

+ 167 - 0
examples/content_tree_analyst/tools/content_tree.py

@@ -0,0 +1,167 @@
+"""
+内容树 API 工具
+
+封装内容树搜索接口:
+1. search_content_tree - 关键词搜索分类和元素
+2. get_category_tree - 获取指定分类的完整路径和子树
+"""
+
+import logging
+from typing import Optional
+
+import httpx
+
+from agent.tools import tool
+from agent.tools.models import ToolResult
+
+logger = logging.getLogger(__name__)
+
+BASE_URL = "http://8.147.104.190:8001"
+
+
+@tool(description="在内容树中搜索分类(category)和元素(element),支持获取祖先路径和子孙节点")
+async def search_content_tree(
+    q: str,
+    source_type: str,
+    entity_type: str = "all",
+    top_k: int = 20,
+    use_description: bool = False,
+    include_ancestors: bool = False,
+    descendant_depth: int = 0,
+) -> ToolResult:
+    """
+    关键词搜索内容树中的分类和元素。
+
+    Args:
+        q: 搜索关键词
+        source_type: 维度,必须是 "实质" / "形式" / "意图" 之一
+        entity_type: 搜索对象类型,"category" / "element" / "all"(默认)
+        top_k: 返回结果数量,1-100(默认20)
+        use_description: 是否同时搜索描述字段(默认仅搜索名称)
+        include_ancestors: 是否返回祖先路径
+        descendant_depth: 返回子孙节点深度,0=不返回,1=直接子节点,2=子+孙...
+    """
+    params = {
+        "q": q,
+        "source_type": source_type,
+        "entity_type": entity_type,
+        "top_k": top_k,
+        "use_description": str(use_description).lower(),
+        "include_ancestors": str(include_ancestors).lower(),
+        "descendant_depth": descendant_depth,
+    }
+
+    try:
+        async with httpx.AsyncClient(timeout=30.0) as client:
+            resp = await client.get(f"{BASE_URL}/api/search", params=params)
+            resp.raise_for_status()
+            data = resp.json()
+
+        count = data.get("count", 0)
+        results = data.get("results", [])
+
+        # 格式化输出
+        lines = [f"搜索「{q}」({source_type}维度)共找到 {count} 条结果:\n"]
+        for r in results:
+            etype = r.get("entity_type", "")
+            name = r.get("name", "")
+            score = r.get("score", 0)
+            if etype == "category":
+                sid = r.get("stable_id", "")
+                path = r.get("path", "")
+                desc = r.get("description", "")
+                lines.append(f"[分类] stable_id={sid} | {path} | score={score:.2f}")
+                if desc:
+                    lines.append(f"  描述: {desc}")
+                ancestors = r.get("ancestors", [])
+                if ancestors:
+                    anc_names = " > ".join(a["name"] for a in ancestors)
+                    lines.append(f"  祖先: {anc_names}")
+                descendants = r.get("descendants", [])
+                if descendants:
+                    desc_names = ", ".join(d["name"] for d in descendants[:10])
+                    lines.append(f"  子孙({len(descendants)}): {desc_names}")
+            else:
+                eid = r.get("entity_id", "")
+                belong = r.get("belong_category_stable_id", "")
+                occ = r.get("occurrence_count", 0)
+                lines.append(f"[元素] entity_id={eid} | {name} | belong_category={belong} | 出现次数={occ} | score={score:.2f}")
+                edesc = r.get("description", "")
+                if edesc:
+                    lines.append(f"  描述: {edesc}")
+            lines.append("")
+
+        return ToolResult(
+            title=f"内容树搜索: {q} ({source_type}) → {count} 条",
+            output="\n".join(lines),
+        )
+
+    except httpx.HTTPError as e:
+        return ToolResult(title="内容树搜索失败", output=f"HTTP 错误: {e}")
+    except Exception as e:
+        logger.exception("search_content_tree error")
+        return ToolResult(title="内容树搜索失败", output=f"错误: {e}")
+
+
+@tool(description="获取指定分类节点的完整路径、祖先和子孙结构(通过 stable_id 精确查询)")
+async def get_category_tree(
+    stable_id: int,
+    source_type: str,
+    include_ancestors: bool = True,
+    descendant_depth: int = -1,
+) -> ToolResult:
+    """
+    获取指定分类的完整路径和子树结构。
+
+    Args:
+        stable_id: 分类的 stable_id
+        source_type: 维度,"实质" / "形式" / "意图"
+        include_ancestors: 是否返回祖先路径(默认 True)
+        descendant_depth: 子孙深度,-1=全部,0=仅当前,1=子节点,2=子+孙...
+    """
+    params = {
+        "source_type": source_type,
+        "include_ancestors": str(include_ancestors).lower(),
+        "descendant_depth": descendant_depth,
+    }
+
+    try:
+        async with httpx.AsyncClient(timeout=30.0) as client:
+            resp = await client.get(f"{BASE_URL}/api/search/category/{stable_id}", params=params)
+            resp.raise_for_status()
+            data = resp.json()
+
+        current = data.get("current", {})
+        ancestors = data.get("ancestors", [])
+        descendants = data.get("descendants", [])
+
+        lines = []
+        lines.append(f"分类节点: {current.get('name', '')} (stable_id={stable_id})")
+        lines.append(f"路径: {current.get('path', '')}")
+        if current.get("description"):
+            lines.append(f"描述: {current['description']}")
+        lines.append("")
+
+        if ancestors:
+            lines.append("祖先路径:")
+            for a in ancestors:
+                lines.append(f"  L{a.get('level', '?')} {a.get('name', '')} (stable_id={a.get('stable_id', '')})")
+            lines.append("")
+
+        if descendants:
+            lines.append(f"子孙节点 ({len(descendants)} 个):")
+            for d in descendants:
+                indent = "  " * d.get("depth_from_parent", 1)
+                leaf_mark = " [叶]" if d.get("is_leaf") else ""
+                lines.append(f"{indent}L{d.get('level', '?')} {d.get('name', '')} (stable_id={d.get('stable_id', '')}){leaf_mark}")
+
+        return ToolResult(
+            title=f"分类树: {current.get('name', stable_id)} (stable_id={stable_id})",
+            output="\n".join(lines),
+        )
+
+    except httpx.HTTPError as e:
+        return ToolResult(title="获取分类树失败", output=f"HTTP 错误: {e}")
+    except Exception as e:
+        logger.exception("get_category_tree error")
+        return ToolResult(title="获取分类树失败", output=f"错误: {e}")

+ 99 - 0
examples/content_tree_analyst/tools/frequent_itemsets.py

@@ -0,0 +1,99 @@
+"""
+频繁项集 API 工具
+
+封装 pattern.aiddit.com 的频繁项集接口,用于查询与指定分类节点
+在优质内容中共同出现的关联要素。
+"""
+
+import logging
+
+import httpx
+
+from agent.tools import tool
+from agent.tools.models import ToolResult
+
+logger = logging.getLogger(__name__)
+
+ITEMSETS_URL = "https://pattern.aiddit.com/api/pattern/tools/get_frequent_itemsets/execute"
+
+HEADERS = {
+    "Accept": "*/*",
+    "Accept-Language": "zh-CN,zh;q=0.9",
+    "Connection": "keep-alive",
+    "Content-Type": "application/json",
+    "Origin": "https://pattern.aiddit.com",
+    "Referer": "https://pattern.aiddit.com/execution/33",
+    "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36",
+}
+
+
+@tool(description="查询与指定分类节点在优质内容中共同出现的频繁项集(关联要素),用于扩展制作需求的关联维度")
+async def get_frequent_itemsets(
+    entity_ids: list,
+    top_n: int = 20,
+    execution_id: int = 33,
+    sort_by: str = "absolute_support",
+) -> ToolResult:
+    """
+    获取与指定分类节点关联的频繁项集。
+
+    Args:
+        entity_ids: 分类节点的 entity_id 列表(即搜索接口返回的 entity_id 字段,非 stable_id)
+        top_n: 返回前 N 个项集,默认 20
+        execution_id: 执行 ID,默认 33
+        sort_by: 排序字段,默认 "absolute_support"
+    """
+    payload = {
+        "execution_id": execution_id,
+        "args": {
+            "top_n": top_n,
+            "category_ids": entity_ids,
+            "sort_by": sort_by,
+        },
+    }
+
+    try:
+        import json as _json
+        async with httpx.AsyncClient(timeout=30.0) as client:
+            resp = await client.post(ITEMSETS_URL, json=payload, headers=HEADERS)
+            resp.raise_for_status()
+            outer = resp.json()
+
+        # result 字段是 JSON 字符串,需要二次解析
+        data = _json.loads(outer["result"])
+        total = data.get("total", 0)
+        groups = data.get("groups", {})
+
+        # 收集所有 group 下的 itemsets
+        all_itemsets = []
+        for group_key, group in groups.items():
+            for itemset in group.get("itemsets", []):
+                itemset["_group"] = group_key
+                all_itemsets.append(itemset)
+
+        lines = [f"频繁项集查询 entity_ids={entity_ids},共 {total} 条,返回 {len(all_itemsets)} 条:\n"]
+        for i, itemset in enumerate(all_itemsets, 1):
+            itemset_id = itemset.get("id", "")
+            item_count = itemset.get("item_count", "")
+            support = itemset.get("support", 0)
+            abs_support = itemset.get("absolute_support", "")
+            lines.append(f"{i}. 项集ID={itemset_id} | 项数={item_count} | support={support:.4f} | abs={abs_support}")
+            for elem in itemset.get("items", []):
+                dim = elem.get("dimension", "")
+                path = elem.get("category_path", "")
+                ename = elem.get("element_name") or ""
+                label = f"{path}({ename})" if ename else path
+                lines.append(f"   [{dim}] {label}")
+            lines.append("")
+
+        return ToolResult(
+            title=f"频繁项集: entity_ids={entity_ids} → {total} 条",
+            output="\n".join(lines),
+        )
+        return ToolResult(
+            title="频繁项集查询失败",
+            output=f"HTTP {e.response.status_code}: {e.response.text[:200]}",
+        )
+    except Exception as e:
+        logger.exception("get_frequent_itemsets error")
+        return ToolResult(title="频繁项集查询失败", output=f"错误: {e}")

+ 1 - 1
frontend/react-template/package.json

@@ -11,7 +11,7 @@
   },
   "dependencies": {
     "@douyinfe/semi-icons": "^2.56.0",
-    "@douyinfe/semi-ui": "^2.56.0",
+    "@douyinfe/semi-ui": "^2.92.2",
     "axios": "^1.6.0",
     "d3": "^7.8.5",
     "jszip": "^3.10.1",

+ 20 - 0
frontend/react-template/src/App.tsx

@@ -5,6 +5,7 @@ import { DetailPanel } from "./components/DetailPanel/DetailPanel";
 
 import type { Goal } from "./types/goal";
 import type { Edge, Message } from "./types/message";
+import type { FlowChartRef } from "./components/FlowChart/FlowChart";
 import { useFlowChartData } from "./components/FlowChart/hooks/useFlowChartData";
 import "./styles/global.css";
 
@@ -19,6 +20,7 @@ function App() {
   const [refreshTrigger, setRefreshTrigger] = useState(0);
   const [messageRefreshTrigger, setMessageRefreshTrigger] = useState(0);
   const bodyRef = useRef<HTMLDivElement | null>(null);
+  const flowChartRef = useRef<FlowChartRef>(null);
 
   // 获取数据以传递给 DetailPanel
   const { msgGroups } = useFlowChartData(selectedTraceId, messageRefreshTrigger);
@@ -33,6 +35,21 @@ function App() {
     setSelectedEdge(null);
   };
 
+  const handleNavigateToMessage = (goalId: string, sequence: number) => {
+    // 先在对应 goal 的 group 里找
+    let target = (msgGroups[goalId] ?? []).find((m) => m.sequence === sequence);
+    // 兜底:跨所有 group 搜索
+    if (!target) {
+      for (const msgs of Object.values(msgGroups)) {
+        target = msgs.find((m) => m.sequence === sequence);
+        if (target) break;
+      }
+    }
+    if (target) handleNodeClick(target);
+    // 顺滑滚动到节点
+    flowChartRef.current?.scrollToSequence(sequence);
+  };
+
   const isGoalNode = (node: Goal | Message): node is Goal => "status" in node && "created_at" in node;
 
   // 根据选中的节点获取对应的消息
@@ -84,6 +101,7 @@ function App() {
           }}
           onTraceCreated={() => setRefreshTrigger((t) => t + 1)}
           onMessageInserted={() => setMessageRefreshTrigger((t) => t + 1)}
+          onNavigateToMessage={handleNavigateToMessage}
         />
       </div>
       <div
@@ -101,6 +119,7 @@ function App() {
             }}
             refreshTrigger={refreshTrigger}
             messageRefreshTrigger={messageRefreshTrigger}
+            flowChartRef={flowChartRef}
           />
         </div>
         {(selectedNode || selectedEdge) && (
@@ -123,6 +142,7 @@ function App() {
                 edge={selectedEdge}
                 messages={selectedMessages as Message[]}
                 onClose={handleCloseDetail}
+                traceId={selectedTraceId ?? undefined}
               />
             </div>
           </>

+ 29 - 0
frontend/react-template/src/api/traceApi.ts

@@ -1,5 +1,20 @@
 import { request } from "./client";
 import type { TraceDetailResponse, TraceListResponse } from "../types/trace";
+import type { KnowledgeLogEntry, FeedbackAction } from "../types/goal";
+
+export interface KnowledgeFeedbackListItem {
+  knowledge_id: string;
+  action: FeedbackAction;
+  eval_status?: string;
+  feedback_text?: string;
+  source: {
+    trace_id: string;
+    goal_id?: string;
+    sequence?: number;
+    feedback_by: string;
+    feedback_at: string;
+  };
+}
 
 export const traceApi = {
   fetchTraces(params?: { status?: string; mode?: string; limit?: number }) {
@@ -86,4 +101,18 @@ export const traceApi = {
       },
     });
   },
+  fetchKnowledgeLog(traceId: string) {
+    return request<{ trace_id: string; entries: KnowledgeLogEntry[] }>(
+      `/api/traces/${traceId}/knowledge_log`
+    );
+  },
+  submitKnowledgeFeedback(
+    traceId: string,
+    data: { feedback_list: KnowledgeFeedbackListItem[] }
+  ) {
+    return request<{ status: string; updated: number }>(
+      `/api/traces/${traceId}/knowledge_feedback`,
+      { method: "POST", data }
+    );
+  },
 };

+ 138 - 0
frontend/react-template/src/components/CreateKnowledgeModal/CreateKnowledgeModal.module.css

@@ -0,0 +1,138 @@
+.body {
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+  padding: 4px 0;
+}
+
+.contextBlock {
+  background: #f9fafb;
+  border: 1px solid #e5e7eb;
+  border-radius: 6px;
+  padding: 10px 12px;
+}
+
+.expandToggle {
+  font-size: 12px;
+  color: #3b82f6;
+  cursor: pointer;
+  user-select: none;
+}
+
+.expandToggle:hover {
+  text-decoration: underline;
+}
+
+.contextContent {
+  margin-top: 8px;
+  font-size: 12px;
+  line-height: 1.6;
+  white-space: pre-wrap;
+  word-break: break-word;
+  color: #6b7280;
+  max-height: 200px;
+  overflow-y: auto;
+}
+
+.field {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+
+.label {
+  font-size: 13px;
+  font-weight: 500;
+  color: #374151;
+}
+
+.required {
+  color: #ef4444;
+}
+
+.typeList {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 10px;
+}
+
+.typeItem {
+  display: flex;
+  align-items: center;
+  gap: 5px;
+  font-size: 13px;
+  color: #374151;
+  cursor: pointer;
+  user-select: none;
+}
+
+.textarea {
+  width: 100%;
+  border: 1px solid #d1d5db;
+  border-radius: 6px;
+  padding: 8px 12px;
+  font-size: 14px;
+  line-height: 1.6;
+  resize: vertical;
+  outline: none;
+  font-family: inherit;
+  color: #374151;
+  box-sizing: border-box;
+}
+
+.textarea:focus {
+  border-color: #3b82f6;
+}
+
+.textarea:disabled {
+  background: #f9fafb;
+  color: #9ca3af;
+}
+
+.footer {
+  display: flex;
+  justify-content: flex-end;
+  gap: 10px;
+  padding: 0 4px;
+}
+
+.cancelBtn {
+  height: 36px;
+  padding: 0 16px;
+  border-radius: 6px;
+  border: 1px solid #d1d5db;
+  background: #fff;
+  color: #374151;
+  font-size: 14px;
+  cursor: pointer;
+}
+
+.cancelBtn:hover:not(:disabled) {
+  background: #f9fafb;
+}
+
+.cancelBtn:disabled {
+  opacity: 0.5;
+  cursor: not-allowed;
+}
+
+.submitBtn {
+  height: 36px;
+  padding: 0 20px;
+  border-radius: 6px;
+  border: none;
+  background: #3b82f6;
+  color: #fff;
+  font-size: 14px;
+  font-weight: 500;
+  cursor: pointer;
+}
+
+.submitBtn:hover:not(:disabled) {
+  background: #2563eb;
+}
+
+.submitBtn:disabled {
+  opacity: 0.5;
+  cursor: not-allowed;
+}

+ 153 - 0
frontend/react-template/src/components/CreateKnowledgeModal/CreateKnowledgeModal.tsx

@@ -0,0 +1,153 @@
+import { useState } from "react";
+import type { FC } from "react";
+import { Modal, Toast } from "@douyinfe/semi-ui";
+import { request } from "../../api/client";
+import styles from "./CreateKnowledgeModal.module.css";
+
+const KNOWLEDGE_TYPES = ["strategy", "tool", "user_profile", "usecase", "definition", "plan"];
+
+interface CreateKnowledgeModalProps {
+  visible: boolean;
+  contextText: string;
+  traceId?: string;
+  goalId?: string;
+  messageId?: string;
+  sequence?: number;
+  onClose: () => void;
+  onCreated?: (knowledgeId: string) => void;
+}
+
+export const CreateKnowledgeModal: FC<CreateKnowledgeModalProps> = ({
+  visible,
+  contextText,
+  traceId,
+  goalId,
+  messageId,
+  sequence,
+  onClose,
+  onCreated,
+}) => {
+  const [comment, setComment] = useState("");
+  const [selectedTypes, setSelectedTypes] = useState<string[]>(["strategy"]);
+  const [isSubmitting, setIsSubmitting] = useState(false);
+  const [isContextExpanded, setIsContextExpanded] = useState(false);
+
+  const toggleType = (type: string) => {
+    setSelectedTypes((prev) =>
+      prev.includes(type) ? prev.filter((t) => t !== type) : [...prev, type]
+    );
+  };
+
+  const handleClose = () => {
+    setComment("");
+    setSelectedTypes(["strategy"]);
+    setIsContextExpanded(false);
+    onClose();
+  };
+
+  const handleSubmit = async () => {
+    if (!comment.trim()) {
+      Toast.warning("请输入评论内容");
+      return;
+    }
+    if (selectedTypes.length === 0) {
+      Toast.warning("请至少选择一个知识类型");
+      return;
+    }
+
+    setIsSubmitting(true);
+    try {
+      const result = await request<{ knowledge_id: string }>("/api/traces/extract_comment", {
+        method: "POST",
+        data: {
+          comment: comment.trim(),
+          context: contextText,
+          types: selectedTypes,
+          source: {
+            name: "user_feedback",
+            category: "exp",
+            trace_id: traceId,
+            goal_id: goalId,
+            message_id: messageId,
+            sequence: sequence ?? null,
+            submitted_by: "user",
+          },
+          scopes: ["org:cybertogether"],
+          owner: "user",
+        },
+      });
+      Toast.success("知识已创建,正在处理去重...");
+      onCreated?.(result.knowledge_id);
+      handleClose();
+    } catch {
+      Toast.error("创建失败,请重试");
+    } finally {
+      setIsSubmitting(false);
+    }
+  };
+
+  const footer = (
+    <div className={styles.footer}>
+      <button className={styles.cancelBtn} onClick={handleClose} disabled={isSubmitting}>
+        取消
+      </button>
+      <button className={styles.submitBtn} onClick={handleSubmit} disabled={isSubmitting || !comment.trim()}>
+        {isSubmitting ? "创建中..." : "创建知识"}
+      </button>
+    </div>
+  );
+
+  return (
+    <Modal
+      title="创建知识"
+      visible={visible}
+      onCancel={handleClose}
+      footer={footer}
+      width={600}
+      maskClosable={false}
+    >
+      <div className={styles.body}>
+        {contextText && (
+          <div className={styles.contextBlock}>
+            <span className={styles.expandToggle} onClick={() => setIsContextExpanded((v) => !v)}>
+              {isContextExpanded ? "▲ 收起上下文" : "▼ 展开上下文"}
+            </span>
+            {isContextExpanded && (
+              <pre className={styles.contextContent}>{contextText}</pre>
+            )}
+          </div>
+        )}
+
+        <div className={styles.field}>
+          <label className={styles.label}>知识类型</label>
+          <div className={styles.typeList}>
+            {KNOWLEDGE_TYPES.map((type) => (
+              <label key={type} className={styles.typeItem}>
+                <input
+                  type="checkbox"
+                  checked={selectedTypes.includes(type)}
+                  onChange={() => toggleType(type)}
+                />
+                <span>{type}</span>
+              </label>
+            ))}
+          </div>
+        </div>
+
+        <div className={styles.field}>
+          <label className={styles.label}>
+            评论 <span className={styles.required}>*</span>
+          </label>
+          <textarea
+            className={styles.textarea}
+            value={comment}
+            onChange={(e) => setComment(e.target.value)}
+            placeholder="描述你的观察、经验或注意事项,LLM 将自动提取结构化知识..."
+            rows={5}
+            disabled={isSubmitting}
+          />
+        </div>
+      </div>
+    </Modal>
+  );
+};

+ 24 - 0
frontend/react-template/src/components/DetailPanel/DetailPanel.module.css

@@ -334,3 +334,27 @@
 .reasoningContent p:last-child {
   margin-bottom: 0;
 }
+
+.createKnowledgeBar {
+  padding: 12px 16px;
+  border-top: 1px solid var(--border-light);
+  background: var(--bg-panel);
+  flex-shrink: 0;
+}
+
+.createKnowledgeBtn {
+  width: 100%;
+  height: 34px;
+  border-radius: 6px;
+  border: 1px solid #d1d5db;
+  background: #fff;
+  color: #374151;
+  font-size: 13px;
+  cursor: pointer;
+  transition: background 0.15s;
+}
+
+.createKnowledgeBtn:hover {
+  background: #f9fafb;
+  border-color: #9ca3af;
+}

+ 65 - 0
frontend/react-template/src/components/DetailPanel/DetailPanel.tsx

@@ -5,12 +5,14 @@ import type { Edge, Message } from "../../types/message";
 import styles from "./DetailPanel.module.css";
 import { ImagePreviewModal } from "../ImagePreview/ImagePreviewModal";
 import { extractImagesFromMessage } from "../../utils/imageExtraction";
+import { CreateKnowledgeModal } from "../CreateKnowledgeModal/CreateKnowledgeModal";
 
 interface DetailPanelProps {
   node: Goal | Message | null;
   edge: Edge | null;
   messages?: Message[];
   onClose: () => void;
+  traceId?: string;
 }
 
 export const DetailPanel = ({
@@ -18,9 +20,11 @@ export const DetailPanel = ({
   edge,
   messages = [],
   onClose,
+  traceId,
 }: DetailPanelProps) => {
   const [previewImage, setPreviewImage] = useState<string | null>(null);
   const [expandedReasoning, setExpandedReasoning] = useState<Set<string>>(new Set());
+  const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
 
   console.log("DetailPanel - node:", node);
   console.log("DetailPanel - edge:", edge);
@@ -188,6 +192,48 @@ export const DetailPanel = ({
   const isMessageNode = (node: Goal | Message): node is Message =>
     "message_id" in node || "role" in node || "sequence" in node;
 
+  // 构建传给 LLM 的上下文文本
+  const buildContextText = (): string => {
+    if (!node) return "";
+    if (isGoal(node)) {
+      const lines: string[] = [];
+      lines.push(`[Goal] ${node.description}`);
+      if (node.summary) lines.push(`[Summary] ${node.summary}`);
+      for (const msg of messages) {
+        const role = msg.role || "unknown";
+        const seq = msg.sequence ?? "";
+        const content = typeof msg.content === "string"
+          ? msg.content
+          : JSON.stringify(msg.content);
+        lines.push(`[${role}#${seq}] ${content}`);
+      }
+      return lines.join("\n");
+    } else {
+      const msg = node as Message;
+      const role = msg.role || "unknown";
+      const seq = msg.sequence ?? "";
+      const content = typeof msg.content === "string"
+        ? msg.content
+        : JSON.stringify(msg.content);
+      return `[${role}#${seq}] ${content}`;
+    }
+  };
+
+  // 提取 Goal/Message 的 ID 信息用于 source 溯源
+  const getNodeIds = () => {
+    if (!node) return {};
+    if (isGoal(node)) {
+      return { goalId: node.id };
+    } else {
+      const msg = node as Message;
+      return {
+        goalId: msg.goal_id,
+        messageId: msg.message_id || msg.id,
+        sequence: msg.sequence,
+      };
+    }
+  };
+
   const renderKnowledge = (knowledge: Goal["knowledge"]) => {
     if (!knowledge || knowledge.length === 0) return null;
 
@@ -355,6 +401,25 @@ export const DetailPanel = ({
         onClose={() => setPreviewImage(null)}
         src={previewImage || ""}
       />
+      {node && (
+        <>
+          <div className={styles.createKnowledgeBar}>
+            <button
+              className={styles.createKnowledgeBtn}
+              onClick={() => setIsCreateModalOpen(true)}
+            >
+              创建知识
+            </button>
+          </div>
+          <CreateKnowledgeModal
+            visible={isCreateModalOpen}
+            contextText={buildContextText()}
+            traceId={traceId}
+            {...getNodeIds()}
+            onClose={() => setIsCreateModalOpen(false)}
+          />
+        </>
+      )}
     </aside>
   );
 };

+ 28 - 4
frontend/react-template/src/components/FlowChart/FlowChart.tsx

@@ -18,6 +18,7 @@ interface FlowChartProps {
 export interface FlowChartRef {
   expandAll: () => void;
   collapseAll: () => void;
+  scrollToSequence: (sequence: number) => void;
 }
 
 export type SubTraceEntry = { id: string; mission?: string };
@@ -662,10 +663,6 @@ const FlowChartComponent: ForwardRefRenderFunction<FlowChartRef, FlowChartProps>
     return ids;
   }, [displayGoals]);
 
-  useImperativeHandle(ref, () => ({
-    expandAll: () => setCollapsedGoals(new Set()),
-    collapseAll: () => setCollapsedGoals(new Set(allGoalIds)),
-  }), [allGoalIds]);
 
   const visibleData = layoutData;
 
@@ -703,6 +700,33 @@ const FlowChartComponent: ForwardRefRenderFunction<FlowChartRef, FlowChartProps>
     };
   }, [visibleData, dimensions]);
 
+  useImperativeHandle(ref, () => ({
+    expandAll: () => setCollapsedGoals(new Set()),
+    collapseAll: () => setCollapsedGoals(new Set(allGoalIds)),
+    scrollToSequence: (sequence: number) => {
+      const targetNode = layoutData?.nodes.find(
+        (n) => n.type === "message" && (n.data as Message).sequence === sequence
+      );
+      if (!targetNode) return;
+      setSelectedNodeId(targetNode.id);
+      onNodeClick?.(targetNode.data as Message);
+      if (viewMode === "scroll" && scrollContainerRef.current) {
+        const rect = scrollContainerRef.current.getBoundingClientRect();
+        scrollContainerRef.current.scrollTo({
+          left: (targetNode.x - contentSize.minX) - rect.width / 2 + 100,
+          top: (targetNode.y - contentSize.minY) - rect.height / 2,
+          behavior: "smooth",
+        });
+      } else if (viewMode === "panzoom" && containerRef.current) {
+        const rect = containerRef.current.getBoundingClientRect();
+        setPanOffset({
+          x: rect.width / 2 - targetNode.x * zoom - 100 * zoom,
+          y: rect.height / 2 - targetNode.y * zoom,
+        });
+      }
+    },
+  }), [allGoalIds, layoutData, contentSize, viewMode, zoom, onNodeClick]);
+
   const toggleView = () => {
     if (viewMode === "scroll") {
       setViewMode("panzoom");

+ 255 - 0
frontend/react-template/src/components/KnowledgeFeedbackModal/KnowledgeFeedbackModal.module.css

@@ -0,0 +1,255 @@
+.hint {
+  background: #fffbeb;
+  border: 1px solid #fde68a;
+  border-radius: 6px;
+  padding: 10px 14px;
+  font-size: 13px;
+  color: #92400e;
+  margin-bottom: 16px;
+}
+
+.loading,
+.empty {
+  text-align: center;
+  color: #9ca3af;
+  padding: 40px 0;
+  font-size: 14px;
+}
+
+.entriesList {
+  display: flex;
+  flex-direction: column;
+  gap: 14px;
+}
+
+.entryCard {
+  border: 1px solid #e5e7eb;
+  border-radius: 8px;
+  padding: 14px 16px;
+  background: #fafafa;
+}
+
+.entryHeader {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  flex-wrap: wrap;
+  margin-bottom: 8px;
+}
+
+.entryId {
+  font-family: monospace;
+  font-size: 12px;
+  color: #6b7280;
+}
+
+.entryMeta {
+  font-size: 12px;
+  color: #9ca3af;
+}
+
+.entryMetaLink {
+  color: #3b82f6;
+  cursor: pointer;
+  border-radius: 4px;
+  padding: 1px 4px;
+  transition: background 0.15s;
+}
+
+.entryMetaLink:hover {
+  background: #eff6ff;
+  text-decoration: underline;
+}
+
+.badge {
+  font-size: 11px;
+  padding: 2px 8px;
+  border-radius: 12px;
+  font-weight: 500;
+}
+
+.badgeHelpful {
+  background: #d1fae5;
+  color: #065f46;
+}
+
+.badgeHarmful {
+  background: #fee2e2;
+  color: #991b1b;
+}
+
+.badgeNeutral {
+  background: #f3f4f6;
+  color: #374151;
+}
+
+.entryTask {
+  font-size: 14px;
+  color: #111827;
+  font-weight: 500;
+  margin-bottom: 8px;
+  line-height: 1.5;
+}
+
+.entryEval {
+  display: flex;
+  align-items: baseline;
+  flex-wrap: wrap;
+  gap: 8px;
+  margin-bottom: 10px;
+  padding: 8px 10px;
+  background: #f0f9ff;
+  border: 1px solid #bae6fd;
+  border-radius: 6px;
+  font-size: 13px;
+}
+
+.evalScore {
+  font-weight: 600;
+  color: #0369a1;
+  white-space: nowrap;
+}
+
+.evalReasoning {
+  margin: 0;
+  color: #374151;
+  line-height: 1.6;
+  flex: 1;
+  min-width: 0;
+}
+
+.evalNoReasoning {
+  color: #9ca3af;
+  font-style: italic;
+}
+
+.entryContentWrapper {
+  margin-bottom: 12px;
+}
+
+.expandToggle {
+  font-size: 12px;
+  color: #3b82f6;
+  cursor: pointer;
+  user-select: none;
+}
+
+.expandToggle:hover {
+  text-decoration: underline;
+}
+
+.entryContent {
+  margin-top: 8px;
+  background: #f9fafb;
+  border: 1px solid #e5e7eb;
+  border-radius: 6px;
+  padding: 10px 12px;
+  font-size: 13px;
+  line-height: 1.6;
+  white-space: pre-wrap;
+  word-break: break-word;
+  color: #374151;
+}
+
+.feedbackRow {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  flex-wrap: wrap;
+}
+
+.feedbackLabel {
+  font-size: 13px;
+  color: #6b7280;
+  white-space: nowrap;
+}
+
+.overrideForm {
+  margin-top: 12px;
+  padding: 12px;
+  background: #fff;
+  border: 1px solid #dbeafe;
+  border-radius: 6px;
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+
+.overrideRow {
+  display: flex;
+  align-items: flex-start;
+  gap: 10px;
+}
+
+.overrideLabel {
+  font-size: 13px;
+  color: #6b7280;
+  white-space: nowrap;
+  padding-top: 6px;
+  min-width: 80px;
+}
+
+.feedbackTextarea {
+  flex: 1;
+  border: 1px solid #d1d5db;
+  border-radius: 6px;
+  padding: 6px 10px;
+  font-size: 13px;
+  resize: vertical;
+  outline: none;
+  font-family: inherit;
+  color: #374151;
+}
+
+.feedbackTextarea:focus {
+  border-color: #3b82f6;
+}
+
+/* Footer */
+.footer {
+  display: flex;
+  justify-content: flex-end;
+  gap: 10px;
+  padding: 0 4px;
+}
+
+.cancelBtn {
+  height: 36px;
+  padding: 0 16px;
+  border-radius: 6px;
+  border: 1px solid #d1d5db;
+  background: #fff;
+  color: #374151;
+  font-size: 14px;
+  cursor: pointer;
+}
+
+.cancelBtn:hover:not(:disabled) {
+  background: #f9fafb;
+}
+
+.cancelBtn:disabled {
+  opacity: 0.5;
+  cursor: not-allowed;
+}
+
+.submitBtn {
+  height: 36px;
+  padding: 0 20px;
+  border-radius: 6px;
+  border: none;
+  background: #3b82f6;
+  color: #fff;
+  font-size: 14px;
+  font-weight: 500;
+  cursor: pointer;
+}
+
+.submitBtn:hover:not(:disabled) {
+  background: #2563eb;
+}
+
+.submitBtn:disabled {
+  opacity: 0.5;
+  cursor: not-allowed;
+}

+ 305 - 0
frontend/react-template/src/components/KnowledgeFeedbackModal/KnowledgeFeedbackModal.tsx

@@ -0,0 +1,305 @@
+import { useEffect, useState } from "react";
+import type { FC } from "react";
+import { Modal, RadioGroup, Radio, Select, Toast } from "@douyinfe/semi-ui";
+import { traceApi } from "../../api/traceApi";
+import type { KnowledgeLogEntry, FeedbackAction, KnowledgeFeedbackState } from "../../types/goal";
+import type { KnowledgeFeedbackListItem } from "../../api/traceApi";
+import styles from "./KnowledgeFeedbackModal.module.css";
+
+interface KnowledgeFeedbackModalProps {
+  visible: boolean;
+  traceId: string;
+  onClose: () => void;
+  onSubmitted?: () => void;
+  onNavigateToMessage?: (goalId: string, sequence: number) => void;
+}
+
+const EVAL_STATUS_OPTIONS = [
+  { value: "helpful", label: "✅ helpful(有效)" },
+  { value: "harmful", label: "🚫 harmful(有害)" },
+  { value: "unused", label: "⬜ unused(未使用)" },
+  { value: "irrelevant", label: "⚠️ irrelevant(无关)" },
+  { value: "neutral", label: "➖ neutral(中性)" },
+];
+
+const EVAL_STATUS_BADGE: Record<string, { label: string; cls: string }> = {
+  helpful:    { label: "helpful",    cls: styles.badgeHelpful },
+  harmful:    { label: "harmful",    cls: styles.badgeHarmful },
+  unused:     { label: "unused",     cls: styles.badgeNeutral },
+  irrelevant: { label: "irrelevant", cls: styles.badgeNeutral },
+  neutral:    { label: "neutral",    cls: styles.badgeNeutral },
+};
+
+export const KnowledgeFeedbackModal: FC<KnowledgeFeedbackModalProps> = ({
+  visible,
+  traceId,
+  onClose,
+  onSubmitted,
+  onNavigateToMessage,
+}) => {
+  const [entries, setEntries] = useState<KnowledgeLogEntry[]>([]);
+  const [feedbackMap, setFeedbackMap] = useState<Record<string, KnowledgeFeedbackState>>({});
+  const [isLoading, setIsLoading] = useState(false);
+  const [isSubmitting, setIsSubmitting] = useState(false);
+  const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
+
+  // 加载 knowledge_log,并去重(同一 knowledge_id 取最新注入)
+  useEffect(() => {
+    if (!visible || !traceId) return;
+
+    setIsLoading(true);
+    traceApi
+      .fetchKnowledgeLog(traceId)
+      .then((log) => {
+        // 去重:同一 knowledge_id 保留最新注入的条目
+        const seen = new Map<string, KnowledgeLogEntry>();
+        for (const entry of log.entries) {
+          const existing = seen.get(entry.knowledge_id);
+          if (!existing || entry.injected_at_sequence > existing.injected_at_sequence) {
+            seen.set(entry.knowledge_id, entry);
+          }
+        }
+        const deduped = Array.from(seen.values());
+        setEntries(deduped);
+
+        // 默认所有条目选「跳过」
+        const initial: Record<string, KnowledgeFeedbackState> = {};
+        deduped.forEach((e) => {
+          // 若已有用户反馈,恢复之前的选择
+          if (e.user_feedback) {
+            initial[e.knowledge_id] = {
+              action: e.user_feedback.action as FeedbackAction,
+              eval_status: e.user_feedback.eval_status,
+              feedback_text: e.user_feedback.feedback_text,
+            };
+          } else {
+            initial[e.knowledge_id] = { action: "skip" };
+          }
+        });
+        setFeedbackMap(initial);
+      })
+      .catch(() => {
+        Toast.error("加载知识日志失败");
+      })
+      .finally(() => setIsLoading(false));
+  }, [visible, traceId]);
+
+  const toggleExpand = (id: string) => {
+    setExpandedIds((prev) => {
+      const next = new Set(prev);
+      if (next.has(id)) next.delete(id);
+      else next.add(id);
+      return next;
+    });
+  };
+
+  const setFeedback = (id: string, patch: Partial<KnowledgeFeedbackState>) => {
+    setFeedbackMap((prev) => ({
+      ...prev,
+      [id]: { ...prev[id], ...patch },
+    }));
+  };
+
+  const confirmedCount = Object.values(feedbackMap).filter(
+    (f) => f.action !== "skip"
+  ).length;
+
+  const handleSubmit = async () => {
+    setIsSubmitting(true);
+    const feedbackList: KnowledgeFeedbackListItem[] = entries
+      .filter((e) => feedbackMap[e.knowledge_id]?.action !== "skip")
+      .map((e) => ({
+        knowledge_id: e.knowledge_id,
+        action: feedbackMap[e.knowledge_id].action,
+        eval_status: feedbackMap[e.knowledge_id].eval_status,
+        feedback_text: feedbackMap[e.knowledge_id].feedback_text,
+        source: {
+          trace_id: traceId,
+          goal_id: e.goal_id,
+          sequence: e.injected_at_sequence,
+          feedback_by: "user",
+          feedback_at: new Date().toISOString(),
+        },
+      }));
+
+    try {
+      await traceApi.submitKnowledgeFeedback(traceId, { feedback_list: feedbackList });
+      localStorage.setItem(`knowledge_feedback_submitted_${traceId}`, "true");
+      Toast.success(`反馈提交成功,共反馈 ${feedbackList.length} 条知识`);
+      onSubmitted?.();
+      onClose();
+    } catch {
+      Toast.error("提交失败,请重试");
+    } finally {
+      setIsSubmitting(false);
+    }
+  };
+
+  const footer = (
+    <div className={styles.footer}>
+      <button className={styles.cancelBtn} onClick={onClose} disabled={isSubmitting}>
+        取消
+      </button>
+      <button
+        className={styles.submitBtn}
+        onClick={handleSubmit}
+        disabled={isSubmitting || entries.length === 0}
+      >
+        {isSubmitting ? "提交中..." : `提交反馈(已反馈 ${confirmedCount}/${entries.length} 条)`}
+      </button>
+    </div>
+  );
+
+  return (
+    <Modal
+      title={`知识使用反馈 — ${traceId}`}
+      visible={visible}
+      onCancel={onClose}
+      footer={footer}
+      width={720}
+      style={{ maxHeight: "90vh" }}
+      bodyStyle={{ overflowY: "auto", maxHeight: "calc(90vh - 120px)", padding: "16px 24px" }}
+      maskClosable={false}
+    >
+      {isLoading ? (
+        <div className={styles.loading}>加载中...</div>
+      ) : entries.length === 0 ? (
+        <div className={styles.empty}>本次 Trace 暂无知识注入记录</div>
+      ) : (
+        <>
+          <div className={styles.hint}>
+            💡 提示:只需对有信心的知识做反馈,不确定的可以跳过(默认)
+          </div>
+          <div className={styles.entriesList}>
+            {entries.map((entry) => {
+              const fb = feedbackMap[entry.knowledge_id] ?? { action: "skip" };
+              const isExpanded = expandedIds.has(entry.knowledge_id);
+              const evalBadge = entry.eval_result?.eval_status
+                ? EVAL_STATUS_BADGE[entry.eval_result.eval_status]
+                : null;
+
+              return (
+                <div key={entry.knowledge_id} className={styles.entryCard}>
+                  {/* 卡片头部 */}
+                  <div className={styles.entryHeader}>
+                    <span className={styles.entryId} title={entry.knowledge_id}>
+                      {entry.knowledge_id.slice(0, 28)}…
+                    </span>
+                    <span
+                      className={`${styles.entryMeta} ${onNavigateToMessage ? styles.entryMetaLink : ""}`}
+                      title={onNavigateToMessage ? "点击跳转到该消息" : undefined}
+                      onClick={
+                        onNavigateToMessage
+                          ? () => {
+                              onNavigateToMessage(entry.goal_id, entry.injected_at_sequence);
+                              onClose();
+                            }
+                          : undefined
+                      }
+                    >
+                      <strong>Goal {entry.goal_id}</strong>
+                      {" · "}
+                      <strong>seq {entry.injected_at_sequence}</strong>
+                    </span>
+                    {evalBadge && (
+                      <span className={`${styles.badge} ${evalBadge.cls}`}>
+                        Agent: {evalBadge.label}
+                      </span>
+                    )}
+                    {!entry.eval_result && (
+                      <span className={`${styles.badge} ${styles.badgeNeutral}`}>未评估</span>
+                    )}
+                  </div>
+
+                  {/* 任务场景 */}
+                  <div className={styles.entryTask}>{entry.task}</div>
+
+                  {/* Agent 评估详情 */}
+                  <div className={styles.entryEval}>
+                    {entry.eval_result ? (
+                      <>
+                        {typeof entry.eval_result.score === "number" && (
+                          <span className={styles.evalScore}>
+                            评分 {entry.eval_result.score}/5
+                          </span>
+                        )}
+                        {entry.eval_result.reason ? (
+                          <p className={styles.evalReasoning}>{entry.eval_result.reason}</p>
+                        ) : (
+                          <span className={styles.evalNoReasoning}>(无评估说明)</span>
+                        )}
+                      </>
+                    ) : (
+                      <span className={styles.evalNoReasoning}>Agent 暂未评估此知识</span>
+                    )}
+                  </div>
+
+                  {/* 知识内容(可展开) */}
+                  <div className={styles.entryContentWrapper}>
+                    <span
+                      className={styles.expandToggle}
+                      onClick={() => toggleExpand(entry.knowledge_id)}
+                    >
+                      {isExpanded ? "▲ 收起内容" : "▼ 展开内容"}
+                    </span>
+                    {isExpanded && (
+                      <pre className={styles.entryContent}>{entry.content}</pre>
+                    )}
+                  </div>
+
+                  {/* 用户反馈选择 */}
+                  <div className={styles.feedbackRow}>
+                    <span className={styles.feedbackLabel}>您的反馈:</span>
+                    <RadioGroup
+                      value={fb.action}
+                      onChange={(e) =>
+                        setFeedback(entry.knowledge_id, { action: e.target.value as FeedbackAction })
+                      }
+                      direction="horizontal"
+                    >
+                      <Radio value="skip">跳过(不确定)</Radio>
+                      <Radio value="confirm" disabled={!entry.eval_result}>
+                        同意 Agent 评估
+                      </Radio>
+                      <Radio value="override">不同意,重新评估</Radio>
+                    </RadioGroup>
+                  </div>
+
+                  {/* 重新评估子表单 */}
+                  {fb.action === "override" && (
+                    <div className={styles.overrideForm}>
+                      <div className={styles.overrideRow}>
+                        <span className={styles.overrideLabel}>评估结果:</span>
+                        <Select
+                          value={fb.eval_status}
+                          onChange={(v) =>
+                            setFeedback(entry.knowledge_id, { eval_status: v as string })
+                          }
+                          optionList={EVAL_STATUS_OPTIONS}
+                          style={{ width: 220 }}
+                          placeholder="请选择评估结果"
+                        />
+                      </div>
+                      <div className={styles.overrideRow}>
+                        <span className={styles.overrideLabel}>说明(可选):</span>
+                        <textarea
+                          className={styles.feedbackTextarea}
+                          value={fb.feedback_text ?? ""}
+                          onChange={(e) =>
+                            setFeedback(entry.knowledge_id, { feedback_text: e.target.value })
+                          }
+                          placeholder="请输入反馈说明(可选)"
+                          rows={2}
+                        />
+                      </div>
+                    </div>
+                  )}
+                </div>
+              );
+            })}
+          </div>
+        </>
+      )}
+    </Modal>
+  );
+};

+ 5 - 2
frontend/react-template/src/components/MainContent/MainContent.tsx

@@ -1,5 +1,5 @@
 import { useRef, useState, useEffect } from "react";
-import type { FC } from "react";
+import type { FC, RefObject } from "react";
 import { Select } from "@douyinfe/semi-ui";
 import { FlowChart } from "../FlowChart/FlowChart";
 import type { FlowChartRef } from "../FlowChart/FlowChart";
@@ -16,6 +16,7 @@ interface MainContentProps {
   onTraceChange?: (traceId: string, title?: string) => void;
   refreshTrigger?: number;
   messageRefreshTrigger?: number;
+  flowChartRef?: RefObject<FlowChartRef>;
 }
 
 interface ConnectionStatusProps {
@@ -40,8 +41,10 @@ export const MainContent: FC<MainContentProps> = ({
   onTraceChange,
   refreshTrigger,
   messageRefreshTrigger,
+  flowChartRef: externalFlowChartRef,
 }) => {
-  const flowChartRef = useRef<FlowChartRef>(null);
+  const internalFlowChartRef = useRef<FlowChartRef>(null);
+  const flowChartRef = externalFlowChartRef ?? internalFlowChartRef;
   const [isAllExpanded, setIsAllExpanded] = useState(true);
   const [traceList, setTraceList] = useState<TraceListItem[]>([]);
   const [cachedGoals, setCachedGoals] = useState<Goal[]>([]);

+ 54 - 0
frontend/react-template/src/components/TopBar/TopBar.tsx

@@ -7,6 +7,7 @@ import { traceApi } from "../../api/traceApi";
 import type { Goal } from "../../types/goal";
 import type { Message } from "../../types/message";
 import { AgentControlPanel } from "../AgentControlPanel/AgentControlPanel";
+import { KnowledgeFeedbackModal } from "../KnowledgeFeedbackModal/KnowledgeFeedbackModal";
 import styles from "./TopBar.module.css";
 
 interface TopBarProps {
@@ -16,6 +17,7 @@ interface TopBarProps {
   onTraceSelect: (traceId: string, title?: string) => void;
   onTraceCreated?: () => void;
   onMessageInserted?: () => void;
+  onNavigateToMessage?: (goalId: string, sequence: number) => void;
 }
 
 export const TopBar: FC<TopBarProps> = ({
@@ -25,6 +27,7 @@ export const TopBar: FC<TopBarProps> = ({
   onTraceSelect,
   onTraceCreated,
   onMessageInserted,
+  onNavigateToMessage,
 }) => {
   const [isModalVisible, setIsModalVisible] = useState(false);
   const [isInsertModalVisible, setIsInsertModalVisible] = useState(false);
@@ -37,6 +40,10 @@ export const TopBar: FC<TopBarProps> = ({
   const [isUploading, setIsUploading] = useState(false);
   // 控制中心面板
   const [isControlPanelVisible, setIsControlPanelVisible] = useState(false);
+  // 知识反馈
+  const [isKnowledgeFeedbackVisible, setIsKnowledgeFeedbackVisible] = useState(false);
+  const [feedbackSubmitted, setFeedbackSubmitted] = useState(false);
+  const [pendingFeedbackCount, setPendingFeedbackCount] = useState(0);
 
   const formApiRef = useRef<{
     getValues: () => { system_prompt?: string; user_prompt?: string; example_project?: string };
@@ -80,6 +87,29 @@ export const TopBar: FC<TopBarProps> = ({
     loadTraces();
   }, [loadTraces]);
 
+  // 切换 Trace 时更新知识反馈状态
+  useEffect(() => {
+    if (!selectedTraceId) {
+      setPendingFeedbackCount(0);
+      setFeedbackSubmitted(false);
+      return;
+    }
+    const submitted =
+      localStorage.getItem(`knowledge_feedback_submitted_${selectedTraceId}`) === "true";
+    setFeedbackSubmitted(submitted);
+    if (!submitted) {
+      traceApi
+        .fetchKnowledgeLog(selectedTraceId)
+        .then((log) => {
+          const uniqueIds = new Set(log.entries.map((e) => e.knowledge_id));
+          setPendingFeedbackCount(uniqueIds.size);
+        })
+        .catch(() => {});
+    } else {
+      setPendingFeedbackCount(0);
+    }
+  }, [selectedTraceId]);
+
   const handleNewTask = async () => {
     setIsModalVisible(true);
     // 加载 example 项目列表
@@ -346,6 +376,20 @@ export const TopBar: FC<TopBarProps> = ({
           >
             经验
           </button>
+          <button
+            className={`${styles.button} ${feedbackSubmitted ? styles.success : pendingFeedbackCount > 0 ? styles.warning : ""}`}
+            onClick={() => {
+              if (!selectedTraceId) { Toast.warning("请先选择一个 Trace"); return; }
+              setIsKnowledgeFeedbackVisible(true);
+            }}
+            disabled={!selectedTraceId}
+          >
+            {feedbackSubmitted
+              ? "知识反馈 ✓"
+              : pendingFeedbackCount > 0
+              ? `知识反馈 (${pendingFeedbackCount})`
+              : "知识反馈"}
+          </button>
           <label className={`${styles.button} ${styles.info}`} style={{ cursor: 'pointer' }}>
             {isUploading ? "上传中..." : "📤 导入"}
             <input
@@ -458,6 +502,16 @@ export const TopBar: FC<TopBarProps> = ({
             {experienceContent ? <ReactMarkdown>{experienceContent}</ReactMarkdown> : "暂无经验数据"}
           </div>
         </Modal>
+        <KnowledgeFeedbackModal
+          visible={isKnowledgeFeedbackVisible}
+          traceId={selectedTraceId || ""}
+          onClose={() => setIsKnowledgeFeedbackVisible(false)}
+          onSubmitted={() => {
+            setFeedbackSubmitted(true);
+            setPendingFeedbackCount(0);
+          }}
+          onNavigateToMessage={onNavigateToMessage}
+        />
       </header>
       {
         isControlPanelVisible && (

+ 33 - 0
frontend/react-template/src/types/goal.ts

@@ -46,3 +46,36 @@ export interface BranchContext {
   created_at: string;
   completed_at?: string;
 }
+
+// ===== Knowledge Feedback Types =====
+
+export interface KnowledgeLogEntry {
+  knowledge_id: string;
+  goal_id: string;
+  injected_at_sequence: number;
+  injected_at: string;
+  task: string;
+  content: string;
+  eval_result: {
+    eval_status?: string;
+    reason?: string;
+    score?: number;
+  } | null;
+  evaluated_at: string | null;
+  evaluated_at_trigger?: string | null;
+  user_feedback: {
+    action: string;
+    eval_status?: string;
+    feedback_by: string;
+    feedback_at: string;
+    feedback_text?: string;
+  } | null;
+}
+
+export type FeedbackAction = "confirm" | "override" | "skip";
+
+export interface KnowledgeFeedbackState {
+  action: FeedbackAction;
+  eval_status?: string;
+  feedback_text?: string;
+}

+ 178 - 10
knowhub/server.py

@@ -308,6 +308,18 @@ class KnowledgeBatchUpdateIn(BaseModel):
     feedback_list: list[dict]
 
 
+
+class KnowledgeVerifyIn(BaseModel):
+    action: str  # "approve" | "reject"
+    verified_by: str = "user"
+
+
+class KnowledgeBatchVerifyIn(BaseModel):
+    knowledge_ids: List[str]
+    action: str  # "approve"
+    verified_by: str
+
+
 class KnowledgeSearchResponse(BaseModel):
     results: list[dict]
     count: int
@@ -542,12 +554,14 @@ class KnowledgeProcessor:
             final_decision = "rejected"
 
         if final_decision == "rejected":
-            milvus_store.update(kid, {"status": "rejected", "updated_at": now})
+            # 记录 rejected 知识的关系(便于溯源为什么被拒绝)
+            rejected_relationships = []
             for rel in relations:
-                if rel.get("type") in ("duplicate", "subset"):
-                    old_id = rel.get("old_id")
-                    if not old_id:
-                        continue
+                old_id = rel.get("old_id")
+                rel_type = rel.get("type", "none")
+                if old_id and rel_type != "none":
+                    rejected_relationships.append({"type": rel_type, "target": old_id})
+                if rel_type in ("duplicate", "subset") and old_id:
                     try:
                         old = milvus_store.get_by_id(old_id)
                         if not old:
@@ -558,13 +572,14 @@ class KnowledgeProcessor:
                         helpful_history.append({
                             "source": "dedup",
                             "related_id": kid,
-                            "relation_type": rel["type"],
+                            "relation_type": rel_type,
                             "timestamp": now
                         })
                         eval_data["helpful_history"] = helpful_history
                         milvus_store.update(old_id, {"eval": eval_data, "updated_at": now})
                     except Exception as e:
                         print(f"[Apply Decision] 更新旧知识 {old_id} helpful 失败: {e}")
+            milvus_store.update(kid, {"status": "rejected", "relationships": json.dumps(rejected_relationships), "updated_at": now})
         else:
             new_relationships = []
             for rel in relations:
@@ -1408,6 +1423,77 @@ def batch_delete_knowledge(knowledge_ids: List[str] = Body(...)):
         raise HTTPException(status_code=500, detail=str(e))
 
 
+@app.post("/api/knowledge/batch_verify")
+async def batch_verify_knowledge(batch: KnowledgeBatchVerifyIn):
+    """批量验证通过(approved → checked)"""
+    if not batch.knowledge_ids:
+        return {"status": "ok", "updated": 0}
+
+    try:
+        now_iso = datetime.now(timezone.utc).isoformat()
+        updated_count = 0
+
+        for kid in batch.knowledge_ids:
+            existing = milvus_store.get_by_id(kid)
+            if not existing:
+                continue
+
+            eval_data = existing.get("eval") or {}
+            eval_data["verification"] = {
+                "status": "checked",
+                "verified_by": batch.verified_by,
+                "verified_at": now_iso,
+                "note": None,
+                "issue_type": None,
+                "issue_action": None,
+            }
+            milvus_store.update(kid, {"eval": eval_data, "status": "checked", "updated_at": int(time.time())})
+            updated_count += 1
+
+        return {"status": "ok", "updated": updated_count}
+
+    except Exception as e:
+        print(f"[Batch Verify] 错误: {e}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+
+@app.post("/api/knowledge/{knowledge_id}/verify")
+async def verify_knowledge(knowledge_id: str, verify: KnowledgeVerifyIn):
+    """知识验证:approve 切换 approved↔checked,reject 设为 rejected"""
+    try:
+        existing = milvus_store.get_by_id(knowledge_id)
+        if not existing:
+            raise HTTPException(status_code=404, detail=f"Knowledge not found: {knowledge_id}")
+
+        current_status = existing.get("status", "approved")
+
+        if verify.action == "approve":
+            # checked → approved(取消验证),其他 → checked
+            new_status = "approved" if current_status == "checked" else "checked"
+            milvus_store.update(knowledge_id, {
+                "status": new_status,
+                "updated_at": int(time.time())
+            })
+            return {"status": "ok", "new_status": new_status,
+                    "message": "已取消验证" if new_status == "approved" else "验证通过"}
+
+        elif verify.action == "reject":
+            milvus_store.update(knowledge_id, {
+                "status": "rejected",
+                "updated_at": int(time.time())
+            })
+            return {"status": "ok", "new_status": "rejected", "message": "已拒绝"}
+
+        else:
+            raise HTTPException(status_code=400, detail=f"Unknown action: {verify.action}")
+
+    except HTTPException:
+        raise
+    except Exception as e:
+        print(f"[Verify Knowledge] 错误: {e}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+
 @app.post("/api/knowledge/batch_update")
 async def batch_update_knowledge(batch: KnowledgeBatchUpdateIn):
     """批量反馈知识有效性"""
@@ -1853,6 +1939,9 @@ def frontend():
                 <button onclick="batchDelete()" id="batchDeleteBtn" class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed" disabled>
                     删除选中 (<span id="selectedCount">0</span>)
                 </button>
+                <button onclick="batchVerify()" id="batchVerifyBtn" class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed" disabled>
+                    ✓ 批量验证通过 (<span id="verifyCount">0</span>)
+                </button>
                 <button onclick="openAddModal()" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg">
                     + 新增知识
                 </button>
@@ -2206,6 +2295,16 @@ def frontend():
                             <span>${new Date(k.created_at).toLocaleDateString()}</span>
                         </div>
                     </div>
+                    <div class="flex gap-2 mt-3 pt-3 border-t border-gray-100">
+                        <button onclick="event.stopPropagation(); verifyKnowledge('${k.id}', 'approve', this)"
+                                class="${k.status === 'checked' ? 'bg-gray-300 hover:bg-gray-400 text-gray-700' : 'bg-green-400 hover:bg-green-500 text-white'} text-xs px-3 py-1 rounded transition-colors">
+                            ${k.status === 'checked' ? '取消验证' : '✓ 验证通过'}
+                        </button>
+                        <button onclick="event.stopPropagation(); verifyKnowledge('${k.id}', 'reject', this)"
+                                class="bg-red-400 hover:bg-red-500 text-white text-xs px-3 py-1 rounded transition-colors">
+                            ✗ 拒绝
+                        </button>
+                    </div>
                 </div>
             `;
             }).join('');
@@ -2236,7 +2335,9 @@ def frontend():
         function updateBatchDeleteButton() {
             const count = selectedIds.size;
             document.getElementById('selectedCount').textContent = count;
+            document.getElementById('verifyCount').textContent = count;
             document.getElementById('batchDeleteBtn').disabled = count === 0;
+            document.getElementById('batchVerifyBtn').disabled = count === 0;
             document.getElementById('selectAllBtn').textContent =
                 selectedIds.size === allKnowledge.length ? '取消全选' : '全选';
         }
@@ -2287,8 +2388,15 @@ def frontend():
         }
 
         async function openEditModal(id) {
-            const k = allKnowledge.find(item => item.id === id);
-            if (!k) return;
+            let k = allKnowledge.find(item => item.id === id);
+            if (!k) {
+                // 当前列表中找不到(可能是 rejected/其他状态),通过 API 单独获取
+                try {
+                    const res = await fetch('/api/knowledge/' + encodeURIComponent(id));
+                    if (!res.ok) { alert('知识未找到: ' + id); return; }
+                    k = await res.json();
+                } catch (e) { alert('获取知识失败: ' + e.message); return; }
+            }
 
             document.getElementById('modalTitle').textContent = '编辑知识';
             document.getElementById('editId').value = k.id;
@@ -2308,8 +2416,13 @@ def frontend():
                 el.checked = types.includes(el.value);
             });
 
-            // 填充 relationships
-            const rels = Array.isArray(k.relationships) ? k.relationships : [];
+            // 填充 relationships(可能是 JSON 字符串或数组)
+            let rels = [];
+            if (Array.isArray(k.relationships)) {
+                rels = k.relationships;
+            } else if (typeof k.relationships === 'string' && k.relationships.startsWith('[')) {
+                try { rels = JSON.parse(k.relationships); } catch(e) {}
+            }
             const section = document.getElementById('relationshipsSection');
             if (rels.length > 0) {
                 const typeColor = {
@@ -2387,6 +2500,61 @@ def frontend():
             await loadKnowledge();
         });
 
+        async function verifyKnowledge(id, action, btn) {
+            if (btn) {
+                btn.disabled = true;
+                btn._origText = btn.textContent;
+                btn.textContent = '处理中...';
+            }
+            try {
+                const res = await fetch('/api/knowledge/' + id + '/verify', {
+                    method: 'POST',
+                    headers: {'Content-Type': 'application/json'},
+                    body: JSON.stringify({ action })
+                });
+                if (!res.ok) throw new Error('请求失败: ' + res.status);
+                if (isSearchMode) {
+                    clearSearch();
+                } else {
+                    loadKnowledge(currentPage);
+                }
+            } catch (error) {
+                console.error('验证错误:', error);
+                alert('操作失败: ' + error.message);
+                if (btn) {
+                    btn.disabled = false;
+                    btn.textContent = btn._origText;
+                }
+            }
+        }
+
+        async function batchVerify() {
+            if (selectedIds.size === 0) return;
+            if (!confirm(`确定要批量验证通过选中的 ${selectedIds.size} 条知识吗?`)) return;
+            const btn = document.getElementById('batchVerifyBtn');
+            if (btn) { btn.disabled = true; btn.textContent = `处理中...`; }
+            try {
+                const ids = Array.from(selectedIds);
+                const res = await fetch('/api/knowledge/batch_verify', {
+                    method: 'POST',
+                    headers: {'Content-Type': 'application/json'},
+                    body: JSON.stringify({ knowledge_ids: ids, action: 'approve', verified_by: 'user' })
+                });
+                if (!res.ok) throw new Error('请求失败: ' + res.status);
+                selectedIds.clear();
+                updateBatchDeleteButton();
+                if (isSearchMode) {
+                    clearSearch();
+                } else {
+                    loadKnowledge(currentPage);
+                }
+            } catch (error) {
+                console.error('批量验证错误:', error);
+                alert('验证失败: ' + error.message);
+                if (btn) { btn.disabled = false; updateBatchDeleteButton(); }
+            }
+        }
+
         function escapeHtml(text) {
             const div = document.createElement('div');
             div.textContent = text;