Spaces:
Running
Running
| import { useState } from "react"; | |
| import { | |
| CheckCircle2, | |
| Layout, | |
| Save, | |
| FolderOpen, | |
| FilePlus, | |
| Download, | |
| Upload, | |
| PlayCircle, | |
| Flag, | |
| Trash2, | |
| BookTemplate, | |
| } from "lucide-react"; | |
| import { Button } from "@/components/ui/button"; | |
| import { Input } from "@/components/ui/input"; | |
| import { Separator } from "@/components/ui/separator"; | |
| import { | |
| Dialog, | |
| DialogContent, | |
| DialogHeader, | |
| DialogTitle, | |
| DialogDescription, | |
| } from "@/components/ui/dialog"; | |
| import { ScrollArea } from "@/components/ui/scroll-area"; | |
| import { Badge } from "@/components/ui/badge"; | |
| import { useGraphStore, type AgentNodeData, type GraphTemplate } from "@/stores/graphStore"; | |
| import type { GraphListItem } from "@/types/graph"; | |
| // ── Predefined graph templates ────────────────────────────────────────── | |
| const GRAPH_TEMPLATES: GraphTemplate[] = [ | |
| { | |
| name: "Linear Pipeline", | |
| description: "Sequential: Researcher -> Writer -> Reviewer", | |
| agents: [ | |
| { agent_id: "researcher", display_name: "Researcher", persona: "Expert researcher", description: "Gathers information", tools: ["web_search"] }, | |
| { agent_id: "writer", display_name: "Writer", persona: "Technical writer", description: "Writes content", tools: [] }, | |
| { agent_id: "reviewer", display_name: "Reviewer", persona: "Quality reviewer", description: "Reviews output", tools: [] }, | |
| ], | |
| edges: [ | |
| { source: "researcher", target: "writer", weight: 1.0, condition: null }, | |
| { source: "writer", target: "reviewer", weight: 1.0, condition: null }, | |
| ], | |
| start_node: "researcher", | |
| end_node: "reviewer", | |
| }, | |
| { | |
| name: "Fan-Out / Fan-In", | |
| description: "Planner fans out to parallel workers, Aggregator collects results", | |
| agents: [ | |
| { agent_id: "planner", display_name: "Planner", persona: "Task planner", description: "Breaks task into subtasks", tools: [] }, | |
| { agent_id: "worker_a", display_name: "Worker A", persona: "Specialist A", description: "Handles subtask A", tools: [] }, | |
| { agent_id: "worker_b", display_name: "Worker B", persona: "Specialist B", description: "Handles subtask B", tools: [] }, | |
| { agent_id: "aggregator", display_name: "Aggregator", persona: "Synthesizer", description: "Combines results", tools: [] }, | |
| ], | |
| edges: [ | |
| { source: "planner", target: "worker_a", weight: 1.0, condition: null }, | |
| { source: "planner", target: "worker_b", weight: 1.0, condition: null }, | |
| { source: "worker_a", target: "aggregator", weight: 1.0, condition: null }, | |
| { source: "worker_b", target: "aggregator", weight: 1.0, condition: null }, | |
| ], | |
| start_node: "planner", | |
| end_node: "aggregator", | |
| }, | |
| { | |
| name: "Conditional Branching", | |
| description: "Writer outputs, conditional edges route to Editor or Fixer based on response", | |
| agents: [ | |
| { agent_id: "writer", display_name: "Writer", persona: "Content writer", description: "Generates draft", tools: [] }, | |
| { agent_id: "editor", display_name: "Editor", persona: "Editor", description: "Polishes good drafts", tools: [] }, | |
| { agent_id: "fixer", display_name: "Fixer", persona: "Error corrector", description: "Fixes problematic drafts", tools: [] }, | |
| { agent_id: "publisher", display_name: "Publisher", persona: "Publisher", description: "Publishes final version", tools: [] }, | |
| ], | |
| edges: [ | |
| { source: "writer", target: "editor", weight: 0.9, condition: "contains:APPROVED" }, | |
| { source: "writer", target: "fixer", weight: 0.7, condition: "source_failed" }, | |
| { source: "editor", target: "publisher", weight: 1.0, condition: null }, | |
| { source: "fixer", target: "publisher", weight: 1.0, condition: null }, | |
| ], | |
| start_node: "writer", | |
| end_node: "publisher", | |
| }, | |
| { | |
| name: "Review Loop", | |
| description: "Coder writes code, Reviewer checks quality; conditional edges route back or forward", | |
| agents: [ | |
| { agent_id: "planner", display_name: "Planner", persona: "Architect", description: "Plans the approach", tools: [] }, | |
| { agent_id: "coder", display_name: "Coder", persona: "Software developer", description: "Writes code", tools: ["code_interpreter"] }, | |
| { agent_id: "reviewer", display_name: "Reviewer", persona: "Code reviewer", description: "Reviews code quality", tools: [] }, | |
| { agent_id: "finalizer", display_name: "Finalizer", persona: "Integrator", description: "Delivers final result", tools: [] }, | |
| ], | |
| edges: [ | |
| { source: "planner", target: "coder", weight: 1.0, condition: null }, | |
| { source: "coder", target: "reviewer", weight: 1.0, condition: null }, | |
| { source: "reviewer", target: "finalizer", weight: 0.9, condition: "contains:LGTM" }, | |
| { source: "reviewer", target: "coder", weight: 0.5, condition: "contains:REVISE" }, | |
| ], | |
| start_node: "planner", | |
| end_node: "finalizer", | |
| }, | |
| { | |
| name: "Diamond with Weights", | |
| description: "Dispatcher -> two parallel paths with different weights -> Merger", | |
| agents: [ | |
| { agent_id: "start", display_name: "Dispatcher", persona: "Task dispatcher", description: "Dispatches task", tools: [] }, | |
| { agent_id: "fast_path", display_name: "Fast Path", persona: "Quick processor", description: "Fast but less thorough", tools: [] }, | |
| { agent_id: "deep_path", display_name: "Deep Path", persona: "Deep analyzer", description: "Slow but thorough", tools: ["web_search"] }, | |
| { agent_id: "merger", display_name: "Merger", persona: "Result merger", description: "Merges results", tools: [] }, | |
| ], | |
| edges: [ | |
| { source: "start", target: "fast_path", weight: 0.6, condition: null }, | |
| { source: "start", target: "deep_path", weight: 1.0, condition: null }, | |
| { source: "fast_path", target: "merger", weight: 0.6, condition: null }, | |
| { source: "deep_path", target: "merger", weight: 1.0, condition: null }, | |
| ], | |
| start_node: "start", | |
| end_node: "merger", | |
| }, | |
| ]; | |
| interface GraphToolbarProps { | |
| onValidate: () => void; | |
| onRun: () => void; | |
| isRunning: boolean; | |
| } | |
| export function GraphToolbar({ onValidate, onRun, isRunning }: GraphToolbarProps) { | |
| const { | |
| graphName, | |
| setGraphName, | |
| autoLayout, | |
| saveGraph, | |
| loadGraph, | |
| newGraph, | |
| loadTemplate, | |
| fetchSavedGraphs, | |
| savedGraphs, | |
| nodes, | |
| validationErrors, | |
| validationWarnings, | |
| setStartNode, | |
| setEndNode, | |
| startNode, | |
| endNode, | |
| toGraphRequest, | |
| } = useGraphStore(); | |
| const [saving, setSaving] = useState(false); | |
| const [loadOpen, setLoadOpen] = useState(false); | |
| const [startEndOpen, setStartEndOpen] = useState(false); | |
| const [templatesOpen, setTemplatesOpen] = useState(false); | |
| const handleSave = async () => { | |
| setSaving(true); | |
| try { | |
| await saveGraph(); | |
| } catch { | |
| // handle error | |
| } finally { | |
| setSaving(false); | |
| } | |
| }; | |
| const handleLoadOpen = async () => { | |
| await fetchSavedGraphs(); | |
| setLoadOpen(true); | |
| }; | |
| const handleLoad = async (graph: GraphListItem) => { | |
| await loadGraph(graph.graph_id); | |
| setLoadOpen(false); | |
| }; | |
| const handleExport = () => { | |
| const data = toGraphRequest(); | |
| const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement("a"); | |
| a.href = url; | |
| a.download = `${graphName.replace(/\s+/g, "-").toLowerCase()}.json`; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| }; | |
| const handleImport = () => { | |
| const input = document.createElement("input"); | |
| input.type = "file"; | |
| input.accept = ".json"; | |
| input.onchange = async (e) => { | |
| const file = (e.target as HTMLInputElement).files?.[0]; | |
| if (!file) return; | |
| const text = await file.text(); | |
| try { | |
| const data = JSON.parse(text); | |
| if (data.name) setGraphName(data.name); | |
| } catch { | |
| // invalid JSON | |
| } | |
| }; | |
| input.click(); | |
| }; | |
| const handleLoadTemplate = (template: GraphTemplate) => { | |
| loadTemplate(template); | |
| setTemplatesOpen(false); | |
| }; | |
| return ( | |
| <> | |
| <div className="flex items-center gap-2 border-b bg-card px-3 py-2"> | |
| <Input | |
| value={graphName} | |
| onChange={(e) => setGraphName(e.target.value)} | |
| className="h-8 w-48 text-sm font-medium" | |
| /> | |
| <Separator orientation="vertical" className="h-6" /> | |
| <Button variant="outline" size="sm" onClick={onValidate} className="gap-1"> | |
| <CheckCircle2 className="h-3.5 w-3.5" /> | |
| Validate | |
| </Button> | |
| {validationErrors.length > 0 && ( | |
| <Badge variant="destructive" className="text-xs"> | |
| {validationErrors.length} error{validationErrors.length > 1 ? "s" : ""} | |
| </Badge> | |
| )} | |
| {validationWarnings.length > 0 && ( | |
| <Badge variant="secondary" className="text-xs"> | |
| {validationWarnings.length} warning{validationWarnings.length > 1 ? "s" : ""} | |
| </Badge> | |
| )} | |
| <Button variant="outline" size="sm" onClick={autoLayout} className="gap-1"> | |
| <Layout className="h-3.5 w-3.5" /> | |
| Auto Layout | |
| </Button> | |
| <Button variant="outline" size="sm" onClick={() => setStartEndOpen(true)} className="gap-1"> | |
| <Flag className="h-3.5 w-3.5" /> | |
| Start/End | |
| </Button> | |
| <Separator orientation="vertical" className="h-6" /> | |
| <Button variant="outline" size="sm" onClick={handleSave} disabled={saving} className="gap-1"> | |
| <Save className="h-3.5 w-3.5" /> | |
| {saving ? "Saving..." : "Save"} | |
| </Button> | |
| <Button variant="outline" size="sm" onClick={handleLoadOpen} className="gap-1"> | |
| <FolderOpen className="h-3.5 w-3.5" /> | |
| Load | |
| </Button> | |
| <Button variant="outline" size="sm" onClick={() => setTemplatesOpen(true)} className="gap-1"> | |
| <BookTemplate className="h-3.5 w-3.5" /> | |
| Templates | |
| </Button> | |
| <Button variant="outline" size="sm" onClick={newGraph} className="gap-1"> | |
| <FilePlus className="h-3.5 w-3.5" /> | |
| New | |
| </Button> | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| onClick={() => { if (nodes.length === 0 || window.confirm("Clear all nodes and edges?")) newGraph(); }} | |
| disabled={nodes.length === 0} | |
| className="gap-1 text-destructive hover:text-destructive" | |
| > | |
| <Trash2 className="h-3.5 w-3.5" /> | |
| Clear | |
| </Button> | |
| <Button variant="ghost" size="icon" className="h-8 w-8" onClick={handleExport}> | |
| <Download className="h-3.5 w-3.5" /> | |
| </Button> | |
| <Button variant="ghost" size="icon" className="h-8 w-8" onClick={handleImport}> | |
| <Upload className="h-3.5 w-3.5" /> | |
| </Button> | |
| <div className="flex-1" /> | |
| <Button onClick={onRun} disabled={isRunning || nodes.length === 0} className="gap-1"> | |
| <PlayCircle className="h-4 w-4" /> | |
| {isRunning ? "Running..." : "Execute"} | |
| </Button> | |
| </div> | |
| {/* Load dialog */} | |
| <Dialog open={loadOpen} onOpenChange={setLoadOpen}> | |
| <DialogContent> | |
| <DialogHeader> | |
| <DialogTitle>Load Workflow</DialogTitle> | |
| <DialogDescription>Select a saved workflow to load.</DialogDescription> | |
| </DialogHeader> | |
| <ScrollArea className="max-h-[400px]"> | |
| {savedGraphs.length === 0 ? ( | |
| <p className="p-4 text-center text-sm text-muted-foreground">No saved workflows.</p> | |
| ) : ( | |
| <div className="space-y-2 p-1"> | |
| {savedGraphs.map((g) => ( | |
| <div | |
| key={g.graph_id} | |
| className="flex items-center justify-between rounded-md border p-3 cursor-pointer hover:bg-accent" | |
| onClick={() => handleLoad(g)} | |
| > | |
| <div> | |
| <div className="font-medium text-sm">{g.name}</div> | |
| <div className="text-xs text-muted-foreground"> | |
| {g.agent_count} agents, {g.edge_count} edges | |
| </div> | |
| </div> | |
| <div className="text-xs text-muted-foreground"> | |
| {g.updated_at ? new Date(g.updated_at).toLocaleDateString() : ""} | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| </ScrollArea> | |
| </DialogContent> | |
| </Dialog> | |
| {/* Templates dialog */} | |
| <Dialog open={templatesOpen} onOpenChange={setTemplatesOpen}> | |
| <DialogContent className="sm:max-w-[520px]"> | |
| <DialogHeader> | |
| <DialogTitle>Graph Templates</DialogTitle> | |
| <DialogDescription>Start from a predefined topology with agents and conditional edges.</DialogDescription> | |
| </DialogHeader> | |
| <ScrollArea className="max-h-[400px]"> | |
| <div className="space-y-2 p-1"> | |
| {GRAPH_TEMPLATES.map((t) => ( | |
| <div | |
| key={t.name} | |
| className="rounded-md border p-3 cursor-pointer hover:bg-accent transition-colors" | |
| onClick={() => handleLoadTemplate(t)} | |
| > | |
| <div className="font-medium text-sm">{t.name}</div> | |
| <div className="text-xs text-muted-foreground mt-0.5">{t.description}</div> | |
| <div className="flex gap-2 mt-1.5"> | |
| <Badge variant="secondary" className="text-[10px]"> | |
| {t.agents.length} agents | |
| </Badge> | |
| <Badge variant="secondary" className="text-[10px]"> | |
| {t.edges.length} edges | |
| </Badge> | |
| {t.edges.some((e) => e.condition) && ( | |
| <Badge variant="outline" className="text-[10px] text-indigo-600"> | |
| conditional | |
| </Badge> | |
| )} | |
| {t.edges.some((e) => e.weight < 1.0) && ( | |
| <Badge variant="outline" className="text-[10px] text-amber-600"> | |
| weighted | |
| </Badge> | |
| )} | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| </ScrollArea> | |
| </DialogContent> | |
| </Dialog> | |
| {/* Start/End node dialog */} | |
| <Dialog open={startEndOpen} onOpenChange={setStartEndOpen}> | |
| <DialogContent> | |
| <DialogHeader> | |
| <DialogTitle>Set Start/End Nodes</DialogTitle> | |
| <DialogDescription> | |
| Select which agent starts and ends the workflow execution. | |
| </DialogDescription> | |
| </DialogHeader> | |
| <div className="space-y-4"> | |
| <div className="space-y-2"> | |
| <label className="text-sm font-medium">Start Node</label> | |
| <div className="space-y-1"> | |
| {nodes.map((n) => ( | |
| <div | |
| key={n.id} | |
| className={`flex items-center gap-2 rounded-md border p-2 cursor-pointer transition-colors ${ | |
| startNode === n.id ? "border-green-500 bg-green-50 dark:bg-green-950" : "hover:bg-accent" | |
| }`} | |
| onClick={() => setStartNode(startNode === n.id ? null : n.id)} | |
| > | |
| <span className="text-sm">{(n.data as unknown as AgentNodeData).displayName}</span> | |
| {startNode === n.id && <Badge className="ml-auto text-xs">START</Badge>} | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| <div className="space-y-2"> | |
| <label className="text-sm font-medium">End Node</label> | |
| <div className="space-y-1"> | |
| {nodes.map((n) => ( | |
| <div | |
| key={n.id} | |
| className={`flex items-center gap-2 rounded-md border p-2 cursor-pointer transition-colors ${ | |
| endNode === n.id ? "border-orange-500 bg-orange-50 dark:bg-orange-950" : "hover:bg-accent" | |
| }`} | |
| onClick={() => setEndNode(endNode === n.id ? null : n.id)} | |
| > | |
| <span className="text-sm">{(n.data as unknown as AgentNodeData).displayName}</span> | |
| {endNode === n.id && <Badge className="ml-auto text-xs">END</Badge>} | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| </div> | |
| </DialogContent> | |
| </Dialog> | |
| </> | |
| ); | |
| } | |