| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137 |
- /**
- * FlowChart 组件 - 纵向流程图
- *
- * 功能说明:
- * 1. 主链节点纵向排列(A -> B -> C)
- * 2. 支持递归展开 sub_goals 和 msgGroup
- * 3. 弧线连接有子内容的节点,直线连接无子内容的节点
- * 4. 点击弧线可以折叠/展开子节点
- * 5. 线条粗细根据嵌套层级递减
- */
- import { useCallback, useEffect, useMemo, useRef, useState, forwardRef, useImperativeHandle } from "react";
- import type { ForwardRefRenderFunction } from "react";
- import type { Goal } from "../../types/goal";
- import type { Edge as EdgeType, Message } from "../../types/message";
- import { ArrowMarkers } from "./components/ArrowMarkers";
- import styles from "./styles/FlowChart.module.css";
- import { Tooltip } from "@douyinfe/semi-ui";
- import { ImagePreviewModal } from "../ImagePreview/ImagePreviewModal";
- import { extractImagesFromMessage } from "../../utils/imageExtraction";
- /**
- * FlowChart 组件的 Props
- */
- 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; // 子追踪点击回调
- }
- /**
- * FlowChart 组件对外暴露的引用接口
- */
- export interface FlowChartRef {
- expandAll: () => void;
- collapseAll: () => void;
- }
- /**
- * 子追踪条目类型
- */
- export type SubTraceEntry = { id: string; mission?: string };
- /**
- * 布局节点类型
- * 用于存储节点的位置、数据和层级信息
- */
- interface LayoutNode {
- id: string; // 节点唯一标识
- x: number; // X 坐标
- y: number; // Y 坐标
- data: Goal | Message; // 节点数据
- type: "goal" | "subgoal" | "message"; // 节点类型
- level: number; // 嵌套层级(0 表示主链节点,1 表示子节点,2 表示孙节点...)
- parentId?: string; // 父节点 ID
- isInvalid?: boolean; // 是否为失效节点
- }
- /**
- * 连接线类型
- * 用于存储连接线的信息和状态
- */
- interface LayoutEdge {
- id: string; // 连接线唯一标识
- source: LayoutNode; // 源节点
- target: LayoutNode; // 目标节点
- type: "arc" | "line"; // 连接线类型:弧线(有子内容)或直线(无子内容)
- level: number; // 嵌套层级,用于计算线条粗细
- collapsible: boolean; // 是否可折叠
- collapsed: boolean; // 是否已折叠
- children?: LayoutNode[]; // 折叠时隐藏的子节点列表
- isInvalid?: boolean; // 是否为失效连接线
- }
- const FlowChartComponent: ForwardRefRenderFunction<FlowChartRef, FlowChartProps> = (
- { goals, msgGroups = {}, invalidBranches, onNodeClick },
- ref,
- ) => {
- // 过滤掉有父节点的 goals,只保留主链节点
- goals = goals.filter((g) => !g.parent_id);
- // 确保 goals 中包含 END 节点,如果没有则自动添加
- const displayGoals = useMemo(() => {
- if (!goals) return [];
- const hasEnd = goals.some((g) => g.id === "END");
- if (hasEnd) return goals;
- const endGoal: Goal = {
- id: "END",
- description: "终止",
- status: "completed",
- created_at: new Date().toISOString(),
- reason: "",
- };
- return [...goals, endGoal];
- }, [goals]);
- // SVG 和容器的引用
- const svgRef = useRef<SVGSVGElement>(null);
- const containerRef = useRef<HTMLDivElement>(null);
- // 画布尺寸状态
- const [dimensions, setDimensions] = useState({ width: 1200, height: 800 });
- // 选中的节点 ID
- const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
- // 折叠状态管理:使用 Set 存储已折叠的边的 ID
- const [collapsedEdges, setCollapsedEdges] = useState<Set<string>>(new Set());
- // 标记是否已经初始化过折叠状态
- const initializedRef = useRef(false);
- // 平移和缩放相关状态
- const [panOffset, setPanOffset] = useState({ x: 0, y: 0 }); // 平移偏移量
- const [isPanning, setIsPanning] = useState(false); // 是否正在平移
- const [zoom, setZoom] = useState(1); // 缩放比例
- const panStartRef = useRef({ x: 0, y: 0, originX: 0, originY: 0 }); // 平移起始位置
- const zoomRange = { min: 0.6, max: 2.4 }; // 缩放范围
- // 视图模式状态:scroll (滚动) 或 panzoom (拖拽缩放)
- const [viewMode, setViewMode] = useState<"scroll" | "panzoom">("scroll");
- // 图片预览状态
- const [previewImage, setPreviewImage] = useState<string | null>(null);
- // 限制缩放比例在允许范围内
- const clampZoom = (value: number) => Math.min(zoomRange.max, Math.max(zoomRange.min, value));
- // 重置视图到初始状态
- const resetView = () => {
- setZoom(1);
- setPanOffset({ x: 0, y: 0 });
- };
- // 监听容器尺寸变化,更新画布尺寸
- useEffect(() => {
- const element = containerRef.current;
- if (!element) return;
- const update = () => {
- const rect = element.getBoundingClientRect();
- const width = Math.max(800, rect.width || 0);
- const height = Math.max(600, rect.height || 0);
- setDimensions({ width, height });
- };
- update();
- if (typeof ResizeObserver === "undefined") {
- window.addEventListener("resize", update);
- return () => window.removeEventListener("resize", update);
- }
- const observer = new ResizeObserver(() => update());
- observer.observe(element);
- return () => observer.disconnect();
- }, []);
- // 处理平移操作
- useEffect(() => {
- if (!isPanning) return;
- const handleMove = (event: MouseEvent) => {
- const { x, y, originX, originY } = panStartRef.current;
- const nextX = originX + (event.clientX - x);
- const nextY = originY + (event.clientY - y);
- setPanOffset({ x: nextX, y: nextY });
- };
- const handleUp = () => {
- setIsPanning(false);
- };
- window.addEventListener("mousemove", handleMove);
- window.addEventListener("mouseup", handleUp);
- return () => {
- window.removeEventListener("mousemove", handleMove);
- window.removeEventListener("mouseup", handleUp);
- };
- }, [isPanning]);
- /**
- * 计算纵向布局
- *
- * 布局算法说明:
- * 1. 使用全局 Y 坐标确保所有节点纵向排列
- * 2. 递归展开每个主链节点的 sub_goals 和 msgGroup
- * 3. 子节点在父节点右侧水平偏移,形成层级结构
- * 4. 生成两种类型的连接线:
- * - 弧线:连接有子内容的节点,可折叠/展开
- * - 直线:连接无子内容的节点或子节点之间的连接
- */
- const layoutData = useMemo(() => {
- if (!displayGoals || displayGoals.length === 0) return { nodes: [], edges: [] };
- const nodes: LayoutNode[] = []; // 所有节点列表
- const edges: LayoutEdge[] = []; // 所有连接线列表
- const NODE_HEIGHT = 110; // 节点间距(纵向)
- const centerX = dimensions.width / 2; // 主链节点的 X 坐标(居中)
- const HORIZONTAL_OFFSET = 0; // 子节点水平偏移量
- let globalY = 100; // 全局 Y 坐标,确保所有节点纵向排列
- /**
- * 递归展开节点及其子节点
- *
- * @param goal - 当前目标节点
- * @param x - X 坐标
- * @param level - 嵌套层级(0 表示主链,1 表示子节点,2 表示孙节点...)
- * @param parentId - 父节点 ID
- * @returns 包含所有节点、第一个节点和最后一个节点的对象
- */
- const expandNode = (
- goal: Goal,
- x: number,
- level: number,
- parentId?: string,
- ): { nodes: LayoutNode[]; firstNode: LayoutNode; lastNode: LayoutNode } => {
- const nodeId = goal.id;
- // 创建当前节点
- const currentNode: LayoutNode = {
- id: nodeId,
- x,
- y: globalY,
- data: goal,
- type: "goal",
- level,
- parentId,
- };
- const localNodes: LayoutNode[] = [currentNode];
- let lastNode = currentNode; // 记录最后一个节点,用于连接到下一个主链节点
- globalY += NODE_HEIGHT; // 更新全局 Y 坐标
- // 1. 先展开 msgGroup(消息组)- 按照用户需求,消息显示在 sub_goals 之前
- const messages = msgGroups[nodeId];
- if (messages && messages.length > 0) {
- messages.forEach((msg) => {
- const msgNode: LayoutNode = {
- id: `${nodeId}-msg-${msg.message_id || Math.random()}`,
- x: x + HORIZONTAL_OFFSET, // 消息节点向右偏移
- y: globalY,
- data: msg,
- type: "message",
- level: level + 1,
- parentId: nodeId,
- };
- localNodes.push(msgNode);
- lastNode = msgNode; // 更新最后一个节点
- globalY += NODE_HEIGHT; // 更新全局 Y 坐标
- });
- }
- // 2. 再递归展开 sub_goals
- if (goal.sub_goals && goal.sub_goals.length > 0) {
- goal.sub_goals.forEach((subGoal) => {
- const subX = x + HORIZONTAL_OFFSET; // 子节点向右偏移
- const result = expandNode(subGoal, subX, level + 1, nodeId);
- localNodes.push(...result.nodes);
- lastNode = result.lastNode; // 更新最后一个节点
- });
- }
- return { nodes: localNodes, firstNode: currentNode, lastNode };
- };
- /**
- * 主链信息数组
- * 记录每个主链节点的第一个节点、最后一个节点和所有子节点
- */
- const mainChainInfo: Array<{
- goal: Goal;
- firstNode: LayoutNode;
- lastNode: LayoutNode;
- allNodes: LayoutNode[];
- }> = [];
- // 展开所有主链节点
- displayGoals.forEach((goal) => {
- const result = expandNode(goal, centerX, 0);
- nodes.push(...result.nodes);
- mainChainInfo.push({
- goal,
- firstNode: result.firstNode,
- lastNode: result.lastNode,
- allNodes: result.nodes,
- });
- });
- /**
- * 生成连接线
- *
- * 连接线生成规则:
- * 1. 主链节点之间:
- * - 如果有子节点:绘制弧线(外层)+ 直线(内层子节点连接)
- * - 如果无子节点:直接绘制直线
- * 2. 子节点之间:
- * - 如果有孙节点:绘制弧线(外层)+ 直线(内层孙节点连接)
- * - 如果无孙节点:直接绘制直线
- * 3. 递归处理所有层级的节点
- */
- for (let i = 0; i < mainChainInfo.length - 1; i++) {
- const current = mainChainInfo[i];
- const next = mainChainInfo[i + 1];
- const hasChildren = current.allNodes.length > 1; // 是否有子节点
- if (hasChildren) {
- // 情况 1:有子节点
- // 绘制弧线从第一个节点到下一个主链节点(外层包裹)
- const arcEdgeId = `arc-${current.firstNode.id}-${next.firstNode.id}`;
- edges.push({
- id: arcEdgeId,
- source: current.firstNode,
- target: next.firstNode,
- type: "arc",
- level: 0,
- collapsible: true, // 可折叠
- collapsed: collapsedEdges.has(arcEdgeId),
- children: current.allNodes.slice(1), // 除了第一个节点外的所有子节点
- });
- // 绘制直线连接子节点(内层)
- const childNodes = current.allNodes.slice(1);
- const directChildren = childNodes.filter((n) => n.parentId === current.firstNode.id);
- // Logic A: Messages Group Arc (A -> s1)
- // 检查是否有消息节点且后面跟着子目标节点
- // 如果有,绘制一条蓝色弧线从主节点到第一个子目标节点,包裹所有消息节点
- const firstSubgoalIndex = directChildren.findIndex((n) => n.type === "goal");
- if (firstSubgoalIndex > 0) {
- const target = directChildren[firstSubgoalIndex];
- const messages = directChildren.slice(0, firstSubgoalIndex);
- const arcId = `arc-msg-${current.firstNode.id}-${target.id}`;
- edges.push({
- id: arcId,
- source: current.firstNode,
- target: target,
- type: "arc",
- level: 1,
- collapsible: true,
- collapsed: collapsedEdges.has(arcId),
- children: messages,
- });
- }
- // 连接第一个节点到第一个子节点
- if (directChildren.length > 0) {
- edges.push({
- id: `line-${current.firstNode.id}-${directChildren[0].id}`,
- source: current.firstNode,
- target: directChildren[0],
- type: "line",
- level: 1,
- collapsible: false,
- collapsed: false,
- });
- }
- // 连接子节点之间
- for (let j = 0; j < directChildren.length - 1; j++) {
- const source = directChildren[j];
- const target = directChildren[j + 1];
- // 检查是否有孙节点
- const grandChildren = nodes.filter((n) => n.parentId === source.id);
- const hasGrandChildren = grandChildren.length > 0;
- if (hasGrandChildren) {
- // 情况 2:有孙节点
- // 绘制弧线从当前子节点到下一个子节点(第二层包裹)
- const arcId = `arc-${source.id}-${target.id}`;
- edges.push({
- id: arcId,
- source,
- target,
- type: "arc",
- level: source.level,
- collapsible: true, // 可折叠
- collapsed: collapsedEdges.has(arcId),
- children: grandChildren,
- });
- // 绘制孙节点之间的直线(内层)
- if (grandChildren.length > 0) {
- // 连接子节点到第一个孙节点
- edges.push({
- id: `line-${source.id}-${grandChildren[0].id}`,
- source,
- target: grandChildren[0],
- type: "line",
- level: source.level + 1,
- collapsible: false,
- collapsed: false,
- });
- // 连接孙节点之间
- for (let k = 0; k < grandChildren.length - 1; k++) {
- edges.push({
- id: `line-${grandChildren[k].id}-${grandChildren[k + 1].id}`,
- source: grandChildren[k],
- target: grandChildren[k + 1],
- type: "line",
- level: source.level + 1,
- collapsible: false,
- collapsed: false,
- });
- }
- // 连接最后一个孙节点到下一个子节点
- edges.push({
- id: `line-${grandChildren[grandChildren.length - 1].id}-${target.id}`,
- source: grandChildren[grandChildren.length - 1],
- target,
- type: "line",
- level: source.level + 1,
- collapsible: false,
- collapsed: false,
- });
- }
- } else {
- // 情况 3:没有孙节点,直接绘制直线
- edges.push({
- id: `line-${source.id}-${target.id}`,
- source,
- target,
- type: "line",
- level: source.level,
- collapsible: false,
- collapsed: false,
- });
- }
- }
- // 连接最后一个子节点到下一个主链节点
- if (childNodes.length > 0) {
- edges.push({
- id: `line-${current.lastNode.id}-${next.firstNode.id}`,
- source: current.lastNode,
- target: next.firstNode,
- type: "line",
- level: 1,
- collapsible: false,
- collapsed: false,
- });
- // Logic C: Last Child Arc (s2 -> B)
- // 如果最后一个直接子节点是 sub_goal 且有子节点,绘制弧线连接到下一个主节点
- const lastChild = directChildren[directChildren.length - 1];
- const lastChildChildren = nodes.filter((n) => n.parentId === lastChild.id);
- if (lastChild.type === "goal" && lastChildChildren.length > 0) {
- const arcId = `arc-${lastChild.id}-${next.firstNode.id}`;
- edges.push({
- id: arcId,
- source: lastChild,
- target: next.firstNode,
- type: "arc",
- level: 1,
- collapsible: true,
- collapsed: collapsedEdges.has(arcId),
- children: lastChildChildren,
- });
- // 补充绘制最后一个子节点的内部连线(直线)
- // 1. 连接 lastChild 到第一个孙节点
- edges.push({
- id: `line-${lastChild.id}-${lastChildChildren[0].id}`,
- source: lastChild,
- target: lastChildChildren[0],
- type: "line",
- level: lastChild.level + 1,
- collapsible: false,
- collapsed: false,
- });
- // 2. 连接孙节点之间
- for (let k = 0; k < lastChildChildren.length - 1; k++) {
- edges.push({
- id: `line-${lastChildChildren[k].id}-${lastChildChildren[k + 1].id}`,
- source: lastChildChildren[k],
- target: lastChildChildren[k + 1],
- type: "line",
- level: lastChild.level + 1,
- collapsible: false,
- collapsed: false,
- });
- }
- }
- }
- } else {
- // 情况 4:没有子节点,直接绘制直线
- edges.push({
- id: `line-${current.firstNode.id}-${next.firstNode.id}`,
- source: current.firstNode,
- target: next.firstNode,
- type: "line",
- level: 0,
- collapsible: false,
- collapsed: false,
- });
- }
- }
- // 处理失效分支(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);
- }
- });
- // Map to store invalid nodes by their anchor parent ID
- const invalidNodesByAnchor = new Map<string, LayoutNode[]>();
- 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; // 向左偏移
- const currentBranchNodes: LayoutNode[] = [];
- branch.forEach((msg, idx) => {
- // Ensure we have a stable ID. If msg.id is missing, use a deterministic fallback.
- // Note: Using Math.random() is bad for React reconciliation, but here we need something unique if ID is missing.
- // Better fallback: `${parentNode.id}-branch-${idx}`
- const stableId = msg.id || `${parentNode.id}-branch-${idx}`;
- const nodeId = `invalid-${stableId}`;
- 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);
- currentBranchNodes.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;
- });
- // Store in map
- if (!invalidNodesByAnchor.has(parentNode.id)) {
- invalidNodesByAnchor.set(parentNode.id, []);
- }
- invalidNodesByAnchor.get(parentNode.id)!.push(...currentBranchNodes);
- }
- }
- });
- // Associate invalid nodes with collapsible edges
- // If a parent node is hidden (part of a collapsed edge), its invalid children should also be hidden
- edges.forEach((edge) => {
- if (edge.collapsible && edge.children) {
- const extraChildren: LayoutNode[] = [];
- edge.children.forEach((child) => {
- if (invalidNodesByAnchor.has(child.id)) {
- extraChildren.push(...invalidNodesByAnchor.get(child.id)!);
- }
- });
- if (extraChildren.length > 0) {
- edge.children.push(...extraChildren);
- }
- }
- });
- }
- return { nodes, edges };
- }, [displayGoals, dimensions, msgGroups, collapsedEdges, invalidBranches]);
- // 暴露给父组件的方法
- useImperativeHandle(
- ref,
- () => ({
- expandAll: () => {
- setCollapsedEdges(new Set());
- },
- collapseAll: () => {
- const allCollapsible = new Set<string>();
- layoutData.edges.forEach((edge) => {
- if (edge.collapsible) {
- allCollapsible.add(edge.id);
- }
- });
- setCollapsedEdges(allCollapsible);
- },
- }),
- [layoutData],
- );
- // 初始化折叠状态:只展开第一个主链节点(A->B)之间的内容
- useEffect(() => {
- if (initializedRef.current || !layoutData || layoutData.edges.length === 0) return;
- // 找出所有可折叠的弧线(level === 0 表示主链节点之间的弧线)
- // const mainChainArcs = layoutData.edges.filter(
- // (edge) => edge.type === "arc" && edge.collapsible && edge.level === 0,
- // );
- // if (mainChainArcs.length > 0) {
- // // 除了第一个弧线外,其他都默认折叠
- // const toCollapse = new Set<string>();
- // mainChainArcs.slice(1).forEach((edge) => {
- // toCollapse.add(edge.id);
- // });
- // setCollapsedEdges(toCollapse);
- // initializedRef.current = true;
- // }
- }, [layoutData]);
- /**
- * 过滤掉被折叠的节点和边
- * 当弧线被折叠时,其子节点和相关的边都会被隐藏
- * 并且调整后续节点的位置,填补折叠后的空白
- */
- const visibleData = useMemo(() => {
- const NODE_HEIGHT = 110;
- // 1. 找出所有折叠的边
- const allCollapsedEdges = layoutData.edges.filter((e) => e.collapsed);
- // 筛选出“有效”的折叠边:如果一个折叠边被另一个更大的折叠边包含(即其节点被隐藏),则忽略它
- // 避免重复计算 shiftY
- const collapsedEdgesList = allCollapsedEdges.filter((edge) => {
- return !allCollapsedEdges.some((other) => {
- if (edge === other) return false;
- // 如果当前边的源节点或目标节点在另一个折叠边的子节点列表中,说明当前边是被包裹在内部的
- return other.children?.some((child) => child.id === edge.source.id || child.id === edge.target.id);
- });
- });
- // 2. 过滤节点:隐藏被折叠的节点
- const visibleNodesRaw = layoutData.nodes.filter((node) => {
- for (const edge of collapsedEdgesList) {
- if (edge.children?.some((child) => child.id === node.id)) {
- return false; // 节点在折叠的边的子节点中,隐藏
- }
- }
- return true; // 节点可见
- });
- // 3. 计算每个节点需要向上移动的距离
- // 逻辑:如果一个节点位于某个折叠区域的“下方”,它需要向上移动
- const shiftedNodes = visibleNodesRaw.map((node) => {
- let shiftY = 0;
- for (const edge of collapsedEdgesList) {
- // 如果当前节点位于折叠边的目标节点下方(或就是目标节点)
- if (node.y >= edge.target.y) {
- const originalDist = edge.target.y - edge.source.y;
- const collapsedDist = NODE_HEIGHT; // 折叠后只保留一个节点间距
- shiftY += originalDist - collapsedDist;
- }
- }
- return {
- ...node,
- y: node.y - shiftY,
- };
- });
- // 创建一个 id -> node 的映射,方便查找
- const nodeMap = new Map(shiftedNodes.map((n) => [n.id, n]));
- // 4. 过滤并更新边
- const visibleEdges = layoutData.edges
- .filter((edge) => {
- const sourceNode = nodeMap.get(edge.source.id);
- const targetNode = nodeMap.get(edge.target.id);
- // 只显示源节点和目标节点都可见的边,或者虽然中间节点被隐藏但作为连接线的边(折叠后的弧线)
- // 对于折叠的弧线,它的 source 和 target 应该是可见的(因为它们是折叠区域的边界)
- return sourceNode && targetNode;
- })
- .map((edge) => ({
- ...edge,
- source: nodeMap.get(edge.source.id)!,
- target: nodeMap.get(edge.target.id)!,
- }));
- return { nodes: shiftedNodes, edges: visibleEdges };
- }, [layoutData]);
- /**
- * 计算内容尺寸(用于滚动模式)
- * 根据节点位置计算包围盒,确保内容完整显示
- */
- const contentSize = useMemo(() => {
- if (visibleData.nodes.length === 0) return { width: 0, height: 0, minX: 0 };
- let minX = Infinity;
- let maxX = -Infinity;
- let maxY = -Infinity;
- visibleData.nodes.forEach((node) => {
- minX = Math.min(minX, node.x);
- maxX = Math.max(maxX, node.x);
- maxY = Math.max(maxY, node.y);
- });
- // 增加一些内边距,确保边缘不被遮挡
- const startX = Math.min(0, minX - 50);
- const endX = maxX + 150;
- // 在 scroll 模式下,使用实际内容尺寸,不受容器尺寸限制
- // 这样可以保持 1:1 比例,通过滚动条查看超出部分
- return {
- width: endX - startX,
- height: maxY + 150,
- minX: startX,
- };
- }, [visibleData]);
- /**
- * 切换视图模式
- * 在滚动模式和拖拽缩放模式之间切换
- * 切换时重置视图状态,防止位置突变
- */
- const toggleView = () => {
- if (viewMode === "scroll") {
- setViewMode("panzoom");
- // 切换到 panzoom 时重置视图
- resetView();
- } else {
- setViewMode("scroll");
- // 切换回 scroll 时也重置视图
- resetView();
- }
- };
- /**
- * 切换折叠状态
- * 点击弧线时调用,切换该弧线的折叠/展开状态
- */
- const toggleCollapse = useCallback((edgeId: string) => {
- setCollapsedEdges((prev) => {
- const next = new Set(prev);
- if (next.has(edgeId)) {
- next.delete(edgeId); // 已折叠,展开
- } else {
- next.add(edgeId); // 未折叠,折叠
- }
- return next;
- });
- }, []);
- /**
- * 绘制弧线路径
- * 使用二次贝塞尔曲线(Quadratic Bezier Curve)
- *
- * @param source - 源节点
- * @param target - 目标节点
- * @param level - 嵌套层级,用于计算弧线偏移量
- * @returns SVG 路径字符串
- */
- const getArcPath = (source: LayoutNode, target: LayoutNode, level: number) => {
- const sx = source.x;
- const sy = source.y + 25; // 从节点底部出发
- const tx = target.x;
- const ty = target.y - 25; // 到节点顶部结束
- // 弧线向右偏移,偏移量根据层级递减
- // 外层弧线偏移量大,内层弧线偏移量小
- // 增加基础偏移量,让弧线更明显
- const offset = 800 - level * 30;
- const midY = (sy + ty) / 2;
- // Q: 二次贝塞尔曲线,控制点在 (sx + offset, midY)
- return `M ${sx},${sy} Q ${sx + offset},${midY} ${tx},${ty}`;
- };
- /**
- * 绘制直线路径
- *
- * @param source - 源节点
- * @param target - 目标节点
- * @returns SVG 路径字符串
- */
- const getLinePath = (source: LayoutNode, target: LayoutNode) => {
- return `M ${source.x},${source.y + 25} L ${target.x},${target.y - 25}`;
- };
- /**
- * 计算线条粗细
- * 根据嵌套层级递减,外层线条更粗,内层线条更细
- *
- * @param level - 嵌套层级
- * @returns 线条粗细(像素)
- */
- const getStrokeWidth = (level: number) => {
- return Math.max(0.1, 6 - level * 2);
- };
- /**
- * 节点点击处理
- * 区分主链节点和子节点,触发不同的回调
- */
- const handleNodeClick = useCallback(
- (node: LayoutNode) => {
- if (node.type === "goal") {
- // const goalData = node.data as Goal;
- // // 只有具有 sub_trace_ids 的子目标节点(agent 委托执行)才触发 trace 切换
- // // 普通的 sub_goal 节点(蓝色节点)没有 sub_trace_ids,应该打开 DetailPanel
- // const hasSubTraces = goalData.sub_trace_ids && goalData.sub_trace_ids.length > 0;
- // if (node.parentId && onSubTraceClick && hasSubTraces) {
- // const parentNode = layoutData.nodes.find((n) => n.id === node.parentId);
- // if (parentNode && parentNode.type === "goal") {
- // // 取第一个 sub_trace_id 作为跳转目标(使用 trace_id,而非 goal.id)
- // const firstEntry = goalData.sub_trace_ids![0];
- // const entry: SubTraceEntry =
- // typeof firstEntry === "string"
- // ? { id: firstEntry }
- // : { id: firstEntry.trace_id, mission: firstEntry.mission };
- // onSubTraceClick(parentNode.data as Goal, entry);
- // return;
- // }
- // }
- // 主链节点 或 没有 sub_trace_ids 的普通子目标节点 → 打开 DetailPanel
- setSelectedNodeId(node.id);
- onNodeClick?.(node.data as Goal);
- } else if (node.type === "message") {
- setSelectedNodeId(node.id);
- onNodeClick?.(node.data as Message);
- }
- },
- [onNodeClick],
- );
- if (!layoutData) return <div>Loading...</div>;
- return (
- <div
- className={styles.container}
- ref={containerRef}
- >
- <div
- className={styles.scrollContainer}
- style={{
- overflow: viewMode === "scroll" ? "auto" : "hidden",
- }}
- >
- <svg
- ref={svgRef}
- viewBox={
- viewMode === "scroll"
- ? `${contentSize.width / 2} 0 ${contentSize.width} ${contentSize.height}`
- : `0 0 ${dimensions.width} ${dimensions.height}`
- }
- preserveAspectRatio="xMidYMid meet"
- className={`${styles.svg} ${isPanning ? styles.panning : ""}`}
- style={{
- cursor: viewMode === "scroll" ? "default" : isPanning ? "grabbing" : "grab",
- width: viewMode === "scroll" ? "100%" : "100%",
- height: viewMode === "scroll" ? contentSize.height : "100%",
- minWidth: viewMode === "scroll" ? contentSize.width : undefined,
- minHeight: viewMode === "scroll" ? contentSize.height : undefined,
- }}
- onWheel={(event) => {
- if (viewMode === "scroll") return; // 滚动模式下使用原生滚动
- // 鼠标滚轮缩放
- event.preventDefault();
- const rect = svgRef.current?.getBoundingClientRect();
- if (!rect) return;
- const cursorX = event.clientX - rect.left;
- const cursorY = event.clientY - rect.top;
- const nextZoom = clampZoom(zoom * (event.deltaY > 0 ? 0.92 : 1.08));
- if (nextZoom === zoom) return;
- const scale = nextZoom / zoom;
- // 以鼠标位置为中心缩放
- setPanOffset((prev) => ({
- x: cursorX - (cursorX - prev.x) * scale,
- y: cursorY - (cursorY - prev.y) * scale,
- }));
- setZoom(nextZoom);
- }}
- onDoubleClick={() => resetView()} // 双击重置视图
- onMouseDown={(event) => {
- if (viewMode === "scroll") return; // 滚动模式下禁用拖拽
- // 开始平移
- if (event.button !== 0) return; // 只响应左键
- const target = event.target as Element;
- // 如果点击的是节点或连接线,不触发平移
- if (target.closest(`.${styles.nodes}`) || target.closest(`.${styles.links}`)) return;
- panStartRef.current = {
- x: event.clientX,
- y: event.clientY,
- originX: panOffset.x,
- originY: panOffset.y,
- };
- setIsPanning(true);
- }}
- >
- <defs>
- <ArrowMarkers /> {/* 箭头标记定义 */}
- </defs>
- {/* 应用平移和缩放变换(仅在 panzoom 模式下) */}
- <g transform={viewMode === "scroll" ? undefined : `translate(${panOffset.x},${panOffset.y}) scale(${zoom})`}>
- {/* 绘制连接线 */}
- <g className={styles.links}>
- {visibleData.edges.map((edge) => {
- // 根据连接线类型选择路径
- const path =
- edge.type === "arc" && !edge.collapsed
- ? getArcPath(edge.source, edge.target, edge.level)
- : getLinePath(edge.source, edge.target);
- const strokeWidth = getStrokeWidth(edge.level); // 根据层级计算线条粗细
- // 根据节点类型决定颜色
- let color = "#2196F3"; // 默认蓝色
- // 判断连接线连接的节点类型
- const sourceIsMessage = edge.source.type === "message";
- const targetIsMessage = edge.target.type === "message";
- const sourceIsMainGoal = edge.source.type === "goal" && edge.source.level === 0;
- const targetIsMainGoal = edge.target.type === "goal" && edge.target.level === 0;
- if (sourceIsMessage || targetIsMessage) {
- // msgGroup 相关的连接线用灰色
- color = "#94a3b8"; // Slate 400
- } else if (sourceIsMainGoal && targetIsMainGoal) {
- // 主节点之间的连接线用绿色
- color = "#10b981"; // Emerald 500
- } else {
- // sub_goals 之间的连接线用蓝色
- color = "#3b82f6"; // Blue 500
- }
- return (
- <g key={edge.id}>
- <path
- d={path}
- fill="none"
- stroke={edge.isInvalid ? "#cbd5e1" : color} // 失效边使用浅灰色 (Slate 300)
- strokeWidth={strokeWidth}
- strokeDasharray={edge.isInvalid ? "5,5" : undefined} // 失效边使用虚线
- markerEnd={edge.isInvalid ? undefined : "url(#arrow-default)"} // 失效边不显示箭头
- style={{ cursor: edge.collapsible ? "pointer" : "default" }} // 可折叠的显示手型光标
- onClick={() => edge.collapsible && toggleCollapse(edge.id)} // 点击切换折叠状态
- />
- {/* 折叠状态提示徽章 */}
- {edge.collapsed && (
- <g
- transform={`translate(${(edge.source.x + edge.target.x) / 2},${
- (edge.source.y + edge.target.y) / 2
- })`}
- onClick={(e) => {
- e.stopPropagation();
- toggleCollapse(edge.id);
- }}
- style={{ cursor: "pointer" }}
- >
- <circle
- r={10}
- fill="#FFFFFF"
- stroke={color}
- strokeWidth={1}
- />
- <text
- x={0}
- y={4}
- fontSize={10}
- fill={color}
- textAnchor="middle"
- fontWeight="bold"
- >
- {edge.children ? edge.children.length : "+"}
- </text>
- </g>
- )}
- </g>
- );
- })}
- </g>
- {/* 绘制节点 */}
- <g className={styles.nodes}>
- {visibleData.nodes.map((node) => {
- const isGoal = node.type === "goal";
- const data = node.data as Goal;
- const text = isGoal ? data.description : (node.data as Message).description || "";
- let thumbnail: string | null = null;
- if (node.type === "message") {
- const images = extractImagesFromMessage(node.data as Message);
- if (images.length > 0) thumbnail = images[0].url;
- }
- let textColor = "#3b82f6"; // Blue 500
- if (node.type === "message") {
- textColor = "#64748b"; // Slate 500
- } else if (node.type === "goal" && node.level === 0) {
- textColor = "#10b981"; // Emerald 500
- }
- if (node.isInvalid) {
- textColor = "#94a3b8"; // Slate 400
- }
- return (
- <g
- key={node.id}
- transform={`translate(${node.x},${node.y})`}
- onClick={() => handleNodeClick(node)}
- style={{ cursor: "pointer" }}
- >
- {/* 节点矩形 */}
- <rect
- x={-70}
- y={-25}
- width={150}
- height={50}
- rx={8}
- fill={isGoal ? "#eff6ff" : "#f8fafc"} // Blue 50 / Slate 50
- stroke={selectedNodeId === node.id ? "#3b82f6" : node.isInvalid ? "#cbd5e1" : "#e2e8f0"} // Selected: Blue 500, Invalid: Slate 300, Default: Slate 200
- strokeWidth={selectedNodeId === node.id ? 2 : 1}
- strokeDasharray={node.isInvalid ? "5,5" : undefined} // 失效节点虚线边框
- style={{
- filter:
- selectedNodeId === node.id
- ? "drop-shadow(0 4px 6px rgb(59 130 246 / 0.3))"
- : "drop-shadow(0 1px 2px rgb(0 0 0 / 0.05))",
- }}
- />
- {/* 节点文本 */}
- <foreignObject
- x={-70}
- y={-25}
- width={150}
- height={50}
- >
- <div
- className="w-full h-full overflow-hidden flex items-center px-2 gap-2"
- style={{
- color: textColor,
- justifyContent: thumbnail ? "space-between" : "center",
- }}
- >
- <span className={`text-xs line-clamp-3 ${thumbnail ? "flex-1 text-left" : "text-center"}`}>
- {text}
- </span>
- {thumbnail && (
- <img
- src={thumbnail}
- alt="thumb"
- className="w-8 h-8 object-cover rounded border border-gray-200 bg-white flex-shrink-0 hover:scale-110 transition-transform"
- loading="lazy"
- onClick={(e) => {
- e.stopPropagation();
- setPreviewImage(thumbnail);
- }}
- />
- )}
- </div>
- </foreignObject>
- </g>
- );
- })}
- </g>
- </g>
- </svg>
- </div>
- {/* 控制按钮 */}
- <div className={styles.controls}>
- {viewMode === "panzoom" && (
- <>
- <button
- type="button"
- className={styles.controlButton}
- onClick={() => setZoom((prev) => clampZoom(prev * 1.1))}
- >
- + {/* 放大 */}
- </button>
- <button
- type="button"
- className={styles.controlButton}
- onClick={() => setZoom((prev) => clampZoom(prev * 0.9))}
- >
- − {/* 缩小 */}
- </button>
- <button
- type="button"
- className={styles.controlButton}
- onClick={resetView}
- >
- 复位
- </button>
- </>
- )}
- <button
- type="button"
- className={styles.controlButton}
- onClick={toggleView}
- >
- {viewMode === "scroll" ? "切换拖拽" : "切换滚动"}
- </button>
- </div>
- <ImagePreviewModal
- visible={!!previewImage}
- onClose={() => setPreviewImage(null)}
- src={previewImage || ""}
- />
- </div>
- );
- };
- export const FlowChart = forwardRef(FlowChartComponent);
|