فهرست منبع

Merge remote-tracking branch 'origin/main'

tanjingyu 3 هفته پیش
والد
کامیت
9e0e86fe29

+ 69 - 18
frontend/react-template/src/components/FlowChart/FlowChart.tsx

@@ -30,6 +30,7 @@ export type SubTraceEntry = { id: string; mission?: string };
 const EMPTY_GOALS: Goal[] = [];
 const EMPTY_GOALS: Goal[] = [];
 
 
 export const FlowChart: FC<FlowChartProps> = ({ goals, msgGroups = {}, onNodeClick, onSubTraceClick }) => {
 export const FlowChart: FC<FlowChartProps> = ({ goals, msgGroups = {}, onNodeClick, onSubTraceClick }) => {
+  console.log("%c [ msgGroups ]-33", "font-size:13px; background:pink; color:#bf2c9f;", msgGroups);
   goals = goals.filter((g) => !g.parent_id);
   goals = goals.filter((g) => !g.parent_id);
 
 
   console.log("%c [ FlowChart-goals ]-33", "font-size:13px; background:pink; color:#bf2c9f;", goals);
   console.log("%c [ FlowChart-goals ]-33", "font-size:13px; background:pink; color:#bf2c9f;", goals);
@@ -55,15 +56,11 @@ export const FlowChart: FC<FlowChartProps> = ({ goals, msgGroups = {}, onNodeCli
   const [selectedSubTrace, setSelectedSubTrace] = useState<{ parentGoal: Goal; entry: SubTraceEntry } | null>(null);
   const [selectedSubTrace, setSelectedSubTrace] = useState<{ parentGoal: Goal; entry: SubTraceEntry } | null>(null);
 
 
   // Use useFlowChartData for subTrace to handle WebSocket connection and data processing
   // Use useFlowChartData for subTrace to handle WebSocket connection and data processing
-  const {
-    messages: subTraceMessages,
-    msgGroups: subTraceMsgGroups,
-    goals: subTraceGoals,
-  } = useFlowChartData(selectedSubTrace?.entry.id || null, EMPTY_GOALS);
-  console.log("%c [ subTraceGoals ]-57", "font-size:13px; background:pink; color:#bf2c9f;", subTraceGoals);
-  console.log("%c [ subTraceMsgGroups ]-57", "font-size:13px; background:pink; color:#bf2c9f;", subTraceMsgGroups);
-  console.log("%c [ subTraceMessages ]-56", "font-size:13px; background:pink; color:#bf2c9f;", subTraceMessages);
-
+  const { messages: subTraceMessages, msgGroups: subMsgGroups } = useFlowChartData(
+    selectedSubTrace?.entry.id || null,
+    EMPTY_GOALS,
+  );
+  console.log("%c [ subMsgGroups ]-60", "font-size:13px; background:pink; color:#bf2c9f;", subMsgGroups);
   const [panOffset, setPanOffset] = useState({ x: 0, y: 0 });
   const [panOffset, setPanOffset] = useState({ x: 0, y: 0 });
   const [isPanning, setIsPanning] = useState(false);
   const [isPanning, setIsPanning] = useState(false);
   const [zoom, setZoom] = useState(1);
   const [zoom, setZoom] = useState(1);
@@ -220,6 +217,8 @@ export const FlowChart: FC<FlowChartProps> = ({ goals, msgGroups = {}, onNodeCli
   // 节点点击:记录选中状态并回传对应边
   // 节点点击:记录选中状态并回传对应边
   const handleNodeClick = useCallback(
   const handleNodeClick = useCallback(
     (node: LayoutNode) => {
     (node: LayoutNode) => {
+      if (node.data.sub_goals && node.data.sub_goals.length > 0) return;
+
       setSelectedNodeId(node.data.id);
       setSelectedNodeId(node.data.id);
       const nearestLink = mainLinks.find((link) => link.target.data.id === node.data.id);
       const nearestLink = mainLinks.find((link) => link.target.data.id === node.data.id);
       const edge: EdgeType | undefined = nearestLink
       const edge: EdgeType | undefined = nearestLink
@@ -354,7 +353,14 @@ export const FlowChart: FC<FlowChartProps> = ({ goals, msgGroups = {}, onNodeCli
     if (!layoutData)
     if (!layoutData)
       return [] as Array<{
       return [] as Array<{
         parentId: string;
         parentId: string;
-        nodes: Array<{ x: number; y: number; fullText: string; shortText: string }>;
+        nodes: Array<{
+          x: number;
+          y: number;
+          fullText: string;
+          shortText: string;
+          data: Goal;
+          mainGoal: Goal;
+        }>;
         paths: Array<{ d: string }>;
         paths: Array<{ d: string }>;
       }>;
       }>;
 
 
@@ -385,11 +391,18 @@ export const FlowChart: FC<FlowChartProps> = ({ goals, msgGroups = {}, onNodeCli
 
 
     const overlays: Array<{
     const overlays: Array<{
       parentId: string;
       parentId: string;
-      nodes: Array<{ x: number; y: number; fullText: string; shortText: string }>;
+      nodes: Array<{
+        x: number;
+        y: number;
+        fullText: string;
+        shortText: string;
+        data: Goal;
+        mainGoal: Goal;
+      }>;
       paths: Array<{ d: string }>;
       paths: Array<{ d: string }>;
     }> = [];
     }> = [];
 
 
-    displayGoals.forEach((goal) => {
+    displayGoals.forEach((goal, index) => {
       const anchorNode = nodeMap.get(goal.id);
       const anchorNode = nodeMap.get(goal.id);
       if (!anchorNode) return;
       if (!anchorNode) return;
 
 
@@ -413,13 +426,44 @@ export const FlowChart: FC<FlowChartProps> = ({ goals, msgGroups = {}, onNodeCli
           y: centerY + Math.sin(angle) * radius,
           y: centerY + Math.sin(angle) * radius,
           fullText,
           fullText,
           shortText: truncateMiddle(fullText, 10),
           shortText: truncateMiddle(fullText, 10),
+          data: subGoal,
+          mainGoal: goal,
         };
         };
       });
       });
 
 
       const nodeHalfH = 5;
       const nodeHalfH = 5;
-      const paths = nodes.map((node) => ({
-        d: buildSegment(anchorX, anchorY + nodeHalfH, node.x, node.y),
-      }));
+      const paths: Array<{ d: string }> = [];
+
+      if (nodes.length > 0) {
+        // Connect Parent to first node
+        paths.push({
+          d: buildSegment(anchorX, anchorY + nodeHalfH, nodes[0].x, nodes[0].y),
+        });
+
+        // Connect subsequent nodes serially
+        for (let i = 0; i < nodes.length - 1; i++) {
+          const source = nodes[i];
+          const target = nodes[i + 1];
+          paths.push({
+            d: buildSegment(source.x, source.y, target.x, target.y),
+          });
+        }
+
+        // Connect last node to the next main node
+        if (index < displayGoals.length - 1) {
+          const nextGoal = displayGoals[index + 1];
+          const nextNode = nodeMap.get(nextGoal.id);
+          if (nextNode) {
+            const lastNode = nodes[nodes.length - 1];
+            // Connect to the left side of the next node (approximate width 140, half 70)
+            const targetX = nextNode.x - 70;
+            const targetY = nextNode.y;
+            paths.push({
+              d: buildSegment(lastNode.x, lastNode.y, targetX, targetY),
+            });
+          }
+        }
+      }
 
 
       overlays.push({ parentId: goal.id, nodes, paths });
       overlays.push({ parentId: goal.id, nodes, paths });
     });
     });
@@ -672,10 +716,9 @@ export const FlowChart: FC<FlowChartProps> = ({ goals, msgGroups = {}, onNodeCli
                             key={`subgoals-path-${overlay.parentId}-${idx}`}
                             key={`subgoals-path-${overlay.parentId}-${idx}`}
                             d={p.d}
                             d={p.d}
                             fill="none"
                             fill="none"
-                            stroke="#7aa0d6"
+                            stroke="#00c853"
                             strokeWidth={1.5}
                             strokeWidth={1.5}
-                            strokeDasharray="6,6"
-                            markerEnd="url(#arrow-default)"
+                            markerEnd="url(#arrow-success)"
                             opacity={0.7}
                             opacity={0.7}
                             strokeLinecap="round"
                             strokeLinecap="round"
                           />
                           />
@@ -686,6 +729,14 @@ export const FlowChart: FC<FlowChartProps> = ({ goals, msgGroups = {}, onNodeCli
                           <g
                           <g
                             key={`subgoals-node-${overlay.parentId}-${idx}`}
                             key={`subgoals-node-${overlay.parentId}-${idx}`}
                             transform={`translate(${node.x},${node.y})`}
                             transform={`translate(${node.x},${node.y})`}
+                            onClick={(e) => {
+                              e.stopPropagation();
+                              const entries = normalizeSubTraceEntries(node.data);
+                              if (entries.length > 0) {
+                                handleSubTraceClick(node.mainGoal, entries[0]);
+                              }
+                            }}
+                            style={{ cursor: "pointer" }}
                           >
                           >
                             <Tooltip content={node.fullText}>
                             <Tooltip content={node.fullText}>
                               <text
                               <text

+ 114 - 3
frontend/react-template/src/components/FlowChart/hooks/useFlowChartData.ts

@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useMemo, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
 import { useWebSocket } from "../../../hooks/useWebSocket";
 import { useWebSocket } from "../../../hooks/useWebSocket";
 import type { Goal } from "../../../types/goal";
 import type { Goal } from "../../../types/goal";
 import type { Message } from "../../../types/message";
 import type { Message } from "../../../types/message";
@@ -45,6 +45,9 @@ export const useFlowChartData = (traceId: string | null, initialGoals: Goal[]) =
   const [goals, setGoals] = useState<Goal[]>(initialGoals);
   const [goals, setGoals] = useState<Goal[]>(initialGoals);
   const [messages, setMessages] = useState<Message[]>([]);
   const [messages, setMessages] = useState<Message[]>([]);
   const [msgGroups, setMsgGroups] = useState<Record<string, Message[]>>({});
   const [msgGroups, setMsgGroups] = useState<Record<string, Message[]>>({});
+  const [sinceEventId, setSinceEventId] = useState(0);
+  const currentEventIdRef = useRef(0);
+  const restReloadingRef = useRef(false);
 
 
   const messageSortKey = useCallback((message: Message): number => {
   const messageSortKey = useCallback((message: Message): number => {
     const mid =
     const mid =
@@ -89,15 +92,120 @@ export const useFlowChartData = (traceId: string | null, initialGoals: Goal[]) =
     setGoals(initialGoals);
     setGoals(initialGoals);
     setMessages([]);
     setMessages([]);
     setMsgGroups({});
     setMsgGroups({});
+    setSinceEventId(0);
+    currentEventIdRef.current = 0;
+    restReloadingRef.current = false;
   }, [initialGoals, traceId]);
   }, [initialGoals, traceId]);
 
 
+  const reloadViaRest = useCallback(async () => {
+    if (!traceId) return;
+    if (restReloadingRef.current) return;
+    restReloadingRef.current = true;
+    try {
+      const [traceRes, messagesRes] = await Promise.all([
+        fetch(`http://localhost:8000/api/traces/${traceId}`),
+        fetch(`http://localhost:8000/api/traces/${traceId}/messages`),
+      ]);
+
+      if (traceRes.ok) {
+        const json = (await traceRes.json()) as unknown;
+        const root = isRecord(json) ? json : {};
+        const trace = isRecord(root.trace) ? root.trace : undefined;
+        const goalTree = isRecord(root.goal_tree) ? root.goal_tree : undefined;
+        const goalList = goalTree && Array.isArray(goalTree.goals) ? (goalTree.goals as Goal[]) : [];
+
+        const lastEventId = trace && typeof trace.last_event_id === "number" ? trace.last_event_id : undefined;
+        if (typeof lastEventId === "number") {
+          currentEventIdRef.current = Math.max(currentEventIdRef.current, lastEventId);
+          setSinceEventId(lastEventId);
+        }
+
+        if (goalList.length > 0) {
+          setGoals((prev) => {
+            const mergedFlat = goalList.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;
+            });
+            return buildSubGoals(mergedFlat);
+          });
+        }
+      }
+
+      if (messagesRes.ok) {
+        const json = (await messagesRes.json()) as unknown;
+        const root = isRecord(json) ? json : {};
+        const list = Array.isArray(root.messages) ? (root.messages as Message[]) : [];
+        const nextMessages = [...list].sort((a, b) => messageSortKey(a) - messageSortKey(b));
+        setMessages(nextMessages);
+        const grouped: Record<string, Message[]> = {};
+        nextMessages.forEach((message) => {
+          const groupKey = typeof message.goal_id === "string" && message.goal_id ? message.goal_id : "START";
+          if (!grouped[groupKey]) grouped[groupKey] = [];
+          grouped[groupKey].push(message);
+        });
+        Object.keys(grouped).forEach((key) => {
+          grouped[key].sort((a, b) => messageSortKey(a) - messageSortKey(b));
+        });
+        setMsgGroups(grouped);
+
+        if (grouped.START && grouped.START.length > 0) {
+          setGoals((prev) => {
+            if (prev.some((g) => g.id === "START")) return prev;
+            const startGoal: Goal = {
+              id: "START",
+              description: "START",
+              status: "completed",
+              created_at: "",
+            };
+            return [startGoal, ...prev];
+          });
+        }
+      }
+    } finally {
+      restReloadingRef.current = false;
+    }
+  }, [messageSortKey, traceId]);
+
   const handleWebSocketMessage = useCallback(
   const handleWebSocketMessage = useCallback(
     (payload: unknown) => {
     (payload: unknown) => {
       const raw = isRecord(payload) ? payload : {};
       const raw = isRecord(payload) ? payload : {};
       const event = (typeof raw.event === "string" && raw.event) || (typeof raw.type === "string" && raw.type) || "";
       const event = (typeof raw.event === "string" && raw.event) || (typeof raw.type === "string" && raw.type) || "";
       const data = isRecord(raw.data) ? raw.data : raw;
       const data = isRecord(raw.data) ? raw.data : raw;
 
 
+      const eventId = typeof raw.event_id === "number" ? raw.event_id : undefined;
+      if (typeof eventId === "number") {
+        currentEventIdRef.current = Math.max(currentEventIdRef.current, eventId);
+      }
+
+      if (event === "error") {
+        const message =
+          (typeof data.message === "string" ? data.message : undefined) ||
+          (typeof raw.message === "string" ? raw.message : undefined) ||
+          "";
+        if (message.includes("Too many missed events")) {
+          void reloadViaRest();
+          const nextSince = currentEventIdRef.current;
+          if (nextSince > 0) setSinceEventId(nextSince);
+        }
+        return;
+      }
+
       if (event === "connected") {
       if (event === "connected") {
+        const currentEventId =
+          (typeof data.current_event_id === "number" ? data.current_event_id : undefined) ||
+          (typeof raw.current_event_id === "number" ? raw.current_event_id : undefined);
+        if (typeof currentEventId === "number") {
+          currentEventIdRef.current = Math.max(currentEventIdRef.current, currentEventId);
+        }
+
         const trace = isRecord(data.trace) ? data.trace : undefined;
         const trace = isRecord(data.trace) ? data.trace : undefined;
         const rawTrace = isRecord(raw.trace) ? raw.trace : undefined;
         const rawTrace = isRecord(raw.trace) ? raw.trace : undefined;
         const goalTree =
         const goalTree =
@@ -192,11 +300,14 @@ export const useFlowChartData = (traceId: string | null, initialGoals: Goal[]) =
         }
         }
       }
       }
     },
     },
-    [messageSortKey, updateMessageGroups],
+    [messageSortKey, reloadViaRest, updateMessageGroups],
   );
   );
 
 
   // 主 Trace 连接
   // 主 Trace 连接
-  const wsOptions = useMemo(() => ({ onMessage: handleWebSocketMessage }), [handleWebSocketMessage]);
+  const wsOptions = useMemo(
+    () => ({ onMessage: handleWebSocketMessage, sinceEventId }),
+    [handleWebSocketMessage, sinceEventId],
+  );
   const { connected } = useWebSocket(traceId, wsOptions);
   const { connected } = useWebSocket(traceId, wsOptions);
 
 
   return { goals, messages, msgGroups, connected };
   return { goals, messages, msgGroups, connected };

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

@@ -15,8 +15,8 @@ interface MainContentProps {
 export const MainContent: FC<MainContentProps> = ({ traceId, onNodeClick }) => {
 export const MainContent: FC<MainContentProps> = ({ traceId, onNodeClick }) => {
   const { trace, loading } = useTrace(traceId);
   const { trace, loading } = useTrace(traceId);
   const initialGoals = useMemo(() => trace?.goal_tree?.goals ?? [], [trace]);
   const initialGoals = useMemo(() => trace?.goal_tree?.goals ?? [], [trace]);
-  const { goals, connected, msgGroups } = useFlowChartData(traceId, initialGoals);
-  console.log("%c [ goals ]-19", "font-size:13px; background:pink; color:#bf2c9f;", goals);
+  const { goals, connected, msgGroups, messages } = useFlowChartData(traceId, initialGoals);
+  console.log("%c [ messages ]-19", "font-size:13px; background:pink; color:#bf2c9f;", messages);
 
 
   if (!traceId && !loading) {
   if (!traceId && !loading) {
     return (
     return (

+ 4 - 3
frontend/react-template/src/hooks/useWebSocket.ts

@@ -4,17 +4,18 @@ interface UseWebSocketOptions {
   onMessage?: (data: unknown) => void;
   onMessage?: (data: unknown) => void;
   onError?: (error: Event) => void;
   onError?: (error: Event) => void;
   onClose?: () => void;
   onClose?: () => void;
+  sinceEventId?: number;
 }
 }
 
 
 export const useWebSocket = (traceId: string | null, options: UseWebSocketOptions = {}) => {
 export const useWebSocket = (traceId: string | null, options: UseWebSocketOptions = {}) => {
   const wsRef = useRef<WebSocket | null>(null);
   const wsRef = useRef<WebSocket | null>(null);
   const [connected, setConnected] = useState(false);
   const [connected, setConnected] = useState(false);
-  const { onMessage, onError, onClose } = options;
+  const { onMessage, onError, onClose, sinceEventId = 0 } = options;
 
 
   useEffect(() => {
   useEffect(() => {
     if (!traceId) return;
     if (!traceId) return;
 
 
-    const url = `ws://localhost:8000/api/traces/${traceId}/watch?since_event_id=0`;
+    const url = `ws://localhost:8000/api/traces/${traceId}/watch?since_event_id=${sinceEventId}`;
     const ws = new WebSocket(url);
     const ws = new WebSocket(url);
 
 
     ws.onopen = () => {
     ws.onopen = () => {
@@ -44,7 +45,7 @@ export const useWebSocket = (traceId: string | null, options: UseWebSocketOption
     return () => {
     return () => {
       ws.close();
       ws.close();
     };
     };
-  }, [traceId, onMessage, onError, onClose]);
+  }, [traceId, onMessage, onError, onClose, sinceEventId]);
 
 
   return { connected, ws: wsRef.current };
   return { connected, ws: wsRef.current };
 };
 };