FlowChart.tsx 41 KB

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