mirror of
https://git.mirrors.martin98.com/https://github.com/bytedance/deer-flow
synced 2025-08-20 12:39:15 +08:00
feat(visualization): add workflow run animation logic
This commit is contained in:
parent
2a02f01580
commit
eabf1f2080
@ -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<SetStateAction<WorkflowNode[]>>,
|
||||
setEdges: Dispatch<SetStateAction<Edge[]>>,
|
||||
) {
|
||||
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 (
|
||||
<ReactFlow
|
||||
style={{
|
||||
@ -259,7 +349,7 @@ export function MultiAgentVisualization() {
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
attributionPosition="top-right"
|
||||
proOptions={{ hideAttribution: true }}
|
||||
colorMode="dark"
|
||||
panOnScroll={false}
|
||||
zoomOnScroll={false}
|
||||
@ -270,6 +360,26 @@ export function MultiAgentVisualization() {
|
||||
className="[mask-image:radial-gradient(800px_circle_at_center,white,transparent)]"
|
||||
bgColor="var(--background)"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-0 left-0 z-10"
|
||||
disabled={isRunning}
|
||||
onClick={() => {
|
||||
void run();
|
||||
}}
|
||||
>
|
||||
<RotateCcw
|
||||
className={cn({
|
||||
"animate-spin": isRunning,
|
||||
})}
|
||||
/>
|
||||
</Button>
|
||||
<div
|
||||
id="auto-run-animation-trigger"
|
||||
ref={ref}
|
||||
className="absolute bottom-0 left-[50%] h-px w-px"
|
||||
/>
|
||||
</ReactFlow>
|
||||
);
|
||||
}
|
||||
|
137
web/src/hooks/use-intersection-observer.ts
Normal file
137
web/src/hooks/use-intersection-observer.ts
Normal file
@ -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<Element | null>(null);
|
||||
|
||||
const [state, setState] = useState<State>(() => ({
|
||||
isIntersecting: initialIsIntersecting,
|
||||
entry: undefined,
|
||||
}));
|
||||
|
||||
const callbackRef =
|
||||
useRef<UseIntersectionObserverOptions["onChange"]>(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<Element | null>(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;
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user