From eabf1f2080cedc7f8f0e1577dd298d7c90c5feff Mon Sep 17 00:00:00 2001 From: Shi Tianxin Date: Tue, 29 Apr 2025 16:46:50 +0800 Subject: [PATCH] feat(visualization): add workflow run animation logic --- .../_components/multi-agent-visualization.tsx | 432 +++++++++++------- web/src/hooks/use-intersection-observer.ts | 137 ++++++ 2 files changed, 408 insertions(+), 161 deletions(-) create mode 100644 web/src/hooks/use-intersection-observer.ts diff --git a/web/src/app/_components/multi-agent-visualization.tsx b/web/src/app/_components/multi-agent-visualization.tsx index 0bee968..36c03ee 100644 --- a/web/src/app/_components/multi-agent-visualization.tsx +++ b/web/src/app/_components/multi-agent-visualization.tsx @@ -9,20 +9,202 @@ import { useEdgesState, Handle, Position, + type Node, + type Edge, } from "@xyflow/react"; -import "@xyflow/react/dist/style.css"; import { Brain, FilePen, MessageSquareQuote, Microscope, + RotateCcw, SquareTerminal, UserCheck, Users, type LucideIcon, } from "lucide-react"; +import { + useCallback, + useEffect, + useState, + type Dispatch, + type SetStateAction, +} from "react"; +import "@xyflow/react/dist/style.css"; import { ShineBorder } from "~/components/magicui/shine-border"; +import { Button } from "~/components/ui/button"; +import { useIntersectionObserver } from "~/hooks/use-intersection-observer"; +import { cn } from "~/lib/utils"; + +const ROW_HEIGHT = 75; +const ROW_1 = 0; +const ROW_2 = ROW_HEIGHT; +const ROW_3 = ROW_HEIGHT * 2; +const ROW_4 = ROW_HEIGHT * 2; +const ROW_5 = ROW_HEIGHT * 3; +const ROW_6 = ROW_HEIGHT * 4; + +export type WorkflowNode = Node<{ + label: string; + icon?: LucideIcon; + active?: boolean; +}>; + +const initialNodes: WorkflowNode[] = [ + { + id: "Start", + type: "circle", + data: { label: "Start" }, + position: { x: 25, y: ROW_1 }, + }, + { + id: "Coordinator", + data: { icon: MessageSquareQuote, label: "Coordinator" }, + position: { x: 150, y: ROW_1 }, + }, + { + id: "Planner", + data: { icon: Brain, label: "Planner" }, + position: { x: 150, y: ROW_2 }, + }, + { + id: "Reporter", + data: { icon: FilePen, label: "Reporter" }, + position: { x: 275, y: ROW_3 }, + }, + { + id: "HumanFeedback", + data: { icon: UserCheck, label: "Human Feedback" }, + position: { x: 25, y: ROW_4 }, + }, + { + id: "ResearchTeam", + data: { icon: Users, label: "Research Team" }, + position: { x: 25, y: ROW_5 }, + }, + { + id: "Researcher", + data: { icon: Microscope, label: "Researcher" }, + position: { x: -75, y: ROW_6 }, + }, + { + id: "Coder", + data: { icon: SquareTerminal, label: "Coder" }, + position: { x: 125, y: ROW_6 }, + }, + { + id: "End", + type: "circle", + data: { label: "End" }, + position: { x: 330, y: ROW_6 }, + }, +]; + +const initialEdges: Edge[] = [ + { + id: "Start->Coordinator", + source: "Start", + target: "Coordinator", + sourceHandle: "right", + targetHandle: "left", + animated: false, + }, + { + id: "Coordinator->Planner", + source: "Coordinator", + target: "Planner", + sourceHandle: "bottom", + targetHandle: "top", + animated: false, + }, + { + id: "Planner->Reporter", + source: "Planner", + target: "Reporter", + sourceHandle: "right", + targetHandle: "top", + animated: false, + }, + { + id: "Planner->HumanFeedback", + source: "Planner", + target: "HumanFeedback", + sourceHandle: "left", + targetHandle: "top", + animated: false, + }, + { + id: "HumanFeedback->Planner", + source: "HumanFeedback", + target: "Planner", + sourceHandle: "right", + targetHandle: "bottom", + animated: false, + }, + { + id: "HumanFeedback->ResearchTeam", + source: "HumanFeedback", + target: "ResearchTeam", + sourceHandle: "bottom", + targetHandle: "top", + animated: false, + }, + { + id: "Reporter->End", + source: "Reporter", + target: "End", + sourceHandle: "bottom", + targetHandle: "top", + animated: false, + }, + { + id: "ResearchTeam->Researcher", + source: "ResearchTeam", + target: "Researcher", + sourceHandle: "left", + targetHandle: "top", + animated: false, + }, + { + id: "ResearchTeam->Coder", + source: "ResearchTeam", + target: "Coder", + sourceHandle: "bottom", + targetHandle: "left", + animated: false, + }, + { + id: "ResearchTeam->Planner", + source: "ResearchTeam", + target: "Planner", + sourceHandle: "right", + targetHandle: "bottom", + animated: false, + }, + { + id: "Researcher->ResearchTeam", + source: "Researcher", + target: "ResearchTeam", + sourceHandle: "right", + targetHandle: "bottom", + animated: false, + }, + { + id: "Coder->ResearchTeam", + source: "Coder", + target: "ResearchTeam", + sourceHandle: "top", + targetHandle: "right", + animated: false, + }, +]; + +const nodeTypes = { + circle: CircleNode, + agent: AgentNode, + default: AgentNode, +}; const WORKFLOW_STEPS = [ { @@ -84,172 +266,80 @@ const WORKFLOW_STEPS = [ }, ]; -const ROW_HEIGHT = 75; -const ROW_1 = 0; -const ROW_2 = ROW_HEIGHT; -const ROW_3 = ROW_HEIGHT * 2; -const ROW_4 = ROW_HEIGHT * 2; -const ROW_5 = ROW_HEIGHT * 3; -const ROW_6 = ROW_HEIGHT * 4; -const initialNodes = [ - { - id: "Start", - type: "circle", - data: { label: "Start" }, - position: { x: 25, y: ROW_1 }, - }, - { - id: "Coordinator", - data: { icon: MessageSquareQuote, label: "Coordinator" }, - position: { x: 150, y: ROW_1 }, - }, - { - id: "Planner", - data: { icon: Brain, label: "Planner" }, - position: { x: 150, y: ROW_2 }, - }, - { - id: "Reporter", - data: { icon: FilePen, label: "Reporter" }, - position: { x: 275, y: ROW_3 }, - }, - { - id: "HumanFeedback", - data: { icon: UserCheck, label: "Human Feedback" }, - position: { x: 25, y: ROW_4 }, - }, - { - id: "ResearchTeam", - data: { icon: Users, label: "Research Team" }, - position: { x: 25, y: ROW_5 }, - }, - { - id: "Researcher", - data: { icon: Microscope, label: "Researcher" }, - position: { x: -75, y: ROW_6 }, - }, - { - id: "Coder", - data: { icon: SquareTerminal, label: "Coder" }, - position: { x: 125, y: ROW_6 }, - }, - { - id: "End", - type: "circle", - data: { label: "End" }, - position: { x: 330, y: ROW_6 }, - }, -]; +function useWorkflowRun( + setNodes: Dispatch>, + setEdges: Dispatch>, +) { + const [isRunning, setIsRunning] = useState(false); -const initialEdges = [ - { - id: "Start->Coordinator", - source: "Start", - target: "Coordinator", - sourceHandle: "right", - targetHandle: "left", - animated: true, - }, - { - id: "Coordinator->Planner", - source: "Coordinator", - target: "Planner", - sourceHandle: "bottom", - targetHandle: "top", - animated: true, - }, - { - id: "Planner->Reporter", - source: "Planner", - target: "Reporter", - sourceHandle: "right", - targetHandle: "top", - animated: true, - }, - { - id: "Planner->HumanFeedback", - source: "Planner", - target: "HumanFeedback", - sourceHandle: "left", - targetHandle: "top", - animated: true, - }, - { - id: "HumanFeedback->Planner", - source: "HumanFeedback", - target: "Planner", - sourceHandle: "right", - targetHandle: "bottom", - animated: true, - }, - { - id: "HumanFeedback->ResearchTeam", - source: "HumanFeedback", - target: "ResearchTeam", - sourceHandle: "bottom", - targetHandle: "top", - animated: true, - }, - { - id: "Reporter->End", - source: "Reporter", - target: "End", - sourceHandle: "bottom", - targetHandle: "top", - animated: true, - }, - { - id: "ResearchTeam->Researcher", - source: "ResearchTeam", - target: "Researcher", - sourceHandle: "left", - targetHandle: "top", - animated: true, - }, - { - id: "ResearchTeam->Coder", - source: "ResearchTeam", - target: "Coder", - sourceHandle: "bottom", - targetHandle: "left", - animated: true, - }, - { - id: "ResearchTeam->Planner", - source: "ResearchTeam", - target: "Planner", - sourceHandle: "right", - targetHandle: "bottom", - animated: true, - }, - { - id: "Researcher->ResearchTeam", - source: "Researcher", - target: "ResearchTeam", - sourceHandle: "right", - targetHandle: "bottom", - animated: true, - }, - { - id: "Coder->ResearchTeam", - source: "Coder", - target: "ResearchTeam", - sourceHandle: "top", - targetHandle: "right", - animated: true, - }, -]; + const clearAnimation = useCallback(() => { + setEdges((edges) => { + return edges.map((edge) => ({ + ...edge, + animated: false, + })); + }); -const nodeTypes = { - circle: CircleNode, - agent: AgentNode, - default: AgentNode, -}; + setNodes((nodes) => { + return nodes.map((node) => ({ + ...node, + data: { ...node.data, active: false }, + })); + }); + }, [setEdges, setNodes]); + + const run = useCallback(async () => { + setIsRunning(true); + clearAnimation(); + for (const step of WORKFLOW_STEPS) { + setNodes((nodes) => { + return nodes.map((node) => ({ + ...node, + data: { + ...node.data, + active: step.activeNodes.includes(node.id), + }, + })); + }); + + setEdges((edges) => { + return edges.map((edge) => ({ + ...edge, + animated: step.activeEdges.includes(edge.id), + })); + }); + + await sleep(1000); + } + clearAnimation(); + setIsRunning(false); + }, [setNodes, setEdges, clearAnimation]); + + return { run, isRunning }; +} + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} export function MultiAgentVisualization() { const [nodes, setNodes] = useNodesState(initialNodes); const [edges, setEdges] = useEdgesState(initialEdges); + const { run, isRunning } = useWorkflowRun(setNodes, setEdges); + const [hasAutoRunned, setHasAutoRunned] = useState(false); + + const { ref } = useIntersectionObserver({ + rootMargin: "0%", + threshold: 0, + onChange: (isIntersecting) => { + if (isIntersecting && !hasAutoRunned) { + setHasAutoRunned(true); + void run(); + } + }, + }); + return ( + +
); } diff --git a/web/src/hooks/use-intersection-observer.ts b/web/src/hooks/use-intersection-observer.ts new file mode 100644 index 0000000..01db352 --- /dev/null +++ b/web/src/hooks/use-intersection-observer.ts @@ -0,0 +1,137 @@ +import { useEffect, useRef, useState } from "react"; + +type State = { + isIntersecting: boolean; + entry?: IntersectionObserverEntry; +}; + +type UseIntersectionObserverOptions = { + root?: Element | Document | null; + rootMargin?: string; + threshold?: number | number[]; + freezeOnceVisible?: boolean; + onChange?: ( + isIntersecting: boolean, + entry: IntersectionObserverEntry, + ) => void; + initialIsIntersecting?: boolean; +}; + +type IntersectionReturn = [ + (node?: Element | null) => void, + boolean, + IntersectionObserverEntry | undefined, +] & { + ref: (node?: Element | null) => void; + isIntersecting: boolean; + entry?: IntersectionObserverEntry; +}; + +export function useIntersectionObserver({ + threshold = 0, + root = null, + rootMargin = "0%", + freezeOnceVisible = false, + initialIsIntersecting = false, + onChange, +}: UseIntersectionObserverOptions = {}): IntersectionReturn { + const [ref, setRef] = useState(null); + + const [state, setState] = useState(() => ({ + isIntersecting: initialIsIntersecting, + entry: undefined, + })); + + const callbackRef = + useRef(undefined); + + callbackRef.current = onChange; + + const frozen = state.entry?.isIntersecting && freezeOnceVisible; + + useEffect(() => { + // Ensure we have a ref to observe + if (!ref) return; + + // Ensure the browser supports the Intersection Observer API + if (!("IntersectionObserver" in window)) return; + + // Skip if frozen + if (frozen) return; + + let unobserve: (() => void) | undefined; + + const observer = new IntersectionObserver( + (entries: IntersectionObserverEntry[]): void => { + const thresholds = Array.isArray(observer.thresholds) + ? observer.thresholds + : [observer.thresholds]; + + entries.forEach((entry) => { + const isIntersecting = + entry.isIntersecting && + thresholds.some( + (threshold) => entry.intersectionRatio >= threshold, + ); + + setState({ isIntersecting, entry }); + + if (callbackRef.current) { + callbackRef.current(isIntersecting, entry); + } + + if (isIntersecting && freezeOnceVisible && unobserve) { + unobserve(); + unobserve = undefined; + } + }); + }, + { threshold, root, rootMargin }, + ); + + observer.observe(ref); + + return () => { + observer.disconnect(); + }; + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + ref, + // eslint-disable-next-line react-hooks/exhaustive-deps + JSON.stringify(threshold), + root, + rootMargin, + frozen, + freezeOnceVisible, + ]); + + // ensures that if the observed element changes, the intersection observer is reinitialized + const prevRef = useRef(null); + + useEffect(() => { + if ( + !ref && + state.entry?.target && + !freezeOnceVisible && + !frozen && + prevRef.current !== state.entry.target + ) { + prevRef.current = state.entry.target; + setState({ isIntersecting: initialIsIntersecting, entry: undefined }); + } + }, [ref, state.entry, freezeOnceVisible, frozen, initialIsIntersecting]); + + const result = [ + setRef, + !!state.isIntersecting, + state.entry, + ] as IntersectionReturn; + + // Support object destructuring, by adding the specific values. + result.ref = result[0]; + result.isIntersecting = result[1]; + result.entry = result[2]; + + return result; +}