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

feat: 增强跟踪详情展示与错误处理

- 扩展 Goal 和 Trace 类型以支持统计数据和子跟踪信息
- 在详情面板中使用 ReactMarkdown 渲染消息内容
- 添加全局错误边界以优雅处理运行时错误
- 实现跟踪完成状态标识和序列连续性检查
- 改进 API 客户端错误处理和响应拦截
- 添加测试相关依赖以支持前端测试
max_liu 1 неделя назад
Родитель
Сommit
ed513cdeaa

Разница между файлами не показана из-за своего большого размера
+ 719 - 43
frontend/react-template/package-lock.json


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

@@ -16,11 +16,14 @@
     "d3": "^7.8.5",
     "react": "^18.2.0",
     "react-dom": "^18.2.0",
+    "react-error-boundary": "^6.1.1",
     "react-markdown": "^10.1.0"
   },
   "devDependencies": {
     "@tailwindcss/postcss": "^4.0.0",
     "@tailwindcss/vite": "^4.0.0",
+    "@testing-library/dom": "^10.4.1",
+    "@testing-library/react": "^16.3.2",
     "@types/d3": "^7.4.3",
     "@types/node": "^20.11.5",
     "@types/react": "^18.2.43",
@@ -32,9 +35,11 @@
     "eslint": "^8.55.0",
     "eslint-plugin-react-hooks": "^4.6.0",
     "eslint-plugin-react-refresh": "^0.4.5",
+    "jsdom": "^28.1.0",
     "postcss": "^8.4.38",
     "tailwindcss": "^4.0.0",
     "typescript": "^5.2.2",
-    "vite": "^5.0.8"
+    "vite": "^5.0.8",
+    "vitest": "^4.0.18"
   }
 }

+ 42 - 12
frontend/react-template/src/api/client.ts

@@ -1,23 +1,53 @@
 import axios from "axios";
+import { Toast } from "@douyinfe/semi-ui";
 
+// Determine base URL from environment variables, or fallback to default
 const DEFAULT_BASE_URL = "http://localhost:8000";
 
-export const baseURL =
-  (typeof import.meta !== "undefined" &&
-    typeof import.meta.env !== "undefined" &&
-    (import.meta.env as { VITE_API_BASE_URL?: string }).VITE_API_BASE_URL) ||
-  DEFAULT_BASE_URL;
+// Handle various environment variable formats (Vite uses import.meta.env.VITE_*)
+const getBaseUrl = () => {
+  if (typeof import.meta !== "undefined" && import.meta.env && import.meta.env.VITE_API_BASE_URL) {
+    return import.meta.env.VITE_API_BASE_URL;
+  }
+  return DEFAULT_BASE_URL;
+};
 
-export const http = axios.create({
-  baseURL,
-  headers: {
-    "Content-Type": "application/json",
-  },
+export const client = axios.create({
+  baseURL: getBaseUrl(),
 });
 
-export async function request<T>(path: string, init?: { method?: string; data?: unknown; params?: Record<string, unknown> }): Promise<T> {
+client.interceptors.response.use(
+  (response) => response,
+  (error) => {
+    // Check if error has a response (server responded with status code outside 2xx)
+    if (error.response) {
+      const { status, data } = error.response;
+      const message = data?.detail || data?.message || "请求失败";
+
+      // Handle specific status codes
+      if (status >= 500) {
+        Toast.error(`服务器错误 (${status}): ${message}`);
+      } else if (status >= 400) {
+        Toast.error(`请求错误 (${status}): ${message}`);
+      }
+    } else if (error.request) {
+      // The request was made but no response was received
+      Toast.error("网络错误: 无法连接到服务器");
+    } else {
+      // Something happened in setting up the request that triggered an Error
+      Toast.error(`请求配置错误: ${error.message}`);
+    }
+
+    return Promise.reject(error);
+  },
+);
+
+export async function request<T>(
+  path: string,
+  init?: { method?: string; data?: unknown; params?: Record<string, unknown> },
+): Promise<T> {
   const method = init?.method || "GET";
-  const response = await http.request<T>({
+  const response = await client.request<T>({
     url: path,
     method,
     params: init?.params,

+ 4 - 3
frontend/react-template/src/components/DetailPanel/DetailPanel.tsx

@@ -1,3 +1,4 @@
+import ReactMarkdown from "react-markdown";
 import type { Goal } from "../../types/goal";
 import type { Edge, Message } from "../../types/message";
 import styles from "./DetailPanel.module.css";
@@ -14,10 +15,10 @@ export const DetailPanel = ({ node, edge, messages = [], onClose }: DetailPanelP
 
   const renderMessageContent = (content: Message["content"]) => {
     if (!content) return "";
-    if (typeof content === "string") return content;
+    if (typeof content === "string") return <ReactMarkdown>{content}</ReactMarkdown>;
 
     // 如果有 text,优先显示 text
-    if (content.text) return content.text;
+    if (content.text) return <ReactMarkdown>{content.text}</ReactMarkdown>;
 
     // 如果有 tool_calls,展示 tool_calls 信息
     if (content.tool_calls && content.tool_calls.length > 0) {
@@ -36,7 +37,7 @@ export const DetailPanel = ({ node, edge, messages = [], onClose }: DetailPanelP
       );
     }
 
-    return JSON.stringify(content);
+    return <ReactMarkdown>{JSON.stringify(content)}</ReactMarkdown>;
   };
 
   const isGoal = (node: Goal | Message): node is Goal => {

+ 63 - 25
frontend/react-template/src/components/FlowChart/hooks/useFlowChartData.ts

@@ -50,9 +50,11 @@ export const useFlowChartData = (traceId: string | null, refreshTrigger?: number
   const [sinceEventId, setSinceEventId] = useState(0);
   const [readyToConnect, setReadyToConnect] = useState(false);
   const currentEventIdRef = useRef(0);
+  const maxSequenceRef = useRef(0);
   const restReloadingRef = useRef(false);
   const [reloading, setReloading] = useState(false);
   const [invalidBranches, setInvalidBranches] = useState<Message[][]>([]);
+  const [traceCompleted, setTraceCompleted] = useState(false);
 
   const messageComparator = useCallback((a: Message, b: Message): number => {
     // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -94,7 +96,9 @@ export const useFlowChartData = (traceId: string | null, refreshTrigger?: number
     setSinceEventId(0);
     setReadyToConnect(false);
     currentEventIdRef.current = 0;
+    maxSequenceRef.current = 0;
     restReloadingRef.current = false;
+    setTraceCompleted(false);
   }, [traceId]);
 
   const reloadViaRest = useCallback(async () => {
@@ -153,6 +157,13 @@ export const useFlowChartData = (traceId: string | null, refreshTrigger?: number
 
         const { availableData: finalMessages, invalidBranches: invalidBranchesTemp } = processRetryLogic(nextMessages);
 
+        // Update max sequence
+        const maxSeq = finalMessages.reduce((max, msg) => {
+          const seq = typeof msg.sequence === "number" ? msg.sequence : -1;
+          return Math.max(max, seq);
+        }, 0);
+        maxSequenceRef.current = maxSeq;
+
         setMessages(finalMessages);
         setInvalidBranches(invalidBranchesTemp);
         const grouped: Record<string, Message[]> = {};
@@ -250,32 +261,41 @@ export const useFlowChartData = (traceId: string | null, refreshTrigger?: number
         if (typeof currentEventId === "number") {
           currentEventIdRef.current = Math.max(currentEventIdRef.current, currentEventId);
         }
+        return;
+      }
 
-        const trace = isRecord(data.trace) ? data.trace : undefined;
-        const rawTrace = isRecord(raw.trace) ? raw.trace : undefined;
-        const goalTree =
-          (isRecord(data.goal_tree) ? data.goal_tree : undefined) ||
-          (trace && isRecord(trace.goal_tree) ? trace.goal_tree : undefined) ||
-          (isRecord(raw.goal_tree) ? raw.goal_tree : undefined) ||
-          (rawTrace && isRecord(rawTrace.goal_tree) ? rawTrace.goal_tree : undefined) ||
-          {};
-        const goalList = isRecord(goalTree) ? goalTree.goals : undefined;
-        const nextGoals = Array.isArray(goalList) ? (goalList as Goal[]) : [];
-        setGoals((prev) => {
-          const mergedFlat = nextGoals.map((ng) => {
-            const existing = prev.find((p) => p.id === ng.id);
-            if (!existing) return ng;
-            const merged: Goal = { ...existing, ...ng };
-            if (existing.sub_trace_ids && !merged.sub_trace_ids) {
-              merged.sub_trace_ids = existing.sub_trace_ids;
-            }
-            if (existing.agent_call_mode && !merged.agent_call_mode) {
-              merged.agent_call_mode = existing.agent_call_mode;
-            }
-            return merged;
+      if (event === "rewind") {
+        console.log("Processing rewind event:", data);
+        const afterSequence =
+          (typeof data.after_sequence === "number" ? data.after_sequence : undefined) ||
+          (typeof raw.after_sequence === "number" ? raw.after_sequence : undefined);
+
+        if (typeof afterSequence === "number") {
+          maxSequenceRef.current = afterSequence;
+          setMessages((prev) =>
+            prev.filter((msg) => (typeof msg.sequence === "number" ? msg.sequence : -1) <= afterSequence),
+          );
+
+          setMsgGroups((prev) => {
+            const next: Record<string, Message[]> = {};
+            Object.entries(prev).forEach(([k, v]) => {
+              const filtered = v.filter(
+                (msg) => (typeof msg.sequence === "number" ? msg.sequence : -1) <= afterSequence,
+              );
+              if (filtered.length > 0) next[k] = filtered;
+            });
+            return next;
           });
-          return buildSubGoals(mergedFlat);
-        });
+
+          // 如果有 goal_tree_snapshot,直接更新 Goals
+          const snapshot = isRecord(data.goal_tree_snapshot) ? data.goal_tree_snapshot : undefined;
+          if (snapshot && Array.isArray(snapshot.goals)) {
+            setGoals(buildSubGoals(snapshot.goals as Goal[]));
+          } else {
+            // 否则触发重载
+            void reloadViaRest();
+          }
+        }
         return;
       }
 
@@ -333,9 +353,27 @@ export const useFlowChartData = (traceId: string | null, refreshTrigger?: number
         return;
       }
 
+      if (event === "trace_completed") {
+        setTraceCompleted(true);
+        return;
+      }
+
       if (event === "message_added") {
         const message = isMessage(data.message) ? data.message : isMessage(raw.message) ? raw.message : null;
         if (message) {
+          // Check sequence continuity
+          const seq = typeof message.sequence === "number" ? message.sequence : -1;
+          if (seq > 0) {
+            if (maxSequenceRef.current > 0 && seq > maxSequenceRef.current + 1) {
+              console.warn(
+                `Message sequence gap detected: current max ${maxSequenceRef.current}, received ${seq}. Triggering reload.`,
+              );
+              void reloadViaRest();
+              return;
+            }
+            maxSequenceRef.current = Math.max(maxSequenceRef.current, seq);
+          }
+
           setMessages((prev) => {
             const next = [...prev, message];
             next.sort(messageComparator);
@@ -356,5 +394,5 @@ export const useFlowChartData = (traceId: string | null, refreshTrigger?: number
   // 只有当 traceId 存在且 REST 加载完成 (readyToConnect) 后才连接 WebSocket
   const { connected } = useWebSocket(readyToConnect ? traceId : null, wsOptions);
 
-  return { goals, messages, msgGroups, connected, reloading, invalidBranches };
+  return { goals, messages, msgGroups, connected, reloading, invalidBranches, traceCompleted };
 };

+ 25 - 0
frontend/react-template/src/components/MainContent/MainContent.module.css

@@ -58,6 +58,31 @@
   background-color: var(--border-medium);
 }
 
+.completedBadge {
+  display: flex;
+  align-items: center;
+  gap: var(--space-xs);
+  padding: 4px 12px;
+  background-color: #ecfdf5; /* Emerald 50 */
+  border: 1px solid var(--color-success);
+  border-radius: var(--radius-full);
+  color: var(--color-success);
+  font-size: 13px;
+  font-weight: 500;
+  animation: fadeIn 300ms ease-out;
+}
+
+@keyframes fadeIn {
+  from {
+    opacity: 0;
+    transform: translateY(-4px);
+  }
+  to {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
+
 .headerRight {
   display: flex;
   align-items: center;

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

@@ -47,7 +47,10 @@ export const MainContent: FC<MainContentProps> = ({
   const [cachedGoals, setCachedGoals] = useState<Goal[]>([]);
   const [cachedMsgGroups, setCachedMsgGroups] = useState<Record<string, Message[]>>({});
   const [cachedInvalidBranches, setCachedInvalidBranches] = useState<Message[][]>([]);
-  const { goals, connected, msgGroups, reloading, invalidBranches } = useFlowChartData(traceId, messageRefreshTrigger);
+  const { goals, connected, msgGroups, reloading, invalidBranches, traceCompleted } = useFlowChartData(
+    traceId,
+    messageRefreshTrigger,
+  );
   console.log("%c [ msgGroups ]-34", "font-size:13px; background:pink; color:#bf2c9f;", msgGroups);
   const displayGoals = goals.length > 0 ? goals : cachedGoals;
   const displayMsgGroups = Object.keys(msgGroups).length > 0 ? msgGroups : cachedMsgGroups;
@@ -111,7 +114,15 @@ export const MainContent: FC<MainContentProps> = ({
   return (
     <div className={styles.main}>
       <div className={styles.header}>
-        <ConnectionStatus isConnected={connected} />
+        <div style={{ display: "flex", alignItems: "center", gap: 16 }}>
+          <ConnectionStatus isConnected={connected} />
+          {traceCompleted && (
+            <div className={styles.completedBadge}>
+              <span style={{ fontSize: 16 }}>✓</span>
+              <span>执行完成</span>
+            </div>
+          )}
+        </div>
         <div className={styles.headerRight}>
           <Select
             value={traceId}

+ 32 - 1
frontend/react-template/src/main.tsx

@@ -1,10 +1,41 @@
 import { createRoot } from "react-dom/client";
+import { ErrorBoundary } from "react-error-boundary";
+import type { FallbackProps } from "react-error-boundary";
 import App from "./App";
 import "./styles/global.css";
 import "./styles/variables.css";
 
 const container = document.getElementById("root");
 
+const ErrorFallback = ({ error, resetErrorBoundary }: FallbackProps) => {
+  return (
+    <div style={{ padding: "20px", textAlign: "center", marginTop: "50px" }}>
+      <h2>Something went wrong:</h2>
+      <pre style={{ color: "red", backgroundColor: "#fce4e4", padding: "10px", borderRadius: "4px" }}>
+        {error instanceof Error ? error.message : String(error)}
+      </pre>
+      <button
+        onClick={resetErrorBoundary}
+        style={{
+          marginTop: "10px",
+          padding: "8px 16px",
+          backgroundColor: "#3b82f6",
+          color: "white",
+          border: "none",
+          borderRadius: "4px",
+          cursor: "pointer",
+        }}
+      >
+        Try again
+      </button>
+    </div>
+  );
+};
+
 if (container) {
-  createRoot(container).render(<App />);
+  createRoot(container).render(
+    <ErrorBoundary FallbackComponent={ErrorFallback}>
+      <App />
+    </ErrorBoundary>,
+  );
 }

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

@@ -1,3 +1,10 @@
+export interface GoalStats {
+  message_count: number;
+  total_tokens: number;
+  total_cost: number;
+  preview?: string;
+}
+
 export interface Goal {
   id: string;
   description: string;
@@ -11,6 +18,8 @@ export interface Goal {
   agent_call_mode?: string;
   sub_trace_ids?: Array<string | { trace_id: string; mission?: string }>;
   sub_goals?: Array<Goal>;
+  self_stats?: GoalStats;
+  cumulative_stats?: GoalStats;
 }
 
 export interface BranchContext {

+ 4 - 1
frontend/react-template/src/types/trace.ts

@@ -11,6 +11,8 @@ export interface TraceListItem {
   current_goal_id: string;
   created_at: string;
   parent_trace_id: string | null;
+  agent_type?: string;
+  parent_goal_id?: string;
 }
 
 export interface TraceListResponse {
@@ -33,5 +35,6 @@ export interface TraceDetailResponse {
     current_id: string | null;
     goals: Goal[];
   };
-  branches: Record<string, BranchContext>;
+  sub_traces?: Record<string, TraceListItem>;
+  branches?: Record<string, BranchContext>; // Deprecated but kept for compatibility
 }

+ 1 - 0
node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json

@@ -0,0 +1 @@
+{"version":"4.0.18","results":[[":frontend/react-template/src/components/FlowChart/hooks/useFlowChartData.spec.ts",{"duration":0,"failed":true}]]}

Некоторые файлы не были показаны из-за большого количества измененных файлов