elksmmx пре 8 часа
родитељ
комит
57f997efeb

+ 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

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