| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116 |
- "use client";
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
- import { Background, BackgroundVariant, Controls, PanOnScrollMode, ReactFlow, useNodesInitialized, useNodesState, useReactFlow, type Node, type OnMove, type Rect } from "@xyflow/react";
- import type { CanvasItem, ExecutionViewV8, OpenTarget } from "@/lib/types";
- import { buildHorizontalLayout, type CanvasNodeData } from "@/lib/layout";
- import { StoryCard } from "@/components/flow/StoryCard";
- import { RoundSummaryCard } from "@/components/flow/RoundSummaryCard";
- import { RunResultCard } from "@/components/flow/RunResultCard";
- import { RoundFrame } from "@/components/flow/RoundFrame";
- import { CandidateCard } from "@/components/flow/CandidateCard";
- import { AgentRegion } from "@/components/flow/AgentRegion";
- import { BranchFrame } from "@/components/flow/BranchFrame";
- import { RetrievalStageFrame } from "@/components/flow/RetrievalStageFrame";
- import { DirectToolGroupCard } from "@/components/flow/DirectToolGroupCard";
- import { RetrievalAgentCard } from "@/components/flow/RetrievalAgentCard";
- import { AgentDecisionCard } from "@/components/decision/AgentDecisionCard";
- const nodeTypes = { storyCard: StoryCard, agentDecision: AgentDecisionCard, candidateCard: CandidateCard, roundSummary: RoundSummaryCard, finalResult: RunResultCard, roundFrame: RoundFrame, agentRegion: AgentRegion, branchFrame: BranchFrame, retrievalStageFrame: RetrievalStageFrame, directToolGroup: DirectToolGroupCard, retrievalAgent: RetrievalAgentCard };
- const MIN_CANVAS_ZOOM = .2;
- const INITIAL_VIEWPORT = { x: 18, y: 94, zoom: MIN_CANVAS_ZOOM };
- export function centeredOverviewViewport(bounds: Pick<Rect, "x" | "width">, canvasWidth: number) {
- return {
- x: canvasWidth / 2 - (bounds.x + bounds.width / 2) * MIN_CANVAS_ZOOM,
- y: INITIAL_VIEWPORT.y,
- zoom: MIN_CANVAS_ZOOM,
- };
- }
- interface Props {
- view: ExecutionViewV8;
- expandedRounds: Set<number>;
- expandedBranches: Set<string>;
- expandedRetrievalAgents: Set<string>;
- onToggleRound: (round: number) => void;
- onToggleBranch: (round: number, branchId: number) => void;
- onToggleRetrievalAgent: (agentId: string) => void;
- onOpen: (item: CanvasItem, target: OpenTarget) => void;
- onPrompt: (promptRef: string) => void;
- selectedId?: string;
- focusRound?: { roundIndex: number; token: number };
- canvasFullscreen?: boolean;
- onExitFullscreen?: () => void;
- }
- export function FlowCanvas(props: Props) {
- const { getNode, getNodesBounds, setCenter, setViewport } = useReactFlow();
- const nodesInitialized = useNodesInitialized();
- const shellRef = useRef<HTMLElement>(null);
- const previousBuild = useRef<string | undefined>(undefined);
- const positionBuild = useRef<string | undefined>(undefined);
- const manualPositions = useRef(new Map<string, { position: { x: number; y: number }; parentId?: string }>());
- const [detailLevel, setDetailLevel] = useState<"full" | "compact" | "overview">("overview");
- 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]);
- const [nodes, setNodes, onNodesChange] = useNodesState<Node<CanvasNodeData>>([]);
- const handleMove: OnMove = useCallback((_, viewport) => {
- setDetailLevel((current) => {
- if (current === "full") return viewport.zoom < .74 ? "compact" : "full";
- if (current === "compact") {
- if (viewport.zoom >= .82) return "full";
- return viewport.zoom < .36 ? "overview" : "compact";
- }
- return viewport.zoom > .42 ? "compact" : "overview";
- });
- }, []);
- useEffect(() => {
- const buildId = String(props.view.header.id);
- if (positionBuild.current !== buildId) {
- positionBuild.current = buildId;
- manualPositions.current.clear();
- }
- setNodes(layout.nodes.map((node) => {
- const saved = manualPositions.current.get(node.id);
- return saved && saved.parentId === node.parentId ? { ...node, position: saved.position } : node;
- }));
- }, [layout.nodes, props.view.header.id, setNodes]);
- const rememberNodePosition = useCallback((_: unknown, node: Node<CanvasNodeData>) => {
- if (!node.draggable) return;
- manualPositions.current.set(node.id, { position: node.position, parentId: node.parentId });
- }, []);
- useEffect(() => {
- const buildId = String(props.view.header.id);
- if (!nodesInitialized || previousBuild.current === buildId) return;
- previousBuild.current = buildId;
- const frame = requestAnimationFrame(() => {
- const topLevelNodes = layout.nodes.filter((node) => !node.parentId);
- const canvasWidth = shellRef.current?.clientWidth || 0;
- const viewport = topLevelNodes.length && canvasWidth
- ? centeredOverviewViewport(getNodesBounds(topLevelNodes), canvasWidth)
- : INITIAL_VIEWPORT;
- void setViewport(viewport, { duration: 0 });
- });
- return () => cancelAnimationFrame(frame);
- }, [props.view.header.id, getNodesBounds, layout.nodes, nodesInitialized, setViewport]);
- useEffect(() => {
- if (!props.focusRound) return;
- const frame = requestAnimationFrame(() => {
- const target = getNode(`frame:round:${props.focusRound!.roundIndex}`) || getNode(`round:${props.focusRound!.roundIndex}:summary`);
- if (target) {
- const isFrame = target.id.startsWith("frame:");
- const width = isFrame ? Number(target.style?.width || 1980) : 320;
- const height = isFrame ? Number(target.style?.height || 720) : 360;
- void setCenter(target.position.x + width / 2, target.position.y + height / 2, { zoom: 1, duration: 220 });
- }
- });
- return () => cancelAnimationFrame(frame);
- }, [props.focusRound?.token, getNode, setCenter]);
- return <section ref={shellRef} className={`flowShell detail-${detailLevel} ${props.canvasFullscreen ? "mobileFullscreen" : ""}`} data-detail-level={detailLevel} aria-label="脚本构建横向运行画布">
- {props.canvasFullscreen ? <button type="button" className="exitFullscreen" onClick={props.onExitFullscreen}>返回轮次列表</button> : null}
- <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 }}>
- <Background variant={BackgroundVariant.Dots} gap={22} size={1.2} color="#343740" />
- <Controls position="bottom-left" showInteractive={false} />
- </ReactFlow>
- </section>;
- }
|