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

feat(flow-chart): 支持重试逻辑并优化消息处理

- 在 Message 类型中添加 parent_sequence 和 sequence 字段以支持重试逻辑
- 新增 retryLogic 工具函数处理失效分支,识别并过滤无效消息
- 重构 useFlowChartData hook,移除对 useTrace 的依赖,简化数据流
- 优化消息排序逻辑,使用 sequence 字段替代 message_id 后缀解析
- 在 FlowChart 组件中展示失效分支,使用虚线边框和灰色样式区分
- 改进 DetailPanel 组件,支持显示消息节点详情并修复类型检查
- 调整 WebSocket 连接时机,确保在 REST 数据加载完成后建立连接
max_liu 1 неделя назад
Родитель
Сommit
2824b54f57

+ 1 - 4
frontend/react-template/src/App.tsx

@@ -5,7 +5,6 @@ import { DetailPanel } from "./components/DetailPanel/DetailPanel";
 import type { Goal } from "./types/goal";
 import type { Edge, Message } from "./types/message";
 import { useFlowChartData } from "./components/FlowChart/hooks/useFlowChartData";
-import { useTrace } from "./hooks/useTrace";
 import "./styles/global.css";
 
 function App() {
@@ -20,9 +19,7 @@ function App() {
   const bodyRef = useRef<HTMLDivElement | null>(null);
 
   // 获取数据以传递给 DetailPanel
-  const { trace } = useTrace(selectedTraceId);
-  const initialGoals = useMemo(() => trace?.goal_tree?.goals ?? [], [trace]);
-  const { msgGroups } = useFlowChartData(selectedTraceId, initialGoals, messageRefreshTrigger);
+  const { msgGroups } = useFlowChartData(selectedTraceId, messageRefreshTrigger);
 
   const handleNodeClick = (node: Goal | Message, edge?: Edge) => {
     setSelectedNode(node);

+ 17 - 8
frontend/react-template/src/components/DetailPanel/DetailPanel.tsx

@@ -3,7 +3,7 @@ import type { Edge, Message } from "../../types/message";
 import styles from "./DetailPanel.module.css";
 
 interface DetailPanelProps {
-  node: Goal | null;
+  node: Goal | Message | null;
   edge: Edge | null;
   messages?: Message[];
   onClose: () => void;
@@ -39,6 +39,13 @@ export const DetailPanel = ({ node, edge, messages = [], onClose }: DetailPanelP
     return JSON.stringify(content);
   };
 
+  const isGoal = (node: Goal | Message): node is Goal => {
+    return "status" in node;
+  };
+
+  const isMessageNode = (node: Goal | Message): node is Message =>
+    "message_id" in node || "role" in node || "content" in node || "goal_id" in node || "tokens" in node;
+
   return (
     <aside className={styles.panel}>
       <div className={styles.header}>
@@ -57,28 +64,30 @@ export const DetailPanel = ({ node, edge, messages = [], onClose }: DetailPanelP
             <div className={styles.sectionTitle}>节点</div>
             <div className={styles.section}>
               <div className={styles.label}>ID</div>
-              <div className={styles.value}>{node.id}</div>
+              <div className={styles.value}>{isMessageNode(node) ? node.message_id || node.id : node.id}</div>
             </div>
             <div className={styles.section}>
               <div className={styles.label}>目标描述</div>
               <div className={styles.value}>{node.description}</div>
             </div>
-            {node.reason && (
+            {isGoal(node) && node.reason && (
               <div className={styles.section}>
                 <div className={styles.label}>创建理由</div>
                 <div className={styles.value}>{node.reason}</div>
               </div>
             )}
-            {node.summary && (
+            {isGoal(node) && node.summary && (
               <div className={styles.section}>
                 <div className={styles.label}>总结</div>
                 <div className={styles.value}>{node.summary}</div>
               </div>
             )}
-            <div className={styles.section}>
-              <div className={styles.label}>状态</div>
-              <div className={styles.value}>{node.status}</div>
-            </div>
+            {isGoal(node) && (
+              <div className={styles.section}>
+                <div className={styles.label}>状态</div>
+                <div className={styles.value}>{node.status}</div>
+              </div>
+            )}
           </>
         )}
         {messages && messages.length > 0 && (

+ 74 - 17
frontend/react-template/src/components/FlowChart/FlowChart.tsx

@@ -23,6 +23,7 @@ import { Tooltip } from "@douyinfe/semi-ui";
 interface FlowChartProps {
   goals: Goal[]; // 目标节点列表
   msgGroups?: Record<string, Message[]>; // 消息组,key 是 goal_id
+  invalidBranches?: Message[][]; // 失效分支列表
   onNodeClick?: (node: Goal | Message, edge?: EdgeType) => void; // 节点点击回调
   onSubTraceClick?: (parentGoal: Goal, entry: SubTraceEntry) => void; // 子追踪点击回调
 }
@@ -52,6 +53,7 @@ interface LayoutNode {
   type: "goal" | "subgoal" | "message"; // 节点类型
   level: number; // 嵌套层级(0 表示主链节点,1 表示子节点,2 表示孙节点...)
   parentId?: string; // 父节点 ID
+  isInvalid?: boolean; // 是否为失效节点
 }
 
 /**
@@ -67,13 +69,13 @@ interface LayoutEdge {
   collapsible: boolean; // 是否可折叠
   collapsed: boolean; // 是否已折叠
   children?: LayoutNode[]; // 折叠时隐藏的子节点列表
+  isInvalid?: boolean; // 是否为失效连接线
 }
 
 const FlowChartComponent: ForwardRefRenderFunction<FlowChartRef, FlowChartProps> = (
-  { goals, msgGroups = {}, onNodeClick, onSubTraceClick },
+  { goals, msgGroups = {}, invalidBranches, onNodeClick, onSubTraceClick },
   ref,
 ) => {
-  console.log("%c [ msgGroups ]-33", "font-size:13px; background:pink; color:#bf2c9f;", msgGroups);
   // 过滤掉有父节点的 goals,只保留主链节点
   goals = goals.filter((g) => !g.parent_id);
 
@@ -276,7 +278,6 @@ const FlowChartComponent: ForwardRefRenderFunction<FlowChartRef, FlowChartProps>
         allNodes: result.nodes,
       });
     });
-    console.log("%c [ displayGoals ]-261", "font-size:13px; background:pink; color:#bf2c9f;", displayGoals);
 
     /**
      * 生成连接线
@@ -291,7 +292,6 @@ const FlowChartComponent: ForwardRefRenderFunction<FlowChartRef, FlowChartProps>
      * 3. 递归处理所有层级的节点
      */
 
-    console.log("%c [ mainChainInfo ]-285", "font-size:13px; background:pink; color:#bf2c9f;", mainChainInfo);
     for (let i = 0; i < mainChainInfo.length - 1; i++) {
       const current = mainChainInfo[i];
       const next = mainChainInfo[i + 1];
@@ -495,8 +495,63 @@ const FlowChartComponent: ForwardRefRenderFunction<FlowChartRef, FlowChartProps>
       }
     }
 
+    // 处理失效分支(invalidBranches)
+    if (invalidBranches && invalidBranches.length > 0) {
+      const validMsgMap = new Map<number, LayoutNode>();
+      nodes.forEach((n) => {
+        // eslint-disable-next-line @typescript-eslint/no-explicit-any
+        const seq = (n.data as any).sequence;
+        if (typeof seq === "number") {
+          validMsgMap.set(seq, n);
+        }
+      });
+
+      invalidBranches.forEach((branch) => {
+        if (branch.length === 0) return;
+        const firstMsg = branch[0];
+        // eslint-disable-next-line @typescript-eslint/no-explicit-any
+        const pSeq = (firstMsg as any).parent_sequence;
+
+        if (typeof pSeq === "number") {
+          const parentNode = validMsgMap.get(pSeq);
+          if (parentNode) {
+            let currentParent = parentNode;
+            const X_OFFSET = -200; // 向左偏移
+
+            branch.forEach((msg, idx) => {
+              const nodeId = `invalid-${msg.id || Math.random()}`;
+              const node: LayoutNode = {
+                id: nodeId,
+                x: parentNode.x + X_OFFSET,
+                y: parentNode.y + (idx + 1) * NODE_HEIGHT,
+                data: msg,
+                type: "message",
+                level: parentNode.level,
+                parentId: parentNode.id,
+                isInvalid: true,
+              };
+              nodes.push(node);
+
+              edges.push({
+                id: `edge-${currentParent.id}-${node.id}`,
+                source: currentParent,
+                target: node,
+                type: "line",
+                level: 0,
+                collapsible: false,
+                collapsed: false,
+                isInvalid: true,
+              });
+
+              currentParent = node;
+            });
+          }
+        }
+      });
+    }
+
     return { nodes, edges };
-  }, [displayGoals, dimensions, msgGroups, collapsedEdges]);
+  }, [displayGoals, dimensions, msgGroups, collapsedEdges, invalidBranches]);
 
   // 暴露给父组件的方法
   useImperativeHandle(
@@ -858,8 +913,9 @@ const FlowChartComponent: ForwardRefRenderFunction<FlowChartRef, FlowChartProps>
                     <path
                       d={path}
                       fill="none"
-                      stroke={color}
+                      stroke={edge.isInvalid ? "#9E9E9E" : color} // 失效边使用灰色
                       strokeWidth={strokeWidth}
+                      strokeDasharray={edge.isInvalid ? "5,5" : undefined} // 失效边使用虚线
                       markerEnd="url(#arrow-default)" // 箭头
                       style={{ cursor: edge.collapsible ? "pointer" : "default" }} // 可折叠的显示手型光标
                       onClick={() => edge.collapsible && toggleCollapse(edge.id)} // 点击切换折叠状态
@@ -928,27 +984,28 @@ const FlowChartComponent: ForwardRefRenderFunction<FlowChartRef, FlowChartProps>
                       height={50}
                       rx={8}
                       fill={isGoal ? "#E3F2FD" : "#F5F5F5"} // 目标节点浅蓝色,消息节点灰色
-                      stroke={selectedNodeId === node.id ? "#2196F3" : "#BDBDBD"} // 选中节点蓝色边框
+                      stroke={selectedNodeId === node.id ? "#2196F3" : node.isInvalid ? "#9E9E9E" : "#BDBDBD"} // 失效节点边框灰色
                       strokeWidth={selectedNodeId === node.id ? 2 : 1}
+                      strokeDasharray={node.isInvalid ? "5,5" : undefined} // 失效节点虚线边框
                     />
                     {/* 节点文本(带 Tooltip) */}
-                    <Tooltip content={text}>
-                      <foreignObject
-                        x={-70}
-                        y={-25}
-                        width={150}
-                        height={50}
-                      >
+                    <foreignObject
+                      x={-70}
+                      y={-25}
+                      width={150}
+                      height={50}
+                    >
+                      <Tooltip content={text}>
                         <div
                           className="w-full h-full overflow-hidden flex items-center justify-center"
                           style={{
                             color: textColor,
                           }}
                         >
-                          <text className="text-xs line-clamp-3 px-1">{text}</text>
+                          <span className="text-xs line-clamp-3 px-1">{text}</span>
                         </div>
-                      </foreignObject>
-                    </Tooltip>
+                      </Tooltip>
+                    </foreignObject>
                   </g>
                 );
               })}

+ 59 - 33
frontend/react-template/src/components/FlowChart/hooks/useFlowChartData.ts

@@ -40,28 +40,26 @@ const buildSubGoals = (flatGoals: Goal[]): Goal[] => {
   });
 };
 
+import { processRetryLogic } from "../utils/retryLogic";
+
 // FlowChart 专用数据 Hook:处理实时事件并聚合消息组
-export const useFlowChartData = (traceId: string | null, initialGoals: Goal[], refreshTrigger?: number) => {
-  const [goals, setGoals] = useState<Goal[]>(initialGoals);
+export const useFlowChartData = (traceId: string | null, refreshTrigger?: number) => {
+  const [goals, setGoals] = useState<Goal[]>([]);
   const [messages, setMessages] = useState<Message[]>([]);
   const [msgGroups, setMsgGroups] = useState<Record<string, Message[]>>({});
   const [sinceEventId, setSinceEventId] = useState(0);
+  const [readyToConnect, setReadyToConnect] = useState(false);
   const currentEventIdRef = useRef(0);
   const restReloadingRef = useRef(false);
   const [reloading, setReloading] = useState(false);
+  const [invalidBranches, setInvalidBranches] = useState<Message[][]>([]);
 
-  const messageSortKey = useCallback((message: Message): number => {
-    const mid =
-      typeof message.message_id === "string"
-        ? message.message_id
-        : typeof message.id === "string"
-          ? message.id
-          : undefined;
-    if (!mid) return 0;
-    if (!mid.includes("-")) return 0;
-    const suffix = mid.slice(mid.lastIndexOf("-") + 1);
-    const num = Number.parseInt(suffix, 10);
-    return Number.isFinite(num) ? num : 0;
+  const messageComparator = useCallback((a: Message, b: Message): number => {
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    const seqA = typeof (a as any).sequence === "number" ? (a as any).sequence : 0;
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    const seqB = typeof (b as any).sequence === "number" ? (b as any).sequence : 0;
+    return seqA - seqB;
   }, []);
 
   const updateMessageGroups = useCallback(
@@ -82,21 +80,19 @@ export const useFlowChartData = (traceId: string | null, initialGoals: Goal[], r
       setMsgGroups((prev) => {
         const existing = prev[groupKey] ? [...prev[groupKey]] : [];
         existing.push(message);
-        existing.sort((a, b) => messageSortKey(a) - messageSortKey(b));
+        existing.sort(messageComparator);
         return { ...prev, [groupKey]: existing };
       });
     },
-    [messageSortKey],
+    [messageComparator],
   );
 
   useEffect(() => {
-    setGoals(initialGoals);
-  }, [initialGoals]);
-
-  useEffect(() => {
+    setGoals([]);
     setMessages([]);
     setMsgGroups({});
     setSinceEventId(0);
+    setReadyToConnect(false);
     currentEventIdRef.current = 0;
     restReloadingRef.current = false;
   }, [traceId]);
@@ -150,17 +146,22 @@ export const useFlowChartData = (traceId: string | null, initialGoals: Goal[], r
         const json = (await messagesRes.json()) as unknown;
         const root = isRecord(json) ? json : {};
         const list = Array.isArray(root.messages) ? (root.messages as Message[]) : [];
+
         const filtered = list.filter((message) => (message as { status?: string }).status !== "abandoned");
-        const nextMessages = [...filtered].sort((a, b) => messageSortKey(a) - messageSortKey(b));
-        setMessages(nextMessages);
+        const nextMessages = [...filtered].sort(messageComparator);
+
+        const { availableData: finalMessages, invalidBranches: invalidBranchesTemp } = processRetryLogic(nextMessages);
+
+        setMessages(finalMessages);
+        setInvalidBranches(invalidBranchesTemp);
         const grouped: Record<string, Message[]> = {};
-        nextMessages.forEach((message) => {
+        finalMessages.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));
+          grouped[key].sort(messageComparator);
         });
         setMsgGroups(grouped);
 
@@ -177,18 +178,42 @@ export const useFlowChartData = (traceId: string | null, initialGoals: Goal[], r
           });
         }
       }
+
+      // REST 请求完成后,允许建立 WebSocket 连接
+      setReadyToConnect(true);
     } finally {
       restReloadingRef.current = false;
       setReloading(false);
     }
     return nextSinceEventId;
-  }, [messageSortKey, traceId]);
+  }, [messageComparator, traceId]);
+
+  const prevTraceIdRef = useRef<string | null>(null);
+  const prevRefreshTriggerRef = useRef<number | undefined>(undefined);
 
   useEffect(() => {
-    if (!traceId) return;
-    if (!refreshTrigger) return;
-    void reloadViaRest();
-  }, [refreshTrigger, reloadViaRest, traceId]);
+    // 确保 traceId 存在
+    if (!traceId) {
+      prevTraceIdRef.current = null;
+      return;
+    }
+
+    // 检查是否发生了变化
+    const traceChanged = traceId !== prevTraceIdRef.current;
+    const refreshTriggerChanged = refreshTrigger !== prevRefreshTriggerRef.current;
+
+    // 只有当 traceId 真正变化,或者 refreshTrigger 真正变化时,才执行加载
+    if (traceChanged || (typeof refreshTrigger === "number" && refreshTrigger > 0 && refreshTriggerChanged)) {
+      prevTraceIdRef.current = traceId;
+      prevRefreshTriggerRef.current = refreshTrigger;
+
+      // 注意:traceId 变化时,另外一个清理 useEffect 也会执行,这里只负责触发加载
+      // 这里不直接调用,而是通过一个 setTimeout 0 来确保清理操作已经完成
+      // 但实际上清理操作是在副作用执行前发生的(对于同一组件)
+
+      void reloadViaRest();
+    }
+  }, [traceId, refreshTrigger, reloadViaRest]); // 添加 reloadViaRest 到依赖列表,但由于我们用了 ref 来控制,所以不会因为它的变化而重复执行
 
   const handleWebSocketMessage = useCallback(
     (payload: unknown) => {
@@ -312,14 +337,14 @@ export const useFlowChartData = (traceId: string | null, initialGoals: Goal[], r
         if (message) {
           setMessages((prev) => {
             const next = [...prev, message];
-            next.sort((a, b) => messageSortKey(a) - messageSortKey(b));
+            next.sort(messageComparator);
             return next;
           });
           updateMessageGroups(message);
         }
       }
     },
-    [messageSortKey, reloadViaRest, updateMessageGroups],
+    [messageComparator, reloadViaRest, updateMessageGroups],
   );
 
   // 主 Trace 连接
@@ -327,7 +352,8 @@ export const useFlowChartData = (traceId: string | null, initialGoals: Goal[], r
     () => ({ onMessage: handleWebSocketMessage, sinceEventId }),
     [handleWebSocketMessage, sinceEventId],
   );
-  const { connected } = useWebSocket(traceId, wsOptions);
+  // 只有当 traceId 存在且 REST 加载完成 (readyToConnect) 后才连接 WebSocket
+  const { connected } = useWebSocket(readyToConnect ? traceId : null, wsOptions);
 
-  return { goals, messages, msgGroups, connected, reloading };
+  return { goals, messages, msgGroups, connected, reloading, invalidBranches };
 };

+ 32 - 0
frontend/react-template/src/components/FlowChart/utils/retryLogic.ts

@@ -0,0 +1,32 @@
+import { Message } from "../../../types/message";
+
+export const processRetryLogic = (
+  messages: Message[],
+): { availableData: Message[]; invalidBranches: Message[][] } => {
+  const invalidBranches: Message[][] = [];
+  const parentLastIndexMap = new Map<number, number>();
+  const invalidIndices = new Set<number>();
+
+  messages.forEach((msg, index) => {
+    const pSeq = msg.parent_sequence;
+    if (typeof pSeq === "number") {
+      if (parentLastIndexMap.has(pSeq)) {
+        const lastIndex = parentLastIndexMap.get(pSeq)!;
+        // 提取失效分支:从上一次出现位置到当前位置之前
+        const invalidBranch = messages.slice(lastIndex, index);
+        if (invalidBranch.length > 0) {
+          invalidBranches.push(invalidBranch);
+          for (let k = lastIndex; k < index; k++) {
+            invalidIndices.add(k);
+          }
+        }
+        parentLastIndexMap.set(pSeq, index);
+      } else {
+        parentLastIndexMap.set(pSeq, index);
+      }
+    }
+  });
+
+  const availableData = messages.filter((_, index) => !invalidIndices.has(index));
+  return { availableData, invalidBranches };
+};

+ 23 - 12
frontend/react-template/src/components/MainContent/MainContent.tsx

@@ -1,9 +1,8 @@
-import { useMemo, useRef, useState, useEffect } from "react";
+import { useRef, useState, useEffect } from "react";
 import type { FC } from "react";
 import { Select } from "@douyinfe/semi-ui";
 import { FlowChart } from "../FlowChart/FlowChart";
 import type { FlowChartRef } from "../FlowChart/FlowChart";
-import { useTrace } from "../../hooks/useTrace";
 import { useFlowChartData } from "../FlowChart/hooks/useFlowChartData";
 import { traceApi } from "../../api/traceApi";
 import type { Goal } from "../../types/goal";
@@ -31,11 +30,13 @@ export const MainContent: FC<MainContentProps> = ({
   const [traceList, setTraceList] = useState<TraceListItem[]>([]);
   const [cachedGoals, setCachedGoals] = useState<Goal[]>([]);
   const [cachedMsgGroups, setCachedMsgGroups] = useState<Record<string, Message[]>>({});
-  const { trace, loading, reload } = useTrace(traceId);
-  const initialGoals = useMemo(() => trace?.goal_tree?.goals ?? [], [trace]);
-  const { goals, connected, msgGroups, reloading } = useFlowChartData(traceId, initialGoals, messageRefreshTrigger);
+  const [cachedInvalidBranches, setCachedInvalidBranches] = useState<Message[][]>([]);
+  const { goals, connected, msgGroups, reloading, invalidBranches } = 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;
+  const displayInvalidBranches =
+    invalidBranches && invalidBranches.length > 0 ? invalidBranches : cachedInvalidBranches;
 
   useEffect(() => {
     const fetchTraces = async () => {
@@ -50,9 +51,8 @@ export const MainContent: FC<MainContentProps> = ({
   }, [refreshTrigger]);
 
   useEffect(() => {
-    if (!messageRefreshTrigger) return;
-    void reload();
-  }, [messageRefreshTrigger, reload]);
+    // 移除 reload 调用,因为 useFlowChartData 内部会监听 messageRefreshTrigger 并重新加载
+  }, [messageRefreshTrigger]);
 
   useEffect(() => {
     if (goals.length > 0) {
@@ -66,12 +66,19 @@ export const MainContent: FC<MainContentProps> = ({
     }
   }, [msgGroups]);
 
+  useEffect(() => {
+    if (invalidBranches && invalidBranches.length > 0) {
+      setCachedInvalidBranches(invalidBranches);
+    }
+  }, [invalidBranches]);
+
   useEffect(() => {
     setCachedGoals([]);
     setCachedMsgGroups({});
+    setCachedInvalidBranches([]);
   }, [traceId]);
 
-  if (!traceId && !loading) {
+  if (!traceId && !reloading) {
     return (
       <div className={styles.main}>
         <div className={styles.header}>
@@ -152,16 +159,20 @@ export const MainContent: FC<MainContentProps> = ({
         </div>
       </div>
       <div className={styles.content}>
-        {loading || reloading ? (
-          <div className={styles.empty}>加载中...</div>
+        {reloading ? (
+          <div className={styles.loading}>加载中...</div>
         ) : displayGoals.length === 0 ? (
-          <div className={styles.empty}>暂无节点</div>
+          <div className={styles.empty}>暂无数据</div>
         ) : (
           <FlowChart
             ref={flowChartRef}
             goals={displayGoals}
             msgGroups={displayMsgGroups}
+            invalidBranches={displayInvalidBranches}
             onNodeClick={onNodeClick}
+            onSubTraceClick={(_parentGoal, entry) => {
+              onTraceChange?.(entry.id, entry.mission || entry.id);
+            }}
           />
         )}
       </div>

+ 2 - 0
frontend/react-template/src/types/message.ts

@@ -17,6 +17,8 @@ export interface Message {
   content?: string | MessageContent;
   description?: string;
   tokens?: number | null;
+  parent_sequence?: number | null;
+  sequence?: number | null;
 }
 
 export interface Edge {