From b152e787cc964dc3337ff4c683115bf7be45472f Mon Sep 17 00:00:00 2001 From: Li Xin Date: Fri, 2 May 2025 17:24:41 +0800 Subject: [PATCH] feat: re-implement MultiAgentVisualization --- .../components/multi-agent-visualization.tsx | 485 +++++------------- web/src/app/landing/store/graph.ts | 183 +++++++ web/src/app/landing/store/index.ts | 5 + web/src/app/landing/store/mav-store.ts | 111 ++++ web/src/app/landing/store/playbook.ts | 78 +++ 5 files changed, 502 insertions(+), 360 deletions(-) create mode 100644 web/src/app/landing/store/graph.ts create mode 100644 web/src/app/landing/store/index.ts create mode 100644 web/src/app/landing/store/playbook.ts diff --git a/web/src/app/landing/components/multi-agent-visualization.tsx b/web/src/app/landing/components/multi-agent-visualization.tsx index fe30ccd..5a4297b 100644 --- a/web/src/app/landing/components/multi-agent-visualization.tsx +++ b/web/src/app/landing/components/multi-agent-visualization.tsx @@ -6,199 +6,39 @@ import { ReactFlow, Background, - useNodesState, - useEdgesState, Handle, Position, - type Node, type Edge, + type ReactFlowInstance, } from "@xyflow/react"; import { - Brain, - FilePen, - MessageSquareQuote, - Microscope, - RotateCcw, - SquareTerminal, - UserCheck, - Users, + Play, type LucideIcon, + ChevronRight, + ChevronLeft, + Pause, + Fullscreen, + Minimize, } from "lucide-react"; -import { - useCallback, - useState, - type Dispatch, - type SetStateAction, -} from "react"; import "@xyflow/react/dist/style.css"; +import { useCallback, useRef, useState } from "react"; import { Tooltip } from "~/components/deer-flow/tooltip"; import { ShineBorder } from "~/components/magicui/shine-border"; import { Button } from "~/components/ui/button"; +import { Slider } from "~/components/ui/slider"; import { useIntersectionObserver } from "~/hooks/use-intersection-observer"; +import { cn } from "~/lib/utils"; -const ROW_HEIGHT = 85; -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: -75, 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, - }, -]; +import { playbook, type GraphNode } from "../store"; +import { + activateStep, + nextStep, + play, + prevStep, + togglePlay, + useMAVStore, +} from "../store/mav-store"; const nodeTypes = { circle: CircleNode, @@ -206,196 +46,121 @@ const nodeTypes = { default: AgentNode, }; -const WORKFLOW_STEPS = [ - { - description: - "The Coordinator is responsible for engaging with the user to understand their problem and requirements.", - tooltipPosition: "right", - activeNodes: ["Start", "Coordinator"], - activeEdges: ["Start->Coordinator"], - }, - { - description: - "If the user's problem is clearly defined, the Coordinator will hand it over to the Planner.", - tooltipPosition: "left", - activeNodes: ["Coordinator", "Planner"], - activeEdges: ["Coordinator->Planner"], - }, - { - description: "Awaiting human feedback to refine the plan.", - tooltipPosition: "left", - activeNodes: ["Planner", "HumanFeedback"], - activeEdges: ["Planner->HumanFeedback"], - }, - { - description: "Updating the plan based on human feedback.", - tooltipPosition: "left", - activeNodes: ["HumanFeedback", "Planner"], - activeEdges: ["HumanFeedback->Planner"], - }, - { - description: - "The Research Team is responsible for conducting the core research tasks.", - tooltipPosition: "left", - activeNodes: ["HumanFeedback", "ResearchTeam"], - activeEdges: ["HumanFeedback->ResearchTeam", "ResearchTeam->HumanFeedback"], - }, - { - description: - "The Researcher is responsible for gathering information using search and crawling tools.", - tooltipPosition: "left", - activeNodes: ["ResearchTeam", "Researcher"], - activeEdges: ["ResearchTeam->Researcher", "Researcher->ResearchTeam"], - }, - { - description: - "The Coder is responsible for writing Python code to solve math problems, data analysis, and more.", - tooltipPosition: "right", - activeNodes: ["ResearchTeam", "Coder"], - activeEdges: ["ResearchTeam->Coder", "Coder->ResearchTeam"], - }, - { - description: - "Once the research tasks are completed, the Researcher will hand over to the Planner.", - tooltipPosition: "left", - activeNodes: ["ResearchTeam", "Planner"], - activeEdges: ["ResearchTeam->Planner"], - }, - { - description: - "If no additional information is required, the Planner will handoff to the Reporter.", - tooltipPosition: "right", - activeNodes: ["Reporter", "Planner"], - activeEdges: ["Planner->Reporter"], - }, - { - description: "The Reporter will prepare a report summarizing the results.", - tooltipPosition: "bottom", - activeNodes: ["End", "Reporter"], - activeEdges: ["Reporter->End"], - }, -]; - -function useWorkflowRun( - setNodes: Dispatch>, - setEdges: Dispatch>, -) { - const [isRunning, setIsRunning] = useState(false); - - const clearAnimation = useCallback(() => { - setEdges((edges) => { - return edges.map((edge) => ({ - ...edge, - animated: true, - })); - }); - - 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), - stepDescription: - step.activeNodes.indexOf(node.id) === step.activeNodes.length - 1 - ? step.description - : undefined, - stepTooltipPosition: step.tooltipPosition, - }, - })); - }); - - setEdges((edges) => { - return edges.map((edge) => ({ - ...edge, - animated: step.activeEdges.includes(edge.id), - })); - }); - - await sleep(step.description.split(" ").length * 360); - } - 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 [hasAutoRun, setHasAutoRun] = useState(false); - - const { ref } = useIntersectionObserver({ +export function MultiAgentVisualization({ className }: { className?: string }) { + const { + graph: { nodes, edges }, + activeStepIndex, + playing, + } = useMAVStore((state) => state); + const flowRef = useRef>(null); + const containerRef = useRef(null); + const [fullscreen, setFullscreen] = useState(false); + const { ref: anchorRef } = useIntersectionObserver({ rootMargin: "0%", threshold: 0, onChange: (isIntersecting) => { - if (isIntersecting && !hasAutoRun) { - setHasAutoRun(true); - void run(); + if (isIntersecting && !playing) { + void play(); } }, }); - + const toggleFullscreen = useCallback(async () => { + if (containerRef.current) { + if (!document.fullscreenElement) { + setFullscreen(true); + await containerRef.current.requestFullscreen(); + setTimeout(() => { + void flowRef.current?.fitView({ maxZoom: 2.5 }); + }, 100); + } else { + setFullscreen(false); + await document.exitFullscreen(); + setTimeout(() => { + void flowRef.current?.fitView(); + }, 100); + } + } + }, []); return ( - - -
-
- {!isRunning && ( - - )} + { + flowRef.current = instance; + }} + > + +
+ +
+
+ + + + + + + + + +
+ = 0 ? activeStepIndex : 0]} + onValueChange={([value]) => { + console.info("jump", value); + activateStep(value!); + }} + /> +
+ + + +
- +
); } diff --git a/web/src/app/landing/store/graph.ts b/web/src/app/landing/store/graph.ts new file mode 100644 index 0000000..ada9af9 --- /dev/null +++ b/web/src/app/landing/store/graph.ts @@ -0,0 +1,183 @@ +// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates +// SPDX-License-Identifier: MIT + +import type { Edge, Node } from "@xyflow/react"; +import { + Brain, + FilePen, + MessageSquareQuote, + Microscope, + SquareTerminal, + UserCheck, + Users, + type LucideIcon, +} from "lucide-react"; + +export type GraphNode = Node<{ + label: string; + icon?: LucideIcon; + active?: boolean; +}>; + +export type Graph = { + nodes: GraphNode[]; + edges: Edge[]; +}; + +const ROW_HEIGHT = 85; +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 const graph: Graph = { + nodes: [ + { + id: "Start", + type: "circle", + data: { label: "Start" }, + position: { x: -75, 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 }, + }, + ], + edges: [ + { + 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, + }, + ], +}; diff --git a/web/src/app/landing/store/index.ts b/web/src/app/landing/store/index.ts new file mode 100644 index 0000000..7bee00b --- /dev/null +++ b/web/src/app/landing/store/index.ts @@ -0,0 +1,5 @@ +// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates +// SPDX-License-Identifier: MIT + +export * from "./graph"; +export * from "./playbook"; diff --git a/web/src/app/landing/store/mav-store.ts b/web/src/app/landing/store/mav-store.ts index e69de29..4004c45 100644 --- a/web/src/app/landing/store/mav-store.ts +++ b/web/src/app/landing/store/mav-store.ts @@ -0,0 +1,111 @@ +// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates +// SPDX-License-Identifier: MIT + +import { create } from "zustand"; + +import { sleep } from "~/core/utils"; + +import { graph, type Graph } from "./graph"; +import { playbook } from "./playbook"; + +// Store for MAV(Multi-Agent Visualization) +export const useMAVStore = create<{ + graph: Graph; + activeStepIndex: number; + playing: boolean; +}>(() => ({ + graph, + activeStepIndex: -1, + playing: false, +})); + +export function activateStep(stepIndex: number) { + const nextStep = playbook.steps[stepIndex]!; + const currentGraph = useMAVStore.getState().graph; + const nextGraph: Graph = { + nodes: currentGraph.nodes.map((node) => ({ + ...node, + data: { + ...node.data, + active: nextStep.activeNodes.includes(node.id), + stepDescription: + nextStep.activeNodes.indexOf(node.id) === + nextStep.activeNodes.length - 1 && nextStep.description, + stepTooltipPosition: + nextStep.activeNodes.indexOf(node.id) === + nextStep.activeNodes.length - 1 && nextStep.tooltipPosition, + }, + })), + edges: currentGraph.edges.map((edge) => ({ + ...edge, + animated: nextStep.activeEdges.includes(edge.id), + })), + }; + useMAVStore.setState({ + activeStepIndex: stepIndex, + graph: nextGraph, + }); +} + +export function nextStep() { + let stepIndex = useMAVStore.getState().activeStepIndex; + if (stepIndex >= playbook.steps.length - 1) { + stepIndex = 0; + } else { + stepIndex++; + } + activateStep(stepIndex); +} + +export function prevStep() { + let stepIndex = useMAVStore.getState().activeStepIndex; + if (stepIndex <= 0) { + stepIndex = playbook.steps.length - 1; + } else { + stepIndex--; + } + activateStep(stepIndex); +} + +export async function play() { + const state = useMAVStore.getState(); + const activeStepIndex = state.activeStepIndex; + if (activeStepIndex >= playbook.steps.length - 1) { + if (state.playing) { + stop(); + return; + } + } + useMAVStore.setState({ + playing: true, + }); + nextStep(); + await sleep(4000); + const playing = useMAVStore.getState().playing; + if (playing) { + await play(); + } +} + +export function pause() { + useMAVStore.setState({ + playing: false, + }); +} + +export async function togglePlay() { + const playing = useMAVStore.getState().playing; + if (playing) { + pause(); + } else { + await play(); + } +} + +export function stop() { + useMAVStore.setState({ + playing: false, + activeStepIndex: -1, + graph, + }); +} diff --git a/web/src/app/landing/store/playbook.ts b/web/src/app/landing/store/playbook.ts new file mode 100644 index 0000000..81e7b74 --- /dev/null +++ b/web/src/app/landing/store/playbook.ts @@ -0,0 +1,78 @@ +// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates +// SPDX-License-Identifier: MIT + +export const playbook = { + steps: [ + { + description: + "The Coordinator is responsible for engaging with the user to understand their problem and requirements.", + activeNodes: ["Start", "Coordinator"], + activeEdges: ["Start->Coordinator"], + tooltipPosition: "right", + }, + { + description: + "If the user's problem is clearly defined, the Coordinator will hand it over to the Planner.", + activeNodes: ["Coordinator", "Planner"], + activeEdges: ["Coordinator->Planner"], + tooltipPosition: "left", + }, + { + description: "Awaiting human feedback to refine the plan.", + activeNodes: ["Planner", "HumanFeedback"], + activeEdges: ["Planner->HumanFeedback"], + tooltipPosition: "left", + }, + { + description: "Updating the plan based on human feedback.", + activeNodes: ["HumanFeedback", "Planner"], + activeEdges: ["HumanFeedback->Planner"], + tooltipPosition: "left", + }, + { + description: + "The Research Team is responsible for conducting the core research tasks.", + activeNodes: ["HumanFeedback", "ResearchTeam"], + activeEdges: [ + "HumanFeedback->ResearchTeam", + "ResearchTeam->HumanFeedback", + ], + tooltipPosition: "left", + }, + { + description: + "The Researcher is responsible for gathering information using search and crawling tools.", + activeNodes: ["ResearchTeam", "Researcher"], + activeEdges: ["ResearchTeam->Researcher", "Researcher->ResearchTeam"], + tooltipPosition: "left", + }, + { + description: + "The Coder is responsible for writing Python code to solve math problems, data analysis, and more.", + tooltipPosition: "right", + activeNodes: ["ResearchTeam", "Coder"], + activeEdges: ["ResearchTeam->Coder", "Coder->ResearchTeam"], + }, + { + description: + "Once the research tasks are completed, the Researcher will hand over to the Planner.", + activeNodes: ["ResearchTeam", "Planner"], + activeEdges: ["ResearchTeam->Planner"], + tooltipPosition: "left", + }, + { + description: + "If no additional information is required, the Planner will handoff to the Reporter.", + activeNodes: ["Reporter", "Planner"], + activeEdges: ["Planner->Reporter"], + tooltipPosition: "right", + }, + { + description: + "The Reporter will prepare a report summarizing the results.", + activeNodes: ["End", "Reporter"], + activeEdges: ["Reporter->End"], + tooltipPosition: "bottom", + }, + ], +};