mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-03 12:22:38 +02:00
add visualiser for agents
This commit is contained in:
parent
6895e54425
commit
d19e84815d
5 changed files with 1582 additions and 34 deletions
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
1291
apps/rowboat/package-lock.json
generated
1291
apps/rowboat/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue