FlowChart.tsx 49 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326
  1. import { useCallback, useEffect, useMemo, useRef, useState, forwardRef, useImperativeHandle } from "react";
  2. import type { ForwardRefRenderFunction } from "react";
  3. import type { Goal } from "../../types/goal";
  4. import type { Edge as EdgeType, Message, MessageContent } from "../../types/message";
  5. import type { AgentMode } from "../../types/trace";
  6. import {
  7. visibleSubTraceEntries,
  8. type SubTraceEntry,
  9. } from "./subTraceEntries";
  10. import { goalStatusPresentation } from "./goalStatus";
  11. import { ArrowMarkers } from "./components/ArrowMarkers";
  12. import styles from "./styles/FlowChart.module.css";
  13. import { ImagePreviewModal } from "../ImagePreview/ImagePreviewModal";
  14. import { extractImagesFromMessage } from "../../utils/imageExtraction";
  15. interface FlowChartProps {
  16. goals: Goal[];
  17. msgGroups?: Record<string, Message[]>;
  18. invalidBranches?: Message[][];
  19. onNodeClick?: (node: Goal | Message, edge?: EdgeType) => void;
  20. onSubTraceClick?: (parentGoal: Goal, entry: SubTraceEntry) => void;
  21. agentMode?: AgentMode;
  22. }
  23. export interface FlowChartRef {
  24. expandAll: () => void;
  25. collapseAll: () => void;
  26. scrollToSequence: (sequence: number) => void;
  27. }
  28. type FlowMessage = Message & { _injectedGoalId?: string };
  29. type NodeMarker = "agent_call" | "agent_return" | "agent_exit" | "agent_resume";
  30. type FoldedNodeData = { goalId: string; count: number };
  31. const AGENT_TOOLS = new Set(["agent", "evaluate"]);
  32. const getContentParts = (content: Message["content"]): MessageContent[] => {
  33. if (Array.isArray(content)) return content;
  34. return content && typeof content === "object" ? [content] : [];
  35. };
  36. const getMessageText = (message: Message): string => {
  37. if (typeof message.content === "string") return message.content;
  38. return getContentParts(message.content).map((part) => part.text || "").join("");
  39. };
  40. const getMessageMarker = (message: Message, previous?: NodeMarker): NodeMarker | undefined => {
  41. const parts = getContentParts(message.content);
  42. const text = getMessageText(message);
  43. const isTerminate = text.includes("TERMINATE");
  44. if (previous === "agent_exit" && !isTerminate) return "agent_resume";
  45. const hasAgentCall = parts.some((part) =>
  46. part.tool_calls?.some((call) =>
  47. AGENT_TOOLS.has(call.function?.name || call.name || ""),
  48. ),
  49. );
  50. if (hasAgentCall) return "agent_call";
  51. const hasAgentReturn = parts.some((part) =>
  52. !!part.tool_name && AGENT_TOOLS.has(part.tool_name),
  53. );
  54. if (hasAgentReturn) return "agent_return";
  55. const hasStructuredContent = !!message.content && typeof message.content === "object";
  56. const hasToolCalls = parts.some((part) => (part.tool_calls?.length || 0) > 0);
  57. if (message.role === "assistant" && hasStructuredContent && text && !isTerminate && !hasToolCalls) {
  58. return "agent_exit";
  59. }
  60. return undefined;
  61. };
  62. const isContinueMessage = (message: Message): boolean =>
  63. getContentParts(message.content).some((part) =>
  64. part.tool_calls?.some((call) => {
  65. const args = call.function?.arguments || call.arguments;
  66. return typeof args === "string" && args.includes("continue_from");
  67. }),
  68. );
  69. interface LayoutNodeBase {
  70. id: string;
  71. x: number;
  72. y: number;
  73. level: number;
  74. parentId?: string;
  75. isInvalid?: boolean;
  76. marker?: NodeMarker;
  77. }
  78. type LayoutNode =
  79. | (LayoutNodeBase & { type: "goal"; data: Goal })
  80. | (LayoutNodeBase & { type: "message"; data: FlowMessage })
  81. | (LayoutNodeBase & { type: "folded"; data: FoldedNodeData });
  82. interface LayoutEdge {
  83. id: string;
  84. source: LayoutNode;
  85. target: LayoutNode;
  86. type: "line" | "bracket" | "sibling_line" | "fold_link" | "arc" | "compression_link";
  87. level: number;
  88. collapsible: boolean;
  89. collapsed: boolean;
  90. children?: LayoutNode[];
  91. isInvalid?: boolean;
  92. isFoldedEdge?: boolean;
  93. }
  94. const FlowChartComponent: ForwardRefRenderFunction<FlowChartRef, FlowChartProps> = (
  95. {
  96. goals,
  97. msgGroups = {},
  98. invalidBranches,
  99. onNodeClick,
  100. onSubTraceClick,
  101. agentMode = "legacy",
  102. },
  103. ref,
  104. ) => {
  105. const displayGoals = useMemo(() => {
  106. if (!goals) return [];
  107. return goals.filter((g) => !g.parent_id);
  108. }, [goals]);
  109. const containerRef = useRef<HTMLDivElement>(null);
  110. const scrollContainerRef = useRef<HTMLDivElement>(null);
  111. const svgRef = useRef<SVGSVGElement>(null);
  112. const [dimensions, setDimensions] = useState({ width: 1200, height: 800 });
  113. const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
  114. const [panOffset, setPanOffset] = useState({ x: 0, y: 0 });
  115. const [isPanning, setIsPanning] = useState(false);
  116. const [zoom, setZoom] = useState(1);
  117. const panStartRef = useRef({ x: 0, y: 0, originX: 0, originY: 0 });
  118. const [hoveredArcId, setHoveredArcId] = useState<string | null>(null);
  119. const [hoverPos, setHoverPos] = useState<{ x: number, y: number } | null>(null);
  120. const zoomRange = { min: 0.2, max: 2.4 };
  121. const [viewMode, setViewMode] = useState<"scroll" | "panzoom">("scroll");
  122. const [previewImage, setPreviewImage] = useState<string | null>(null);
  123. const clampZoom = (value: number) => Math.min(zoomRange.max, Math.max(zoomRange.min, value));
  124. const resetView = () => {
  125. setZoom(1);
  126. setPanOffset({ x: 0, y: 0 });
  127. };
  128. useEffect(() => {
  129. const element = containerRef.current;
  130. if (!element) return;
  131. const update = () => {
  132. const rect = element.getBoundingClientRect();
  133. const width = Math.max(800, rect.width || 0);
  134. const height = Math.max(600, rect.height || 0);
  135. setDimensions({ width, height });
  136. };
  137. update();
  138. if (typeof ResizeObserver === "undefined") {
  139. window.addEventListener("resize", update);
  140. return () => window.removeEventListener("resize", update);
  141. }
  142. const observer = new ResizeObserver(() => update());
  143. observer.observe(element);
  144. return () => observer.disconnect();
  145. }, []);
  146. useEffect(() => {
  147. if (!isPanning) return;
  148. const handleMove = (event: MouseEvent) => {
  149. const { x, y, originX, originY } = panStartRef.current;
  150. const nextX = originX + (event.clientX - x);
  151. const nextY = originY + (event.clientY - y);
  152. setPanOffset({ x: nextX, y: nextY });
  153. };
  154. const handleUp = () => setIsPanning(false);
  155. window.addEventListener("mousemove", handleMove);
  156. window.addEventListener("mouseup", handleUp);
  157. return () => {
  158. window.removeEventListener("mousemove", handleMove);
  159. window.removeEventListener("mouseup", handleUp);
  160. };
  161. }, [isPanning]);
  162. const [collapsedGoals, setCollapsedGoals] = useState<Set<string>>(new Set());
  163. const toggleCollapse = useCallback((goalId: string, e: React.MouseEvent) => {
  164. e.stopPropagation();
  165. setCollapsedGoals((prev) => {
  166. const next = new Set(prev);
  167. if (next.has(goalId)) next.delete(goalId);
  168. else next.add(goalId);
  169. return next;
  170. });
  171. }, []);
  172. const layoutData = useMemo(() => {
  173. if (!displayGoals || displayGoals.length === 0) return { nodes: [], edges: [] };
  174. const nodes: LayoutNode[] = [];
  175. const edges: LayoutEdge[] = [];
  176. const goalLevels = new Map<string, number>();
  177. const goalParents = new Map<string, string>();
  178. let maxGoalLevel = -1;
  179. const computeGoalHierarchy = (list: Goal[], level: number, parentId?: string) => {
  180. list.forEach((goal) => {
  181. goalLevels.set(goal.id, level);
  182. maxGoalLevel = Math.max(maxGoalLevel, level);
  183. if (parentId) {
  184. goalParents.set(goal.id, parentId);
  185. }
  186. if (goal.sub_goals && goal.sub_goals.length > 0) {
  187. computeGoalHierarchy(goal.sub_goals, level + 1, goal.id);
  188. }
  189. });
  190. };
  191. computeGoalHierarchy(displayGoals, 0);
  192. const isGoalCollapsed = (goalId?: string) => {
  193. if (!goalId) return false;
  194. let curr: string | undefined = goalId;
  195. while (curr) {
  196. if (collapsedGoals.has(curr)) return true;
  197. curr = goalParents.get(curr);
  198. }
  199. return false;
  200. };
  201. let allMessages: FlowMessage[] = [];
  202. Object.entries(msgGroups).forEach(([goalId, msgs]) => {
  203. msgs.forEach((m) => {
  204. allMessages.push({ ...m, id: m.id || m.message_id, _injectedGoalId: goalId });
  205. });
  206. });
  207. allMessages.sort((a, b) => {
  208. const seqA = typeof a.sequence === "number" ? a.sequence : 0;
  209. const seqB = typeof b.sequence === "number" ? b.sequence : 0;
  210. return seqA - seqB;
  211. });
  212. allMessages = allMessages.filter((msg, idx, self) => {
  213. const msgId = msg.id || `msg-${idx}`;
  214. return idx === self.findIndex((t) => (t.id || `msg-${idx}`) === msgId);
  215. });
  216. const MESSAGE_X = 200;
  217. const MESSAGE_Y_START = 100;
  218. const MESSAGE_Y_STEP = 100;
  219. const GOAL_X_START = 460;
  220. const GOAL_X_STEP = 200;
  221. const msgNodeMap = new Map<string, LayoutNode>();
  222. const foldedNodeMap = new Map<string, LayoutNode>();
  223. const timelineNodes: LayoutNode[] = [];
  224. const timelineItems: (FlowMessage | { _isFoldedNode: true; goalId: string; count: number })[] = [];
  225. const foldedGoalsContent = new Map<string, number>();
  226. let currentFold: string | null = null;
  227. allMessages.forEach(msg => {
  228. const goalId = msg._injectedGoalId || msg.goal_id;
  229. let highestCollapsed: string | null = null;
  230. let curr: string | undefined = goalId;
  231. while (curr) {
  232. if (collapsedGoals.has(curr)) highestCollapsed = curr;
  233. curr = goalParents.get(curr);
  234. }
  235. if (highestCollapsed) {
  236. foldedGoalsContent.set(highestCollapsed, (foldedGoalsContent.get(highestCollapsed) || 0) + 1);
  237. if (currentFold !== highestCollapsed) {
  238. timelineItems.push({ _isFoldedNode: true, goalId: highestCollapsed, count: 1 });
  239. currentFold = highestCollapsed;
  240. } else {
  241. const lastItem = timelineItems[timelineItems.length - 1] as { _isFoldedNode: true, count: number };
  242. if (lastItem && lastItem._isFoldedNode) {
  243. lastItem.count++;
  244. }
  245. }
  246. } else {
  247. timelineItems.push(msg);
  248. currentFold = null;
  249. }
  250. });
  251. let currentY = MESSAGE_Y_START;
  252. let previousMarker: LayoutNode["marker"] = undefined;
  253. timelineItems.forEach((item, i) => {
  254. if ("_isFoldedNode" in item) {
  255. const count = item.count;
  256. const stableId = `folded-${item.goalId}-chunk-${i}`;
  257. const node: LayoutNode = {
  258. id: stableId,
  259. x: MESSAGE_X,
  260. y: currentY,
  261. data: { goalId: item.goalId, count },
  262. type: "folded",
  263. level: maxGoalLevel + 1,
  264. parentId: item.goalId,
  265. };
  266. nodes.push(node);
  267. timelineNodes.push(node);
  268. if (!foldedNodeMap.has(item.goalId)) {
  269. foldedNodeMap.set(item.goalId, node);
  270. }
  271. } else {
  272. const msg = item as FlowMessage;
  273. const marker = getMessageMarker(msg, previousMarker);
  274. previousMarker = marker || previousMarker;
  275. const stableId = msg.id || `msg-${i}`;
  276. const node: LayoutNode = {
  277. id: stableId,
  278. x: MESSAGE_X,
  279. y: currentY,
  280. data: msg,
  281. type: "message",
  282. level: maxGoalLevel + 1,
  283. parentId: msg._injectedGoalId || msg.goal_id,
  284. marker,
  285. };
  286. nodes.push(node);
  287. timelineNodes.push(node);
  288. msgNodeMap.set(node.id, node);
  289. }
  290. currentY += MESSAGE_Y_STEP;
  291. });
  292. for (let i = 0; i < timelineNodes.length - 1; i++) {
  293. const source = timelineNodes[i];
  294. const target = timelineNodes[i + 1];
  295. let shouldConnect = true;
  296. if (target.type === "message") {
  297. const pSeq = target.data.parent_sequence;
  298. const sourceSeq = source.type === "message" ? source.data.sequence : undefined;
  299. if (source.type !== "folded" && typeof pSeq === "number" && sourceSeq !== pSeq) {
  300. shouldConnect = false;
  301. }
  302. }
  303. if (shouldConnect) {
  304. const isFoldedEdge = source.type === "folded" || target.type === "folded";
  305. edges.push({
  306. id: `seq-${source.id}-${target.id}`,
  307. source,
  308. target,
  309. type: "line",
  310. level: 0,
  311. collapsible: false,
  312. collapsed: false,
  313. isFoldedEdge
  314. });
  315. }
  316. }
  317. const seqNodeMap = new Map<number, LayoutNode>();
  318. timelineNodes.forEach((node) => {
  319. if (node.type === "message") {
  320. const seq = node.data.sequence;
  321. if (typeof seq === "number") seqNodeMap.set(seq, node);
  322. }
  323. });
  324. timelineNodes.forEach((node, i) => {
  325. if (node.type === "message") {
  326. const pSeq = node.data.parent_sequence;
  327. if (typeof pSeq === "number") {
  328. const parentNode = seqNodeMap.get(pSeq);
  329. if (parentNode) {
  330. const pIdx = timelineNodes.indexOf(parentNode);
  331. if (i > pIdx + 1) { // It's a non-immediate chronological jump
  332. edges.push({
  333. id: `arc-${parentNode.id}-${node.id}`,
  334. source: parentNode,
  335. target: node,
  336. type: "arc",
  337. level: 0,
  338. collapsible: false,
  339. collapsed: false,
  340. });
  341. }
  342. }
  343. }
  344. }
  345. });
  346. if (timelineNodes.length > 0) {
  347. const firstMsg = timelineNodes[0];
  348. const lastMsg = timelineNodes[timelineNodes.length - 1];
  349. const startY = firstMsg.y - MESSAGE_Y_STEP;
  350. const startNode: LayoutNode = { id: "SYSTEM_START", x: MESSAGE_X, y: startY, data: { description: "START" } as Goal, type: "goal", level: 0 };
  351. nodes.push(startNode);
  352. edges.push({ id: "sys-start", source: startNode, target: firstMsg, type: "line", level: 0, collapsible: false, collapsed: false });
  353. const hasEndGoal = displayGoals.some((goal) => goal.id === "END");
  354. const endY = lastMsg.y + MESSAGE_Y_STEP;
  355. const endNode: LayoutNode = { id: "SYSTEM_END", x: MESSAGE_X, y: endY, data: { description: hasEndGoal ? "END" : "TERMINATED" } as Goal, type: "goal", level: 0 };
  356. nodes.push(endNode);
  357. edges.push({ id: "sys-end", source: lastMsg, target: endNode, type: "line", level: 0, collapsible: false, collapsed: false });
  358. }
  359. const allGoalNodes: LayoutNode[] = [];
  360. const placedGoals: { x: number; y: number; minY: number; maxY: number; hasBracket: boolean }[] = [];
  361. const placeGoalsBottomUp = (goalsList: Goal[], parentLevel: number): LayoutNode[] => {
  362. const resultNodes: LayoutNode[] = [];
  363. goalsList.forEach(goal => {
  364. if (goal.id === "START" || goal.id === "END") return;
  365. if (goal.parent_id && isGoalCollapsed(goal.parent_id)) return;
  366. const isSelfCollapsed = collapsedGoals.has(goal.id);
  367. const level = parentLevel + 1;
  368. maxGoalLevel = Math.max(maxGoalLevel, level);
  369. let subGoalNodes: LayoutNode[] = [];
  370. if (!isSelfCollapsed && goal.sub_goals && goal.sub_goals.length > 0) {
  371. subGoalNodes = placeGoalsBottomUp(goal.sub_goals, level);
  372. }
  373. const directMsgNodes: LayoutNode[] = [];
  374. if (!isSelfCollapsed) {
  375. timelineNodes.forEach((node) => {
  376. if (node.type === "message") {
  377. const m = node.data as Message & { _injectedGoalId: string };
  378. if ((m._injectedGoalId === goal.id || m.goal_id === goal.id) && m.id) {
  379. directMsgNodes.push(node);
  380. }
  381. }
  382. });
  383. }
  384. let children = [...subGoalNodes, ...directMsgNodes];
  385. let y = MESSAGE_Y_START;
  386. if (isSelfCollapsed) {
  387. const foldedNode = foldedNodeMap.get(goal.id);
  388. if (foldedNode) {
  389. y = foldedNode.y;
  390. } else {
  391. y = allGoalNodes.length > 0 ? Math.max(...allGoalNodes.map(n => n.y)) + MESSAGE_Y_STEP : MESSAGE_Y_START;
  392. }
  393. children = [];
  394. } else if (children.length > 0) {
  395. const minY = Math.min(...children.map(c => c.y));
  396. const maxY = Math.max(...children.map(c => c.y));
  397. y = (minY + maxY) / 2;
  398. } else {
  399. y = allGoalNodes.length > 0 ? Math.max(...allGoalNodes.map(n => n.y)) + MESSAGE_Y_STEP : MESSAGE_Y_START;
  400. }
  401. const node: LayoutNode = {
  402. id: goal.id,
  403. x: 0,
  404. y,
  405. data: goal,
  406. type: "goal",
  407. level,
  408. parentId: goal.parent_id,
  409. };
  410. allGoalNodes.push(node);
  411. if (isSelfCollapsed) {
  412. const myFoldedChunks = timelineNodes.filter(
  413. (timelineNode) => timelineNode.type === "folded" && timelineNode.data.goalId === goal.id,
  414. );
  415. myFoldedChunks.forEach((chunk, idx) => {
  416. edges.push({
  417. id: `fold-link-${goal.id}-${idx}`,
  418. source: node,
  419. target: chunk,
  420. type: "fold_link",
  421. level,
  422. collapsible: false,
  423. collapsed: false
  424. });
  425. });
  426. } else if (children.length > 0) {
  427. edges.push({
  428. id: `bracket-${goal.id}`,
  429. source: node,
  430. target: node,
  431. type: "bracket",
  432. level: level,
  433. collapsible: false,
  434. collapsed: false,
  435. children: children
  436. });
  437. }
  438. resultNodes.push(node);
  439. });
  440. return resultNodes;
  441. };
  442. placeGoalsBottomUp(displayGoals, -1);
  443. timelineNodes.forEach((node) => {
  444. if (node.type === "message") {
  445. const textStr = getMessageText(node.data);
  446. const isCompression = textStr.includes("摘要") || textStr.includes("压缩");
  447. if (isCompression) {
  448. const goalId = node.data._injectedGoalId || node.data.goal_id;
  449. if (goalId) {
  450. const goalNode = allGoalNodes.find(g => g.id === goalId);
  451. if (goalNode) {
  452. edges.push({
  453. id: `comp-link-${node.id}`,
  454. source: node,
  455. target: goalNode,
  456. type: "compression_link",
  457. level: 0,
  458. collapsible: false,
  459. collapsed: false,
  460. });
  461. }
  462. }
  463. }
  464. }
  465. });
  466. // Compute Sibling Connections
  467. const childrenByParent = new Map<string | undefined, LayoutNode[]>();
  468. allGoalNodes.forEach(node => {
  469. const pId = node.parentId;
  470. if (!childrenByParent.has(pId)) childrenByParent.set(pId, []);
  471. childrenByParent.get(pId)!.push(node);
  472. });
  473. childrenByParent.forEach(group => {
  474. group.sort((a, b) => a.y - b.y);
  475. for (let i = 0; i < group.length - 1; i++) {
  476. edges.push({
  477. id: `sibling-${group[i].id}-${group[i + 1].id}`,
  478. source: group[i],
  479. target: group[i + 1],
  480. type: "sibling_line",
  481. level: group[i].level,
  482. collapsible: false,
  483. collapsed: false
  484. });
  485. }
  486. });
  487. allGoalNodes.forEach(node => {
  488. const isSelfCollapsed = collapsedGoals.has(node.id);
  489. const effectiveLevel = isSelfCollapsed ? maxGoalLevel : node.level;
  490. let x = GOAL_X_START + (maxGoalLevel - effectiveLevel) * GOAL_X_STEP;
  491. let minY = node.y;
  492. let maxY = node.y;
  493. const myBracket = edges.find(e => e.type === "bracket" && e.source.id === node.id);
  494. const hasBracket = !!(myBracket && myBracket.children && myBracket.children.length > 0);
  495. if (hasBracket) {
  496. minY = Math.min(...myBracket!.children!.map(c => c.y));
  497. maxY = Math.max(...myBracket!.children!.map(c => c.y));
  498. }
  499. let collision = true;
  500. let attempts = 0;
  501. while (collision && attempts < 10) {
  502. collision = false;
  503. for (const placed of placedGoals) {
  504. if (Math.abs(placed.x - x) < 10) {
  505. const nodeOverlap = Math.abs(placed.y - node.y) < 70;
  506. const bracketOverlap = hasBracket && placed.hasBracket && !(maxY + 20 < placed.minY || minY - 20 > placed.maxY);
  507. if (nodeOverlap || bracketOverlap) {
  508. collision = true;
  509. x += 200;
  510. break;
  511. }
  512. }
  513. }
  514. attempts++;
  515. }
  516. node.x = x;
  517. placedGoals.push({ x, y: node.y, minY, maxY, hasBracket });
  518. nodes.push(node);
  519. });
  520. if (invalidBranches && invalidBranches.length > 0) {
  521. const validMsgMap = new Map<number, LayoutNode>();
  522. nodes.forEach((n) => {
  523. if (n.type === "message") {
  524. const seq = n.data.sequence;
  525. if (typeof seq === "number") {
  526. validMsgMap.set(seq, n);
  527. }
  528. }
  529. });
  530. const branchesByParent = new Map<string, Message[][]>();
  531. invalidBranches.forEach((branch) => {
  532. if (branch.length === 0) return;
  533. const firstMsg = branch[0];
  534. const pSeq = firstMsg.parent_sequence;
  535. if (typeof pSeq === "number") {
  536. const parentNode = validMsgMap.get(pSeq);
  537. if (parentNode) {
  538. if (!branchesByParent.has(parentNode.id)) branchesByParent.set(parentNode.id, []);
  539. branchesByParent.get(parentNode.id)!.push(branch);
  540. }
  541. }
  542. });
  543. branchesByParent.forEach((branches, parentId) => {
  544. const parentNode = nodes.find((n) => n.id === parentId);
  545. if (!parentNode) return;
  546. branches.forEach((branch, branchIdx) => {
  547. const offsetX = 220 + branchIdx * 200;
  548. let currentParent = parentNode;
  549. let branchPrevMarker = parentNode.marker;
  550. branch.forEach((msg, idx) => {
  551. const stableId = msg.id || `${parentNode.id}-branch-${branchIdx}-${idx}`;
  552. const nodeId = `invalid-${stableId}`;
  553. const marker = getMessageMarker(msg, branchPrevMarker);
  554. branchPrevMarker = marker || branchPrevMarker;
  555. const node: LayoutNode = {
  556. id: nodeId,
  557. x: parentNode.x - offsetX,
  558. y: parentNode.y + (idx + 1) * MESSAGE_Y_STEP,
  559. data: msg,
  560. type: "message",
  561. level: parentNode.level,
  562. parentId: parentNode.id,
  563. isInvalid: false,
  564. marker,
  565. };
  566. nodes.push(node);
  567. edges.push({
  568. id: `edge-${currentParent.id}-${node.id}`,
  569. source: currentParent,
  570. target: node,
  571. type: "line",
  572. level: 0,
  573. collapsible: false,
  574. collapsed: false,
  575. isInvalid: false,
  576. });
  577. currentParent = node;
  578. });
  579. });
  580. });
  581. }
  582. return { nodes, edges };
  583. }, [displayGoals, msgGroups, invalidBranches, collapsedGoals]);
  584. const allGoalIds = useMemo(() => {
  585. const ids: string[] = [];
  586. const traverse = (list: Goal[]) => {
  587. list.forEach((g) => {
  588. ids.push(g.id);
  589. if (g.sub_goals) traverse(g.sub_goals);
  590. });
  591. };
  592. if (displayGoals) traverse(displayGoals);
  593. return ids;
  594. }, [displayGoals]);
  595. const visibleData = layoutData;
  596. const contentSize = useMemo(() => {
  597. if (visibleData.nodes.length === 0) return { width: 0, height: 0, minX: 0, minY: 0 };
  598. let minX = Infinity;
  599. let maxX = -Infinity;
  600. let minY = Infinity;
  601. let maxY = -Infinity;
  602. visibleData.nodes.forEach((node) => {
  603. minX = Math.min(minX, node.x);
  604. maxX = Math.max(maxX, node.x);
  605. minY = Math.min(minY, node.y);
  606. maxY = Math.max(maxY, node.y);
  607. });
  608. minX -= 70;
  609. maxX += 70;
  610. const centerX = (minX + maxX) / 2;
  611. const baseWidth = (maxX - minX) + 400;
  612. const baseHeight = (maxY - minY) + 300;
  613. const finalWidth = Math.max(dimensions.width, baseWidth);
  614. const finalHeight = Math.max(dimensions.height, baseHeight);
  615. const startX = centerX - finalWidth / 2;
  616. const startY = minY - 150;
  617. return {
  618. width: finalWidth,
  619. height: finalHeight,
  620. minX: startX,
  621. minY: startY
  622. };
  623. }, [visibleData, dimensions]);
  624. useImperativeHandle(ref, () => ({
  625. expandAll: () => setCollapsedGoals(new Set()),
  626. collapseAll: () => setCollapsedGoals(new Set(allGoalIds)),
  627. scrollToSequence: (sequence: number) => {
  628. const targetNode = layoutData?.nodes.find(
  629. (n) => n.type === "message" && (n.data as Message).sequence === sequence
  630. );
  631. if (!targetNode) return;
  632. setSelectedNodeId(targetNode.id);
  633. onNodeClick?.(targetNode.data as Message);
  634. if (viewMode === "scroll" && scrollContainerRef.current) {
  635. const rect = scrollContainerRef.current.getBoundingClientRect();
  636. scrollContainerRef.current.scrollTo({
  637. left: (targetNode.x - contentSize.minX) - rect.width / 2 + 100,
  638. top: (targetNode.y - contentSize.minY) - rect.height / 2,
  639. behavior: "smooth",
  640. });
  641. } else if (viewMode === "panzoom" && containerRef.current) {
  642. const rect = containerRef.current.getBoundingClientRect();
  643. setPanOffset({
  644. x: rect.width / 2 - targetNode.x * zoom - 100 * zoom,
  645. y: rect.height / 2 - targetNode.y * zoom,
  646. });
  647. }
  648. },
  649. }), [allGoalIds, layoutData, contentSize, viewMode, zoom, onNodeClick]);
  650. const toggleView = () => {
  651. if (viewMode === "scroll") {
  652. setViewMode("panzoom");
  653. resetView();
  654. } else {
  655. setViewMode("scroll");
  656. resetView();
  657. }
  658. };
  659. const getBracketPath = (source: LayoutNode, children: LayoutNode[] | undefined) => {
  660. const sx = source.x - 70;
  661. const sy = Math.round(source.y);
  662. const dropX = sx - 40;
  663. const endX = dropX - 30;
  664. if (!children || children.length === 0) {
  665. return `M ${sx},${sy} L ${dropX},${sy}`;
  666. }
  667. const minY = Math.round(Math.min(...children.map(c => c.y)));
  668. const maxY = Math.round(Math.max(...children.map(c => c.y)));
  669. if (children.length === 1 || Math.abs(minY - maxY) < 1) {
  670. return `M ${sx},${sy} L ${dropX},${sy} M ${endX},${minY} L ${dropX},${minY}`;
  671. }
  672. return `M ${sx},${sy} L ${dropX},${sy} M ${endX},${minY} L ${dropX},${minY} L ${dropX},${maxY} L ${endX},${maxY}`;
  673. };
  674. const getLinePath = (source: LayoutNode, target: LayoutNode) => {
  675. if (source.isInvalid && target.isInvalid) {
  676. return `M ${source.x},${source.y + 25} L ${target.x},${target.y - 28}`;
  677. }
  678. if (!source.isInvalid && target.isInvalid) {
  679. // Diverging from main timeline
  680. return `M ${source.x - 40},${source.y + 25} L ${target.x},${target.y - 28}`;
  681. }
  682. if (Math.abs(source.x - target.x) < 1) {
  683. if (source.id === "SYSTEM_START" || target.id === "SYSTEM_END") {
  684. return `M ${source.x},${source.y + 25} L ${target.x},${target.y - 28}`;
  685. }
  686. return `M ${source.x},${source.y + 25} L ${target.x},${target.y - 28}`;
  687. } else {
  688. if (source.x > target.x) {
  689. return `M ${source.x - 70},${source.y} L ${target.x + 75},${target.y}`;
  690. } else {
  691. return `M ${source.x + 70},${source.y} L ${target.x - 75},${target.y}`;
  692. }
  693. }
  694. };
  695. const getBracketColor = (level: number) => {
  696. if (level === 0) return "#10b981"; // Emerald
  697. if (level === 1) return "#ef4444"; // Red
  698. if (level === 2) return "#3b82f6"; // Blue
  699. return "#f59e0b"; // Amber
  700. };
  701. const getStrokeWidth = (level: number, type: string) => {
  702. if (type === "bracket") return Math.max(1.5, 4 - level * 1);
  703. return 2;
  704. };
  705. const handleNodeClick = useCallback(
  706. (node: LayoutNode, e: React.MouseEvent) => {
  707. e.stopPropagation();
  708. setSelectedNodeId(node.id);
  709. if (node.type === "goal") {
  710. onNodeClick?.(node.data as Goal);
  711. } else if (node.type === "message") {
  712. onNodeClick?.(node.data as Message);
  713. }
  714. },
  715. [onNodeClick],
  716. );
  717. const jumpToNode = (nodeId: string, e: React.MouseEvent) => {
  718. e.stopPropagation();
  719. const targetNode = layoutData?.nodes.find((node) => node.id === nodeId);
  720. if (!targetNode) return;
  721. setSelectedNodeId(targetNode.id);
  722. if (targetNode.type === "goal") {
  723. onNodeClick?.(targetNode.data as Goal);
  724. } else if (targetNode.type === "message") {
  725. onNodeClick?.(targetNode.data as Message);
  726. }
  727. if (viewMode === "scroll" && scrollContainerRef.current) {
  728. const rect = scrollContainerRef.current.getBoundingClientRect();
  729. scrollContainerRef.current.scrollTo({
  730. left: (targetNode.x - contentSize.minX) - rect.width / 2 + 100,
  731. top: (targetNode.y - contentSize.minY) - rect.height / 2,
  732. behavior: "smooth"
  733. });
  734. } else if (viewMode === "panzoom" && containerRef.current) {
  735. const rect = containerRef.current.getBoundingClientRect();
  736. setPanOffset({
  737. x: rect.width / 2 - targetNode.x * zoom - 100 * zoom,
  738. y: rect.height / 2 - targetNode.y * zoom
  739. });
  740. }
  741. };
  742. if (!layoutData) return <div>Loading...</div>;
  743. return (
  744. <div className={styles.container} ref={containerRef}>
  745. <div
  746. ref={scrollContainerRef}
  747. className={styles.scrollContainer}
  748. style={{ overflow: viewMode === "scroll" ? "auto" : "hidden" }}
  749. >
  750. <svg
  751. ref={svgRef}
  752. viewBox={
  753. viewMode === "scroll"
  754. ? `${contentSize.minX} ${contentSize.minY} ${contentSize.width} ${contentSize.height}`
  755. : `0 0 ${dimensions.width} ${dimensions.height}`
  756. }
  757. preserveAspectRatio="xMidYMid meet"
  758. className={`${styles.svg} ${isPanning ? styles.panning : ""}`}
  759. style={{
  760. cursor: viewMode === "scroll" ? "default" : isPanning ? "grabbing" : "grab",
  761. width: viewMode === "scroll" ? contentSize.width : "100%",
  762. height: viewMode === "scroll" ? contentSize.height : "100%",
  763. }}
  764. onWheel={(event) => {
  765. if (viewMode === "scroll") return;
  766. event.preventDefault();
  767. const rect = svgRef.current?.getBoundingClientRect();
  768. if (!rect) return;
  769. const cursorX = event.clientX - rect.left;
  770. const cursorY = event.clientY - rect.top;
  771. const nextZoom = clampZoom(zoom * (event.deltaY > 0 ? 0.92 : 1.08));
  772. if (nextZoom === zoom) return;
  773. const scale = nextZoom / zoom;
  774. setPanOffset((prev) => ({
  775. x: cursorX - (cursorX - prev.x) * scale,
  776. y: cursorY - (cursorY - prev.y) * scale,
  777. }));
  778. setZoom(nextZoom);
  779. }}
  780. onDoubleClick={() => resetView()}
  781. onMouseDown={(event) => {
  782. if (viewMode === "scroll") return;
  783. if (event.button !== 0) return;
  784. const target = event.target as Element;
  785. if (target.closest(`.${styles.nodes}`) || target.closest(`.${styles.links}`)) return;
  786. setSelectedNodeId(null);
  787. panStartRef.current = {
  788. x: event.clientX,
  789. y: event.clientY,
  790. originX: panOffset.x,
  791. originY: panOffset.y,
  792. };
  793. setIsPanning(true);
  794. }}
  795. >
  796. <defs>
  797. <ArrowMarkers />
  798. </defs>
  799. <g transform={viewMode === "scroll" ? undefined : `translate(${panOffset.x},${panOffset.y}) scale(${zoom})`}>
  800. <g className={styles.links}>
  801. {visibleData.edges.map((edge) => {
  802. if (edge.type === "sibling_line") {
  803. return (
  804. <path
  805. key={edge.id}
  806. d={`M ${edge.source.x},${edge.source.y + 25} L ${edge.target.x},${edge.target.y - 25}`}
  807. fill="none"
  808. stroke="#cbd5e1"
  809. strokeWidth={2}
  810. strokeDasharray="6,6"
  811. markerEnd="url(#arrow-default)"
  812. />
  813. );
  814. }
  815. if (edge.type === "arc") {
  816. const isSelected = edge.source.id === selectedNodeId || edge.target.id === selectedNodeId;
  817. if (!isSelected && hoveredArcId !== edge.id) return null;
  818. const sx = edge.source.x - 70;
  819. const sy = edge.source.y;
  820. const tx = edge.target.x - 70;
  821. const ty = edge.target.y;
  822. const dist = Math.abs(ty - sy);
  823. const bulge = 40 + Math.min(200, dist / 4);
  824. const arcPath = `M ${sx},${sy} C ${sx - bulge},${sy} ${tx - bulge},${ty} ${tx},${ty}`;
  825. return (
  826. <g
  827. key={edge.id}
  828. onMouseEnter={(e) => {
  829. if (hoveredArcId !== edge.id) {
  830. setHoveredArcId(edge.id);
  831. const svg = e.currentTarget.closest("svg");
  832. const ctm = (e.currentTarget as SVGGElement).getScreenCTM();
  833. if (svg && ctm) {
  834. const point = svg.createSVGPoint();
  835. point.x = e.clientX;
  836. point.y = e.clientY;
  837. const localPoint = point.matrixTransform(ctm.inverse());
  838. setHoverPos({ x: localPoint.x, y: localPoint.y });
  839. }
  840. }
  841. }}
  842. onMouseLeave={() => {
  843. setHoveredArcId(null);
  844. setHoverPos(null);
  845. }}
  846. style={{ cursor: "pointer" }}
  847. >
  848. <path
  849. d={arcPath}
  850. fill="none"
  851. stroke="#94a3b8"
  852. strokeWidth={hoveredArcId === edge.id ? 4 : 3}
  853. strokeDasharray={hoveredArcId === edge.id ? undefined : "5,5"}
  854. markerEnd="url(#arrow-default)"
  855. style={{ pointerEvents: "none" }}
  856. />
  857. {/* Track Mouse ONLY while on the line */}
  858. <path
  859. d={arcPath}
  860. fill="none"
  861. stroke="transparent"
  862. strokeWidth={30}
  863. style={{ pointerEvents: "stroke" }}
  864. />
  865. {hoveredArcId === edge.id && hoverPos && (
  866. <g transform={`translate(${hoverPos.x - 14}, ${hoverPos.y + 5})`}>
  867. <rect width={70} height={24} fill="#94a3b8" rx={4} onClick={(e) => jumpToNode(edge.source.id, e)} />
  868. <text x={35} y={16} fill="white" textAnchor="middle" style={{ fontSize: 12, pointerEvents: "none" }}>向上跳转</text>
  869. <rect y={30} width={70} height={24} fill="#94a3b8" rx={4} onClick={(e) => jumpToNode(edge.target.id, e)} />
  870. <text x={35} y={46} fill="white" textAnchor="middle" style={{ fontSize: 12, pointerEvents: "none" }}>向下跳转</text>
  871. </g>
  872. )}
  873. </g>
  874. );
  875. }
  876. if (edge.type === "fold_link") {
  877. const sx = edge.source.x - 70;
  878. const sy = edge.source.y;
  879. const tx = edge.target.x + 22;
  880. const ty = edge.target.y;
  881. const midX = sx - 30;
  882. const foldPathStr = `M ${sx},${sy} L ${midX},${sy} L ${midX},${ty} L ${tx},${ty}`;
  883. return (
  884. <g key={edge.id} style={{ cursor: "pointer" }} onClick={(e) => toggleCollapse(edge.source.id, e)}>
  885. <path d={foldPathStr} fill="none" stroke="transparent" strokeWidth={24} />
  886. <path
  887. d={foldPathStr}
  888. fill="none"
  889. stroke="#10b981"
  890. strokeWidth={2}
  891. strokeDasharray="4,4"
  892. />
  893. </g>
  894. );
  895. }
  896. if (edge.type === "compression_link") {
  897. // horizontal, then vertical, then horizontal to goal
  898. const sx = edge.source.x + 70;
  899. const sy = edge.source.y;
  900. const tx = edge.target.x - 70;
  901. const ty = edge.target.y;
  902. const midX = sx + 40;
  903. const pathStr = `M ${sx},${sy} L ${midX},${sy} L ${midX},${ty} L ${tx},${ty}`;
  904. return (
  905. <path
  906. key={edge.id}
  907. d={pathStr}
  908. fill="none"
  909. stroke="#94a3b8"
  910. strokeWidth={1.5}
  911. strokeDasharray="4,4"
  912. markerEnd="url(#arrow-default)"
  913. />
  914. );
  915. }
  916. const isBracket = edge.type === "bracket";
  917. let pathStr = "";
  918. let strokeWidth = 2;
  919. let color = "#94a3b8";
  920. if (isBracket) {
  921. pathStr = getBracketPath(edge.source, edge.children);
  922. strokeWidth = getStrokeWidth(edge.level, edge.type);
  923. color = getBracketColor(edge.level);
  924. } else if (edge.isFoldedEdge) {
  925. pathStr = getLinePath(edge.source, edge.target);
  926. strokeWidth = 3;
  927. color = "#10b981";
  928. } else {
  929. pathStr = getLinePath(edge.source, edge.target);
  930. }
  931. if (isBracket) {
  932. return (
  933. <g key={edge.id} style={{ cursor: "pointer" }} onClick={(e) => toggleCollapse(edge.source.id, e)}>
  934. <path d={pathStr} fill="none" stroke="transparent" strokeWidth={24} />
  935. <path
  936. d={pathStr}
  937. fill="none"
  938. stroke={edge.isInvalid ? "#e2e8f0" : color}
  939. strokeWidth={strokeWidth}
  940. strokeDasharray={edge.isInvalid ? "5,5" : undefined}
  941. strokeLinejoin="round"
  942. />
  943. </g>
  944. );
  945. }
  946. return (
  947. <path
  948. key={edge.id}
  949. d={pathStr}
  950. fill="none"
  951. stroke={edge.isInvalid ? "#e2e8f0" : color}
  952. strokeWidth={strokeWidth}
  953. strokeDasharray={edge.isInvalid ? "5,5" : undefined}
  954. markerEnd={edge.isInvalid || edge.isFoldedEdge ? undefined : "url(#arrow-default)"}
  955. strokeLinejoin="round"
  956. style={edge.isFoldedEdge ? { cursor: "pointer" } : undefined}
  957. onClick={edge.isFoldedEdge ? (e) => toggleCollapse(edge.source.parentId || "", e) : undefined}
  958. />
  959. );
  960. })}
  961. </g>
  962. <g className={styles.nodes}>
  963. {visibleData.nodes.map((node) => {
  964. if (node.type === "folded") {
  965. const count = node.data.count;
  966. const goalId = node.data.goalId;
  967. return (
  968. <g
  969. key={node.id}
  970. transform={`translate(${node.x},${node.y})`}
  971. style={{ cursor: "pointer" }}
  972. onClick={(e) => toggleCollapse(goalId, e)}
  973. >
  974. <circle r={14} cx={0} cy={0} fill="#ecfdf5" stroke="#10b981" strokeWidth={2} />
  975. <text x={0} y={4} fontSize={11} fontWeight="bold" fill="#047857" textAnchor="middle">
  976. {count}
  977. </text>
  978. </g>
  979. );
  980. }
  981. const isGoal = node.type === "goal";
  982. const data = node.data as Goal;
  983. const subTraceEntries = isGoal
  984. ? visibleSubTraceEntries(data, agentMode)
  985. : [];
  986. const statusPresentation = isGoal
  987. ? goalStatusPresentation(data.status)
  988. : undefined;
  989. let text = isGoal ? data.description : (node.data as Message).description || "";
  990. let thumbnail: string | null = null;
  991. if (node.type === "message") {
  992. const images = extractImagesFromMessage(node.data as Message);
  993. if (images.length > 0) thumbnail = images[0].url;
  994. }
  995. let textColor = "#3b82f6";
  996. if (node.type === "message") {
  997. textColor = "#64748b";
  998. if (node.marker === "agent_call") textColor = "#f59e0b";
  999. else if (node.marker === "agent_return") textColor = "#8b5cf6";
  1000. else if (node.marker === "agent_exit") textColor = "#ef4444";
  1001. else if (node.marker === "agent_resume") textColor = "#06b6d4";
  1002. } else if (node.type === "goal" && node.level === 0) {
  1003. textColor = "#10b981";
  1004. } else if (node.type === "goal" && node.level === 1) {
  1005. textColor = "#ef4444";
  1006. }
  1007. if (node.isInvalid) textColor = "#94a3b8";
  1008. let emoji = "";
  1009. if (node.type === "message" && node.id !== "SYSTEM_START" && node.id !== "SYSTEM_END") {
  1010. if (isContinueMessage(node.data)) {
  1011. emoji = "🔄🤖 ";
  1012. text = "Agent 重调";
  1013. } else if (node.marker === "agent_call") {
  1014. emoji = "🤖 ";
  1015. text = "Agent 调用";
  1016. } else if (node.marker === "agent_return") {
  1017. emoji = "✅ ";
  1018. text = "Agent 返回";
  1019. } else if (node.marker === "agent_exit") {
  1020. emoji = "🏁 ";
  1021. text = "Agent 结束";
  1022. } else if (node.marker === "agent_resume") {
  1023. emoji = "🔄 ";
  1024. text = "Agent 续跑: " + text;
  1025. }
  1026. }
  1027. let nodeFill = isGoal ? "#eff6ff" : "#f8fafc";
  1028. let nodeStroke = selectedNodeId === node.id ? "#3b82f6" : node.isInvalid ? "#cbd5e1" : "#e2e8f0";
  1029. if (isGoal) {
  1030. if (node.level === 0) { nodeFill = "#ecfdf5"; nodeStroke = "#10b981"; }
  1031. else if (node.level === 1) { nodeFill = "#fef2f2"; nodeStroke = "#ef4444"; }
  1032. else if (node.level === 2) { nodeFill = "#eff6ff"; nodeStroke = "#3b82f6"; }
  1033. if (statusPresentation) {
  1034. nodeFill = statusPresentation.fill;
  1035. nodeStroke = statusPresentation.stroke;
  1036. textColor = statusPresentation.text;
  1037. }
  1038. }
  1039. if (node.id === "SYSTEM_START" || node.id === "SYSTEM_END") {
  1040. nodeFill = "#f8fafc"; nodeStroke = "#cbd5e1"; textColor = "#64748b";
  1041. }
  1042. if (node.marker === "agent_call") {
  1043. nodeFill = "#fffbeb";
  1044. nodeStroke = selectedNodeId === node.id ? "#f59e0b" : "#fcd34d";
  1045. } else if (node.marker === "agent_return") {
  1046. nodeFill = "#f5f3ff";
  1047. nodeStroke = selectedNodeId === node.id ? "#8b5cf6" : "#c4b5fd";
  1048. } else if (node.marker === "agent_exit") {
  1049. nodeFill = "#fef2f2";
  1050. nodeStroke = selectedNodeId === node.id ? "#ef4444" : "#fca5a5";
  1051. } else if (node.marker === "agent_resume") {
  1052. nodeFill = "#ecfeff";
  1053. nodeStroke = selectedNodeId === node.id ? "#06b6d4" : "#67e8f9";
  1054. }
  1055. // Detect remote children (targeted by arcs)
  1056. const hasRemoteParent = visibleData.edges.some((e) => e.type === "arc" && e.target.id === node.id);
  1057. return (
  1058. <g key={node.id} transform={`translate(${node.x},${node.y})`} onClick={(e) => handleNodeClick(node, e)}>
  1059. {hasRemoteParent && node.type === "message" && (
  1060. <rect
  1061. x={-73} y={-28} width={146} height={56} rx={10}
  1062. fill="none" stroke="#94a3b8" strokeWidth={2} strokeDasharray="4,2"
  1063. style={{ pointerEvents: "none" }}
  1064. />
  1065. )}
  1066. <rect
  1067. x={-70} y={-25} width={140} height={50} rx={8}
  1068. fill={nodeFill} stroke={selectedNodeId === node.id ? "#8b5cf6" : nodeStroke} strokeWidth={selectedNodeId === node.id ? 2 : 1}
  1069. strokeDasharray={node.isInvalid ? "5,5" : undefined}
  1070. style={{
  1071. 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))"
  1072. }}
  1073. />
  1074. <foreignObject x={-70} y={-25} width={140} height={50}>
  1075. <div className="w-full h-full overflow-hidden flex items-center justify-between px-2 gap-2" style={{ color: textColor }}>
  1076. {isGoal && node.id !== "SYSTEM_START" && node.id !== "SYSTEM_END" && (
  1077. <div
  1078. onClick={(e) => toggleCollapse(node.id, e)}
  1079. className="text-[10px] w-4 h-4 flex items-center justify-center border rounded bg-white text-gray-500 hover:bg-gray-100 flex-shrink-0"
  1080. style={{ pointerEvents: "auto" }}
  1081. >
  1082. {collapsedGoals.has(node.id) ? '+' : '-'}
  1083. </div>
  1084. )}
  1085. <span className="text-xs font-semibold line-clamp-3 w-full text-center" style={{ justifyContent: thumbnail ? "flex-start" : "center", textAlign: thumbnail ? "left" : "center" }}>{emoji}{text}</span>
  1086. {thumbnail && (
  1087. <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" onClick={(e) => { e.stopPropagation(); setPreviewImage(thumbnail as string); }} />
  1088. )}
  1089. </div>
  1090. </foreignObject>
  1091. {node.type === "message" && (
  1092. <g transform="translate(-70, -25)">
  1093. <circle r={10} cx={0} cy={0} fill="#f1f5f9" stroke={nodeStroke} strokeWidth={1} />
  1094. <text x={0} y={3} fontSize={10} fontWeight="bold" fill={textColor} textAnchor="middle">
  1095. {node.data.sequence ?? ""}
  1096. </text>
  1097. </g>
  1098. )}
  1099. {statusPresentation && (
  1100. <g transform="translate(0, -35)" style={{ pointerEvents: "none" }}>
  1101. <rect
  1102. x={-42}
  1103. y={-8}
  1104. width={84}
  1105. height={16}
  1106. rx={8}
  1107. fill={statusPresentation.fill}
  1108. stroke={statusPresentation.stroke}
  1109. />
  1110. <text
  1111. x={0}
  1112. y={4}
  1113. fontSize={9}
  1114. fontWeight="bold"
  1115. fill={statusPresentation.text}
  1116. textAnchor="middle"
  1117. >
  1118. {statusPresentation.label}
  1119. </text>
  1120. </g>
  1121. )}
  1122. {subTraceEntries.map((entry, index) => {
  1123. const spacing = 22;
  1124. const startX = -((subTraceEntries.length - 1) * spacing) / 2;
  1125. return (
  1126. <g
  1127. key={entry.id}
  1128. transform={`translate(${startX + index * spacing}, 36)`}
  1129. style={{ cursor: "pointer" }}
  1130. onClick={(event) => {
  1131. event.stopPropagation();
  1132. onSubTraceClick?.(data, entry);
  1133. }}
  1134. >
  1135. <title>{entry.mission || entry.id}</title>
  1136. <rect
  1137. x={-10}
  1138. y={-8}
  1139. width={20}
  1140. height={16}
  1141. rx={5}
  1142. fill="#fffbeb"
  1143. stroke="#f59e0b"
  1144. strokeWidth={1}
  1145. />
  1146. <text
  1147. x={0}
  1148. y={4}
  1149. fontSize={9}
  1150. fontWeight="bold"
  1151. fill="#b45309"
  1152. textAnchor="middle"
  1153. >
  1154. A{index + 1}
  1155. </text>
  1156. </g>
  1157. );
  1158. })}
  1159. </g>
  1160. );
  1161. })}
  1162. </g>
  1163. </g>
  1164. </svg>
  1165. </div>
  1166. <div className={styles.controls}>
  1167. {viewMode === "panzoom" && (
  1168. <>
  1169. <button type="button" className={styles.controlButton} onClick={() => setZoom((prev) => clampZoom(prev * 1.1))}>+</button>
  1170. <button type="button" className={styles.controlButton} onClick={() => setZoom((prev) => clampZoom(prev * 0.9))}>−</button>
  1171. <button type="button" className={styles.controlButton} onClick={resetView}>复位</button>
  1172. </>
  1173. )}
  1174. <button type="button" className={styles.controlButton} onClick={toggleView}>
  1175. {viewMode === "scroll" ? "切换拖拽" : "切换滚动"}
  1176. </button>
  1177. </div>
  1178. <ImagePreviewModal visible={!!previewImage} onClose={() => setPreviewImage(null)} src={previewImage || ""} />
  1179. </div>
  1180. );
  1181. };
  1182. export const FlowChart = forwardRef(FlowChartComponent);