gMAS / web_ui /frontend /src /components /graph /GraphToolbar.tsx
Артём Боярских
fix: fixed some bugs
81f5c1c
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>
</>
);
}