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

add control pannel on frontend

guantao 6 часов назад
Родитель
Сommit
e74ba8e5a1

+ 159 - 51
agent/core/runner.py

@@ -267,6 +267,55 @@ class AgentRunner:
         # 知识保存跟踪(每个 trace 独立)
         self._saved_knowledge_ids: Dict[str, List[str]] = {}  # trace_id → [knowledge_ids]
 
+        # 知识确认等待(每个 trace 独立)
+        self._pending_confirmations: Dict[str, asyncio.Future] = {}  # trace_id → Future
+
+    async def _wait_for_confirmation(self, trace_id: str, confirm_type: str, data: Dict[str, Any], timeout: float = 300) -> Dict[str, Any]:
+        """
+        广播知识确认请求并暂停等待前端响应。
+
+        Args:
+            trace_id: Trace ID
+            confirm_type: "knowledge_injection" | "knowledge_save"
+            data: 确认请求的数据(知识内容等)
+            timeout: 超时秒数,默认 300 秒
+
+        Returns:
+            用户响应 {"action": "confirm"|"reject", "edited_args"?: {...}}
+        """
+        from agent.trace.websocket import broadcast_knowledge_confirm_request
+        await broadcast_knowledge_confirm_request(trace_id, confirm_type, data)
+
+        loop = asyncio.get_event_loop()
+        future = loop.create_future()
+        self._pending_confirmations[trace_id] = future
+
+        try:
+            result = await asyncio.wait_for(future, timeout=timeout)
+            return result
+        except asyncio.TimeoutError:
+            logger.warning(f"[Knowledge Confirm] 等待确认超时 ({timeout}s),默认确认: trace={trace_id}, type={confirm_type}")
+            return {"action": "confirm"}
+        finally:
+            self._pending_confirmations.pop(trace_id, None)
+
+    async def resolve_confirmation(self, trace_id: str, response: Dict[str, Any]) -> bool:
+        """
+        前端调用,resolve 等待中的确认 Future。
+
+        Args:
+            trace_id: Trace ID
+            response: {"action": "confirm"|"reject", "edited_args"?: {...}}
+
+        Returns:
+            True 如果成功 resolve,False 如果没有等待中的确认
+        """
+        future = self._pending_confirmations.get(trace_id)
+        if future is None or future.done():
+            return False
+        future.set_result(response)
+        return True
+
     # ===== 核心公开方法 =====
 
     async def run(
@@ -411,6 +460,10 @@ class AgentRunner:
         if cancel_event is None:
             return False
         cancel_event.set()
+        # 如果有等待中的知识确认,立即 resolve 以解除阻塞
+        future = self._pending_confirmations.get(trace_id)
+        if future and not future.done():
+            future.set_result({"action": "confirm"})
         return True
 
     # ===== 单次调用(保留)=====
@@ -561,6 +614,12 @@ class AgentRunner:
             completed_at=None,
         )
         trace_obj.status = "running"
+        # 广播状态变化给前端
+        try:
+            from agent.trace.websocket import broadcast_trace_status_changed
+            await broadcast_trace_status_changed(config.trace_id, "running")
+        except Exception:
+            pass
 
         return trace_obj, goal_tree, sequence
 
@@ -862,6 +921,13 @@ class AgentRunner:
         _cached_exp_text = ""
 
         for iteration in range(config.max_iterations):
+            # 更新活动时间(表明trace正在活跃运行)
+            if self.trace_store:
+                await self.trace_store.update_trace(
+                    trace_id,
+                    last_activity_at=datetime.now()
+                )
+
             # 检查取消信号
             cancel_event = self._cancel_events.get(trace_id)
             if cancel_event and cancel_event.is_set():
@@ -873,6 +939,12 @@ class AgentRunner:
                         head_sequence=head_seq,
                         completed_at=datetime.now(),
                     )
+                    # 广播状态变化给前端
+                    try:
+                        from agent.trace.websocket import broadcast_trace_status_changed
+                        await broadcast_trace_status_changed(trace_id, "stopped")
+                    except Exception:
+                        pass
                     trace_obj = await self.trace_store.get_trace(trace_id)
                     if trace_obj:
                         yield trace_obj
@@ -1028,21 +1100,41 @@ class AgentRunner:
                             context={"runner": self},
                         )
                         if relevant_exps:
-                            # 保存到 goal 对象
-                            current_goal.knowledge = relevant_exps
-                            logger.info(f"[Knowledge Injection] 已将 {len(relevant_exps)} 条知识注入到 goal {current_goal.id}: {current_goal.description[:40]}")
-                            logger.debug(f"[Knowledge Injection] 注入的知识 IDs: {[exp.get('id') for exp in relevant_exps]}")
-                            # 持久化保存 goal_tree
-                            await self.trace_store.update_goal_tree(trace_id, goal_tree)
-                            self.used_ex_ids = [exp['id'] for exp in relevant_exps]
-                            parts = [f"[{exp['id']}] {exp['content']}" for exp in relevant_exps]
-                            _cached_exp_text = "## 参考历史经验\n" + "\n\n".join(parts)
-                            logger.info(
-                                "经验检索: goal='%s', 命中 %d 条 %s",
-                                current_goal.description[:40],
-                                len(relevant_exps),
-                                self.used_ex_ids,
-                            )
+                            # 暂停等待用户确认知识注入
+                            try:
+                                confirm_result = await self._wait_for_confirmation(
+                                    trace_id, "knowledge_injection", {
+                                        "goal_id": current_goal.id,
+                                        "goal_description": current_goal.description,
+                                        "knowledge_items": relevant_exps,
+                                    }
+                                )
+                                if confirm_result.get("action") == "reject":
+                                    logger.info(f"[Knowledge Injection] 用户拒绝注入知识到 goal {current_goal.id}")
+                                    relevant_exps = []
+                            except Exception as e:
+                                logger.warning(f"[Knowledge Injection] 确认流程异常,默认注入: {e}")
+
+                            if relevant_exps:
+                                # 保存到 goal 对象
+                                current_goal.knowledge = relevant_exps
+                                logger.info(f"[Knowledge Injection] 已将 {len(relevant_exps)} 条知识注入到 goal {current_goal.id}: {current_goal.description[:40]}")
+                                logger.debug(f"[Knowledge Injection] 注入的知识 IDs: {[exp.get('id') for exp in relevant_exps]}")
+                                # 持久化保存 goal_tree
+                                await self.trace_store.update_goal_tree(trace_id, goal_tree)
+                                self.used_ex_ids = [exp['id'] for exp in relevant_exps]
+                                parts = [f"[{exp['id']}] {exp['content']}" for exp in relevant_exps]
+                                _cached_exp_text = "## 参考历史经验\n" + "\n\n".join(parts)
+                                logger.info(
+                                    "经验检索: goal='%s', 命中 %d 条 %s",
+                                    current_goal.description[:40],
+                                    len(relevant_exps),
+                                    self.used_ex_ids,
+                                )
+                            else:
+                                current_goal.knowledge = []
+                                await self.trace_store.update_goal_tree(trace_id, goal_tree)
+                                _cached_exp_text = ""
                         else:
                             current_goal.knowledge = []
                             logger.info(f"[Knowledge Injection] goal {current_goal.id} 未找到相关知识")
@@ -1122,43 +1214,14 @@ class AgentRunner:
                     sequence += 1
 
 
-            # 调用 LLM(同时监听 cancel 信号,stop 时能立即中断)
-            cancel_event = self._cancel_events.get(trace_id)
-            llm_task = asyncio.create_task(
-                self.llm_call(
-                    messages=llm_messages,
-                    model=config.model,
-                    tools=tool_schemas,
-                    temperature=config.temperature,
-                    **config.extra_llm_params,
-                )
+            # 调用 LLM(等待完成后再检查 cancel 信号,不中断正在进行的调用)
+            result = await self.llm_call(
+                messages=llm_messages,
+                model=config.model,
+                tools=tool_schemas,
+                temperature=config.temperature,
+                **config.extra_llm_params,
             )
-            if cancel_event:
-                cancel_wait = asyncio.create_task(cancel_event.wait())
-                done, pending = await asyncio.wait(
-                    {llm_task, cancel_wait},
-                    return_when=asyncio.FIRST_COMPLETED,
-                )
-                for t in pending:
-                    t.cancel()
-                if cancel_wait in done:
-                    # cancel 先触发,直接停止
-                    llm_task.cancel()
-                    logger.info(f"Trace {trace_id} cancelled during LLM call")
-                    if self.trace_store:
-                        await self.trace_store.update_trace(
-                            trace_id,
-                            status="stopped",
-                            head_sequence=head_seq,
-                            completed_at=datetime.now(),
-                        )
-                        trace_obj = await self.trace_store.get_trace(trace_id)
-                        if trace_obj:
-                            yield trace_obj
-                    return
-                result = llm_task.result()
-            else:
-                result = await llm_task
 
             response_content = result.get("content", "")
             tool_calls = result.get("tool_calls")
@@ -1300,6 +1363,51 @@ class AgentRunner:
                     elif tool_args is None:
                         tool_args = {}
 
+                    # save_knowledge 暂停确认:在实际执行前等待用户确认
+                    if tool_name == "save_knowledge":
+                        try:
+                            confirm_result = await self._wait_for_confirmation(
+                                trace_id, "knowledge_save", {
+                                    "tool_args": tool_args,
+                                }
+                            )
+                            if confirm_result.get("action") == "reject":
+                                logger.info(f"[Knowledge Save] 用户拒绝保存知识: {tool_args.get('scenario', '')[:40]}")
+                                tool_result = {"text": "用户拒绝保存此知识。"}
+                                # 跳过实际执行,直接构造 tool result
+                                tool_text = tool_result["text"]
+                                tool_images = []
+                                tool_usage = None
+
+                                history.append({
+                                    "role": "tool",
+                                    "tool_call_id": tc["id"],
+                                    "content": tool_text,
+                                })
+
+                                if self.trace_store:
+                                    tool_message = Message.create(
+                                        trace_id=trace_id,
+                                        role="tool",
+                                        sequence=sequence,
+                                        goal_id=current_goal_id,
+                                        parent_sequence=head_seq if head_seq > 0 else None,
+                                        content=tool_text,
+                                        tool_call_id=tc["id"],
+                                        tool_name=tool_name,
+                                    )
+                                    await self.trace_store.add_message(tool_message)
+                                    yield tool_message
+                                    head_seq = sequence
+                                    sequence += 1
+                                continue
+                            elif confirm_result.get("edited_args"):
+                                # 用户编辑了内容
+                                tool_args.update(confirm_result["edited_args"])
+                                logger.info(f"[Knowledge Save] 用户编辑了知识内容后确认保存")
+                        except Exception as e:
+                            logger.warning(f"[Knowledge Save] 确认流程异常,默认执行: {e}")
+
                     tool_result = await self.tools.execute(
                         tool_name,
                         tool_args,

+ 17 - 0
agent/trace/models.py

@@ -86,6 +86,7 @@ class Trace:
     # 时间
     created_at: datetime = field(default_factory=datetime.now)
     completed_at: Optional[datetime] = None
+    last_activity_at: datetime = field(default_factory=datetime.now)  # 最后活动时间(用于判断是否真正运行中)
 
     @classmethod
     def create(
@@ -100,6 +101,21 @@ class Trace:
             **kwargs
         )
 
+    @classmethod
+    def from_dict(cls, data: Dict[str, Any]) -> "Trace":
+        """从字典创建 Trace(处理日期字段反序列化)"""
+        from dateutil import parser
+
+        # 处理日期字段
+        if "created_at" in data and isinstance(data["created_at"], str):
+            data["created_at"] = parser.isoparse(data["created_at"])
+        if "completed_at" in data and isinstance(data["completed_at"], str):
+            data["completed_at"] = parser.isoparse(data["completed_at"])
+        if "last_activity_at" in data and isinstance(data["last_activity_at"], str):
+            data["last_activity_at"] = parser.isoparse(data["last_activity_at"])
+
+        return cls(**data)
+
     def to_dict(self) -> Dict[str, Any]:
         """转换为字典"""
         return {
@@ -133,6 +149,7 @@ class Trace:
             "error_message": self.error_message,
             "created_at": self.created_at.isoformat() if self.created_at else None,
             "completed_at": self.completed_at.isoformat() if self.completed_at else None,
+            "last_activity_at": self.last_activity_at.isoformat() if self.last_activity_at else None,
         }
 
 

+ 67 - 4
agent/trace/run_api.py

@@ -100,6 +100,18 @@ class StopResponse(BaseModel):
     status: str  # "stopping" | "not_running"
 
 
+class KnowledgeConfirmRequest(BaseModel):
+    """知识确认请求"""
+    action: str = Field(..., description="confirm | reject")
+    edited_args: Optional[Dict[str, Any]] = Field(None, description="用户编辑后的参数(可选)")
+
+
+class KnowledgeConfirmResponse(BaseModel):
+    """知识确认响应"""
+    trace_id: str
+    status: str  # "resolved" | "no_pending"
+
+
 class ReflectResponse(BaseModel):
     """反思响应"""
     trace_id: str
@@ -411,6 +423,16 @@ async def run_trace(trace_id: str, req: TraceRunRequest):
                 logger.error(f"Unexpected error loading run.py environment for project {project_name} in trace {trace_id}:\n{traceback.format_exc()}")
 
     config = RunConfig(trace_id=trace_id, after_sequence=after_sequence)
+
+    # 恢复运行时,将状态从 stopped 改回 running,并广播状态变化
+    if runner.trace_store and trace_id:
+        current_trace = await runner.trace_store.get_trace(trace_id)
+        if current_trace and current_trace.status == "stopped":
+            await runner.trace_store.update_trace(trace_id, status="running")
+            # 广播状态变化给前端
+            from agent.trace.websocket import broadcast_trace_status_changed
+            await broadcast_trace_status_changed(trace_id, "running")
+
     task = asyncio.create_task(_run_in_background(trace_id, req.messages, config, runner_instance=runner))
     _running_tasks[trace_id] = task
 
@@ -448,6 +470,26 @@ async def stop_trace(trace_id: str):
     return StopResponse(trace_id=trace_id, status="stopping")
 
 
+@router.post("/{trace_id}/knowledge-confirm", response_model=KnowledgeConfirmResponse)
+async def knowledge_confirm(trace_id: str, req: KnowledgeConfirmRequest):
+    """
+    响应知识确认请求(注入或保存)
+
+    前端用户确认/拒绝后调用此端点,resolve runner 中等待的 Future。
+    """
+    runner = _get_runner()
+
+    resolved = await runner.resolve_confirmation(trace_id, {
+        "action": req.action,
+        "edited_args": req.edited_args,
+    })
+
+    if not resolved:
+        return KnowledgeConfirmResponse(trace_id=trace_id, status="no_pending")
+
+    return KnowledgeConfirmResponse(trace_id=trace_id, status="resolved")
+
+
 @router.post("/{trace_id}/reflect", response_model=ReflectResponse)
 async def reflect_trace(trace_id: str, req: ReflectRequest):
     """
@@ -477,7 +519,7 @@ async def reflect_trace(trace_id: str, req: ReflectRequest):
         raise HTTPException(status_code=409, detail="Cannot reflect on a running trace. Stop it first.")
 
     # 1. 构建完整历史消息(沿 parent chain)
-    history, _, _ = await runner._build_history(
+    history, _, _, _ = await runner._build_history(
         trace_id=trace_id,
         new_messages=[],
         goal_tree=None,
@@ -491,7 +533,7 @@ async def reflect_trace(trace_id: str, req: ReflectRequest):
         trace_id=trace_id,
         messages=history,
         llm_call_fn=runner.llm_call,
-        model=runner.model or "anthropic/claude-3-5-sonnet",
+        model=trace.model or "anthropic/claude-3-5-sonnet",
         focus=req.focus
     )
 
@@ -574,13 +616,34 @@ async def compact_trace(trace_id: str):
 
 @router.get("/running", tags=["run"])
 async def list_running():
-    """列出正在运行的 Trace"""
+    """列出正在运行的 Trace(包含活跃状态判断)"""
+    from datetime import datetime, timedelta
+
+    runner = _get_runner()
     running = []
+
     for tid, task in list(_running_tasks.items()):
         if task.done():
             _running_tasks.pop(tid, None)
         else:
-            running.append(tid)
+            # 获取trace详情,检查最后活动时间
+            trace_info = {"trace_id": tid, "is_active": True}
+
+            if runner.trace_store:
+                try:
+                    trace = await runner.trace_store.get_trace(tid)
+                    if trace:
+                        # 判断是否真正活跃:最后活动时间在30秒内
+                        if hasattr(trace, 'last_activity_at') and trace.last_activity_at:
+                            time_since_activity = (datetime.now() - trace.last_activity_at).total_seconds()
+                            trace_info["is_active"] = time_since_activity < 30
+                            trace_info["seconds_since_activity"] = int(time_since_activity)
+                        trace_info["status"] = trace.status
+                except Exception:
+                    pass
+
+            running.append(trace_info)
+
     return {"running": running}
 
 

+ 3 - 3
agent/trace/store.py

@@ -103,7 +103,7 @@ class FileSystemTraceStore:
         if data.get("completed_at"):
             data["completed_at"] = datetime.fromisoformat(data["completed_at"])
 
-        return Trace(**data)
+        return Trace.from_dict(data)
 
     async def update_trace(self, trace_id: str, **updates) -> None:
         """更新 Trace"""
@@ -161,7 +161,7 @@ class FileSystemTraceStore:
                 if data.get("completed_at"):
                     data["completed_at"] = datetime.fromisoformat(data["completed_at"])
 
-                traces.append(Trace(**data))
+                traces.append(Trace.from_dict(data))
             except Exception:
                 continue
 
@@ -703,7 +703,7 @@ class FileSystemTraceStore:
             return []
 
         events = []
-        with events_file.open('r') as f:
+        with events_file.open('r', encoding='utf-8') as f:
             for line in f:
                 try:
                     event = json.loads(line.strip())

+ 74 - 0
agent/trace/websocket.py

@@ -362,6 +362,80 @@ async def broadcast_trace_completed(trace_id: str, total_messages: int):
         del _active_connections[trace_id]
 
 
+async def broadcast_trace_status_changed(trace_id: str, status: str):
+    """
+    广播 Trace 状态变化事件(用于暂停/继续等状态切换)
+
+    Args:
+        trace_id: Trace ID
+        status: 新状态 (running/stopped/completed/failed)
+    """
+    if trace_id not in _active_connections:
+        return
+
+    store = get_trace_store()
+    trace = await store.get_trace(trace_id)
+    if not trace:
+        return
+
+    event_id = await store.append_event(trace_id, "trace_status_changed", {
+        "status": status
+    })
+
+    message = {
+        "event": "trace_status_changed",
+        "event_id": event_id,
+        "ts": datetime.now().isoformat(),
+        "trace_id": trace_id,
+        "status": status,
+        "total_cost": trace.total_cost,
+        "total_messages": trace.total_messages
+    }
+
+    await _broadcast_to_trace(trace_id, message)
+
+
+async def broadcast_knowledge_confirm_request(trace_id: str, confirm_type: str, data: dict):
+    """
+    广播知识确认请求事件,前端收到后弹出确认弹窗。
+    如果当前没有活跃 WebSocket 连接,会等待连接建立后再广播(最多等 60 秒)。
+
+    Args:
+        trace_id: Trace ID
+        confirm_type: "knowledge_injection" | "knowledge_save"
+        data: 确认请求数据(知识内容等)
+    """
+    # 等待 WebSocket 连接建立
+    waited = 0
+    while (trace_id not in _active_connections or not _active_connections[trace_id]) and waited < 60:
+        await asyncio.sleep(1)
+        waited += 1
+
+    if trace_id not in _active_connections or not _active_connections[trace_id]:
+        import logging
+        logging.getLogger(__name__).warning(
+            f"[Knowledge Confirm] 等待 WebSocket 连接超时 (60s),无法广播确认请求: trace={trace_id}, type={confirm_type}"
+        )
+        return
+
+    store = get_trace_store()
+    event_id = await store.append_event(trace_id, "knowledge_confirm_request", {
+        "confirm_type": confirm_type,
+        "data": data,
+    })
+
+    message = {
+        "event": "knowledge_confirm_request",
+        "event_id": event_id,
+        "ts": datetime.now().isoformat(),
+        "trace_id": trace_id,
+        "confirm_type": confirm_type,
+        "data": data,
+    }
+
+    await _broadcast_to_trace(trace_id, message)
+
+
 # ===== 内部辅助函数 =====
 
 

+ 9 - 2
examples/restore/run.py

@@ -334,10 +334,17 @@ async def init_project_env(user_messages: list = None) -> tuple:
         messages.extend(user_messages)
 
     # 4. 初始化 Browser (如果已经存在 session,init_browser_session 内部会处理)
-    browser_mode_name = "云浏览器" if BROWSER_TYPE == "cloud" else "本地浏览器"
+    # Windows 平台自动使用云浏览器(本地浏览器在 Windows 上有兼容性问题)
+    import platform
+    actual_browser_type = BROWSER_TYPE
+    if platform.system() == "Windows" and BROWSER_TYPE == "local":
+        actual_browser_type = "cloud"
+        print("⚠️ Windows 平台检测到本地浏览器配置,自动切换为云浏览器模式")
+
+    browser_mode_name = "云浏览器" if actual_browser_type == "cloud" else "本地浏览器"
     print(f"🌐 正在初始化{browser_mode_name}...")
     await init_browser_session(
-        browser_type=BROWSER_TYPE,
+        browser_type=actual_browser_type,
         headless=HEADLESS,
         url="about:blank"
     )

BIN
frontend/images/image.png


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

@@ -56,6 +56,12 @@ export const traceApi = {
       data,
     });
   },
+  confirmKnowledge(traceId: string, data: { action: "confirm" | "reject"; edited_args?: Record<string, unknown> }) {
+    return request<{ trace_id: string; status: string }>(`/api/traces/${traceId}/knowledge-confirm`, {
+      method: "POST",
+      data,
+    });
+  },
   getExperiences() {
     return request<string>("/api/experiences");
   },

+ 48 - 1
frontend/react-template/src/components/AgentControlPanel/AgentControlPanel.tsx

@@ -1,6 +1,7 @@
 import { useCallback, useEffect, useRef, useState } from "react";
 import type { FC } from "react";
 import { traceApi } from "../../api/traceApi";
+import { KnowledgeConfirmModal, type KnowledgeConfirmData } from "../KnowledgeConfirmModal/KnowledgeConfirmModal";
 import styles from "./AgentControlPanel.module.css";
 
 interface LogLine {
@@ -88,12 +89,15 @@ export const AgentControlPanel: FC<AgentControlPanelProps> = ({
     const [isSubmitting, setIsSubmitting] = useState(false);
     const [isReflecting, setIsReflecting] = useState(false);
     const [isCompacting, setIsCompacting] = useState(false);
+    const [knowledgeConfirm, setKnowledgeConfirm] = useState<KnowledgeConfirmData | null>(null);
     const logsEndRef = useRef<HTMLDivElement>(null);
     const backendLogsEndRef = useRef<HTMLDivElement>(null);
     const wsRef = useRef<WebSocket | null>(null);
     const logWsRef = useRef<WebSocket | null>(null);
     const autoScrollRef = useRef(true);
     const logContainerRef = useRef<HTMLDivElement>(null);
+    const handledConfirmEventsRef = useRef<Set<number>>(new Set());
+    const isReplayingRef = useRef(true); // 历史事件重放阶段标记
 
     const appendLog = useCallback((line: LogLine) => {
         setLogs((prev) => [...prev.slice(-500), line]); // 最多保留500条
@@ -102,6 +106,7 @@ export const AgentControlPanel: FC<AgentControlPanelProps> = ({
     // WebSocket 监听 trace 事件
     useEffect(() => {
         if (!traceId) return;
+        isReplayingRef.current = true; // 每次新连接重置为重放状态
         const wsBase = API_BASE.replace(/^http(s?):\/\//, "ws$1://").replace(/\/+$/, "");
         const url = `${wsBase}/api/traces/${traceId}/watch?since_event_id=0`;
         const ws = new WebSocket(url);
@@ -116,12 +121,35 @@ export const AgentControlPanel: FC<AgentControlPanelProps> = ({
             try {
                 const data: TraceEvent = JSON.parse(event.data);
 
-                // 连接成功事件:即根据服务端返回的真实状态初始化按
+                // 连接成功事件:即根据服务端返回的真实状态初始化按
                 if (data.event === "connected") {
                     const realStatus = data.trace_status || (data.is_running ? "running" : "unknown");
                     setTraceStatus(realStatus);
                     setIsStatusLoaded(true);
                     appendLog({ time: now(), type: "system", text: `当前状态: ${realStatus.toUpperCase()}` });
+                    // connected 事件之后的历史重放结束,后续为实时事件
+                    // 使用 setTimeout 确保在同批历史事件处理完后再标记
+                    setTimeout(() => { isReplayingRef.current = false; }, 0);
+                    return;
+                }
+
+                // 知识确认请求:仅在实时运行时弹窗,跳过历史重放;同一 event_id 不重复弹
+                if (data.event === "knowledge_confirm_request") {
+                    const eventId = (data as unknown as { event_id?: number }).event_id;
+                    if (isReplayingRef.current) return; // 历史重放阶段,跳过
+                    if (eventId != null && handledConfirmEventsRef.current.has(eventId)) return; // 已处理过
+                    if (eventId != null) handledConfirmEventsRef.current.add(eventId);
+                    const d = data as unknown as { confirm_type: string; data: Record<string, unknown> };
+                    // 先暂停执行,让状态面板感知到
+                    if (traceId) {
+                        traceApi.stopTrace(traceId).catch(() => {});
+                    }
+                    setKnowledgeConfirm({
+                        confirm_type: d.confirm_type as KnowledgeConfirmData["confirm_type"],
+                        data: d.data as KnowledgeConfirmData["data"],
+                    });
+                    const label = d.confirm_type === "knowledge_injection" ? "知识注入" : "知识保存";
+                    appendLog({ time: now(), type: "warn", text: `等待用户确认: ${label}` });
                     return;
                 }
 
@@ -319,6 +347,20 @@ export const AgentControlPanel: FC<AgentControlPanelProps> = ({
         }
     };
 
+    const handleKnowledgeConfirm = async (action: "confirm" | "reject", editedArgs?: Record<string, unknown>) => {
+        if (!traceId) return;
+        const label = knowledgeConfirm?.confirm_type === "knowledge_injection" ? "知识注入" : "知识保存";
+        setKnowledgeConfirm(null);
+        try {
+            await traceApi.confirmKnowledge(traceId, { action, edited_args: editedArgs });
+            appendLog({ time: now(), type: "system", text: `${label}: 用户${action === "confirm" ? "确认" : "拒绝"}` });
+            // 确认/拒绝后恢复执行
+            await traceApi.runTrace(traceId);
+        } catch {
+            appendLog({ time: now(), type: "error", text: `${label}确认请求失败` });
+        }
+    };
+
     const handleSubmitIntervention = async () => {
         const text = interventionText.trim();
         if (!text || !traceId) return;
@@ -516,6 +558,11 @@ export const AgentControlPanel: FC<AgentControlPanelProps> = ({
                     </span>
                 </div>
             </div>
+            <KnowledgeConfirmModal
+                visible={knowledgeConfirm !== null}
+                confirmData={knowledgeConfirm}
+                onConfirm={handleKnowledgeConfirm}
+            />
         </div>
     );
 };

+ 166 - 0
frontend/react-template/src/components/KnowledgeConfirmModal/KnowledgeConfirmModal.tsx

@@ -0,0 +1,166 @@
+import { useState, type FC } from "react";
+import { Modal, TextArea, Tag, Descriptions } from "@douyinfe/semi-ui";
+
+interface KnowledgeItem {
+  id: string;
+  scenario?: string;
+  content: string;
+  tags?: { type?: string[] };
+  score?: number;
+}
+
+export interface KnowledgeConfirmData {
+  confirm_type: "knowledge_injection" | "knowledge_save";
+  data: {
+    // knowledge_injection
+    goal_id?: string;
+    goal_description?: string;
+    knowledge_items?: KnowledgeItem[];
+    // knowledge_save
+    tool_args?: {
+      scenario?: string;
+      content?: string;
+      tags_type?: string[];
+      urls?: string[];
+      score?: number;
+    };
+  };
+}
+
+interface KnowledgeConfirmModalProps {
+  visible: boolean;
+  confirmData: KnowledgeConfirmData | null;
+  onConfirm: (action: "confirm" | "reject", editedArgs?: Record<string, unknown>) => void;
+}
+
+export const KnowledgeConfirmModal: FC<KnowledgeConfirmModalProps> = ({
+  visible,
+  confirmData,
+  onConfirm,
+}) => {
+  const [editedContent, setEditedContent] = useState("");
+  const [editedScenario, setEditedScenario] = useState("");
+
+  const isInjection = confirmData?.confirm_type === "knowledge_injection";
+  const isSave = confirmData?.confirm_type === "knowledge_save";
+
+  const handleOpen = () => {
+    if (isSave && confirmData?.data.tool_args) {
+      setEditedContent(confirmData.data.tool_args.content || "");
+      setEditedScenario(confirmData.data.tool_args.scenario || "");
+    }
+  };
+
+  const handleConfirm = () => {
+    if (isSave) {
+      const args = confirmData?.data.tool_args;
+      const hasEdits =
+        editedContent !== (args?.content || "") ||
+        editedScenario !== (args?.scenario || "");
+      onConfirm(
+        "confirm",
+        hasEdits ? { content: editedContent, scenario: editedScenario } : undefined,
+      );
+    } else {
+      onConfirm("confirm");
+    }
+  };
+
+  if (!confirmData) return null;
+
+  return (
+    <Modal
+      visible={visible}
+      title={isInjection ? "📚 知识注入确认" : "💾 知识保存确认"}
+      onCancel={() => onConfirm("reject")}
+      onOk={handleConfirm}
+      okText="确认"
+      cancelText="拒绝"
+      centered
+      width={700}
+      afterVisibleChange={(v: boolean) => v && handleOpen()}
+      bodyStyle={{ maxHeight: "70vh", overflow: "auto" }}
+    >
+      {isInjection && (
+        <div>
+          <Descriptions
+            data={[
+              { key: "目标 Goal", value: confirmData.data.goal_description || "-" },
+              { key: "匹配知识数", value: String(confirmData.data.knowledge_items?.length || 0) },
+            ]}
+            style={{ marginBottom: 16 }}
+          />
+          <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
+            {confirmData.data.knowledge_items?.map((item) => (
+              <div
+                key={item.id}
+                style={{
+                  border: "1px solid #e0e0e0",
+                  borderRadius: 8,
+                  padding: 12,
+                  background: "#fafafa",
+                }}
+              >
+                <div style={{ marginBottom: 6, fontWeight: 600, fontSize: 13 }}>
+                  {item.id}
+                  {item.tags?.type?.map((t) => (
+                    <Tag key={t} size="small" style={{ marginLeft: 6 }} color="blue">
+                      {t}
+                    </Tag>
+                  ))}
+                </div>
+                {item.scenario && (
+                  <div style={{ fontSize: 12, color: "#666", marginBottom: 4 }}>
+                    场景: {item.scenario}
+                  </div>
+                )}
+                <div style={{ fontSize: 13, whiteSpace: "pre-wrap" }}>{item.content}</div>
+              </div>
+            ))}
+          </div>
+        </div>
+      )}
+
+      {isSave && confirmData.data.tool_args && (
+        <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
+          <div>
+            <div style={{ marginBottom: 4, fontWeight: 600, fontSize: 13 }}>标签</div>
+            <div>
+              {confirmData.data.tool_args.tags_type?.map((t) => (
+                <Tag key={t} size="small" color="blue" style={{ marginRight: 4 }}>
+                  {t}
+                </Tag>
+              ))}
+            </div>
+          </div>
+          <div>
+            <div style={{ marginBottom: 4, fontWeight: 600, fontSize: 13 }}>场景</div>
+            <TextArea
+              value={editedScenario}
+              onChange={(v: string) => setEditedScenario(v)}
+              autosize={{ minRows: 2, maxRows: 5 }}
+            />
+          </div>
+          <div>
+            <div style={{ marginBottom: 4, fontWeight: 600, fontSize: 13 }}>内容</div>
+            <TextArea
+              value={editedContent}
+              onChange={(v: string) => setEditedContent(v)}
+              autosize={{ minRows: 4, maxRows: 12 }}
+            />
+          </div>
+          {confirmData.data.tool_args.urls && confirmData.data.tool_args.urls.length > 0 && (
+            <div>
+              <div style={{ marginBottom: 4, fontWeight: 600, fontSize: 13 }}>参考链接</div>
+              <ul style={{ margin: 0, paddingLeft: 20, fontSize: 12 }}>
+                {confirmData.data.tool_args.urls.map((url, i) => (
+                  <li key={i}>{url}</li>
+                ))}
+              </ul>
+            </div>
+          )}
+        </div>
+      )}
+    </Modal>
+  );
+};