add visualiser for agents

This commit is contained in:
tusharmagar 2025-07-17 10:57:03 +05:30
parent 6895e54425
commit d19e84815d
5 changed files with 1582 additions and 34 deletions

View file

@ -0,0 +1,184 @@
import React, { useEffect, useRef } from "react";
import mermaid from "mermaid";
import { Workflow } from "../../../lib/types/workflow_types";
function sanitizeId(name: string): string {
return name.replace(/[^a-zA-Z0-9\s_-]/g, "").replace(/[\s-]+/g, "_");
}
function generateMermaidFromWorkflow(workflow: any, isDark: boolean): string {
const startAgentName = workflow.startAgent;
const agents: any[] = workflow.agents || [];
const tools: any[] = workflow.tools || [];
// Light and dark mode colors
const toolFillLight = '#ede9fe';
const toolStrokeLight = '#a78bfa';
const toolFillDark = '#312e81';
const toolStrokeDark = '#a78bfa';
const agentFillLight = '#EBF5FB';
const agentStrokeLight = '#85C1E9';
const agentFillDark = '#1e293b';
const agentStrokeDark = '#a78bfa';
const startFillLight = '#FEF9E7';
const startStrokeLight = '#F8C471';
const startFillDark = '#92400e';
const startStrokeDark = '#f59e0b';
const entryFillLight = '#22C55E';
const entryStrokeLight = '#16A34A';
const entryFillDark = '#22c55e';
const entryStrokeDark = '#4ade80';
const textLight = '#34495E';
const textDark = '#fff';
const mermaidCode = [
"graph LR",
// Agent node style
` classDef agent fill:${isDark ? agentFillDark : agentFillLight},stroke:${isDark ? agentStrokeDark : agentStrokeLight},stroke-width:3px,color:${isDark ? textDark : textLight},font-size:16px,radius:12px`,
// Tool node style
` classDef tool fill:${isDark ? toolFillDark : toolFillLight},stroke:${toolStrokeLight},stroke-width:3px,color:${isDark ? textDark : textLight},font-size:16px,radius:12px`,
// Start agent node style
` classDef startAgent fill:${isDark ? startFillDark : startFillLight},stroke:${isDark ? startStrokeDark : startStrokeLight},stroke-width:3px,color:${isDark ? textDark : textLight},font-size:18px,radius:12px`,
// Entry node style
` classDef entry fill:${isDark ? entryFillDark : entryFillLight},stroke:${isDark ? entryStrokeDark : entryStrokeLight},stroke-width:3px,color:${isDark ? textDark : '#fff'},font-size:16px,radius:12px`
];
if (startAgentName) {
const startAgentId = sanitizeId(startAgentName);
mermaidCode.push(`\n %% -- Entry Point --`);
mermaidCode.push(` Entry([Start]) --> ${startAgentId}`);
mermaidCode.push(` class Entry entry`);
}
mermaidCode.push(`\n %% -- Agent Nodes --`);
for (const agent of agents) {
const agentName = agent.name;
const agentId = sanitizeId(agentName);
const nodeLabel = `🤖 ${agentName}`;
mermaidCode.push(` ${agentId}([\"${nodeLabel}\"])`);
if (agentName === startAgentName) {
mermaidCode.push(` class ${agentId} startAgent`);
} else {
mermaidCode.push(` class ${agentId} agent`);
}
}
// --- Tool Nodes ---
// 1. Collect all tool names from workflow.tools
const toolNamesFromArray = new Set(tools.map((tool: any) => tool.name));
// 2. Collect all tool names mentioned in agent instructions
const agentMentionPattern = /\[@agent:([^\]]+)\]\(#mention[^\)]*\)/g;
const toolMentionPattern = /\[@tool:([^\]]+)\]\(#mention[^\)]*\)/g;
const toolNamesFromMentions = new Set<string>();
for (const agent of agents) {
const instructions = agent.instructions || "";
let match: RegExpExecArray | null;
while ((match = toolMentionPattern.exec(instructions))) {
toolNamesFromMentions.add(match[1]);
}
}
// 3. Union of all tool names
const allToolNames = new Set([...toolNamesFromArray, ...toolNamesFromMentions]);
// 4. Generate tool nodes for all
mermaidCode.push(`\n %% -- Tool Nodes --`);
for (const toolName of allToolNames) {
const toolId = sanitizeId(toolName);
mermaidCode.push(` ${toolId}([\"🛠️ ${toolName}\"])`);
mermaidCode.push(` class ${toolId} tool`);
}
// --- Connections ---
mermaidCode.push(`\n %% -- Connections --`);
for (const agent of agents) {
const currentAgentId = sanitizeId(agent.name);
const instructions = agent.instructions || "";
const calledAgents = new Set<string>();
let match: RegExpExecArray | null;
while ((match = agentMentionPattern.exec(instructions))) {
calledAgents.add(match[1]);
}
for (const calledAgent of Array.from(calledAgents)) {
const calledAgentId = sanitizeId(calledAgent);
mermaidCode.push(` ${currentAgentId} -- \"delegates to\" --> ${calledAgentId}`);
}
const calledTools = new Set<string>();
while ((match = toolMentionPattern.exec(instructions))) {
calledTools.add(match[1]);
}
for (const calledTool of Array.from(calledTools)) {
const calledToolId = sanitizeId(calledTool);
mermaidCode.push(` ${currentAgentId} -- \"uses\" --> ${calledToolId}`);
}
}
return mermaidCode.join("\n");
}
function getCssVarValue(varName: string, fallback: string) {
if (typeof window === 'undefined') return fallback;
let value = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
// If the value looks like HSL (e.g. '0 0% 9%' or '0 0% 3.9%' or '0 0% 9% / 1'), wrap it in hsl()
if (/^[\d.]+\s+[\d.]+%\s+[\d.]+%(\s*\/\s*[\d.]+)?$/.test(value)) {
value = `hsl(${value})`;
}
return value || fallback;
}
export const AgentGraphVisualizer = ({ workflow }: { workflow: any }) => {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (ref.current && workflow) {
// Only check theme on mount/render
const isDark = document.documentElement.classList.contains('dark');
mermaid.initialize({
startOnLoad: true,
theme: isDark ? 'dark' : 'default',
themeVariables: {
background: getCssVarValue('--background', isDark ? '#18181b' : '#fff'),
primaryColor: isDark ? '#a78bfa' : getCssVarValue('--primary', '#4f46e5'),
primaryTextColor: isDark ? '#fff' : getCssVarValue('--foreground', '#18181b'),
fontSize: '20px',
nodeTextColor: isDark ? '#fff' : getCssVarValue('--foreground', '#18181b'),
edgeLabelBackground: isDark ? 'transparent' : getCssVarValue('--background', '#fff'),
clusterBkg: getCssVarValue('--background', isDark ? '#18181b' : '#fff'),
clusterBorder: isDark ? '#a78bfa' : getCssVarValue('--border', '#e5e7eb'),
lineColor: isDark ? '#a78bfa' : '#6366f1',
arrowheadColor: isDark ? '#a78bfa' : '#6366f1',
},
});
ref.current.innerHTML = generateMermaidFromWorkflow(workflow, isDark);
ref.current.className = "mermaid";
mermaid.init(undefined, ref.current);
}
}, [workflow]);
// Center the graph vertically and horizontally
return (
<div
style={{
width: "100%",
height: "100%",
minHeight: 0,
background: "var(--background)",
display: "flex",
alignItems: "flex-start",
justifyContent: "center",
overflow: "auto",
padding: "16px",
}}
>
<div
ref={ref}
style={{
width: "100%",
height: "fit-content",
minHeight: 0,
fontSize: 20,
}}
/>
</div>
);
};

View file

@ -2,7 +2,7 @@ import { z } from "zod";
import { WorkflowPrompt, WorkflowAgent, WorkflowTool } from "../../../lib/types/workflow_types";
import { Dropdown, DropdownItem, DropdownTrigger, DropdownMenu } from "@heroui/react";
import { useRef, useEffect, useState } from "react";
import { EllipsisVerticalIcon, ImportIcon, PlusIcon, Brain, Boxes, Wrench, PenLine, Library, ChevronDown, ChevronRight, ServerIcon, Component, ScrollText, GripVertical, Users, Cog, CheckCircle2 } from "lucide-react";
import { EllipsisVerticalIcon, ImportIcon, PlusIcon, Brain, Boxes, Wrench, PenLine, Library, ChevronDown, ChevronRight, ServerIcon, Component, ScrollText, GripVertical, Users, Cog, CheckCircle2, Eye } from "lucide-react";
import { DndContext, DragEndEvent, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
import { SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
@ -36,7 +36,7 @@ interface EntityListProps {
projectTools: z.infer<typeof WorkflowTool>[];
prompts: z.infer<typeof WorkflowPrompt>[];
selectedEntity: {
type: "agent" | "tool" | "prompt";
type: "agent" | "tool" | "prompt" | "visualise";
name: string;
} | null;
startAgentName: string | null;
@ -51,6 +51,7 @@ interface EntityListProps {
onDeleteAgent: (name: string) => void;
onDeleteTool: (name: string) => void;
onDeletePrompt: (name: string) => void;
onShowVisualise: (name: string) => void;
}
interface EmptyStateProps {
@ -99,7 +100,7 @@ const ListItemWithMenu = ({
)}>
{dragHandle}
<button
ref={selectedRef}
ref={selectedRef as React.RefObject<HTMLButtonElement>}
className={clsx(
"flex-1 flex items-center gap-2 text-sm text-left",
{
@ -143,7 +144,7 @@ interface ServerCardProps {
serverName: string;
tools: z.infer<typeof WorkflowTool>[];
selectedEntity: {
type: "agent" | "tool" | "prompt";
type: "agent" | "tool" | "prompt" | "visualise";
name: string;
} | null;
onSelectTool: (name: string) => void;
@ -233,6 +234,7 @@ export function EntityList({
onDeletePrompt,
projectId,
onReorderAgents,
onShowVisualise,
}: EntityListProps & {
projectId: string,
onReorderAgents: (agents: z.infer<typeof WorkflowAgent>[]) => void
@ -246,7 +248,7 @@ export function EntityList({
};
// Merge workflow tools with project tools
const mergedTools = [...tools, ...projectTools];
const selectedRef = useRef<HTMLButtonElement | null>(null);
const selectedRef = useRef<HTMLButtonElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [containerHeight, setContainerHeight] = useState<number>(0);
@ -390,20 +392,35 @@ export function EntityList({
<Brain className="w-4 h-4" />
<span>Agents</span>
</div>
<Button
variant="secondary"
size="sm"
onClick={(e) => {
e.stopPropagation();
setExpandedPanels(prev => ({ ...prev, agents: true }));
setShowAgentTypeModal(true);
}}
className={`group ${buttonClasses}`}
showHoverContent={true}
hoverContent="Add Agent"
>
<PlusIcon className="w-4 h-4" />
</Button>
<div className="flex items-center gap-1">
<Button
variant="secondary"
size="sm"
onClick={(e) => {
e.stopPropagation();
onShowVisualise("visualise");
}}
className={`group ${buttonClasses}`}
showHoverContent={true}
hoverContent="Visualise Agents"
>
<Eye className="w-4 h-4" />
</Button>
<Button
variant="secondary"
size="sm"
onClick={(e) => {
e.stopPropagation();
setExpandedPanels(prev => ({ ...prev, agents: true }));
setShowAgentTypeModal(true);
}}
className={`group ${buttonClasses}`}
showHoverContent={true}
hoverContent="Add Agent"
>
<PlusIcon className="w-4 h-4" />
</Button>
</div>
</button>
}
>
@ -743,7 +760,7 @@ function EntityDropdown({
interface ComposioCardProps {
card: ComposioToolkit;
selectedEntity: {
type: "agent" | "tool" | "prompt";
type: "agent" | "tool" | "prompt" | "visualise";
name: string;
} | null;
onSelectTool: (name: string) => void;

View file

@ -23,10 +23,13 @@ import { Copilot } from "../copilot/app";
import { publishWorkflow, renameWorkflow, saveWorkflow } from "../../../actions/workflow_actions";
import { PublishedBadge } from "./published_badge";
import { BackIcon, HamburgerIcon, WorkflowIcon } from "../../../lib/components/icons";
import { CopyIcon, ImportIcon, Layers2Icon, RadioIcon, RedoIcon, ServerIcon, Sparkles, UndoIcon, RocketIcon, PenLine, AlertTriangle, DownloadIcon } from "lucide-react";
import { CopyIcon, ImportIcon, Layers2Icon, RadioIcon, RedoIcon, ServerIcon, Sparkles, UndoIcon, RocketIcon, PenLine, AlertTriangle, DownloadIcon, XIcon } from "lucide-react";
import { EntityList } from "./entity_list";
import { ProductTour } from "@/components/common/product-tour";
import { ModelsResponse } from "@/app/lib/types/billing_types";
import { AgentGraphVisualizer } from "../entities/AgentGraphVisualizer";
import { Panel } from "@/components/common/panel-common";
import { Button as CustomButton } from "@/components/ui/button";
enablePatches();
@ -41,7 +44,7 @@ interface StateItem {
publishedWorkflowId: string | null;
publishing: boolean;
selection: {
type: "agent" | "tool" | "prompt";
type: "agent" | "tool" | "prompt" | "visualise";
name: string;
} | null;
saving: boolean;
@ -138,6 +141,10 @@ export type Action = {
} | {
type: "reorder_agents";
agents: z.infer<typeof WorkflowAgent>[];
} | {
type: "show_visualise";
} | {
type: "hide_visualise";
};
function reducer(state: State, action: Action): State {
@ -232,6 +239,18 @@ function reducer(state: State, action: Action): State {
currentIndex: state.currentIndex + 1,
};
}
case "show_visualise": {
newState = produce(state, draft => {
draft.present.selection = { type: "visualise", name: "visualise" };
});
break;
}
case "hide_visualise": {
newState = produce(state, draft => {
draft.present.selection = null;
});
break;
}
default: {
const [nextState, patches, inversePatches] = produceWithPatches(
state.present,
@ -700,6 +719,14 @@ export function WorkflowEditor({
function handleUnselectPrompt() {
dispatch({ type: "unselect_prompt" });
}
function handleShowVisualise() {
dispatch({ type: "show_visualise" });
}
function handleHideVisualise() {
dispatch({ type: "hide_visualise" });
}
function handleAddAgent(agent: Partial<z.infer<typeof WorkflowAgent>> = {}) {
const agentWithModel = {
@ -993,7 +1020,14 @@ export function WorkflowEditor({
tools={state.present.workflow.tools}
projectTools={projectTools}
prompts={state.present.workflow.prompts}
selectedEntity={state.present.selection}
selectedEntity={
state.present.selection &&
(state.present.selection.type === "agent" ||
state.present.selection.type === "tool" ||
state.present.selection.type === "prompt")
? state.present.selection
: null
}
startAgentName={state.present.workflow.startAgent}
onSelectAgent={handleSelectAgent}
onSelectTool={handleSelectTool}
@ -1006,6 +1040,7 @@ export function WorkflowEditor({
onDeleteAgent={handleDeleteAgent}
onDeleteTool={handleDeleteTool}
onDeletePrompt={handleDeletePrompt}
onShowVisualise={handleShowVisualise}
projectId={state.present.workflow.projectId}
onReorderAgents={handleReorderAgents}
/>
@ -1074,6 +1109,30 @@ export function WorkflowEditor({
handleUpdate={handleUpdatePrompt.bind(null, state.present.selection.name)}
handleClose={handleUnselectPrompt}
/>}
{state.present.selection?.type === "visualise" && (
<Panel
title={
<div className="flex items-center justify-between w-full">
<div className="text-base font-semibold text-gray-900 dark:text-gray-100">
Agent Graph Visualizer
</div>
<CustomButton
variant="secondary"
size="sm"
onClick={handleHideVisualise}
showHoverContent={true}
hoverContent="Close"
>
<XIcon className="w-4 h-4" />
</CustomButton>
</div>
}
>
<div className="h-full overflow-hidden">
<AgentGraphVisualizer workflow={state.present.workflow} />
</div>
</Panel>
)}
</ResizablePanel>
{showCopilot && (
<>
@ -1089,13 +1148,17 @@ export function WorkflowEditor({
workflow={state.present.workflow}
dispatch={dispatch}
chatContext={
state.present.selection ? {
type: state.present.selection.type,
name: state.present.selection.name
} : chatMessages.length > 0 ? {
type: 'chat',
messages: chatMessages
} : undefined
state.present.selection &&
(state.present.selection.type === "agent" ||
state.present.selection.type === "tool" ||
state.present.selection.type === "prompt")
? {
type: state.present.selection.type,
name: state.present.selection.name
}
: chatMessages.length > 0
? { type: 'chat', messages: chatMessages }
: undefined
}
isInitialState={isInitialState}
dataSources={dataSources}

File diff suppressed because it is too large Load diff

View file

@ -50,6 +50,7 @@
"ioredis": "^5.6.1",
"jose": "^5.9.6",
"lucide-react": "^0.465.0",
"mermaid": "^11.8.1",
"mongodb": "^6.8.0",
"next": "15.3.4",
"openai": "^4.67.2",