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

feat(frontend): 增加工具审批执行闸门

扩展 Trace API 客户端和类型定义,支持读取待审批批次并一次提交全部 approve/reject 决策。

控制面板在 waiting_confirmation 状态展示整批工具、原始参数、可编辑字段和自动批准项;只有 editable_params 可以修改,并以 JSON 形式校验后提交。

审批期间禁用继续、反思、压缩和人工插入,提交成功后恢复运行状态;视觉上明确区分人工确认、自动执行和只读参数。

补齐键盘焦点、窄屏布局和 reduced-motion 适配,并新增 API 与组件测试及 Vitest 配置。
SamLee 13 часов назад
Родитель
Сommit
98713f261e

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

@@ -0,0 +1,36 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const mocks = vi.hoisted(() => ({ request: vi.fn() }));
+
+vi.mock("./client", () => ({ request: mocks.request }));
+
+import { traceApi } from "./traceApi";
+
+describe("traceApi tool approvals", () => {
+  beforeEach(() => vi.clearAllMocks());
+
+  it("fetches the pending batch from the trace-scoped endpoint", async () => {
+    mocks.request.mockResolvedValue({ trace_id: "root@child", batch: null });
+
+    await traceApi.fetchPendingToolApproval("root@child");
+
+    expect(mocks.request).toHaveBeenCalledWith(
+      "/api/traces/root%40child/tool-approvals/pending",
+    );
+  });
+
+  it("posts the complete decision list to the selected batch", async () => {
+    const decisions = [
+      { tool_call_id: "call-1", decision: "approve" as const, edited_arguments: { query: "new" } },
+      { tool_call_id: "call-2", decision: "reject" as const },
+    ];
+    mocks.request.mockResolvedValue({ trace_id: "trace-1", status: "started", message: "" });
+
+    await traceApi.decideToolApproval("trace-1", "batch/1", decisions);
+
+    expect(mocks.request).toHaveBeenCalledWith(
+      "/api/traces/trace-1/tool-approvals/batch%2F1",
+      { method: "POST", data: { decisions } },
+    );
+  });
+});

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

@@ -16,6 +16,45 @@ export interface KnowledgeFeedbackListItem {
   };
 }
 
+export type ToolApprovalDecision = "approve" | "reject";
+
+export interface ToolApprovalCallV1 {
+  tool_call_id: string;
+  tool_name: string;
+  original_arguments: Record<string, unknown>;
+  effective_arguments: Record<string, unknown>;
+  argument_hash: string;
+  editable_params: string[];
+  requires_confirmation: boolean;
+  decision: "pending" | "approved" | "rejected" | "auto_approved";
+  execution_status:
+    | "not_started"
+    | "executing"
+    | "executed"
+    | "rejected"
+    | "execution_unknown";
+  result_message_id: string | null;
+  decided_at: string | null;
+}
+
+export interface ToolApprovalBatchV1 {
+  schema_version: 1;
+  batch_id: string;
+  trace_id: string;
+  assistant_message_id: string;
+  assistant_sequence: number;
+  status: "pending" | "decided" | "executing" | "completed" | "cancelled" | "execution_unknown";
+  calls: ToolApprovalCallV1[];
+  created_at: string;
+  updated_at: string;
+}
+
+export interface ToolApprovalDecisionInput {
+  tool_call_id: string;
+  decision: ToolApprovalDecision;
+  edited_arguments?: Record<string, unknown>;
+}
+
 export const traceApi = {
   fetchTraces(params?: { status?: string; mode?: string; limit?: number }) {
     const query = new URLSearchParams();
@@ -60,6 +99,24 @@ export const traceApi = {
       method: "POST",
     });
   },
+  fetchPendingToolApproval(traceId: string) {
+    return request<{ trace_id: string; batch: ToolApprovalBatchV1 | null }>(
+      `/api/traces/${encodeURIComponent(traceId)}/tool-approvals/pending`,
+    );
+  },
+  decideToolApproval(
+    traceId: string,
+    batchId: string,
+    decisions: ToolApprovalDecisionInput[],
+  ) {
+    return request<{ trace_id: string; status: string; message: string }>(
+      `/api/traces/${encodeURIComponent(traceId)}/tool-approvals/${encodeURIComponent(batchId)}`,
+      {
+        method: "POST",
+        data: { decisions },
+      },
+    );
+  },
   compactTrace(traceId: string) {
     return request<{ previous_count: number; new_count: number; message: string }>(`/api/traces/${traceId}/compact`, {
       method: "POST",

+ 380 - 1
frontend/react-template/src/components/AgentControlPanel/AgentControlPanel.module.css

@@ -64,6 +64,11 @@
     animation: pulse 1.5s infinite;
 }
 
+.statusDot.waiting_confirmation {
+    background: #f59e0b;
+    box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.18);
+}
+
 .statusDot.stopped {
     background: #f59e0b;
 }
@@ -107,6 +112,11 @@
     color: #16a34a;
 }
 
+.badge_waiting_confirmation {
+    background: #fff7d6;
+    color: #92400e;
+}
+
 .badge_stopped {
     background: #fef3c7;
     color: #d97706;
@@ -164,6 +174,13 @@
     background: #bbf7d0;
 }
 
+.btnApprovalWaiting {
+    background: #fff8e6;
+    color: #92400e;
+    border-color: #f59e0b;
+    opacity: 1 !important;
+}
+
 .btnStop {
     background: #fff1f2;
     color: #e11d48;
@@ -199,6 +216,368 @@
     color: #374151;
 }
 
+/* ===== 工具审批:整批执行闸门 ===== */
+.approvalSection {
+    position: relative;
+    flex: 0 1 auto;
+    max-height: 54vh;
+    overflow-y: auto;
+    padding: 18px 20px 16px 26px;
+    background: #fffdf7;
+    border-bottom: 1px solid #ead9ad;
+}
+
+.approvalSection::before {
+    content: "";
+    position: absolute;
+    inset: 0 auto 0 0;
+    width: 6px;
+    background: repeating-linear-gradient(
+        -45deg,
+        #1e293b 0,
+        #1e293b 7px,
+        #fbbf24 7px,
+        #fbbf24 14px
+    );
+}
+
+.approvalHeader {
+    display: flex;
+    align-items: flex-start;
+    justify-content: space-between;
+    gap: 20px;
+    margin-bottom: 14px;
+}
+
+.approvalEyebrow {
+    display: block;
+    margin-bottom: 4px;
+    color: #a16207;
+    font-family: "SF Mono", "Fira Code", monospace;
+    font-size: 10px;
+    font-weight: 800;
+    letter-spacing: 0.14em;
+}
+
+.approvalTitle {
+    margin: 0;
+    color: #1e293b;
+    font-size: 17px;
+    line-height: 1.35;
+}
+
+.approvalDescription {
+    max-width: 480px;
+    margin: 5px 0 0;
+    color: #6b5b37;
+    font-size: 12px;
+    line-height: 1.55;
+}
+
+.approvalBatchMeta {
+    display: flex;
+    flex-direction: column;
+    align-items: flex-end;
+    gap: 3px;
+    flex-shrink: 0;
+    color: #92400e;
+    font-size: 12px;
+    font-weight: 700;
+}
+
+.approvalBatchMeta code {
+    color: #8a7650;
+    font-size: 10px;
+    font-weight: 500;
+}
+
+.approvalError {
+    margin-bottom: 12px;
+    padding: 9px 11px;
+    border: 1px solid #fecaca;
+    border-radius: 8px;
+    background: #fff1f2;
+    color: #b91c1c;
+    font-size: 12px;
+}
+
+.approvalLoading {
+    display: flex;
+    align-items: center;
+    gap: 9px;
+    padding: 20px 4px;
+    color: #7c6a44;
+    font-size: 12px;
+}
+
+.approvalLoadingMark {
+    width: 8px;
+    height: 8px;
+    border-radius: 50%;
+    background: #f59e0b;
+    animation: approvalBlink 1.1s ease-in-out infinite;
+}
+
+@keyframes approvalBlink {
+    50% {
+        opacity: 0.3;
+        transform: scale(0.8);
+    }
+}
+
+.approvalCallList {
+    display: grid;
+    gap: 10px;
+}
+
+.approvalCall {
+    overflow: hidden;
+    border: 1px solid #e8d8b7;
+    border-radius: 10px;
+    background: #ffffff;
+    box-shadow: 0 3px 10px rgba(104, 78, 28, 0.05);
+    transition: border-color 0.15s, opacity 0.15s;
+}
+
+.approvalCallRejected {
+    border-color: #fecaca;
+    opacity: 0.72;
+}
+
+.approvalCallHeader {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    padding: 10px 12px;
+    border-bottom: 1px solid #f2ead8;
+    background: #fffcf4;
+}
+
+.approvalCallIndex {
+    color: #b0893d;
+    font-family: "SF Mono", "Fira Code", monospace;
+    font-size: 11px;
+    font-weight: 700;
+}
+
+.approvalCallIdentity {
+    display: flex;
+    flex-direction: column;
+    gap: 2px;
+    min-width: 0;
+}
+
+.approvalCallIdentity strong {
+    color: #1e293b;
+    font-family: "SF Mono", "Fira Code", monospace;
+    font-size: 13px;
+}
+
+.approvalCallIdentity code {
+    overflow: hidden;
+    color: #94a3b8;
+    font-size: 9px;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+}
+
+.autoApprovalBadge {
+    margin-left: auto;
+    padding: 3px 7px;
+    border-radius: 999px;
+    background: #eef2ff;
+    color: #4f46e5;
+    font-size: 10px;
+    font-weight: 700;
+}
+
+.approvalArguments {
+    display: grid;
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+    gap: 9px;
+    padding: 11px 12px;
+}
+
+.approvalArgument {
+    min-width: 0;
+}
+
+.approvalArgumentLabel {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 8px;
+    margin-bottom: 4px;
+}
+
+.approvalArgumentLabel code {
+    overflow: hidden;
+    color: #334155;
+    font-size: 11px;
+    font-weight: 700;
+    text-overflow: ellipsis;
+}
+
+.approvalArgumentLabel span {
+    color: #a08a5c;
+    font-size: 9px;
+    white-space: nowrap;
+}
+
+.approvalArgumentValue,
+.approvalArgumentEditor {
+    box-sizing: border-box;
+    width: 100%;
+    min-height: 44px;
+    margin: 0;
+    padding: 7px 8px;
+    border: 1px solid #e2e8f0;
+    border-radius: 7px;
+    background: #f8fafc;
+    color: #334155;
+    font-family: "SF Mono", "Fira Code", monospace;
+    font-size: 10px;
+    line-height: 1.45;
+    overflow-wrap: anywhere;
+    white-space: pre-wrap;
+}
+
+.approvalArgumentEditor {
+    resize: vertical;
+    outline: none;
+    transition: border-color 0.15s, box-shadow 0.15s;
+}
+
+.approvalArgumentEditor:focus {
+    border-color: #d97706;
+    box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.14);
+}
+
+.approvalArgumentEditor:disabled {
+    color: #94a3b8;
+    cursor: not-allowed;
+}
+
+.approvalArgumentInvalid {
+    border-color: #ef4444;
+}
+
+.approvalFieldError {
+    display: block;
+    margin-top: 3px;
+    color: #dc2626;
+    font-size: 10px;
+}
+
+.approvalNoArguments {
+    color: #94a3b8;
+    font-size: 11px;
+}
+
+.approvalDecisionGroup {
+    display: grid;
+    grid-template-columns: 1fr 1fr;
+    gap: 8px;
+    padding: 0 12px 11px;
+}
+
+.approvalDecisionButton {
+    height: 32px;
+    border: 1px solid #d8dee8;
+    border-radius: 7px;
+    background: #fff;
+    color: #64748b;
+    cursor: pointer;
+    font-size: 11px;
+    font-weight: 700;
+    transition: all 0.15s;
+}
+
+.approvalDecisionButton:hover:not(:disabled) {
+    border-color: #94a3b8;
+    color: #1e293b;
+}
+
+.approvalDecisionButton:focus-visible,
+.approvalSubmitButton:focus-visible {
+    outline: 3px solid rgba(245, 158, 11, 0.3);
+    outline-offset: 2px;
+}
+
+.approvalDecisionApprove {
+    border-color: #16a34a;
+    background: #f0fdf4;
+    color: #15803d;
+}
+
+.approvalDecisionReject {
+    border-color: #e11d48;
+    background: #fff1f2;
+    color: #be123c;
+}
+
+.approvalDecisionButton:disabled,
+.approvalSubmitButton:disabled {
+    cursor: not-allowed;
+    opacity: 0.45;
+}
+
+.approvalFooter {
+    display: flex;
+    align-items: center;
+    justify-content: flex-end;
+    gap: 14px;
+    margin-top: 12px;
+    color: #806c44;
+    font-size: 11px;
+}
+
+.approvalSubmitButton {
+    min-width: 190px;
+    height: 36px;
+    padding: 0 16px;
+    border: 1px solid #1e293b;
+    border-radius: 8px;
+    background: #1e293b;
+    color: #fffdf7;
+    cursor: pointer;
+    font-size: 12px;
+    font-weight: 700;
+    transition: background 0.15s, transform 0.15s;
+}
+
+.approvalSubmitButton:hover:not(:disabled) {
+    background: #0f172a;
+    transform: translateY(-1px);
+}
+
+@media (max-width: 680px) {
+    .approvalHeader,
+    .approvalFooter {
+        align-items: stretch;
+        flex-direction: column;
+    }
+
+    .approvalBatchMeta {
+        align-items: flex-start;
+    }
+
+    .approvalArguments {
+        grid-template-columns: 1fr;
+    }
+
+    .approvalSubmitButton {
+        width: 100%;
+    }
+}
+
+@media (prefers-reduced-motion: reduce) {
+    .approvalLoadingMark,
+    .statusDot.running {
+        animation: none;
+    }
+}
+
 /* ===== 控制台日志区 ===== */
 .logSection {
     flex: 1;
@@ -505,4 +884,4 @@
 
 .wsOff {
     color: #9ca3af;
-}
+}

+ 184 - 0
frontend/react-template/src/components/AgentControlPanel/AgentControlPanel.test.tsx

@@ -0,0 +1,184 @@
+// @vitest-environment jsdom
+
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("./AgentControlPanel.module.css", () => ({
+  default: new Proxy<Record<string, string>>({}, {
+    get: (_target, property) => String(property),
+  }),
+}));
+
+import { AgentControlPanel } from "./AgentControlPanel";
+import type { ToolApprovalBatchV1 } from "../../api/traceApi";
+
+const mocks = vi.hoisted(() => ({
+  fetchTraceDetail: vi.fn(),
+  fetchPendingToolApproval: vi.fn(),
+  decideToolApproval: vi.fn(),
+}));
+
+vi.mock("../../api/traceApi", async () => ({
+  traceApi: {
+    fetchTraceDetail: mocks.fetchTraceDetail,
+    fetchPendingToolApproval: mocks.fetchPendingToolApproval,
+    decideToolApproval: mocks.decideToolApproval,
+  },
+}));
+
+class MockWebSocket {
+  onopen: (() => void) | null = null;
+  onmessage: ((event: MessageEvent<string>) => void) | null = null;
+  onerror: (() => void) | null = null;
+  onclose: (() => void) | null = null;
+
+  close() {}
+  send() {}
+}
+
+const batch: ToolApprovalBatchV1 = {
+  schema_version: 1,
+  batch_id: "approval-batch-1",
+  trace_id: "trace-123",
+  assistant_message_id: "message-7",
+  assistant_sequence: 7,
+  status: "pending",
+  created_at: "2026-07-17T10:00:00",
+  updated_at: "2026-07-17T10:00:00",
+  calls: [
+    {
+      tool_call_id: "call-search",
+      tool_name: "search_web",
+      original_arguments: { query: "old query", limit: 3 },
+      effective_arguments: { query: "old query", limit: 3 },
+      argument_hash: "a".repeat(64),
+      editable_params: ["query"],
+      requires_confirmation: true,
+      decision: "pending",
+      execution_status: "not_started",
+      result_message_id: null,
+      decided_at: null,
+    },
+    {
+      tool_call_id: "call-delete",
+      tool_name: "delete_file",
+      original_arguments: { path: "/tmp/example.txt" },
+      effective_arguments: { path: "/tmp/example.txt" },
+      argument_hash: "b".repeat(64),
+      editable_params: [],
+      requires_confirmation: true,
+      decision: "pending",
+      execution_status: "not_started",
+      result_message_id: null,
+      decided_at: null,
+    },
+    {
+      tool_call_id: "call-read",
+      tool_name: "read_context",
+      original_arguments: {},
+      effective_arguments: {},
+      argument_hash: "c".repeat(64),
+      editable_params: [],
+      requires_confirmation: false,
+      decision: "auto_approved",
+      execution_status: "not_started",
+      result_message_id: null,
+      decided_at: null,
+    },
+  ],
+};
+
+const waitingTrace = {
+  trace: {
+    trace_id: "trace-123",
+    mode: "agent",
+    task: "approval test",
+    status: "waiting_confirmation",
+    total_messages: 7,
+    total_tokens: 100,
+    total_cost: 0.01,
+    current_goal_id: "1",
+    created_at: "2026-07-17T10:00:00",
+    completed_at: null,
+    parent_trace_id: null,
+  },
+  goal_tree: null,
+  sub_traces: {},
+};
+
+describe("AgentControlPanel tool approval", () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    vi.stubGlobal("WebSocket", MockWebSocket);
+    Object.defineProperty(Element.prototype, "scrollIntoView", {
+      configurable: true,
+      value: vi.fn(),
+    });
+    mocks.fetchTraceDetail.mockResolvedValue(waitingTrace);
+    mocks.fetchPendingToolApproval.mockResolvedValue({
+      trace_id: "trace-123",
+      batch,
+    });
+    mocks.decideToolApproval.mockResolvedValue({
+      trace_id: "trace-123",
+      status: "started",
+      message: "Approved tool batch resume started",
+    });
+  });
+
+  it("submits every pending call once and preserves read-only arguments", async () => {
+    render(<AgentControlPanel traceId="trace-123" onClose={vi.fn()} />);
+
+    await screen.findByText("search_web");
+    expect(screen.getByText("read_context")).toBeTruthy();
+    expect(screen.getByText("随批次自动执行")).toBeTruthy();
+    expect(screen.queryByLabelText("编辑 search_web.limit")).toBeNull();
+
+    const submit = screen.getByRole("button", { name: "提交 2 项决定并继续" }) as HTMLButtonElement;
+    expect(submit.disabled).toBe(true);
+
+    fireEvent.change(screen.getByLabelText("编辑 search_web.query"), {
+      target: { value: '"updated query"' },
+    });
+    fireEvent.click(screen.getByRole("button", { name: "批准 search_web" }));
+    fireEvent.click(screen.getByRole("button", { name: "拒绝 delete_file" }));
+
+    expect(submit.disabled).toBe(false);
+    fireEvent.click(submit);
+
+    await waitFor(() => {
+      expect(mocks.decideToolApproval).toHaveBeenCalledWith(
+        "trace-123",
+        "approval-batch-1",
+        [
+          {
+            tool_call_id: "call-search",
+            decision: "approve",
+            edited_arguments: { query: "updated query", limit: 3 },
+          },
+          {
+            tool_call_id: "call-delete",
+            decision: "reject",
+          },
+        ],
+      );
+    });
+
+    await waitFor(() => expect(screen.queryByText("工具执行等待你的决定")).toBeNull());
+    expect(screen.getByText("RUNNING")).toBeTruthy();
+  });
+
+  it("blocks the batch when an approved editable value is invalid JSON", async () => {
+    render(<AgentControlPanel traceId="trace-123" onClose={vi.fn()} />);
+
+    const editor = await screen.findByLabelText("编辑 search_web.query");
+    fireEvent.change(editor, { target: { value: "not-json" } });
+    fireEvent.click(screen.getByRole("button", { name: "批准 search_web" }));
+    fireEvent.click(screen.getByRole("button", { name: "拒绝 delete_file" }));
+
+    expect(screen.getByText("请输入有效 JSON")).toBeTruthy();
+    const submit = screen.getByRole("button", { name: "提交 2 项决定并继续" }) as HTMLButtonElement;
+    expect(submit.disabled).toBe(true);
+    expect(mocks.decideToolApproval).not.toHaveBeenCalled();
+  });
+});

+ 362 - 9
frontend/react-template/src/components/AgentControlPanel/AgentControlPanel.tsx

@@ -1,6 +1,11 @@
-import { useCallback, useEffect, useRef, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
 import type { FC } from "react";
 import { traceApi } from "../../api/traceApi";
+import type {
+    ToolApprovalBatchV1,
+    ToolApprovalDecision,
+    ToolApprovalDecisionInput,
+} from "../../api/traceApi";
 import styles from "./AgentControlPanel.module.css";
 
 interface LogLine {
@@ -22,6 +27,9 @@ interface BackendLogEntry {
     message: string;
 }
 
+type ApprovalDecisions = Record<string, ToolApprovalDecision | undefined>;
+type ApprovalDrafts = Record<string, Record<string, string>>;
+
 // WebSocket trace 事件类型
 interface TraceEvent {
     event?: string;
@@ -58,6 +66,19 @@ function now() {
     return new Date().toLocaleTimeString("zh-CN", { hour12: false });
 }
 
+function formatJson(value: unknown): string {
+    if (value === undefined) return "null";
+    return JSON.stringify(value, null, 2);
+}
+
+function errorMessage(error: unknown): string {
+    if (typeof error === "object" && error !== null) {
+        const response = (error as { response?: { data?: { detail?: unknown } } }).response;
+        if (typeof response?.data?.detail === "string") return response.data.detail;
+    }
+    return error instanceof Error ? error.message : "工具审批请求失败";
+}
+
 const API_BASE =
     (typeof window !== "undefined"
         ? (window as unknown as { CONFIG?: { API_BASE_URL?: string } }).CONFIG?.API_BASE_URL
@@ -88,6 +109,12 @@ export const AgentControlPanel: FC<AgentControlPanelProps> = ({
     const [isSubmitting, setIsSubmitting] = useState(false);
     const [isReflecting, setIsReflecting] = useState(false);
     const [isCompacting, setIsCompacting] = useState(false);
+    const [approvalBatch, setApprovalBatch] = useState<ToolApprovalBatchV1 | null>(null);
+    const [approvalDecisions, setApprovalDecisions] = useState<ApprovalDecisions>({});
+    const [approvalDrafts, setApprovalDrafts] = useState<ApprovalDrafts>({});
+    const [isApprovalLoading, setIsApprovalLoading] = useState(false);
+    const [isApprovalSubmitting, setIsApprovalSubmitting] = useState(false);
+    const [approvalError, setApprovalError] = useState<string | null>(null);
     const logsEndRef = useRef<HTMLDivElement>(null);
     const backendLogsEndRef = useRef<HTMLDivElement>(null);
     const wsRef = useRef<WebSocket | null>(null);
@@ -134,6 +161,8 @@ export const AgentControlPanel: FC<AgentControlPanelProps> = ({
 
                     if (data.status === "running") {
                         appendLog({ time: now(), type: "system", text: "执行已恢复运行。" });
+                    } else if (data.status === "waiting_confirmation") {
+                        appendLog({ time: now(), type: "warn", text: "工具调用已暂停,等待整批审批。" });
                     } else if (data.status === "stopped") {
                         appendLog({ time: now(), type: "warn", text: "执行已挂起,等待人工干预..." });
                     } else if (data.status === "completed") {
@@ -182,11 +211,12 @@ export const AgentControlPanel: FC<AgentControlPanelProps> = ({
 
         // 初始加载 trace 状态(作为备用手段,主要依靠 connected 事件)
         traceApi.fetchTraceDetail(traceId).then((detail) => {
+            const trace = detail.trace;
             // 仅在状态未被 WebSocket 初始化时才覆盖
-            setTraceStatus(s => s === "unknown" ? (detail.status || "unknown") : s);
+            setTraceStatus(s => s === "unknown" ? (trace.status || "unknown") : s);
             setIsStatusLoaded(true);
-            setTotalCost(detail.total_cost ?? 0);
-            setTotalSteps(detail.total_messages ?? 0);
+            setTotalCost(trace.total_cost ?? 0);
+            setTotalSteps(trace.total_messages ?? 0);
         }).catch(() => { });
 
         // --- 新增:全局后端日志监听 ---
@@ -209,6 +239,72 @@ export const AgentControlPanel: FC<AgentControlPanelProps> = ({
         };
     }, [traceId, appendLog]);
 
+    // Trace 切换时不保留上一批工具的本地决定。
+    useEffect(() => {
+        setApprovalBatch(null);
+        setApprovalDecisions({});
+        setApprovalDrafts({});
+        setApprovalError(null);
+        setIsApprovalLoading(false);
+        setIsApprovalSubmitting(false);
+    }, [traceId]);
+
+    // waiting_confirmation 可能比批次读取稍早到达,因此在空结果时短轮询。
+    useEffect(() => {
+        if (!traceId || traceStatus !== "waiting_confirmation" || approvalBatch) return;
+
+        let active = true;
+        let retryTimer: number | undefined;
+
+        const loadApproval = async () => {
+            setIsApprovalLoading(true);
+            try {
+                const response = await traceApi.fetchPendingToolApproval(traceId);
+                if (!active) return;
+                setApprovalError(null);
+                if (response.batch) {
+                    setApprovalBatch(response.batch);
+                } else {
+                    retryTimer = window.setTimeout(() => void loadApproval(), 1200);
+                }
+            } catch (error) {
+                if (!active) return;
+                setApprovalError(errorMessage(error));
+                retryTimer = window.setTimeout(() => void loadApproval(), 2000);
+            } finally {
+                if (active) setIsApprovalLoading(false);
+            }
+        };
+
+        void loadApproval();
+        return () => {
+            active = false;
+            if (retryTimer !== undefined) window.clearTimeout(retryTimer);
+        };
+    }, [traceId, traceStatus, approvalBatch]);
+
+    useEffect(() => {
+        if (traceStatus === "unknown" || traceStatus === "waiting_confirmation") return;
+        setApprovalBatch(null);
+        setApprovalDecisions({});
+        setApprovalDrafts({});
+        setApprovalError(null);
+    }, [traceStatus]);
+
+    useEffect(() => {
+        if (!approvalBatch) return;
+        const drafts: ApprovalDrafts = {};
+        for (const call of approvalBatch.calls) {
+            if (call.decision !== "pending") continue;
+            drafts[call.tool_call_id] = {};
+            for (const param of call.editable_params) {
+                drafts[call.tool_call_id][param] = formatJson(call.original_arguments[param]);
+            }
+        }
+        setApprovalDecisions({});
+        setApprovalDrafts(drafts);
+    }, [approvalBatch]);
+
     // 自动滚动 (逻辑复用)
     useEffect(() => {
         if (!autoScrollRef.current) return;
@@ -226,6 +322,93 @@ export const AgentControlPanel: FC<AgentControlPanelProps> = ({
         autoScrollRef.current = isAtBottom;
     };
 
+    const pendingApprovalCalls = useMemo(
+        () => approvalBatch?.calls.filter((call) => call.decision === "pending") ?? [],
+        [approvalBatch],
+    );
+
+    const approvalDraftErrors = useMemo(() => {
+        const errors: Record<string, string> = {};
+        for (const call of pendingApprovalCalls) {
+            if (approvalDecisions[call.tool_call_id] !== "approve") continue;
+            for (const param of call.editable_params) {
+                const key = `${call.tool_call_id}:${param}`;
+                try {
+                    JSON.parse(approvalDrafts[call.tool_call_id]?.[param] ?? "null");
+                } catch {
+                    errors[key] = "请输入有效 JSON";
+                }
+            }
+        }
+        return errors;
+    }, [approvalDecisions, approvalDrafts, pendingApprovalCalls]);
+
+    const allApprovalDecisionsReady =
+        pendingApprovalCalls.length > 0 &&
+        pendingApprovalCalls.every((call) => Boolean(approvalDecisions[call.tool_call_id])) &&
+        Object.keys(approvalDraftErrors).length === 0;
+
+    const chooseApprovalDecision = (
+        toolCallId: string,
+        decision: ToolApprovalDecision,
+    ) => {
+        setApprovalDecisions((previous) => ({ ...previous, [toolCallId]: decision }));
+        setApprovalError(null);
+    };
+
+    const updateApprovalDraft = (toolCallId: string, param: string, value: string) => {
+        setApprovalDrafts((previous) => ({
+            ...previous,
+            [toolCallId]: {
+                ...previous[toolCallId],
+                [param]: value,
+            },
+        }));
+        setApprovalError(null);
+    };
+
+    const handleSubmitApprovals = async () => {
+        if (!traceId || !approvalBatch || !allApprovalDecisionsReady) return;
+
+        const decisions: ToolApprovalDecisionInput[] = pendingApprovalCalls.map((call) => {
+            const decision = approvalDecisions[call.tool_call_id];
+            if (decision === "reject") {
+                return { tool_call_id: call.tool_call_id, decision };
+            }
+
+            const editedArguments = { ...call.original_arguments };
+            for (const param of call.editable_params) {
+                editedArguments[param] = JSON.parse(
+                    approvalDrafts[call.tool_call_id]?.[param] ?? "null",
+                ) as unknown;
+            }
+            return {
+                tool_call_id: call.tool_call_id,
+                decision: "approve",
+                edited_arguments: editedArguments,
+            };
+        });
+
+        setIsApprovalSubmitting(true);
+        setApprovalError(null);
+        try {
+            await traceApi.decideToolApproval(traceId, approvalBatch.batch_id, decisions);
+            setTraceStatus("running");
+            setApprovalBatch(null);
+            setApprovalDecisions({});
+            setApprovalDrafts({});
+            appendLog({
+                time: now(),
+                type: "system",
+                text: `已提交 ${decisions.length} 项工具决定,Agent 继续执行。`,
+            });
+        } catch (error) {
+            setApprovalError(errorMessage(error));
+        } finally {
+            setIsApprovalSubmitting(false);
+        }
+    };
+
     // 轮询等待 trace 停止
     const waitUntilStopped = async (id: string, maxMs = 30000): Promise<boolean> => {
         const interval = 300;
@@ -233,7 +416,7 @@ export const AgentControlPanel: FC<AgentControlPanelProps> = ({
         while (waited < maxMs) {
             try {
                 const detail = await traceApi.fetchTraceDetail(id);
-                if (detail.status !== "running") return true;
+                if (detail.trace.status !== "running") return true;
             } catch {
                 return true;
             }
@@ -344,10 +527,12 @@ export const AgentControlPanel: FC<AgentControlPanelProps> = ({
     };
 
     const isRunning = traceStatus === "running";
+    const isWaitingForApproval = traceStatus === "waiting_confirmation";
     const isStopped = traceStatus === "stopped" || traceStatus === "completed" || traceStatus === "failed";
 
     const statusLabel: Record<string, string> = {
         running: "RUNNING",
+        waiting_confirmation: "APPROVAL REQUIRED",
         stopped: "PAUSED",
         completed: "COMPLETED",
         failed: "FAILED",
@@ -367,7 +552,14 @@ export const AgentControlPanel: FC<AgentControlPanelProps> = ({
                         </span>
                     </div>
                     <div className={styles.headerRight}>
-                        {isRunning ? (
+                        {isWaitingForApproval ? (
+                            <button
+                                className={`${styles.btn} ${styles.btnApprovalWaiting}`}
+                                disabled
+                            >
+                                ◆ 等待审批
+                            </button>
+                        ) : isRunning ? (
                             <button
                                 className={`${styles.btn} ${styles.btnPause}`}
                                 onClick={handlePause}
@@ -398,6 +590,166 @@ export const AgentControlPanel: FC<AgentControlPanelProps> = ({
                     </div>
                 </div>
 
+                {/* === 工具执行闸门:整批决定后才恢复 Agent === */}
+                {isWaitingForApproval && (
+                    <section className={styles.approvalSection} aria-labelledby="tool-approval-title">
+                        <div className={styles.approvalHeader}>
+                            <div>
+                                <span className={styles.approvalEyebrow}>EXECUTION GATE</span>
+                                <h2 id="tool-approval-title" className={styles.approvalTitle}>
+                                    工具执行等待你的决定
+                                </h2>
+                                <p className={styles.approvalDescription}>
+                                    逐项批准或拒绝;全部决定会一次提交,期间不会执行任何工具。
+                                </p>
+                            </div>
+                            {approvalBatch && (
+                                <div className={styles.approvalBatchMeta}>
+                                    <span>{pendingApprovalCalls.length} 项待确认</span>
+                                    <code>SEQ {approvalBatch.assistant_sequence}</code>
+                                </div>
+                            )}
+                        </div>
+
+                        {approvalError && (
+                            <div className={styles.approvalError} role="alert">
+                                {approvalError}
+                            </div>
+                        )}
+
+                        {!approvalBatch ? (
+                            <div className={styles.approvalLoading} aria-live="polite">
+                                <span className={styles.approvalLoadingMark} />
+                                {isApprovalLoading ? "正在读取待确认调用…" : "暂未读取到审批批次,即将重试。"}
+                            </div>
+                        ) : (
+                            <>
+                                <div className={styles.approvalCallList}>
+                                    {approvalBatch.calls.map((call, index) => {
+                                        const isPending = call.decision === "pending";
+                                        const selectedDecision = approvalDecisions[call.tool_call_id];
+                                        const argumentNames = Array.from(new Set([
+                                            ...Object.keys(call.original_arguments),
+                                            ...call.editable_params,
+                                        ]));
+
+                                        return (
+                                            <article
+                                                className={`${styles.approvalCall} ${
+                                                    selectedDecision === "reject" ? styles.approvalCallRejected : ""
+                                                }`}
+                                                key={call.tool_call_id}
+                                            >
+                                                <div className={styles.approvalCallHeader}>
+                                                    <span className={styles.approvalCallIndex}>
+                                                        {String(index + 1).padStart(2, "0")}
+                                                    </span>
+                                                    <div className={styles.approvalCallIdentity}>
+                                                        <strong>{call.tool_name}</strong>
+                                                        <code>{call.tool_call_id}</code>
+                                                    </div>
+                                                    {!isPending && (
+                                                        <span className={styles.autoApprovalBadge}>随批次自动执行</span>
+                                                    )}
+                                                </div>
+
+                                                <div className={styles.approvalArguments}>
+                                                    {argumentNames.length === 0 ? (
+                                                        <span className={styles.approvalNoArguments}>无参数</span>
+                                                    ) : argumentNames.map((param) => {
+                                                        const editable = isPending && call.editable_params.includes(param);
+                                                        const draftKey = `${call.tool_call_id}:${param}`;
+                                                        return (
+                                                            <div className={styles.approvalArgument} key={param}>
+                                                                <div className={styles.approvalArgumentLabel}>
+                                                                    <code>{param}</code>
+                                                                    <span>{editable ? "可编辑 · JSON" : "只读"}</span>
+                                                                </div>
+                                                                {editable ? (
+                                                                    <>
+                                                                        <textarea
+                                                                            aria-label={`编辑 ${call.tool_name}.${param}`}
+                                                                            className={`${styles.approvalArgumentEditor} ${
+                                                                                approvalDraftErrors[draftKey] ? styles.approvalArgumentInvalid : ""
+                                                                            }`}
+                                                                            value={approvalDrafts[call.tool_call_id]?.[param] ?? "null"}
+                                                                            onChange={(event) => updateApprovalDraft(
+                                                                                call.tool_call_id,
+                                                                                param,
+                                                                                event.target.value,
+                                                                            )}
+                                                                            disabled={selectedDecision === "reject" || isApprovalSubmitting}
+                                                                            rows={Math.min(5, Math.max(2, (approvalDrafts[call.tool_call_id]?.[param] ?? "").split("\n").length))}
+                                                                        />
+                                                                        {approvalDraftErrors[draftKey] && (
+                                                                            <span className={styles.approvalFieldError}>
+                                                                                {approvalDraftErrors[draftKey]}
+                                                                            </span>
+                                                                        )}
+                                                                    </>
+                                                                ) : (
+                                                                    <pre className={styles.approvalArgumentValue}>
+                                                                        {formatJson(call.original_arguments[param])}
+                                                                    </pre>
+                                                                )}
+                                                            </div>
+                                                        );
+                                                    })}
+                                                </div>
+
+                                                {isPending && (
+                                                    <div className={styles.approvalDecisionGroup}>
+                                                        <button
+                                                            type="button"
+                                                            aria-pressed={selectedDecision === "approve"}
+                                                            aria-label={`批准 ${call.tool_name}`}
+                                                            className={`${styles.approvalDecisionButton} ${
+                                                                selectedDecision === "approve" ? styles.approvalDecisionApprove : ""
+                                                            }`}
+                                                            onClick={() => chooseApprovalDecision(call.tool_call_id, "approve")}
+                                                            disabled={isApprovalSubmitting}
+                                                        >
+                                                            批准执行
+                                                        </button>
+                                                        <button
+                                                            type="button"
+                                                            aria-pressed={selectedDecision === "reject"}
+                                                            aria-label={`拒绝 ${call.tool_name}`}
+                                                            className={`${styles.approvalDecisionButton} ${
+                                                                selectedDecision === "reject" ? styles.approvalDecisionReject : ""
+                                                            }`}
+                                                            onClick={() => chooseApprovalDecision(call.tool_call_id, "reject")}
+                                                            disabled={isApprovalSubmitting}
+                                                        >
+                                                            拒绝调用
+                                                        </button>
+                                                    </div>
+                                                )}
+                                            </article>
+                                        );
+                                    })}
+                                </div>
+
+                                <div className={styles.approvalFooter}>
+                                    <span>
+                                        {Object.values(approvalDecisions).filter(Boolean).length}/{pendingApprovalCalls.length} 项已决定
+                                    </span>
+                                    <button
+                                        type="button"
+                                        className={styles.approvalSubmitButton}
+                                        onClick={() => void handleSubmitApprovals()}
+                                        disabled={!allApprovalDecisionsReady || isApprovalSubmitting}
+                                    >
+                                        {isApprovalSubmitting
+                                            ? "正在提交整批决定…"
+                                            : `提交 ${pendingApprovalCalls.length} 项决定并继续`}
+                                    </button>
+                                </div>
+                            </>
+                        )}
+                    </section>
+                )}
+
                 {/* === 控制台日志区 === */}
                 <div className={styles.logSection}>
                     <div className={styles.logHeader}>
@@ -468,7 +820,7 @@ export const AgentControlPanel: FC<AgentControlPanelProps> = ({
                     <button
                         className={`${styles.actionCard} ${isReflecting ? styles.actionCardActive : ""}`}
                         onClick={handleReflect}
-                        disabled={isReflecting || isRunning}
+                        disabled={isReflecting || isRunning || isWaitingForApproval}
                     >
                         <span className={styles.actionIcon}>🧠</span>
                         <span className={styles.actionLabel}>{isReflecting ? "反思中..." : "触发反思"}</span>
@@ -476,7 +828,7 @@ export const AgentControlPanel: FC<AgentControlPanelProps> = ({
                     <button
                         className={`${styles.actionCard} ${isCompacting ? styles.actionCardActive : ""}`}
                         onClick={handleCompact}
-                        disabled={isCompacting || isRunning}
+                        disabled={isCompacting || isRunning || isWaitingForApproval}
                     >
                         <span className={styles.actionIcon}>⊞</span>
                         <span className={styles.actionLabel}>{isCompacting ? "压缩中..." : "压缩上下文"}</span>
@@ -490,6 +842,7 @@ export const AgentControlPanel: FC<AgentControlPanelProps> = ({
                         placeholder="插入人工干预消息..."
                         value={interventionText}
                         onChange={(e) => setInterventionText(e.target.value)}
+                        disabled={isWaitingForApproval}
                         rows={3}
                         onKeyDown={(e) => {
                             if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
@@ -501,7 +854,7 @@ export const AgentControlPanel: FC<AgentControlPanelProps> = ({
                     <button
                         className={styles.submitBtn}
                         onClick={handleSubmitIntervention}
-                        disabled={isSubmitting || !interventionText.trim()}
+                        disabled={isSubmitting || isWaitingForApproval || !interventionText.trim()}
                     >
                         {isSubmitting ? "提交中..." : "提交指令"}
                     </button>

+ 15 - 0
frontend/react-template/src/types/trace.ts

@@ -23,6 +23,21 @@ export interface TraceListResponse {
 }
 
 export interface TraceDetailResponse {
+  trace: TraceListItem & {
+    total_tokens: number;
+    completed_at: string | null;
+    context?: Record<string, unknown>;
+  };
+  goal_tree: {
+    mission: string;
+    current_id: string | null;
+    goals: Goal[];
+  } | null;
+  sub_traces?: Record<string, TraceListItem>;
+}
+
+/** @deprecated Trace 详情现由 TraceDetailResponse.trace 承载。 */
+export interface LegacyFlatTraceDetail {
   trace_id: string;
   mode: string;
   task: string;

+ 8 - 0
frontend/react-template/vitest.config.ts

@@ -0,0 +1,8 @@
+import { defineConfig } from "vitest/config";
+
+// 单元测试不需要 Tailwind 的生产 CSS 生成链;使用独立配置避免测试运行时加载构建插件。
+export default defineConfig({
+  test: {
+    clearMocks: true,
+  },
+});