FlowCanvas.tsx 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116
  1. "use client";
  2. import { useCallback, useEffect, useMemo, useRef, useState } from "react";
  3. import { Background, BackgroundVariant, Controls, PanOnScrollMode, ReactFlow, useNodesInitialized, useNodesState, useReactFlow, type Node, type OnMove, type Rect } from "@xyflow/react";
  4. import type { CanvasItem, ExecutionViewV8, OpenTarget } from "@/lib/types";
  5. import { buildHorizontalLayout, type CanvasNodeData } from "@/lib/layout";
  6. import { StoryCard } from "@/components/flow/StoryCard";
  7. import { RoundSummaryCard } from "@/components/flow/RoundSummaryCard";
  8. import { RunResultCard } from "@/components/flow/RunResultCard";
  9. import { RoundFrame } from "@/components/flow/RoundFrame";
  10. import { CandidateCard } from "@/components/flow/CandidateCard";
  11. import { AgentRegion } from "@/components/flow/AgentRegion";
  12. import { BranchFrame } from "@/components/flow/BranchFrame";
  13. import { RetrievalStageFrame } from "@/components/flow/RetrievalStageFrame";
  14. import { DirectToolGroupCard } from "@/components/flow/DirectToolGroupCard";
  15. import { RetrievalAgentCard } from "@/components/flow/RetrievalAgentCard";
  16. import { AgentDecisionCard } from "@/components/decision/AgentDecisionCard";
  17. const nodeTypes = { storyCard: StoryCard, agentDecision: AgentDecisionCard, candidateCard: CandidateCard, roundSummary: RoundSummaryCard, finalResult: RunResultCard, roundFrame: RoundFrame, agentRegion: AgentRegion, branchFrame: BranchFrame, retrievalStageFrame: RetrievalStageFrame, directToolGroup: DirectToolGroupCard, retrievalAgent: RetrievalAgentCard };
  18. const MIN_CANVAS_ZOOM = .2;
  19. const INITIAL_VIEWPORT = { x: 18, y: 94, zoom: MIN_CANVAS_ZOOM };
  20. export function centeredOverviewViewport(bounds: Pick<Rect, "x" | "width">, canvasWidth: number) {
  21. return {
  22. x: canvasWidth / 2 - (bounds.x + bounds.width / 2) * MIN_CANVAS_ZOOM,
  23. y: INITIAL_VIEWPORT.y,
  24. zoom: MIN_CANVAS_ZOOM,
  25. };
  26. }
  27. interface Props {
  28. view: ExecutionViewV8;
  29. expandedRounds: Set<number>;
  30. expandedBranches: Set<string>;
  31. expandedRetrievalAgents: Set<string>;
  32. onToggleRound: (round: number) => void;
  33. onToggleBranch: (round: number, branchId: number) => void;
  34. onToggleRetrievalAgent: (agentId: string) => void;
  35. onOpen: (item: CanvasItem, target: OpenTarget) => void;
  36. onPrompt: (promptRef: string) => void;
  37. selectedId?: string;
  38. focusRound?: { roundIndex: number; token: number };
  39. canvasFullscreen?: boolean;
  40. onExitFullscreen?: () => void;
  41. }
  42. export function FlowCanvas(props: Props) {
  43. const { getNode, getNodesBounds, setCenter, setViewport } = useReactFlow();
  44. const nodesInitialized = useNodesInitialized();
  45. const shellRef = useRef<HTMLElement>(null);
  46. const previousBuild = useRef<string | undefined>(undefined);
  47. const positionBuild = useRef<string | undefined>(undefined);
  48. const manualPositions = useRef(new Map<string, { position: { x: number; y: number }; parentId?: string }>());
  49. const [detailLevel, setDetailLevel] = useState<"full" | "compact" | "overview">("overview");
  50. const layout = useMemo(() => buildHorizontalLayout(props.view, { buildId: String(props.view.header.id), expandedRounds: props.expandedRounds, expandedBranches: props.expandedBranches, expandedRetrievalAgents: props.expandedRetrievalAgents, detailLevel, onOpen: props.onOpen, onPrompt: props.onPrompt, onToggleRound: props.onToggleRound, onToggleBranch: props.onToggleBranch, onToggleRetrievalAgent: props.onToggleRetrievalAgent }), [props.view, props.expandedRounds, props.expandedBranches, props.expandedRetrievalAgents, detailLevel, props.onOpen, props.onPrompt, props.onToggleRound, props.onToggleBranch, props.onToggleRetrievalAgent]);
  51. const [nodes, setNodes, onNodesChange] = useNodesState<Node<CanvasNodeData>>([]);
  52. const handleMove: OnMove = useCallback((_, viewport) => {
  53. setDetailLevel((current) => {
  54. if (current === "full") return viewport.zoom < .74 ? "compact" : "full";
  55. if (current === "compact") {
  56. if (viewport.zoom >= .82) return "full";
  57. return viewport.zoom < .36 ? "overview" : "compact";
  58. }
  59. return viewport.zoom > .42 ? "compact" : "overview";
  60. });
  61. }, []);
  62. useEffect(() => {
  63. const buildId = String(props.view.header.id);
  64. if (positionBuild.current !== buildId) {
  65. positionBuild.current = buildId;
  66. manualPositions.current.clear();
  67. }
  68. setNodes(layout.nodes.map((node) => {
  69. const saved = manualPositions.current.get(node.id);
  70. return saved && saved.parentId === node.parentId ? { ...node, position: saved.position } : node;
  71. }));
  72. }, [layout.nodes, props.view.header.id, setNodes]);
  73. const rememberNodePosition = useCallback((_: unknown, node: Node<CanvasNodeData>) => {
  74. if (!node.draggable) return;
  75. manualPositions.current.set(node.id, { position: node.position, parentId: node.parentId });
  76. }, []);
  77. useEffect(() => {
  78. const buildId = String(props.view.header.id);
  79. if (!nodesInitialized || previousBuild.current === buildId) return;
  80. previousBuild.current = buildId;
  81. const frame = requestAnimationFrame(() => {
  82. const topLevelNodes = layout.nodes.filter((node) => !node.parentId);
  83. const canvasWidth = shellRef.current?.clientWidth || 0;
  84. const viewport = topLevelNodes.length && canvasWidth
  85. ? centeredOverviewViewport(getNodesBounds(topLevelNodes), canvasWidth)
  86. : INITIAL_VIEWPORT;
  87. void setViewport(viewport, { duration: 0 });
  88. });
  89. return () => cancelAnimationFrame(frame);
  90. }, [props.view.header.id, getNodesBounds, layout.nodes, nodesInitialized, setViewport]);
  91. useEffect(() => {
  92. if (!props.focusRound) return;
  93. const frame = requestAnimationFrame(() => {
  94. const target = getNode(`frame:round:${props.focusRound!.roundIndex}`) || getNode(`round:${props.focusRound!.roundIndex}:summary`);
  95. if (target) {
  96. const isFrame = target.id.startsWith("frame:");
  97. const width = isFrame ? Number(target.style?.width || 1980) : 320;
  98. const height = isFrame ? Number(target.style?.height || 720) : 360;
  99. void setCenter(target.position.x + width / 2, target.position.y + height / 2, { zoom: 1, duration: 220 });
  100. }
  101. });
  102. return () => cancelAnimationFrame(frame);
  103. }, [props.focusRound?.token, getNode, setCenter]);
  104. return <section ref={shellRef} className={`flowShell detail-${detailLevel} ${props.canvasFullscreen ? "mobileFullscreen" : ""}`} data-detail-level={detailLevel} aria-label="脚本构建横向运行画布">
  105. {props.canvasFullscreen ? <button type="button" className="exitFullscreen" onClick={props.onExitFullscreen}>返回轮次列表</button> : null}
  106. <ReactFlow nodes={nodes.map((node) => ({ ...node, selected: node.id === props.selectedId }))} edges={layout.edges} nodeTypes={nodeTypes} onNodesChange={onNodesChange} onNodeDrag={rememberNodePosition} onNodeDragStop={rememberNodePosition} defaultViewport={INITIAL_VIEWPORT} minZoom={MIN_CANVAS_ZOOM} maxZoom={1.5} onMove={handleMove} panOnScroll panOnScrollMode={PanOnScrollMode.Free} panOnScrollSpeed={.7} zoomOnScroll={false} zoomOnPinch nodesConnectable={false} elementsSelectable proOptions={{ hideAttribution: true }}>
  107. <Background variant={BackgroundVariant.Dots} gap={22} size={1.2} color="#343740" />
  108. <Controls position="bottom-left" showInteractive={false} />
  109. </ReactFlow>
  110. </section>;
  111. }