FlowChart.tsx 41 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137
  1. /**
  2. * FlowChart 组件 - 纵向流程图
  3. *
  4. * 功能说明:
  5. * 1. 主链节点纵向排列(A -> B -> C)
  6. * 2. 支持递归展开 sub_goals 和 msgGroup
  7. * 3. 弧线连接有子内容的节点,直线连接无子内容的节点
  8. * 4. 点击弧线可以折叠/展开子节点
  9. * 5. 线条粗细根据嵌套层级递减
  10. */
  11. import { useCallback, useEffect, useMemo, useRef, useState, forwardRef, useImperativeHandle } from "react";
  12. import type { ForwardRefRenderFunction } from "react";
  13. import type { Goal } from "../../types/goal";
  14. import type { Edge as EdgeType, Message } from "../../types/message";
  15. import { ArrowMarkers } from "./components/ArrowMarkers";
  16. import styles from "./styles/FlowChart.module.css";
  17. import { Tooltip } from "@douyinfe/semi-ui";
  18. import { ImagePreviewModal } from "../ImagePreview/ImagePreviewModal";
  19. import { extractImagesFromMessage } from "../../utils/imageExtraction";
  20. /**
  21. * FlowChart 组件的 Props
  22. */
  23. interface FlowChartProps {
  24. goals: Goal[]; // 目标节点列表
  25. msgGroups?: Record<string, Message[]>; // 消息组,key 是 goal_id
  26. invalidBranches?: Message[][]; // 失效分支列表
  27. onNodeClick?: (node: Goal | Message, edge?: EdgeType) => void; // 节点点击回调
  28. onSubTraceClick?: (parentGoal: Goal, entry: SubTraceEntry) => void; // 子追踪点击回调
  29. }
  30. /**
  31. * FlowChart 组件对外暴露的引用接口
  32. */
  33. export interface FlowChartRef {
  34. expandAll: () => void;
  35. collapseAll: () => void;
  36. }
  37. /**
  38. * 子追踪条目类型
  39. */
  40. export type SubTraceEntry = { id: string; mission?: string };
  41. /**
  42. * 布局节点类型
  43. * 用于存储节点的位置、数据和层级信息
  44. */
  45. interface LayoutNode {
  46. id: string; // 节点唯一标识
  47. x: number; // X 坐标
  48. y: number; // Y 坐标
  49. data: Goal | Message; // 节点数据
  50. type: "goal" | "subgoal" | "message"; // 节点类型
  51. level: number; // 嵌套层级(0 表示主链节点,1 表示子节点,2 表示孙节点...)
  52. parentId?: string; // 父节点 ID
  53. isInvalid?: boolean; // 是否为失效节点
  54. }
  55. /**
  56. * 连接线类型
  57. * 用于存储连接线的信息和状态
  58. */
  59. interface LayoutEdge {
  60. id: string; // 连接线唯一标识
  61. source: LayoutNode; // 源节点
  62. target: LayoutNode; // 目标节点
  63. type: "arc" | "line"; // 连接线类型:弧线(有子内容)或直线(无子内容)
  64. level: number; // 嵌套层级,用于计算线条粗细
  65. collapsible: boolean; // 是否可折叠
  66. collapsed: boolean; // 是否已折叠
  67. children?: LayoutNode[]; // 折叠时隐藏的子节点列表
  68. isInvalid?: boolean; // 是否为失效连接线
  69. }
  70. const FlowChartComponent: ForwardRefRenderFunction<FlowChartRef, FlowChartProps> = (
  71. { goals, msgGroups = {}, invalidBranches, onNodeClick },
  72. ref,
  73. ) => {
  74. // 过滤掉有父节点的 goals,只保留主链节点
  75. goals = goals.filter((g) => !g.parent_id);
  76. // 确保 goals 中包含 END 节点,如果没有则自动添加
  77. const displayGoals = useMemo(() => {
  78. if (!goals) return [];
  79. const hasEnd = goals.some((g) => g.id === "END");
  80. if (hasEnd) return goals;
  81. const endGoal: Goal = {
  82. id: "END",
  83. description: "终止",
  84. status: "completed",
  85. created_at: new Date().toISOString(),
  86. reason: "",
  87. };
  88. return [...goals, endGoal];
  89. }, [goals]);
  90. // SVG 和容器的引用
  91. const svgRef = useRef<SVGSVGElement>(null);
  92. const containerRef = useRef<HTMLDivElement>(null);
  93. // 画布尺寸状态
  94. const [dimensions, setDimensions] = useState({ width: 1200, height: 800 });
  95. // 选中的节点 ID
  96. const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
  97. // 折叠状态管理:使用 Set 存储已折叠的边的 ID
  98. const [collapsedEdges, setCollapsedEdges] = useState<Set<string>>(new Set());
  99. // 标记是否已经初始化过折叠状态
  100. const initializedRef = useRef(false);
  101. // 平移和缩放相关状态
  102. const [panOffset, setPanOffset] = useState({ x: 0, y: 0 }); // 平移偏移量
  103. const [isPanning, setIsPanning] = useState(false); // 是否正在平移
  104. const [zoom, setZoom] = useState(1); // 缩放比例
  105. const panStartRef = useRef({ x: 0, y: 0, originX: 0, originY: 0 }); // 平移起始位置
  106. const zoomRange = { min: 0.6, max: 2.4 }; // 缩放范围
  107. // 视图模式状态:scroll (滚动) 或 panzoom (拖拽缩放)
  108. const [viewMode, setViewMode] = useState<"scroll" | "panzoom">("scroll");
  109. // 图片预览状态
  110. const [previewImage, setPreviewImage] = useState<string | null>(null);
  111. // 限制缩放比例在允许范围内
  112. const clampZoom = (value: number) => Math.min(zoomRange.max, Math.max(zoomRange.min, value));
  113. // 重置视图到初始状态
  114. const resetView = () => {
  115. setZoom(1);
  116. setPanOffset({ x: 0, y: 0 });
  117. };
  118. // 监听容器尺寸变化,更新画布尺寸
  119. useEffect(() => {
  120. const element = containerRef.current;
  121. if (!element) return;
  122. const update = () => {
  123. const rect = element.getBoundingClientRect();
  124. const width = Math.max(800, rect.width || 0);
  125. const height = Math.max(600, rect.height || 0);
  126. setDimensions({ width, height });
  127. };
  128. update();
  129. if (typeof ResizeObserver === "undefined") {
  130. window.addEventListener("resize", update);
  131. return () => window.removeEventListener("resize", update);
  132. }
  133. const observer = new ResizeObserver(() => update());
  134. observer.observe(element);
  135. return () => observer.disconnect();
  136. }, []);
  137. // 处理平移操作
  138. useEffect(() => {
  139. if (!isPanning) return;
  140. const handleMove = (event: MouseEvent) => {
  141. const { x, y, originX, originY } = panStartRef.current;
  142. const nextX = originX + (event.clientX - x);
  143. const nextY = originY + (event.clientY - y);
  144. setPanOffset({ x: nextX, y: nextY });
  145. };
  146. const handleUp = () => {
  147. setIsPanning(false);
  148. };
  149. window.addEventListener("mousemove", handleMove);
  150. window.addEventListener("mouseup", handleUp);
  151. return () => {
  152. window.removeEventListener("mousemove", handleMove);
  153. window.removeEventListener("mouseup", handleUp);
  154. };
  155. }, [isPanning]);
  156. /**
  157. * 计算纵向布局
  158. *
  159. * 布局算法说明:
  160. * 1. 使用全局 Y 坐标确保所有节点纵向排列
  161. * 2. 递归展开每个主链节点的 sub_goals 和 msgGroup
  162. * 3. 子节点在父节点右侧水平偏移,形成层级结构
  163. * 4. 生成两种类型的连接线:
  164. * - 弧线:连接有子内容的节点,可折叠/展开
  165. * - 直线:连接无子内容的节点或子节点之间的连接
  166. */
  167. const layoutData = useMemo(() => {
  168. if (!displayGoals || displayGoals.length === 0) return { nodes: [], edges: [] };
  169. const nodes: LayoutNode[] = []; // 所有节点列表
  170. const edges: LayoutEdge[] = []; // 所有连接线列表
  171. const NODE_HEIGHT = 110; // 节点间距(纵向)
  172. const centerX = dimensions.width / 2; // 主链节点的 X 坐标(居中)
  173. const HORIZONTAL_OFFSET = 0; // 子节点水平偏移量
  174. let globalY = 100; // 全局 Y 坐标,确保所有节点纵向排列
  175. /**
  176. * 递归展开节点及其子节点
  177. *
  178. * @param goal - 当前目标节点
  179. * @param x - X 坐标
  180. * @param level - 嵌套层级(0 表示主链,1 表示子节点,2 表示孙节点...)
  181. * @param parentId - 父节点 ID
  182. * @returns 包含所有节点、第一个节点和最后一个节点的对象
  183. */
  184. const expandNode = (
  185. goal: Goal,
  186. x: number,
  187. level: number,
  188. parentId?: string,
  189. ): { nodes: LayoutNode[]; firstNode: LayoutNode; lastNode: LayoutNode } => {
  190. const nodeId = goal.id;
  191. // 创建当前节点
  192. const currentNode: LayoutNode = {
  193. id: nodeId,
  194. x,
  195. y: globalY,
  196. data: goal,
  197. type: "goal",
  198. level,
  199. parentId,
  200. };
  201. const localNodes: LayoutNode[] = [currentNode];
  202. let lastNode = currentNode; // 记录最后一个节点,用于连接到下一个主链节点
  203. globalY += NODE_HEIGHT; // 更新全局 Y 坐标
  204. // 1. 先展开 msgGroup(消息组)- 按照用户需求,消息显示在 sub_goals 之前
  205. const messages = msgGroups[nodeId];
  206. if (messages && messages.length > 0) {
  207. messages.forEach((msg) => {
  208. const msgNode: LayoutNode = {
  209. id: `${nodeId}-msg-${msg.message_id || Math.random()}`,
  210. x: x + HORIZONTAL_OFFSET, // 消息节点向右偏移
  211. y: globalY,
  212. data: msg,
  213. type: "message",
  214. level: level + 1,
  215. parentId: nodeId,
  216. };
  217. localNodes.push(msgNode);
  218. lastNode = msgNode; // 更新最后一个节点
  219. globalY += NODE_HEIGHT; // 更新全局 Y 坐标
  220. });
  221. }
  222. // 2. 再递归展开 sub_goals
  223. if (goal.sub_goals && goal.sub_goals.length > 0) {
  224. goal.sub_goals.forEach((subGoal) => {
  225. const subX = x + HORIZONTAL_OFFSET; // 子节点向右偏移
  226. const result = expandNode(subGoal, subX, level + 1, nodeId);
  227. localNodes.push(...result.nodes);
  228. lastNode = result.lastNode; // 更新最后一个节点
  229. });
  230. }
  231. return { nodes: localNodes, firstNode: currentNode, lastNode };
  232. };
  233. /**
  234. * 主链信息数组
  235. * 记录每个主链节点的第一个节点、最后一个节点和所有子节点
  236. */
  237. const mainChainInfo: Array<{
  238. goal: Goal;
  239. firstNode: LayoutNode;
  240. lastNode: LayoutNode;
  241. allNodes: LayoutNode[];
  242. }> = [];
  243. // 展开所有主链节点
  244. displayGoals.forEach((goal) => {
  245. const result = expandNode(goal, centerX, 0);
  246. nodes.push(...result.nodes);
  247. mainChainInfo.push({
  248. goal,
  249. firstNode: result.firstNode,
  250. lastNode: result.lastNode,
  251. allNodes: result.nodes,
  252. });
  253. });
  254. /**
  255. * 生成连接线
  256. *
  257. * 连接线生成规则:
  258. * 1. 主链节点之间:
  259. * - 如果有子节点:绘制弧线(外层)+ 直线(内层子节点连接)
  260. * - 如果无子节点:直接绘制直线
  261. * 2. 子节点之间:
  262. * - 如果有孙节点:绘制弧线(外层)+ 直线(内层孙节点连接)
  263. * - 如果无孙节点:直接绘制直线
  264. * 3. 递归处理所有层级的节点
  265. */
  266. for (let i = 0; i < mainChainInfo.length - 1; i++) {
  267. const current = mainChainInfo[i];
  268. const next = mainChainInfo[i + 1];
  269. const hasChildren = current.allNodes.length > 1; // 是否有子节点
  270. if (hasChildren) {
  271. // 情况 1:有子节点
  272. // 绘制弧线从第一个节点到下一个主链节点(外层包裹)
  273. const arcEdgeId = `arc-${current.firstNode.id}-${next.firstNode.id}`;
  274. edges.push({
  275. id: arcEdgeId,
  276. source: current.firstNode,
  277. target: next.firstNode,
  278. type: "arc",
  279. level: 0,
  280. collapsible: true, // 可折叠
  281. collapsed: collapsedEdges.has(arcEdgeId),
  282. children: current.allNodes.slice(1), // 除了第一个节点外的所有子节点
  283. });
  284. // 绘制直线连接子节点(内层)
  285. const childNodes = current.allNodes.slice(1);
  286. const directChildren = childNodes.filter((n) => n.parentId === current.firstNode.id);
  287. // Logic A: Messages Group Arc (A -> s1)
  288. // 检查是否有消息节点且后面跟着子目标节点
  289. // 如果有,绘制一条蓝色弧线从主节点到第一个子目标节点,包裹所有消息节点
  290. const firstSubgoalIndex = directChildren.findIndex((n) => n.type === "goal");
  291. if (firstSubgoalIndex > 0) {
  292. const target = directChildren[firstSubgoalIndex];
  293. const messages = directChildren.slice(0, firstSubgoalIndex);
  294. const arcId = `arc-msg-${current.firstNode.id}-${target.id}`;
  295. edges.push({
  296. id: arcId,
  297. source: current.firstNode,
  298. target: target,
  299. type: "arc",
  300. level: 1,
  301. collapsible: true,
  302. collapsed: collapsedEdges.has(arcId),
  303. children: messages,
  304. });
  305. }
  306. // 连接第一个节点到第一个子节点
  307. if (directChildren.length > 0) {
  308. edges.push({
  309. id: `line-${current.firstNode.id}-${directChildren[0].id}`,
  310. source: current.firstNode,
  311. target: directChildren[0],
  312. type: "line",
  313. level: 1,
  314. collapsible: false,
  315. collapsed: false,
  316. });
  317. }
  318. // 连接子节点之间
  319. for (let j = 0; j < directChildren.length - 1; j++) {
  320. const source = directChildren[j];
  321. const target = directChildren[j + 1];
  322. // 检查是否有孙节点
  323. const grandChildren = nodes.filter((n) => n.parentId === source.id);
  324. const hasGrandChildren = grandChildren.length > 0;
  325. if (hasGrandChildren) {
  326. // 情况 2:有孙节点
  327. // 绘制弧线从当前子节点到下一个子节点(第二层包裹)
  328. const arcId = `arc-${source.id}-${target.id}`;
  329. edges.push({
  330. id: arcId,
  331. source,
  332. target,
  333. type: "arc",
  334. level: source.level,
  335. collapsible: true, // 可折叠
  336. collapsed: collapsedEdges.has(arcId),
  337. children: grandChildren,
  338. });
  339. // 绘制孙节点之间的直线(内层)
  340. if (grandChildren.length > 0) {
  341. // 连接子节点到第一个孙节点
  342. edges.push({
  343. id: `line-${source.id}-${grandChildren[0].id}`,
  344. source,
  345. target: grandChildren[0],
  346. type: "line",
  347. level: source.level + 1,
  348. collapsible: false,
  349. collapsed: false,
  350. });
  351. // 连接孙节点之间
  352. for (let k = 0; k < grandChildren.length - 1; k++) {
  353. edges.push({
  354. id: `line-${grandChildren[k].id}-${grandChildren[k + 1].id}`,
  355. source: grandChildren[k],
  356. target: grandChildren[k + 1],
  357. type: "line",
  358. level: source.level + 1,
  359. collapsible: false,
  360. collapsed: false,
  361. });
  362. }
  363. // 连接最后一个孙节点到下一个子节点
  364. edges.push({
  365. id: `line-${grandChildren[grandChildren.length - 1].id}-${target.id}`,
  366. source: grandChildren[grandChildren.length - 1],
  367. target,
  368. type: "line",
  369. level: source.level + 1,
  370. collapsible: false,
  371. collapsed: false,
  372. });
  373. }
  374. } else {
  375. // 情况 3:没有孙节点,直接绘制直线
  376. edges.push({
  377. id: `line-${source.id}-${target.id}`,
  378. source,
  379. target,
  380. type: "line",
  381. level: source.level,
  382. collapsible: false,
  383. collapsed: false,
  384. });
  385. }
  386. }
  387. // 连接最后一个子节点到下一个主链节点
  388. if (childNodes.length > 0) {
  389. edges.push({
  390. id: `line-${current.lastNode.id}-${next.firstNode.id}`,
  391. source: current.lastNode,
  392. target: next.firstNode,
  393. type: "line",
  394. level: 1,
  395. collapsible: false,
  396. collapsed: false,
  397. });
  398. // Logic C: Last Child Arc (s2 -> B)
  399. // 如果最后一个直接子节点是 sub_goal 且有子节点,绘制弧线连接到下一个主节点
  400. const lastChild = directChildren[directChildren.length - 1];
  401. const lastChildChildren = nodes.filter((n) => n.parentId === lastChild.id);
  402. if (lastChild.type === "goal" && lastChildChildren.length > 0) {
  403. const arcId = `arc-${lastChild.id}-${next.firstNode.id}`;
  404. edges.push({
  405. id: arcId,
  406. source: lastChild,
  407. target: next.firstNode,
  408. type: "arc",
  409. level: 1,
  410. collapsible: true,
  411. collapsed: collapsedEdges.has(arcId),
  412. children: lastChildChildren,
  413. });
  414. // 补充绘制最后一个子节点的内部连线(直线)
  415. // 1. 连接 lastChild 到第一个孙节点
  416. edges.push({
  417. id: `line-${lastChild.id}-${lastChildChildren[0].id}`,
  418. source: lastChild,
  419. target: lastChildChildren[0],
  420. type: "line",
  421. level: lastChild.level + 1,
  422. collapsible: false,
  423. collapsed: false,
  424. });
  425. // 2. 连接孙节点之间
  426. for (let k = 0; k < lastChildChildren.length - 1; k++) {
  427. edges.push({
  428. id: `line-${lastChildChildren[k].id}-${lastChildChildren[k + 1].id}`,
  429. source: lastChildChildren[k],
  430. target: lastChildChildren[k + 1],
  431. type: "line",
  432. level: lastChild.level + 1,
  433. collapsible: false,
  434. collapsed: false,
  435. });
  436. }
  437. }
  438. }
  439. } else {
  440. // 情况 4:没有子节点,直接绘制直线
  441. edges.push({
  442. id: `line-${current.firstNode.id}-${next.firstNode.id}`,
  443. source: current.firstNode,
  444. target: next.firstNode,
  445. type: "line",
  446. level: 0,
  447. collapsible: false,
  448. collapsed: false,
  449. });
  450. }
  451. }
  452. // 处理失效分支(invalidBranches)
  453. if (invalidBranches && invalidBranches.length > 0) {
  454. const validMsgMap = new Map<number, LayoutNode>();
  455. nodes.forEach((n) => {
  456. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  457. const seq = (n.data as any).sequence;
  458. if (typeof seq === "number") {
  459. validMsgMap.set(seq, n);
  460. }
  461. });
  462. // Map to store invalid nodes by their anchor parent ID
  463. const invalidNodesByAnchor = new Map<string, LayoutNode[]>();
  464. invalidBranches.forEach((branch) => {
  465. if (branch.length === 0) return;
  466. const firstMsg = branch[0];
  467. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  468. const pSeq = (firstMsg as any).parent_sequence;
  469. if (typeof pSeq === "number") {
  470. const parentNode = validMsgMap.get(pSeq);
  471. if (parentNode) {
  472. let currentParent = parentNode;
  473. const X_OFFSET = -200; // 向左偏移
  474. const currentBranchNodes: LayoutNode[] = [];
  475. branch.forEach((msg, idx) => {
  476. // Ensure we have a stable ID. If msg.id is missing, use a deterministic fallback.
  477. // Note: Using Math.random() is bad for React reconciliation, but here we need something unique if ID is missing.
  478. // Better fallback: `${parentNode.id}-branch-${idx}`
  479. const stableId = msg.id || `${parentNode.id}-branch-${idx}`;
  480. const nodeId = `invalid-${stableId}`;
  481. const node: LayoutNode = {
  482. id: nodeId,
  483. x: parentNode.x + X_OFFSET,
  484. y: parentNode.y + (idx + 1) * NODE_HEIGHT,
  485. data: msg,
  486. type: "message",
  487. level: parentNode.level,
  488. parentId: parentNode.id,
  489. isInvalid: true,
  490. };
  491. nodes.push(node);
  492. currentBranchNodes.push(node);
  493. edges.push({
  494. id: `edge-${currentParent.id}-${node.id}`,
  495. source: currentParent,
  496. target: node,
  497. type: "line",
  498. level: 0,
  499. collapsible: false,
  500. collapsed: false,
  501. isInvalid: true,
  502. });
  503. currentParent = node;
  504. });
  505. // Store in map
  506. if (!invalidNodesByAnchor.has(parentNode.id)) {
  507. invalidNodesByAnchor.set(parentNode.id, []);
  508. }
  509. invalidNodesByAnchor.get(parentNode.id)!.push(...currentBranchNodes);
  510. }
  511. }
  512. });
  513. // Associate invalid nodes with collapsible edges
  514. // If a parent node is hidden (part of a collapsed edge), its invalid children should also be hidden
  515. edges.forEach((edge) => {
  516. if (edge.collapsible && edge.children) {
  517. const extraChildren: LayoutNode[] = [];
  518. edge.children.forEach((child) => {
  519. if (invalidNodesByAnchor.has(child.id)) {
  520. extraChildren.push(...invalidNodesByAnchor.get(child.id)!);
  521. }
  522. });
  523. if (extraChildren.length > 0) {
  524. edge.children.push(...extraChildren);
  525. }
  526. }
  527. });
  528. }
  529. return { nodes, edges };
  530. }, [displayGoals, dimensions, msgGroups, collapsedEdges, invalidBranches]);
  531. // 暴露给父组件的方法
  532. useImperativeHandle(
  533. ref,
  534. () => ({
  535. expandAll: () => {
  536. setCollapsedEdges(new Set());
  537. },
  538. collapseAll: () => {
  539. const allCollapsible = new Set<string>();
  540. layoutData.edges.forEach((edge) => {
  541. if (edge.collapsible) {
  542. allCollapsible.add(edge.id);
  543. }
  544. });
  545. setCollapsedEdges(allCollapsible);
  546. },
  547. }),
  548. [layoutData],
  549. );
  550. // 初始化折叠状态:只展开第一个主链节点(A->B)之间的内容
  551. useEffect(() => {
  552. if (initializedRef.current || !layoutData || layoutData.edges.length === 0) return;
  553. // 找出所有可折叠的弧线(level === 0 表示主链节点之间的弧线)
  554. // const mainChainArcs = layoutData.edges.filter(
  555. // (edge) => edge.type === "arc" && edge.collapsible && edge.level === 0,
  556. // );
  557. // if (mainChainArcs.length > 0) {
  558. // // 除了第一个弧线外,其他都默认折叠
  559. // const toCollapse = new Set<string>();
  560. // mainChainArcs.slice(1).forEach((edge) => {
  561. // toCollapse.add(edge.id);
  562. // });
  563. // setCollapsedEdges(toCollapse);
  564. // initializedRef.current = true;
  565. // }
  566. }, [layoutData]);
  567. /**
  568. * 过滤掉被折叠的节点和边
  569. * 当弧线被折叠时,其子节点和相关的边都会被隐藏
  570. * 并且调整后续节点的位置,填补折叠后的空白
  571. */
  572. const visibleData = useMemo(() => {
  573. const NODE_HEIGHT = 110;
  574. // 1. 找出所有折叠的边
  575. const allCollapsedEdges = layoutData.edges.filter((e) => e.collapsed);
  576. // 筛选出“有效”的折叠边:如果一个折叠边被另一个更大的折叠边包含(即其节点被隐藏),则忽略它
  577. // 避免重复计算 shiftY
  578. const collapsedEdgesList = allCollapsedEdges.filter((edge) => {
  579. return !allCollapsedEdges.some((other) => {
  580. if (edge === other) return false;
  581. // 如果当前边的源节点或目标节点在另一个折叠边的子节点列表中,说明当前边是被包裹在内部的
  582. return other.children?.some((child) => child.id === edge.source.id || child.id === edge.target.id);
  583. });
  584. });
  585. // 2. 过滤节点:隐藏被折叠的节点
  586. const visibleNodesRaw = layoutData.nodes.filter((node) => {
  587. for (const edge of collapsedEdgesList) {
  588. if (edge.children?.some((child) => child.id === node.id)) {
  589. return false; // 节点在折叠的边的子节点中,隐藏
  590. }
  591. }
  592. return true; // 节点可见
  593. });
  594. // 3. 计算每个节点需要向上移动的距离
  595. // 逻辑:如果一个节点位于某个折叠区域的“下方”,它需要向上移动
  596. const shiftedNodes = visibleNodesRaw.map((node) => {
  597. let shiftY = 0;
  598. for (const edge of collapsedEdgesList) {
  599. // 如果当前节点位于折叠边的目标节点下方(或就是目标节点)
  600. if (node.y >= edge.target.y) {
  601. const originalDist = edge.target.y - edge.source.y;
  602. const collapsedDist = NODE_HEIGHT; // 折叠后只保留一个节点间距
  603. shiftY += originalDist - collapsedDist;
  604. }
  605. }
  606. return {
  607. ...node,
  608. y: node.y - shiftY,
  609. };
  610. });
  611. // 创建一个 id -> node 的映射,方便查找
  612. const nodeMap = new Map(shiftedNodes.map((n) => [n.id, n]));
  613. // 4. 过滤并更新边
  614. const visibleEdges = layoutData.edges
  615. .filter((edge) => {
  616. const sourceNode = nodeMap.get(edge.source.id);
  617. const targetNode = nodeMap.get(edge.target.id);
  618. // 只显示源节点和目标节点都可见的边,或者虽然中间节点被隐藏但作为连接线的边(折叠后的弧线)
  619. // 对于折叠的弧线,它的 source 和 target 应该是可见的(因为它们是折叠区域的边界)
  620. return sourceNode && targetNode;
  621. })
  622. .map((edge) => ({
  623. ...edge,
  624. source: nodeMap.get(edge.source.id)!,
  625. target: nodeMap.get(edge.target.id)!,
  626. }));
  627. return { nodes: shiftedNodes, edges: visibleEdges };
  628. }, [layoutData]);
  629. /**
  630. * 计算内容尺寸(用于滚动模式)
  631. * 根据节点位置计算包围盒,确保内容完整显示
  632. */
  633. const contentSize = useMemo(() => {
  634. if (visibleData.nodes.length === 0) return { width: 0, height: 0, minX: 0 };
  635. let minX = Infinity;
  636. let maxX = -Infinity;
  637. let maxY = -Infinity;
  638. visibleData.nodes.forEach((node) => {
  639. minX = Math.min(minX, node.x);
  640. maxX = Math.max(maxX, node.x);
  641. maxY = Math.max(maxY, node.y);
  642. });
  643. // 增加一些内边距,确保边缘不被遮挡
  644. const startX = Math.min(0, minX - 50);
  645. const endX = maxX + 150;
  646. // 在 scroll 模式下,使用实际内容尺寸,不受容器尺寸限制
  647. // 这样可以保持 1:1 比例,通过滚动条查看超出部分
  648. return {
  649. width: endX - startX,
  650. height: maxY + 150,
  651. minX: startX,
  652. };
  653. }, [visibleData]);
  654. /**
  655. * 切换视图模式
  656. * 在滚动模式和拖拽缩放模式之间切换
  657. * 切换时重置视图状态,防止位置突变
  658. */
  659. const toggleView = () => {
  660. if (viewMode === "scroll") {
  661. setViewMode("panzoom");
  662. // 切换到 panzoom 时重置视图
  663. resetView();
  664. } else {
  665. setViewMode("scroll");
  666. // 切换回 scroll 时也重置视图
  667. resetView();
  668. }
  669. };
  670. /**
  671. * 切换折叠状态
  672. * 点击弧线时调用,切换该弧线的折叠/展开状态
  673. */
  674. const toggleCollapse = useCallback((edgeId: string) => {
  675. setCollapsedEdges((prev) => {
  676. const next = new Set(prev);
  677. if (next.has(edgeId)) {
  678. next.delete(edgeId); // 已折叠,展开
  679. } else {
  680. next.add(edgeId); // 未折叠,折叠
  681. }
  682. return next;
  683. });
  684. }, []);
  685. /**
  686. * 绘制弧线路径
  687. * 使用二次贝塞尔曲线(Quadratic Bezier Curve)
  688. *
  689. * @param source - 源节点
  690. * @param target - 目标节点
  691. * @param level - 嵌套层级,用于计算弧线偏移量
  692. * @returns SVG 路径字符串
  693. */
  694. const getArcPath = (source: LayoutNode, target: LayoutNode, level: number) => {
  695. const sx = source.x;
  696. const sy = source.y + 25; // 从节点底部出发
  697. const tx = target.x;
  698. const ty = target.y - 25; // 到节点顶部结束
  699. // 弧线向右偏移,偏移量根据层级递减
  700. // 外层弧线偏移量大,内层弧线偏移量小
  701. // 增加基础偏移量,让弧线更明显
  702. const offset = 800 - level * 30;
  703. const midY = (sy + ty) / 2;
  704. // Q: 二次贝塞尔曲线,控制点在 (sx + offset, midY)
  705. return `M ${sx},${sy} Q ${sx + offset},${midY} ${tx},${ty}`;
  706. };
  707. /**
  708. * 绘制直线路径
  709. *
  710. * @param source - 源节点
  711. * @param target - 目标节点
  712. * @returns SVG 路径字符串
  713. */
  714. const getLinePath = (source: LayoutNode, target: LayoutNode) => {
  715. return `M ${source.x},${source.y + 25} L ${target.x},${target.y - 25}`;
  716. };
  717. /**
  718. * 计算线条粗细
  719. * 根据嵌套层级递减,外层线条更粗,内层线条更细
  720. *
  721. * @param level - 嵌套层级
  722. * @returns 线条粗细(像素)
  723. */
  724. const getStrokeWidth = (level: number) => {
  725. return Math.max(0.1, 6 - level * 2);
  726. };
  727. /**
  728. * 节点点击处理
  729. * 区分主链节点和子节点,触发不同的回调
  730. */
  731. const handleNodeClick = useCallback(
  732. (node: LayoutNode) => {
  733. if (node.type === "goal") {
  734. // const goalData = node.data as Goal;
  735. // // 只有具有 sub_trace_ids 的子目标节点(agent 委托执行)才触发 trace 切换
  736. // // 普通的 sub_goal 节点(蓝色节点)没有 sub_trace_ids,应该打开 DetailPanel
  737. // const hasSubTraces = goalData.sub_trace_ids && goalData.sub_trace_ids.length > 0;
  738. // if (node.parentId && onSubTraceClick && hasSubTraces) {
  739. // const parentNode = layoutData.nodes.find((n) => n.id === node.parentId);
  740. // if (parentNode && parentNode.type === "goal") {
  741. // // 取第一个 sub_trace_id 作为跳转目标(使用 trace_id,而非 goal.id)
  742. // const firstEntry = goalData.sub_trace_ids![0];
  743. // const entry: SubTraceEntry =
  744. // typeof firstEntry === "string"
  745. // ? { id: firstEntry }
  746. // : { id: firstEntry.trace_id, mission: firstEntry.mission };
  747. // onSubTraceClick(parentNode.data as Goal, entry);
  748. // return;
  749. // }
  750. // }
  751. // 主链节点 或 没有 sub_trace_ids 的普通子目标节点 → 打开 DetailPanel
  752. setSelectedNodeId(node.id);
  753. onNodeClick?.(node.data as Goal);
  754. } else if (node.type === "message") {
  755. setSelectedNodeId(node.id);
  756. onNodeClick?.(node.data as Message);
  757. }
  758. },
  759. [onNodeClick],
  760. );
  761. if (!layoutData) return <div>Loading...</div>;
  762. return (
  763. <div
  764. className={styles.container}
  765. ref={containerRef}
  766. >
  767. <div
  768. className={styles.scrollContainer}
  769. style={{
  770. overflow: viewMode === "scroll" ? "auto" : "hidden",
  771. }}
  772. >
  773. <svg
  774. ref={svgRef}
  775. viewBox={
  776. viewMode === "scroll"
  777. ? `${contentSize.width / 2} 0 ${contentSize.width} ${contentSize.height}`
  778. : `0 0 ${dimensions.width} ${dimensions.height}`
  779. }
  780. preserveAspectRatio="xMidYMid meet"
  781. className={`${styles.svg} ${isPanning ? styles.panning : ""}`}
  782. style={{
  783. cursor: viewMode === "scroll" ? "default" : isPanning ? "grabbing" : "grab",
  784. width: viewMode === "scroll" ? "100%" : "100%",
  785. height: viewMode === "scroll" ? contentSize.height : "100%",
  786. minWidth: viewMode === "scroll" ? contentSize.width : undefined,
  787. minHeight: viewMode === "scroll" ? contentSize.height : undefined,
  788. }}
  789. onWheel={(event) => {
  790. if (viewMode === "scroll") return; // 滚动模式下使用原生滚动
  791. // 鼠标滚轮缩放
  792. event.preventDefault();
  793. const rect = svgRef.current?.getBoundingClientRect();
  794. if (!rect) return;
  795. const cursorX = event.clientX - rect.left;
  796. const cursorY = event.clientY - rect.top;
  797. const nextZoom = clampZoom(zoom * (event.deltaY > 0 ? 0.92 : 1.08));
  798. if (nextZoom === zoom) return;
  799. const scale = nextZoom / zoom;
  800. // 以鼠标位置为中心缩放
  801. setPanOffset((prev) => ({
  802. x: cursorX - (cursorX - prev.x) * scale,
  803. y: cursorY - (cursorY - prev.y) * scale,
  804. }));
  805. setZoom(nextZoom);
  806. }}
  807. onDoubleClick={() => resetView()} // 双击重置视图
  808. onMouseDown={(event) => {
  809. if (viewMode === "scroll") return; // 滚动模式下禁用拖拽
  810. // 开始平移
  811. if (event.button !== 0) return; // 只响应左键
  812. const target = event.target as Element;
  813. // 如果点击的是节点或连接线,不触发平移
  814. if (target.closest(`.${styles.nodes}`) || target.closest(`.${styles.links}`)) return;
  815. panStartRef.current = {
  816. x: event.clientX,
  817. y: event.clientY,
  818. originX: panOffset.x,
  819. originY: panOffset.y,
  820. };
  821. setIsPanning(true);
  822. }}
  823. >
  824. <defs>
  825. <ArrowMarkers /> {/* 箭头标记定义 */}
  826. </defs>
  827. {/* 应用平移和缩放变换(仅在 panzoom 模式下) */}
  828. <g transform={viewMode === "scroll" ? undefined : `translate(${panOffset.x},${panOffset.y}) scale(${zoom})`}>
  829. {/* 绘制连接线 */}
  830. <g className={styles.links}>
  831. {visibleData.edges.map((edge) => {
  832. // 根据连接线类型选择路径
  833. const path =
  834. edge.type === "arc" && !edge.collapsed
  835. ? getArcPath(edge.source, edge.target, edge.level)
  836. : getLinePath(edge.source, edge.target);
  837. const strokeWidth = getStrokeWidth(edge.level); // 根据层级计算线条粗细
  838. // 根据节点类型决定颜色
  839. let color = "#2196F3"; // 默认蓝色
  840. // 判断连接线连接的节点类型
  841. const sourceIsMessage = edge.source.type === "message";
  842. const targetIsMessage = edge.target.type === "message";
  843. const sourceIsMainGoal = edge.source.type === "goal" && edge.source.level === 0;
  844. const targetIsMainGoal = edge.target.type === "goal" && edge.target.level === 0;
  845. if (sourceIsMessage || targetIsMessage) {
  846. // msgGroup 相关的连接线用灰色
  847. color = "#94a3b8"; // Slate 400
  848. } else if (sourceIsMainGoal && targetIsMainGoal) {
  849. // 主节点之间的连接线用绿色
  850. color = "#10b981"; // Emerald 500
  851. } else {
  852. // sub_goals 之间的连接线用蓝色
  853. color = "#3b82f6"; // Blue 500
  854. }
  855. return (
  856. <g key={edge.id}>
  857. <path
  858. d={path}
  859. fill="none"
  860. stroke={edge.isInvalid ? "#cbd5e1" : color} // 失效边使用浅灰色 (Slate 300)
  861. strokeWidth={strokeWidth}
  862. strokeDasharray={edge.isInvalid ? "5,5" : undefined} // 失效边使用虚线
  863. markerEnd={edge.isInvalid ? undefined : "url(#arrow-default)"} // 失效边不显示箭头
  864. style={{ cursor: edge.collapsible ? "pointer" : "default" }} // 可折叠的显示手型光标
  865. onClick={() => edge.collapsible && toggleCollapse(edge.id)} // 点击切换折叠状态
  866. />
  867. {/* 折叠状态提示徽章 */}
  868. {edge.collapsed && (
  869. <g
  870. transform={`translate(${(edge.source.x + edge.target.x) / 2},${
  871. (edge.source.y + edge.target.y) / 2
  872. })`}
  873. onClick={(e) => {
  874. e.stopPropagation();
  875. toggleCollapse(edge.id);
  876. }}
  877. style={{ cursor: "pointer" }}
  878. >
  879. <circle
  880. r={10}
  881. fill="#FFFFFF"
  882. stroke={color}
  883. strokeWidth={1}
  884. />
  885. <text
  886. x={0}
  887. y={4}
  888. fontSize={10}
  889. fill={color}
  890. textAnchor="middle"
  891. fontWeight="bold"
  892. >
  893. {edge.children ? edge.children.length : "+"}
  894. </text>
  895. </g>
  896. )}
  897. </g>
  898. );
  899. })}
  900. </g>
  901. {/* 绘制节点 */}
  902. <g className={styles.nodes}>
  903. {visibleData.nodes.map((node) => {
  904. const isGoal = node.type === "goal";
  905. const data = node.data as Goal;
  906. const text = isGoal ? data.description : (node.data as Message).description || "";
  907. let thumbnail: string | null = null;
  908. if (node.type === "message") {
  909. const images = extractImagesFromMessage(node.data as Message);
  910. if (images.length > 0) thumbnail = images[0].url;
  911. }
  912. let textColor = "#3b82f6"; // Blue 500
  913. if (node.type === "message") {
  914. textColor = "#64748b"; // Slate 500
  915. } else if (node.type === "goal" && node.level === 0) {
  916. textColor = "#10b981"; // Emerald 500
  917. }
  918. if (node.isInvalid) {
  919. textColor = "#94a3b8"; // Slate 400
  920. }
  921. return (
  922. <g
  923. key={node.id}
  924. transform={`translate(${node.x},${node.y})`}
  925. onClick={() => handleNodeClick(node)}
  926. style={{ cursor: "pointer" }}
  927. >
  928. {/* 节点矩形 */}
  929. <rect
  930. x={-70}
  931. y={-25}
  932. width={150}
  933. height={50}
  934. rx={8}
  935. fill={isGoal ? "#eff6ff" : "#f8fafc"} // Blue 50 / Slate 50
  936. stroke={selectedNodeId === node.id ? "#3b82f6" : node.isInvalid ? "#cbd5e1" : "#e2e8f0"} // Selected: Blue 500, Invalid: Slate 300, Default: Slate 200
  937. strokeWidth={selectedNodeId === node.id ? 2 : 1}
  938. strokeDasharray={node.isInvalid ? "5,5" : undefined} // 失效节点虚线边框
  939. style={{
  940. filter:
  941. selectedNodeId === node.id
  942. ? "drop-shadow(0 4px 6px rgb(59 130 246 / 0.3))"
  943. : "drop-shadow(0 1px 2px rgb(0 0 0 / 0.05))",
  944. }}
  945. />
  946. {/* 节点文本 */}
  947. <foreignObject
  948. x={-70}
  949. y={-25}
  950. width={150}
  951. height={50}
  952. >
  953. <div
  954. className="w-full h-full overflow-hidden flex items-center px-2 gap-2"
  955. style={{
  956. color: textColor,
  957. justifyContent: thumbnail ? "space-between" : "center",
  958. }}
  959. >
  960. <span className={`text-xs line-clamp-3 ${thumbnail ? "flex-1 text-left" : "text-center"}`}>
  961. {text}
  962. </span>
  963. {thumbnail && (
  964. <img
  965. src={thumbnail}
  966. alt="thumb"
  967. className="w-8 h-8 object-cover rounded border border-gray-200 bg-white flex-shrink-0 hover:scale-110 transition-transform"
  968. loading="lazy"
  969. onClick={(e) => {
  970. e.stopPropagation();
  971. setPreviewImage(thumbnail);
  972. }}
  973. />
  974. )}
  975. </div>
  976. </foreignObject>
  977. </g>
  978. );
  979. })}
  980. </g>
  981. </g>
  982. </svg>
  983. </div>
  984. {/* 控制按钮 */}
  985. <div className={styles.controls}>
  986. {viewMode === "panzoom" && (
  987. <>
  988. <button
  989. type="button"
  990. className={styles.controlButton}
  991. onClick={() => setZoom((prev) => clampZoom(prev * 1.1))}
  992. >
  993. + {/* 放大 */}
  994. </button>
  995. <button
  996. type="button"
  997. className={styles.controlButton}
  998. onClick={() => setZoom((prev) => clampZoom(prev * 0.9))}
  999. >
  1000. − {/* 缩小 */}
  1001. </button>
  1002. <button
  1003. type="button"
  1004. className={styles.controlButton}
  1005. onClick={resetView}
  1006. >
  1007. 复位
  1008. </button>
  1009. </>
  1010. )}
  1011. <button
  1012. type="button"
  1013. className={styles.controlButton}
  1014. onClick={toggleView}
  1015. >
  1016. {viewMode === "scroll" ? "切换拖拽" : "切换滚动"}
  1017. </button>
  1018. </div>
  1019. <ImagePreviewModal
  1020. visible={!!previewImage}
  1021. onClose={() => setPreviewImage(null)}
  1022. src={previewImage || ""}
  1023. />
  1024. </div>
  1025. );
  1026. };
  1027. export const FlowChart = forwardRef(FlowChartComponent);