Procházet zdrojové kódy

feat(flowchart): 支持子任务追踪与可视化

- 在 Goal 类型中新增 agent_call_mode 和 sub_trace_ids 字段,用于支持子任务追踪
- 为流程图组件添加子任务链接的可视化渲染,支持曲线偏移与点击交互
- 扩展 WebSocket 数据处理逻辑,保留并合并子任务 ID 信息
- 新增 WebSocket 心跳端点 /ws_ping 用于连接测试
- 优化消息排序与错误处理,提升前端稳定性
max_liu před 3 týdny
rodič
revize
e527f0c661

+ 9 - 1
api_server.py

@@ -5,7 +5,9 @@ API Server - FastAPI 应用入口
 """
 
 import logging
-from fastapi import FastAPI
+import json
+import os
+from fastapi import FastAPI, Request, WebSocket
 from fastapi.middleware.cors import CORSMiddleware
 import uvicorn
 
@@ -59,6 +61,12 @@ app.include_router(api_router)
 # Step 树 WebSocket
 app.include_router(ws_router)
 
+@app.websocket("/ws_ping")
+async def ws_ping(websocket: WebSocket):
+    await websocket.accept()
+    await websocket.send_text("pong")
+    await websocket.close()
+
 
 # ===== 健康检查 =====
 

+ 5 - 3
frontend/htmlTemplate/templateData.py

@@ -249,8 +249,10 @@ async def _watch_ws_events(trace_id: str, since_event_id: int = 0, ws_url: Optio
                     event = data.get("event")
                     if event:
                         print(f"收到事件: {event}")
-        except Exception:
-            print("WebSocket 连接断开,1 秒后重连")
+        except Exception as e:
+            import traceback
+            traceback.print_exc()
+            print(f"WebSocket 连接断开: {e},1 秒后重连")
             await asyncio.sleep(1)
 
 
@@ -291,7 +293,7 @@ if __name__ == "__main__":
             # save_ws_data_to_file(args.trace_id, args.since_event_id, args.ws_url)
     else:
         trace_list_data = generate_trace_list()
-        print(f"🐒trace_list_data: {trace_list_data}")
+        # print(f"🐒trace_list_data: {trace_list_data}")
 
         traces = trace_list_data.get("traces") or []
         # trace_id = traces[0].get("trace_id") if traces else None

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 13
frontend/htmlTemplate/ws_data/event.jsonl


+ 123 - 14
frontend/react-template/src/components/FlowChart/FlowChart.tsx

@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useRef, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
 import type { FC } from "react";
 import * as d3 from "d3";
 import type { Goal } from "../../types/goal";
@@ -14,6 +14,7 @@ interface FlowChartProps {
   goals: Goal[];
   msgGroups?: Record<string, Message[]>;
   onNodeClick?: (node: Goal, edge?: EdgeType) => void;
+  onSubTraceClick?: (parentGoal: Goal, entry: SubTraceEntry) => void;
 }
 
 interface LayoutNode extends d3.HierarchyPointNode<Goal> {
@@ -23,8 +24,9 @@ interface LayoutNode extends d3.HierarchyPointNode<Goal> {
 
 type TreeGoal = Goal & { children?: TreeGoal[] };
 const VIRTUAL_ROOT_ID = "__VIRTUAL_ROOT__";
+export type SubTraceEntry = { id: string; mission?: string };
 
-export const FlowChart: FC<FlowChartProps> = ({ goals, msgGroups = {}, onNodeClick }) => {
+export const FlowChart: FC<FlowChartProps> = ({ goals, msgGroups = {}, onNodeClick, onSubTraceClick }) => {
   // 确保 goals 中包含 END 节点
   const displayGoals = useMemo(() => {
     if (!goals) return [];
@@ -176,21 +178,96 @@ export const FlowChart: FC<FlowChartProps> = ({ goals, msgGroups = {}, onNodeCli
     return [...nonVirtualLinks, ...links];
   }, [layoutData, displayGoals]);
 
-  // 节点点击:记录选中状态并回传对应边
-  const handleNodeClick = (node: LayoutNode) => {
-    setSelectedNodeId(node.data.id);
-    const nearestLink = mainLinks.find((link) => link.target.data.id === node.data.id);
-    const edge: EdgeType | undefined = nearestLink
-      ? {
-          id: `${nearestLink.source.data.id}-${nearestLink.target.data.id}`,
-          source: nearestLink.source.data.id,
-          target: nearestLink.target.data.id,
-          label: "",
+  const normalizeSubTraceEntries = (goal: Goal): SubTraceEntry[] => {
+    const raw = goal.sub_trace_ids || [];
+    return raw
+      .map((item) => {
+        if (typeof item === "string") return { id: item };
+        if (item && typeof item === "object" && "trace_id" in item) {
+          const meta = item as { trace_id?: unknown; mission?: unknown };
+          const id = typeof meta.trace_id === "string" ? meta.trace_id : "";
+          const mission = typeof meta.mission === "string" ? meta.mission : undefined;
+          return id ? { id, mission } : null;
         }
-      : undefined;
-    onNodeClick?.(node.data, edge);
+        return null;
+      })
+      .filter((entry): entry is SubTraceEntry => !!entry && entry.id.length > 0);
   };
 
+  // 节点点击:记录选中状态并回传对应边
+  const handleNodeClick = useCallback(
+    (node: LayoutNode) => {
+      setSelectedNodeId(node.data.id);
+      const nearestLink = mainLinks.find((link) => link.target.data.id === node.data.id);
+      const edge: EdgeType | undefined = nearestLink
+        ? {
+            id: `${nearestLink.source.data.id}-${nearestLink.target.data.id}`,
+            source: nearestLink.source.data.id,
+            target: nearestLink.target.data.id,
+            label: "",
+          }
+        : undefined;
+      onNodeClick?.(node.data, edge);
+    },
+    [mainLinks, onNodeClick],
+  );
+
+  const subTraceLinks = useMemo(() => {
+    if (!layoutData)
+      return [] as Array<{
+        link: d3.HierarchyPointLink<Goal>;
+        label?: string;
+        key: string;
+        onClick: () => void;
+        curveOffset?: number;
+      }>;
+    const nodeMap = new Map(layoutData.nodes.map((node) => [node.data.id, node]));
+    const orderedIds = displayGoals.map((goal) => goal.id);
+    const nextIdMap = new Map<string, string>();
+    for (let i = 0; i < orderedIds.length - 1; i += 1) {
+      nextIdMap.set(orderedIds[i], orderedIds[i + 1]);
+    }
+
+    const links: Array<{
+      link: d3.HierarchyPointLink<Goal>;
+      label?: string;
+      key: string;
+      onClick: () => void;
+      curveOffset?: number;
+    }> = [];
+
+    displayGoals.forEach((goal) => {
+      const entries = normalizeSubTraceEntries(goal);
+      if (entries.length === 0) return;
+      const sourceBase = nodeMap.get(goal.id);
+      const nextId = nextIdMap.get(goal.id);
+      const targetBase = nextId ? nodeMap.get(nextId) : undefined;
+      if (!sourceBase || !targetBase) return;
+
+      const centerIndex = (entries.length - 1) / 2;
+      entries.forEach((entry, index) => {
+        const offset = (index - centerIndex) * 60;
+        const onClick = () => {
+          if (onSubTraceClick) {
+            onSubTraceClick(goal, entry);
+          } else {
+            handleNodeClick(sourceBase);
+          }
+        };
+
+        links.push({
+          link: { source: sourceBase, target: targetBase },
+          label: entry.mission,
+          key: `${goal.id}-${entry.id}-explore-${index}`,
+          onClick,
+          curveOffset: offset,
+        });
+      });
+    });
+
+    return links;
+  }, [displayGoals, handleNodeClick, layoutData, onSubTraceClick]);
+
   // 当前选中节点的消息链
   const selectedMessages = useMemo(() => {
     if (!selectedNodeId) return [];
@@ -345,6 +422,22 @@ export const FlowChart: FC<FlowChartProps> = ({ goals, msgGroups = {}, onNodeCli
                         );
                       })}
                   </g>
+                  {subTraceLinks.length > 0 && (
+                    <g className={styles.links}>
+                      {subTraceLinks.map((item) => (
+                        <Edge
+                          key={`subtrace-line-${item.key}`}
+                          link={item.link}
+                          label={item.label}
+                          highlighted={false}
+                          dimmed={false}
+                          onClick={item.onClick}
+                          mode="line"
+                          curveOffset={item.curveOffset}
+                        />
+                      ))}
+                    </g>
+                  )}
                   <g className={styles.nodes}>
                     {layoutData.nodes
                       .filter((node) => node.data.id !== VIRTUAL_ROOT_ID)
@@ -387,6 +480,22 @@ export const FlowChart: FC<FlowChartProps> = ({ goals, msgGroups = {}, onNodeCli
                         );
                       })}
                   </g>
+                  {subTraceLinks.length > 0 && (
+                    <g className={styles.links}>
+                      {subTraceLinks.map((item) => (
+                        <Edge
+                          key={`subtrace-label-${item.key}`}
+                          link={item.link}
+                          label={item.label}
+                          highlighted={false}
+                          dimmed={false}
+                          onClick={item.onClick}
+                          mode="label"
+                          curveOffset={item.curveOffset}
+                        />
+                      ))}
+                    </g>
+                  )}
                   {messageOverlay && (
                     <g>
                       {messageOverlay.paths.map((p, idx) => (

+ 8 - 2
frontend/react-template/src/components/FlowChart/components/Edge.tsx

@@ -12,9 +12,10 @@ interface EdgeProps {
   dimmed: boolean;
   onClick: () => void;
   mode?: "line" | "label";
+  curveOffset?: number;
 }
 
-export const Edge: FC<EdgeProps> = ({ link, label, highlighted, dimmed, onClick, mode = "line" }) => {
+export const Edge: FC<EdgeProps> = ({ link, label, highlighted, dimmed, onClick, mode = "line", curveOffset = 0 }) => {
   const { source, target } = link;
   const truncateMiddle = (text: string, limit: number) => {
     if (text.length <= limit) return text;
@@ -31,6 +32,11 @@ export const Edge: FC<EdgeProps> = ({ link, label, highlighted, dimmed, onClick,
     const startX = sourceX + direction * nodeHalfW;
     const endX = targetX - direction * nodeHalfW;
     const midX = (startX + endX) / 2;
+
+    if (curveOffset !== 0) {
+      return `M${startX},${sourceY} C${midX},${sourceY + curveOffset} ${midX},${targetY + curveOffset} ${endX},${targetY}`;
+    }
+
     return `M${startX},${sourceY} C${midX},${sourceY} ${midX},${targetY} ${endX},${targetY}`;
   };
 
@@ -75,7 +81,7 @@ export const Edge: FC<EdgeProps> = ({ link, label, highlighted, dimmed, onClick,
     const startX = sourceX + direction * nodeHalfW;
     const endX = targetX - direction * nodeHalfW;
     const midX = (startX + endX) / 2;
-    const midY = (sourceY + targetY) / 2;
+    const midY = (sourceY + targetY) / 2 + curveOffset * 0.75;
 
     const truncated = truncateMiddle(label, 6);
 

+ 29 - 6
frontend/react-template/src/components/FlowChart/hooks/useFlowChartData.ts

@@ -81,8 +81,19 @@ export const useFlowChartData = (traceId: string | null, initialGoals: Goal[]) =
           (rawTrace && isRecord(rawTrace.goal_tree) ? rawTrace.goal_tree : undefined) ||
           {};
         const goalList = isRecord(goalTree) ? goalTree.goals : undefined;
+        console.log("%c [ goalList ]-90", "font-size:13px; background:pink; color:#bf2c9f;", goalList);
         const nextGoals = Array.isArray(goalList) ? (goalList as Goal[]) : [];
-        setGoals(nextGoals);
+        console.log("%c [ nextGoals ]-91", "font-size:13px; background:pink; color:#bf2c9f;", nextGoals);
+        setGoals((prev) => {
+          return nextGoals.map((ng) => {
+            const existing = prev.find((p) => p.id === ng.id);
+            // 保留 sub_trace_ids,如果 WebSocket 数据中缺失但本地已有
+            if (existing && existing.sub_trace_ids && !ng.sub_trace_ids) {
+              return { ...ng, sub_trace_ids: existing.sub_trace_ids };
+            }
+            return ng;
+          });
+        });
         return;
       }
 
@@ -93,7 +104,13 @@ export const useFlowChartData = (traceId: string | null, initialGoals: Goal[]) =
           const next = [...prev];
           const idx = next.findIndex((g) => g.id === goal.id);
           if (idx >= 0) {
-            next[idx] = { ...next[idx], ...goal };
+            const existing = next[idx];
+            const merged = { ...existing, ...goal };
+            // 保留 sub_trace_ids,如果 WebSocket 数据中缺失但本地已有
+            if (existing.sub_trace_ids && !merged.sub_trace_ids) {
+              merged.sub_trace_ids = existing.sub_trace_ids;
+            }
+            next[idx] = merged;
             return next;
           }
           next.push(goal as Goal);
@@ -133,14 +150,20 @@ export const useFlowChartData = (traceId: string | null, initialGoals: Goal[]) =
 
       if (event === "message_added") {
         const message = isMessage(data.message) ? data.message : isMessage(raw.message) ? raw.message : null;
-        if (!message) return;
-        setMessages((prev: Message[]) => [...prev, message]);
-        updateMessageGroups(message);
+        if (message) {
+          setMessages((prev) => {
+            const next = [...prev, message];
+            next.sort((a, b) => messageSortKey(a) - messageSortKey(b));
+            return next;
+          });
+          updateMessageGroups(message);
+        }
       }
     },
-    [updateMessageGroups],
+    [messageSortKey, updateMessageGroups],
   );
 
+  // 主 Trace 连接
   const wsOptions = useMemo(() => ({ onMessage: handleWebSocketMessage }), [handleWebSocketMessage]);
   const { connected } = useWebSocket(traceId, wsOptions);
 

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

@@ -8,6 +8,8 @@ export interface Goal {
   created_at: string;
   completed_at?: string;
   metadata?: Record<string, unknown>;
+  agent_call_mode?: string;
+  sub_trace_ids?: Array<string | { trace_id: string; mission?: string }>;
 }
 
 export interface BranchContext {

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů