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

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

guantao 1 день назад
Родитель
Сommit
5df7e9f1ae
28 измененных файлов с 2862 добавлено и 17 удалено
  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登录完成后请告诉我,我会保存登录状态。谢谢!"
         "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 的查询接口
 提供 Trace、GoalTree、Message 的查询接口
 """
 """
 
 
+import os
+import json
+import httpx
+from datetime import datetime, timezone
 from typing import List, Optional, Dict, Any
 from typing import List, Optional, Dict, Any
 from fastapi import APIRouter, HTTPException, Query
 from fastapi import APIRouter, HTTPException, Query
 from pydantic import BaseModel
 from pydantic import BaseModel
+from agent.llm.openrouter import openrouter_llm_call
 
 
 from .protocols import TraceStore
 from .protocols import TraceStore
 
 
@@ -171,3 +176,158 @@ async def get_messages(
     return MessagesResponse(
     return MessagesResponse(
         messages=[m.to_dict() for m in messages]
         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)
         log = await self.get_knowledge_log(trace_id)
         return [e for e in log["entries"] if e["eval_result"] is None]
         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 logging
 import json
 import json
 import os
 import os
+from dotenv import load_dotenv
+load_dotenv()
 from fastapi import FastAPI, Request, WebSocket
 from fastapi import FastAPI, Request, WebSocket
 from fastapi.middleware.cors import CORSMiddleware
 from fastapi.middleware.cors import CORSMiddleware
 import uvicorn
 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": {
   "dependencies": {
     "@douyinfe/semi-icons": "^2.56.0",
     "@douyinfe/semi-icons": "^2.56.0",
-    "@douyinfe/semi-ui": "^2.56.0",
+    "@douyinfe/semi-ui": "^2.92.2",
     "axios": "^1.6.0",
     "axios": "^1.6.0",
     "d3": "^7.8.5",
     "d3": "^7.8.5",
     "jszip": "^3.10.1",
     "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 { Goal } from "./types/goal";
 import type { Edge, Message } from "./types/message";
 import type { Edge, Message } from "./types/message";
+import type { FlowChartRef } from "./components/FlowChart/FlowChart";
 import { useFlowChartData } from "./components/FlowChart/hooks/useFlowChartData";
 import { useFlowChartData } from "./components/FlowChart/hooks/useFlowChartData";
 import "./styles/global.css";
 import "./styles/global.css";
 
 
@@ -19,6 +20,7 @@ function App() {
   const [refreshTrigger, setRefreshTrigger] = useState(0);
   const [refreshTrigger, setRefreshTrigger] = useState(0);
   const [messageRefreshTrigger, setMessageRefreshTrigger] = useState(0);
   const [messageRefreshTrigger, setMessageRefreshTrigger] = useState(0);
   const bodyRef = useRef<HTMLDivElement | null>(null);
   const bodyRef = useRef<HTMLDivElement | null>(null);
+  const flowChartRef = useRef<FlowChartRef>(null);
 
 
   // 获取数据以传递给 DetailPanel
   // 获取数据以传递给 DetailPanel
   const { msgGroups } = useFlowChartData(selectedTraceId, messageRefreshTrigger);
   const { msgGroups } = useFlowChartData(selectedTraceId, messageRefreshTrigger);
@@ -33,6 +35,21 @@ function App() {
     setSelectedEdge(null);
     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;
   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)}
           onTraceCreated={() => setRefreshTrigger((t) => t + 1)}
           onMessageInserted={() => setMessageRefreshTrigger((t) => t + 1)}
           onMessageInserted={() => setMessageRefreshTrigger((t) => t + 1)}
+          onNavigateToMessage={handleNavigateToMessage}
         />
         />
       </div>
       </div>
       <div
       <div
@@ -101,6 +119,7 @@ function App() {
             }}
             }}
             refreshTrigger={refreshTrigger}
             refreshTrigger={refreshTrigger}
             messageRefreshTrigger={messageRefreshTrigger}
             messageRefreshTrigger={messageRefreshTrigger}
+            flowChartRef={flowChartRef}
           />
           />
         </div>
         </div>
         {(selectedNode || selectedEdge) && (
         {(selectedNode || selectedEdge) && (
@@ -123,6 +142,7 @@ function App() {
                 edge={selectedEdge}
                 edge={selectedEdge}
                 messages={selectedMessages as Message[]}
                 messages={selectedMessages as Message[]}
                 onClose={handleCloseDetail}
                 onClose={handleCloseDetail}
+                traceId={selectedTraceId ?? undefined}
               />
               />
             </div>
             </div>
           </>
           </>

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

@@ -1,5 +1,20 @@
 import { request } from "./client";
 import { request } from "./client";
 import type { TraceDetailResponse, TraceListResponse } from "../types/trace";
 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 = {
 export const traceApi = {
   fetchTraces(params?: { status?: string; mode?: string; limit?: number }) {
   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 {
 .reasoningContent p:last-child {
   margin-bottom: 0;
   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 styles from "./DetailPanel.module.css";
 import { ImagePreviewModal } from "../ImagePreview/ImagePreviewModal";
 import { ImagePreviewModal } from "../ImagePreview/ImagePreviewModal";
 import { extractImagesFromMessage } from "../../utils/imageExtraction";
 import { extractImagesFromMessage } from "../../utils/imageExtraction";
+import { CreateKnowledgeModal } from "../CreateKnowledgeModal/CreateKnowledgeModal";
 
 
 interface DetailPanelProps {
 interface DetailPanelProps {
   node: Goal | Message | null;
   node: Goal | Message | null;
   edge: Edge | null;
   edge: Edge | null;
   messages?: Message[];
   messages?: Message[];
   onClose: () => void;
   onClose: () => void;
+  traceId?: string;
 }
 }
 
 
 export const DetailPanel = ({
 export const DetailPanel = ({
@@ -18,9 +20,11 @@ export const DetailPanel = ({
   edge,
   edge,
   messages = [],
   messages = [],
   onClose,
   onClose,
+  traceId,
 }: DetailPanelProps) => {
 }: DetailPanelProps) => {
   const [previewImage, setPreviewImage] = useState<string | null>(null);
   const [previewImage, setPreviewImage] = useState<string | null>(null);
   const [expandedReasoning, setExpandedReasoning] = useState<Set<string>>(new Set());
   const [expandedReasoning, setExpandedReasoning] = useState<Set<string>>(new Set());
+  const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
 
 
   console.log("DetailPanel - node:", node);
   console.log("DetailPanel - node:", node);
   console.log("DetailPanel - edge:", edge);
   console.log("DetailPanel - edge:", edge);
@@ -188,6 +192,48 @@ export const DetailPanel = ({
   const isMessageNode = (node: Goal | Message): node is Message =>
   const isMessageNode = (node: Goal | Message): node is Message =>
     "message_id" in node || "role" in node || "sequence" in node;
     "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"]) => {
   const renderKnowledge = (knowledge: Goal["knowledge"]) => {
     if (!knowledge || knowledge.length === 0) return null;
     if (!knowledge || knowledge.length === 0) return null;
 
 
@@ -355,6 +401,25 @@ export const DetailPanel = ({
         onClose={() => setPreviewImage(null)}
         onClose={() => setPreviewImage(null)}
         src={previewImage || ""}
         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>
     </aside>
   );
   );
 };
 };

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

@@ -18,6 +18,7 @@ interface FlowChartProps {
 export interface FlowChartRef {
 export interface FlowChartRef {
   expandAll: () => void;
   expandAll: () => void;
   collapseAll: () => void;
   collapseAll: () => void;
+  scrollToSequence: (sequence: number) => void;
 }
 }
 
 
 export type SubTraceEntry = { id: string; mission?: string };
 export type SubTraceEntry = { id: string; mission?: string };
@@ -662,10 +663,6 @@ const FlowChartComponent: ForwardRefRenderFunction<FlowChartRef, FlowChartProps>
     return ids;
     return ids;
   }, [displayGoals]);
   }, [displayGoals]);
 
 
-  useImperativeHandle(ref, () => ({
-    expandAll: () => setCollapsedGoals(new Set()),
-    collapseAll: () => setCollapsedGoals(new Set(allGoalIds)),
-  }), [allGoalIds]);
 
 
   const visibleData = layoutData;
   const visibleData = layoutData;
 
 
@@ -703,6 +700,33 @@ const FlowChartComponent: ForwardRefRenderFunction<FlowChartRef, FlowChartProps>
     };
     };
   }, [visibleData, dimensions]);
   }, [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 = () => {
   const toggleView = () => {
     if (viewMode === "scroll") {
     if (viewMode === "scroll") {
       setViewMode("panzoom");
       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 { useRef, useState, useEffect } from "react";
-import type { FC } from "react";
+import type { FC, RefObject } from "react";
 import { Select } from "@douyinfe/semi-ui";
 import { Select } from "@douyinfe/semi-ui";
 import { FlowChart } from "../FlowChart/FlowChart";
 import { FlowChart } from "../FlowChart/FlowChart";
 import type { FlowChartRef } from "../FlowChart/FlowChart";
 import type { FlowChartRef } from "../FlowChart/FlowChart";
@@ -16,6 +16,7 @@ interface MainContentProps {
   onTraceChange?: (traceId: string, title?: string) => void;
   onTraceChange?: (traceId: string, title?: string) => void;
   refreshTrigger?: number;
   refreshTrigger?: number;
   messageRefreshTrigger?: number;
   messageRefreshTrigger?: number;
+  flowChartRef?: RefObject<FlowChartRef>;
 }
 }
 
 
 interface ConnectionStatusProps {
 interface ConnectionStatusProps {
@@ -40,8 +41,10 @@ export const MainContent: FC<MainContentProps> = ({
   onTraceChange,
   onTraceChange,
   refreshTrigger,
   refreshTrigger,
   messageRefreshTrigger,
   messageRefreshTrigger,
+  flowChartRef: externalFlowChartRef,
 }) => {
 }) => {
-  const flowChartRef = useRef<FlowChartRef>(null);
+  const internalFlowChartRef = useRef<FlowChartRef>(null);
+  const flowChartRef = externalFlowChartRef ?? internalFlowChartRef;
   const [isAllExpanded, setIsAllExpanded] = useState(true);
   const [isAllExpanded, setIsAllExpanded] = useState(true);
   const [traceList, setTraceList] = useState<TraceListItem[]>([]);
   const [traceList, setTraceList] = useState<TraceListItem[]>([]);
   const [cachedGoals, setCachedGoals] = useState<Goal[]>([]);
   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 { Goal } from "../../types/goal";
 import type { Message } from "../../types/message";
 import type { Message } from "../../types/message";
 import { AgentControlPanel } from "../AgentControlPanel/AgentControlPanel";
 import { AgentControlPanel } from "../AgentControlPanel/AgentControlPanel";
+import { KnowledgeFeedbackModal } from "../KnowledgeFeedbackModal/KnowledgeFeedbackModal";
 import styles from "./TopBar.module.css";
 import styles from "./TopBar.module.css";
 
 
 interface TopBarProps {
 interface TopBarProps {
@@ -16,6 +17,7 @@ interface TopBarProps {
   onTraceSelect: (traceId: string, title?: string) => void;
   onTraceSelect: (traceId: string, title?: string) => void;
   onTraceCreated?: () => void;
   onTraceCreated?: () => void;
   onMessageInserted?: () => void;
   onMessageInserted?: () => void;
+  onNavigateToMessage?: (goalId: string, sequence: number) => void;
 }
 }
 
 
 export const TopBar: FC<TopBarProps> = ({
 export const TopBar: FC<TopBarProps> = ({
@@ -25,6 +27,7 @@ export const TopBar: FC<TopBarProps> = ({
   onTraceSelect,
   onTraceSelect,
   onTraceCreated,
   onTraceCreated,
   onMessageInserted,
   onMessageInserted,
+  onNavigateToMessage,
 }) => {
 }) => {
   const [isModalVisible, setIsModalVisible] = useState(false);
   const [isModalVisible, setIsModalVisible] = useState(false);
   const [isInsertModalVisible, setIsInsertModalVisible] = useState(false);
   const [isInsertModalVisible, setIsInsertModalVisible] = useState(false);
@@ -37,6 +40,10 @@ export const TopBar: FC<TopBarProps> = ({
   const [isUploading, setIsUploading] = useState(false);
   const [isUploading, setIsUploading] = useState(false);
   // 控制中心面板
   // 控制中心面板
   const [isControlPanelVisible, setIsControlPanelVisible] = 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<{
   const formApiRef = useRef<{
     getValues: () => { system_prompt?: string; user_prompt?: string; example_project?: string };
     getValues: () => { system_prompt?: string; user_prompt?: string; example_project?: string };
@@ -80,6 +87,29 @@ export const TopBar: FC<TopBarProps> = ({
     loadTraces();
     loadTraces();
   }, [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 () => {
   const handleNewTask = async () => {
     setIsModalVisible(true);
     setIsModalVisible(true);
     // 加载 example 项目列表
     // 加载 example 项目列表
@@ -346,6 +376,20 @@ export const TopBar: FC<TopBarProps> = ({
           >
           >
             经验
             经验
           </button>
           </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' }}>
           <label className={`${styles.button} ${styles.info}`} style={{ cursor: 'pointer' }}>
             {isUploading ? "上传中..." : "📤 导入"}
             {isUploading ? "上传中..." : "📤 导入"}
             <input
             <input
@@ -458,6 +502,16 @@ export const TopBar: FC<TopBarProps> = ({
             {experienceContent ? <ReactMarkdown>{experienceContent}</ReactMarkdown> : "暂无经验数据"}
             {experienceContent ? <ReactMarkdown>{experienceContent}</ReactMarkdown> : "暂无经验数据"}
           </div>
           </div>
         </Modal>
         </Modal>
+        <KnowledgeFeedbackModal
+          visible={isKnowledgeFeedbackVisible}
+          traceId={selectedTraceId || ""}
+          onClose={() => setIsKnowledgeFeedbackVisible(false)}
+          onSubmitted={() => {
+            setFeedbackSubmitted(true);
+            setPendingFeedbackCount(0);
+          }}
+          onNavigateToMessage={onNavigateToMessage}
+        />
       </header>
       </header>
       {
       {
         isControlPanelVisible && (
         isControlPanelVisible && (

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

@@ -46,3 +46,36 @@ export interface BranchContext {
   created_at: string;
   created_at: string;
   completed_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]
     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):
 class KnowledgeSearchResponse(BaseModel):
     results: list[dict]
     results: list[dict]
     count: int
     count: int
@@ -542,12 +554,14 @@ class KnowledgeProcessor:
             final_decision = "rejected"
             final_decision = "rejected"
 
 
         if final_decision == "rejected":
         if final_decision == "rejected":
-            milvus_store.update(kid, {"status": "rejected", "updated_at": now})
+            # 记录 rejected 知识的关系(便于溯源为什么被拒绝)
+            rejected_relationships = []
             for rel in relations:
             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:
                     try:
                         old = milvus_store.get_by_id(old_id)
                         old = milvus_store.get_by_id(old_id)
                         if not old:
                         if not old:
@@ -558,13 +572,14 @@ class KnowledgeProcessor:
                         helpful_history.append({
                         helpful_history.append({
                             "source": "dedup",
                             "source": "dedup",
                             "related_id": kid,
                             "related_id": kid,
-                            "relation_type": rel["type"],
+                            "relation_type": rel_type,
                             "timestamp": now
                             "timestamp": now
                         })
                         })
                         eval_data["helpful_history"] = helpful_history
                         eval_data["helpful_history"] = helpful_history
                         milvus_store.update(old_id, {"eval": eval_data, "updated_at": now})
                         milvus_store.update(old_id, {"eval": eval_data, "updated_at": now})
                     except Exception as e:
                     except Exception as e:
                         print(f"[Apply Decision] 更新旧知识 {old_id} helpful 失败: {e}")
                         print(f"[Apply Decision] 更新旧知识 {old_id} helpful 失败: {e}")
+            milvus_store.update(kid, {"status": "rejected", "relationships": json.dumps(rejected_relationships), "updated_at": now})
         else:
         else:
             new_relationships = []
             new_relationships = []
             for rel in relations:
             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))
         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")
 @app.post("/api/knowledge/batch_update")
 async def batch_update_knowledge(batch: KnowledgeBatchUpdateIn):
 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>
                 <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>)
                     删除选中 (<span id="selectedCount">0</span>)
                 </button>
                 </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 onclick="openAddModal()" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg">
                     + 新增知识
                     + 新增知识
                 </button>
                 </button>
@@ -2206,6 +2295,16 @@ def frontend():
                             <span>${new Date(k.created_at).toLocaleDateString()}</span>
                             <span>${new Date(k.created_at).toLocaleDateString()}</span>
                         </div>
                         </div>
                     </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>
                 </div>
             `;
             `;
             }).join('');
             }).join('');
@@ -2236,7 +2335,9 @@ def frontend():
         function updateBatchDeleteButton() {
         function updateBatchDeleteButton() {
             const count = selectedIds.size;
             const count = selectedIds.size;
             document.getElementById('selectedCount').textContent = count;
             document.getElementById('selectedCount').textContent = count;
+            document.getElementById('verifyCount').textContent = count;
             document.getElementById('batchDeleteBtn').disabled = count === 0;
             document.getElementById('batchDeleteBtn').disabled = count === 0;
+            document.getElementById('batchVerifyBtn').disabled = count === 0;
             document.getElementById('selectAllBtn').textContent =
             document.getElementById('selectAllBtn').textContent =
                 selectedIds.size === allKnowledge.length ? '取消全选' : '全选';
                 selectedIds.size === allKnowledge.length ? '取消全选' : '全选';
         }
         }
@@ -2287,8 +2388,15 @@ def frontend():
         }
         }
 
 
         async function openEditModal(id) {
         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('modalTitle').textContent = '编辑知识';
             document.getElementById('editId').value = k.id;
             document.getElementById('editId').value = k.id;
@@ -2308,8 +2416,13 @@ def frontend():
                 el.checked = types.includes(el.value);
                 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');
             const section = document.getElementById('relationshipsSection');
             if (rels.length > 0) {
             if (rels.length > 0) {
                 const typeColor = {
                 const typeColor = {
@@ -2387,6 +2500,61 @@ def frontend():
             await loadKnowledge();
             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) {
         function escapeHtml(text) {
             const div = document.createElement('div');
             const div = document.createElement('div');
             div.textContent = text;
             div.textContent = text;