diff --git a/ui/package-lock.json b/ui/package-lock.json
index bc603d81a..29661532c 100644
--- a/ui/package-lock.json
+++ b/ui/package-lock.json
@@ -28,6 +28,7 @@
"@radix-ui/react-tooltip": "^1.2.7",
"@tailwindcss/typography": "^0.5.16",
"@types/uuid": "^10.0.0",
+ "@xyflow/react": "^12.9.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -4269,6 +4270,55 @@
"integrity": "sha512-FYKQLvCsRYxZ3fp+XsoCiJZ1aK3x17RmaZjHI4Ou43khFkXPycrQaXo9b1J07PNlEfWnRtUc9loxHXzKjSsbYg==",
"dev": true
},
+ "node_modules/@types/d3-color": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
+ "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-drag": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
+ "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-interpolate": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
+ "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-color": "*"
+ }
+ },
+ "node_modules/@types/d3-selection": {
+ "version": "3.0.11",
+ "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
+ "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-transition": {
+ "version": "3.0.9",
+ "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
+ "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-zoom": {
+ "version": "3.0.8",
+ "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
+ "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-interpolate": "*",
+ "@types/d3-selection": "*"
+ }
+ },
"node_modules/@types/debug": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
@@ -4872,6 +4922,66 @@
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
"license": "ISC"
},
+ "node_modules/@xyflow/react": {
+ "version": "12.9.2",
+ "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.9.2.tgz",
+ "integrity": "sha512-Xr+LFcysHCCoc5KRHaw+FwbqbWYxp9tWtk1mshNcqy25OAPuaKzXSdqIMNOA82TIXF/gFKo0Wgpa6PU7wUUVqw==",
+ "license": "MIT",
+ "dependencies": {
+ "@xyflow/system": "0.0.72",
+ "classcat": "^5.0.3",
+ "zustand": "^4.4.0"
+ },
+ "peerDependencies": {
+ "react": ">=17",
+ "react-dom": ">=17"
+ }
+ },
+ "node_modules/@xyflow/react/node_modules/zustand": {
+ "version": "4.5.7",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
+ "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
+ "license": "MIT",
+ "dependencies": {
+ "use-sync-external-store": "^1.2.2"
+ },
+ "engines": {
+ "node": ">=12.7.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=16.8",
+ "immer": ">=9.0.6",
+ "react": ">=16.8"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@xyflow/system": {
+ "version": "0.0.72",
+ "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.72.tgz",
+ "integrity": "sha512-WBI5Aau0fXTXwxHPzceLNS6QdXggSWnGjDtj/gG669crApN8+SCmEtkBth1m7r6pStNo/5fI9McEi7Dk0ymCLA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-drag": "^3.0.7",
+ "@types/d3-interpolate": "^3.0.4",
+ "@types/d3-selection": "^3.0.10",
+ "@types/d3-transition": "^3.0.8",
+ "@types/d3-zoom": "^3.0.8",
+ "d3-drag": "^3.0.0",
+ "d3-interpolate": "^3.0.1",
+ "d3-selection": "^3.0.0",
+ "d3-zoom": "^3.0.0"
+ }
+ },
"node_modules/abab": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz",
@@ -6009,6 +6119,12 @@
"url": "https://polar.sh/cva"
}
},
+ "node_modules/classcat": {
+ "version": "5.0.5",
+ "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
+ "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
+ "license": "MIT"
+ },
"node_modules/clean-stack": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
@@ -6609,6 +6725,111 @@
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
+ "node_modules/d3-color": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
+ "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-dispatch": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
+ "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-drag": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
+ "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-selection": "3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-ease": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
+ "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-interpolate": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+ "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-selection": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
+ "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-timer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
+ "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-transition": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
+ "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3",
+ "d3-dispatch": "1 - 3",
+ "d3-ease": "1 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-timer": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "peerDependencies": {
+ "d3-selection": "2 - 3"
+ }
+ },
+ "node_modules/d3-zoom": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
+ "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-drag": "2 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-selection": "2 - 3",
+ "d3-transition": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@@ -16172,6 +16393,15 @@
}
}
},
+ "node_modules/use-sync-external-store": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
diff --git a/ui/package.json b/ui/package.json
index 82a3d9b4c..f1fece9d8 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -33,6 +33,7 @@
"@radix-ui/react-tooltip": "^1.2.7",
"@tailwindcss/typography": "^0.5.16",
"@types/uuid": "^10.0.0",
+ "@xyflow/react": "^12.9.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
diff --git a/ui/src/app/actions/agents.ts b/ui/src/app/actions/agents.ts
index 8ef821799..bb96cb146 100644
--- a/ui/src/app/actions/agents.ts
+++ b/ui/src/app/actions/agents.ts
@@ -1,10 +1,9 @@
"use server";
-import { AgentSpec, BaseResponse } from "@/types";
+import { AgentSpec, BaseResponse, AgentFormData } from "@/types";
import { Agent, AgentResponse, Tool } from "@/types";
import { revalidatePath } from "next/cache";
import { fetchApi, createErrorResponse } from "./utils";
-import { AgentFormData } from "@/components/AgentsProvider";
import { isMcpTool } from "@/lib/toolUtils";
import { k8sRefUtils } from "@/lib/k8sUtils";
diff --git a/ui/src/app/agents/new/page.tsx b/ui/src/app/agents/new/page.tsx
index a7571b06b..f01f66b2f 100644
--- a/ui/src/app/agents/new/page.tsx
+++ b/ui/src/app/agents/new/page.tsx
@@ -5,7 +5,7 @@ import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Loader2, Settings2, PlusCircle, Trash2 } from "lucide-react";
-import { ModelConfig, AgentType } from "@/types";
+import { ModelConfig, AgentType, AgentFormData } from "@/types";
import { SystemPromptSection } from "@/components/create/SystemPromptSection";
import { ModelSelectionSection } from "@/components/create/ModelSelectionSection";
import { ToolsSection } from "@/components/create/ToolsSection";
@@ -14,13 +14,15 @@ import { useAgents } from "@/components/AgentsProvider";
import { LoadingState } from "@/components/LoadingState";
import { ErrorState } from "@/components/ErrorState";
import KagentLogo from "@/components/kagent-logo";
-import { AgentFormData } from "@/components/AgentsProvider";
import { Tool, EnvVar } from "@/types";
import { toast } from "sonner";
import { NamespaceCombobox } from "@/components/NamespaceCombobox";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { VisualAgentBuilder } from "@/components/agent-builder/VisualAgentBuilder";
+import { BuilderModeToggle } from "@/components/agent-builder/BuilderModeToggle";
+import { VisualBuilderErrorBoundary } from "@/components/agent-builder/ErrorBoundary";
interface ValidationErrors {
name?: string;
@@ -99,6 +101,23 @@ function AgentPageContent({ isEditMode, agentName, agentNamespace }: AgentPageCo
errors: {},
});
+ // Builder mode state (only for non-edit mode)
+ const [builderMode, setBuilderMode] = useState<'form' | 'visual'>('form');
+
+ // Hide footer when in visual builder mode
+ useEffect(() => {
+ if (builderMode === 'visual' && !isEditMode) {
+ document.body.classList.add('hide-footer');
+ } else {
+ document.body.classList.remove('hide-footer');
+ }
+
+ // Cleanup on unmount
+ return () => {
+ document.body.classList.remove('hide-footer');
+ };
+ }, [builderMode, isEditMode]);
+
// Fetch existing agent data if in edit mode
useEffect(() => {
const fetchAgentData = async () => {
@@ -305,10 +324,56 @@ function AgentPageContent({ isEditMode, agentName, agentNamespace }: AgentPageCo
}
return (
-
-
-
{isEditMode ? "Edit Agent" : "Create New Agent"}
+
+
+ {/* Header with title and builder mode toggle */}
+
+
{isEditMode ? "Edit Agent" : "Create New Agent"}
+ {!isEditMode && (
+
+ )}
+
+ {builderMode === 'visual' && !isEditMode ? (
+
+ {
+ setState(prev => ({ ...prev, errors }));
+ }}
+ onGraphDataChange={(graphData) => {
+ // Sync visual builder changes to form state
+ setState(prev => ({
+ ...prev,
+ name: graphData.name,
+ namespace: graphData.namespace,
+ description: graphData.description,
+ systemPrompt: graphData.systemPrompt || '',
+ selectedModel: graphData.modelName ? {
+ ref: graphData.modelName,
+ model: graphData.modelName,
+ } : null,
+ selectedTools: graphData.tools || [],
+ }));
+ }}
+ initialFormData={{
+ name: state.name,
+ namespace: state.namespace,
+ description: state.description,
+ type: state.agentType,
+ systemPrompt: state.systemPrompt,
+ modelName: state.selectedModel?.ref,
+ tools: state.selectedTools,
+ stream: true,
+ }}
+ onCreateAgent={handleSaveAgent}
+ isSubmitting={state.isSubmitting}
+ />
+
+ ) : (
@@ -578,21 +643,26 @@ function AgentPageContent({ isEditMode, agentName, agentNamespace }: AgentPageCo
>
)}
-
-
-
+ )}
+
+ {/* Submit button visible only in form mode */}
+ {builderMode === 'form' && (
+
+
+
+ )}
);
diff --git a/ui/src/app/globals.css b/ui/src/app/globals.css
index a6987249f..610f2ed6e 100644
--- a/ui/src/app/globals.css
+++ b/ui/src/app/globals.css
@@ -89,4 +89,9 @@ body {
body {
@apply bg-background text-foreground;
}
+}
+
+/* Hide footer when in visual builder mode */
+body.hide-footer footer {
+ display: none !important;
}
\ No newline at end of file
diff --git a/ui/src/app/layout.tsx b/ui/src/app/layout.tsx
index 43f9c75e0..1ca5226d3 100644
--- a/ui/src/app/layout.tsx
+++ b/ui/src/app/layout.tsx
@@ -27,7 +27,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
- {children}
+ {children}
diff --git a/ui/src/components/AgentsProvider.tsx b/ui/src/components/AgentsProvider.tsx
index fcf10cd79..5966474dc 100644
--- a/ui/src/components/AgentsProvider.tsx
+++ b/ui/src/components/AgentsProvider.tsx
@@ -3,7 +3,7 @@
import React, { createContext, useContext, useState, useEffect, ReactNode, useCallback } from "react";
import { getAgent as getAgentAction, createAgent, getAgents } from "@/app/actions/agents";
import { getTools } from "@/app/actions/tools";
-import type { Agent, Tool, AgentResponse, BaseResponse, ModelConfig, ToolsResponse, AgentType, EnvVar } from "@/types";
+import type { Agent, AgentResponse, BaseResponse, ModelConfig, ToolsResponse, AgentFormData } from "@/types";
import { getModelConfigs } from "@/app/actions/modelConfigs";
import { isResourceNameValid } from "@/lib/utils";
@@ -18,30 +18,6 @@ interface ValidationErrors {
tools?: string;
}
-export interface AgentFormData {
- name: string;
- namespace: string;
- description: string;
- type?: AgentType;
- // Declarative fields
- systemPrompt?: string;
- modelName?: string;
- tools: Tool[];
- stream?: boolean;
- byoImage?: string;
- byoCmd?: string;
- byoArgs?: string[];
- // Shared deployment optional fields
- replicas?: number;
- imagePullSecrets?: Array<{ name: string }>;
- volumes?: unknown[];
- volumeMounts?: unknown[];
- labels?: Record;
- annotations?: Record;
- env?: EnvVar[];
- imagePullPolicy?: string;
-}
-
interface AgentsContextType {
agents: AgentResponse[];
models: ModelConfig[];
diff --git a/ui/src/components/agent-builder/BuilderModeToggle.tsx b/ui/src/components/agent-builder/BuilderModeToggle.tsx
new file mode 100644
index 000000000..11de6531a
--- /dev/null
+++ b/ui/src/components/agent-builder/BuilderModeToggle.tsx
@@ -0,0 +1,41 @@
+"use client";
+
+import * as React from "react";
+import { FileText, Workflow } from "lucide-react";
+
+interface BuilderModeToggleProps {
+ mode: 'form' | 'visual';
+ onModeChange: (mode: 'form' | 'visual') => void;
+ disabled?: boolean;
+}
+
+export function BuilderModeToggle({ mode, onModeChange, disabled }: BuilderModeToggleProps) {
+ return (
+
+
+
+
+ );
+}
diff --git a/ui/src/components/agent-builder/Canvas.tsx b/ui/src/components/agent-builder/Canvas.tsx
new file mode 100644
index 000000000..1e80d0e1a
--- /dev/null
+++ b/ui/src/components/agent-builder/Canvas.tsx
@@ -0,0 +1,175 @@
+"use client";
+
+import * as React from "react";
+import { ReactFlow, ConnectionLineType, Background, Controls, MiniMap } from "@xyflow/react";
+import "@xyflow/react/dist/style.css";
+import { Trash2 } from "lucide-react";
+import { Button } from "@/components/ui/button";
+
+import { BasicInfoNode } from "./nodes/BasicInfoNode";
+import { LLMNode } from "./nodes/LLMNode";
+import { SystemPromptNode } from "./nodes/SystemPromptNode";
+import { ToolNode } from "./nodes/ToolNode";
+import { OutputNode } from "./nodes/OutputNode";
+import type { CanvasProps } from "@/types";
+
+// Add custom styles for React Flow controls
+const controlsStyle = `
+ .react-flow__controls button {
+ background: white !important;
+ border: 1px solid #d1d5db !important;
+ color: #1f2937 !important;
+ }
+ .react-flow__controls button svg {
+ fill: #1f2937 !important;
+ }
+ .react-flow__controls button:hover {
+ background: #f3f4f6 !important;
+ }
+`;
+
+const nodeTypes = {
+ 'basic-info': BasicInfoNode,
+ llm: LLMNode,
+ 'system-prompt': SystemPromptNode,
+ tool: ToolNode,
+ output: OutputNode,
+} as const;
+
+export function Canvas({
+ nodes,
+ edges,
+ onNodesChange,
+ onEdgesChange,
+ onConnect,
+ onNodeSelect,
+ onNodesSelect,
+ onEdgeSelect,
+ onDelete
+}: CanvasProps) {
+ const [hasSelection, setHasSelection] = React.useState(false);
+ const prevSelectionRef = React.useRef<{ nodeIds: string[], edgeIds: string[] }>({ nodeIds: [], edgeIds: [] });
+
+ const handleSelectionChange = React.useCallback((selection: { nodes: Array<{ id: string }>, edges: Array<{ id: string }> }) => {
+ const selectedNodesList = selection.nodes || [];
+ const selectedEdgesList = selection.edges || [];
+
+ const currentNodeIds = selectedNodesList.map(n => n.id).sort();
+ const currentEdgeIds = selectedEdgesList.map(e => e.id).sort();
+
+ const prevNodeIds = prevSelectionRef.current.nodeIds;
+ const prevEdgeIds = prevSelectionRef.current.edgeIds;
+
+ // Check if selection has actually changed
+ const nodesChanged = currentNodeIds.length !== prevNodeIds.length ||
+ currentNodeIds.some((id, idx) => id !== prevNodeIds[idx]);
+ const edgesChanged = currentEdgeIds.length !== prevEdgeIds.length ||
+ currentEdgeIds.some((id, idx) => id !== prevEdgeIds[idx]);
+
+ if (!nodesChanged && !edgesChanged) {
+ return; // No change, skip update
+ }
+
+ // Update ref with current selection
+ prevSelectionRef.current = { nodeIds: currentNodeIds, edgeIds: currentEdgeIds };
+
+ // Update hasSelection state
+ setHasSelection(currentNodeIds.length > 0 || currentEdgeIds.length > 0);
+
+ // Call parent callbacks only if something changed
+ if (currentNodeIds.length > 1) {
+ // Multiple nodes selected
+ onNodesSelect(currentNodeIds);
+ onNodeSelect(null);
+ onEdgeSelect([]);
+ } else if (currentNodeIds.length === 1) {
+ // Single node selected
+ onNodeSelect(currentNodeIds[0]);
+ onNodesSelect([]);
+ onEdgeSelect([]);
+ } else if (currentEdgeIds.length > 0) {
+ // Edges selected
+ onEdgeSelect(currentEdgeIds);
+ onNodeSelect(null);
+ onNodesSelect([]);
+ } else {
+ // Nothing selected
+ onNodeSelect(null);
+ onNodesSelect([]);
+ onEdgeSelect([]);
+ }
+ }, [onNodeSelect, onNodesSelect, onEdgeSelect]);
+
+ // Safety check to prevent React errors
+ if (!nodes || !Array.isArray(nodes) || !edges || !Array.isArray(edges)) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+ {/* Floating Delete Button */}
+ {hasSelection && (
+
+
+
+ )}
+
+
+
+
+ {
+ switch (node.type) {
+ case 'basic-info': return '#6b7280';
+ case 'llm': return '#3b82f6';
+ case 'system-prompt': return '#10b981';
+ case 'tool': return '#f97316';
+ case 'output': return '#ef4444';
+ default: return '#6b7280';
+ }
+ }}
+ nodeStrokeWidth={3}
+ nodeBorderRadius={2}
+ />
+
+
+ );
+}
diff --git a/ui/src/components/agent-builder/ErrorBoundary.tsx b/ui/src/components/agent-builder/ErrorBoundary.tsx
new file mode 100644
index 000000000..b22e2e261
--- /dev/null
+++ b/ui/src/components/agent-builder/ErrorBoundary.tsx
@@ -0,0 +1,90 @@
+"use client";
+
+import React from "react";
+import { AlertTriangle } from "lucide-react";
+
+interface ErrorBoundaryState {
+ hasError: boolean;
+ error?: Error;
+}
+
+interface ErrorBoundaryProps {
+ children: React.ReactNode;
+ fallback?: React.ComponentType<{ error?: Error; resetError: () => void }>;
+}
+
+export class VisualBuilderErrorBoundary extends React.Component {
+ constructor(props: ErrorBoundaryProps) {
+ super(props);
+ this.state = { hasError: false };
+ }
+
+ static getDerivedStateFromError(error: Error): ErrorBoundaryState {
+ return { hasError: true, error };
+ }
+
+ componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
+ console.error("Visual Builder Error:", error, errorInfo);
+ }
+
+ resetError = () => {
+ this.setState({ hasError: false, error: undefined });
+ };
+
+ render() {
+ if (this.state.hasError) {
+ if (this.props.fallback) {
+ const FallbackComponent = this.props.fallback;
+ return ;
+ }
+
+ return (
+
+
+
+
Something went wrong
+
+ The visual builder encountered an error. Please try refreshing the page.
+
+
+
+
+ );
+ }
+
+ return this.props.children;
+ }
+}
+
+export function DefaultErrorFallback({ error, resetError }: { error?: Error; resetError: () => void }) {
+ return (
+
+
+
+
Visual Builder Error
+
+ {error?.message || "An unexpected error occurred in the visual builder."}
+
+
+
+
+
+
+
+ );
+}
diff --git a/ui/src/components/agent-builder/NodeLibrary.tsx b/ui/src/components/agent-builder/NodeLibrary.tsx
new file mode 100644
index 000000000..e11e222a7
--- /dev/null
+++ b/ui/src/components/agent-builder/NodeLibrary.tsx
@@ -0,0 +1,111 @@
+"use client";
+
+import * as React from "react";
+import { Info, Brain, FileText, Wrench, Send } from "lucide-react";
+import { Card, CardContent } from "@/components/ui/card";
+import type { NodeLibraryProps, NodeTypeDefinition, MVP_NODE_TYPES } from "@/types";
+
+const NODE_TYPES: NodeTypeDefinition[] = [
+ {
+ type: 'basic-info',
+ label: 'Basic Info',
+ icon: Info,
+ color: 'gray',
+ description: 'Agent metadata and configuration',
+ category: 'core'
+ },
+ {
+ type: 'llm',
+ label: 'LLM Model',
+ icon: Brain,
+ color: 'blue',
+ description: 'Configure language model and parameters',
+ category: 'core'
+ },
+ {
+ type: 'system-prompt',
+ label: 'System Prompt',
+ icon: FileText,
+ color: 'green',
+ description: 'Define agent behavior and instructions',
+ category: 'core'
+ },
+ {
+ type: 'tool',
+ label: 'Tool',
+ icon: Wrench,
+ color: 'orange',
+ description: 'Add tools and capabilities',
+ category: 'core'
+ },
+ {
+ type: 'output',
+ label: 'Output',
+ icon: Send,
+ color: 'red',
+ description: 'Format and structure responses',
+ category: 'core'
+ },
+];
+
+export function NodeLibrary({ onNodeAdd, availableTypes }: NodeLibraryProps) {
+ // Filter nodes based on available types (MVP support)
+ const filteredNodeTypes = availableTypes
+ ? NODE_TYPES.filter(nodeType => availableTypes.includes(nodeType.type))
+ : NODE_TYPES;
+
+ const handleNodeClick = (nodeType: NodeTypeDefinition) => {
+ // Add node at a default position (will be adjusted by canvas)
+ onNodeAdd(nodeType.type);
+ };
+
+ const handleKeyDown = (event: React.KeyboardEvent, nodeType: NodeTypeDefinition) => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ handleNodeClick(nodeType);
+ }
+ };
+
+ return (
+
+
+
Node Library
+
+ {filteredNodeTypes.map((nodeType) => {
+ const IconComponent = nodeType.icon;
+ return (
+
handleNodeClick(nodeType)}
+ onKeyDown={(e) => handleKeyDown(e, nodeType)}
+ tabIndex={0}
+ role="button"
+ aria-label={`Add ${nodeType.label} node`}
+ >
+
+
+
+ {nodeType.label}
+
+
+ {nodeType.description}
+
+
+
+ );
+ })}
+
+
+
+
Instructions
+
+ - • Click nodes to add them to canvas
+ - • Connect nodes to create flow
+ - • Select nodes to configure properties
+
+
+
+
+ );
+}
diff --git a/ui/src/components/agent-builder/VisualAgentBuilder.tsx b/ui/src/components/agent-builder/VisualAgentBuilder.tsx
new file mode 100644
index 000000000..9e20381f6
--- /dev/null
+++ b/ui/src/components/agent-builder/VisualAgentBuilder.tsx
@@ -0,0 +1,335 @@
+"use client";
+
+import * as React from "react";
+import { useState, useCallback, useMemo, useRef } from "react";
+import { Node, NodeChange, EdgeChange, addEdge, applyNodeChanges, applyEdgeChanges } from "@xyflow/react";
+import "@xyflow/react/dist/style.css";
+
+import { NodeLibrary } from "./NodeLibrary";
+import { Canvas } from "./Canvas";
+import { NodeProperties } from "./properties/NodeProperties";
+import { convertFormDataToGraph, convertGraphToAgentData } from "@/lib/agent-builder/graphConverter";
+import { validateVisualGraph } from "@/lib/agent-builder/validation";
+import { generateNodeId, calculateNodePosition, getDefaultNodeData, createDefaultGraph } from "@/lib/agent-builder/utils";
+import type {
+ AgentFormData,
+ VisualBuilderValidationResult,
+ VisualAgentBuilderProps,
+ VisualNode,
+ VisualEdge
+} from "@/types";
+import { MVP_NODE_TYPES } from "@/types";
+
+const DEFAULT_SYSTEM_PROMPT = `You're a helpful agent, made by the kagent team.
+
+# Instructions
+ - If user question is unclear, ask for clarification before running any tools
+ - Always be helpful and friendly
+ - If you don't know how to answer the question DO NOT make things up, tell the user "Sorry, I don't know how to answer that" and ask them to clarify the question further
+ - If you are unable to help, or something goes wrong, refer the user to https://kagent.dev for more information or support.
+
+# Response format:
+- ALWAYS format your response as Markdown
+- Your response will include a summary of actions you took and an explanation of the result
+- If you created any artifacts such as files or resources, you will include those in your response as well`;
+
+export function VisualAgentBuilder({
+ onValidationChange,
+ onGraphDataChange,
+ initialFormData,
+ onCreateAgent,
+ isSubmitting
+}: VisualAgentBuilderProps) {
+ const [nodes, setNodes] = useState([]);
+ const [edges, setEdges] = useState([]);
+ const [selectedNode, setSelectedNode] = useState(null);
+ const [selectedNodes, setSelectedNodes] = useState([]);
+ const [selectedEdges, setSelectedEdges] = useState([]);
+ const [validationResult, setValidationResult] = useState(null);
+ const [isInitialized, setIsInitialized] = useState(false);
+ const initializationRef = useRef(false);
+
+ // Initialize graph from form data if provided - only run once
+ React.useEffect(() => {
+ if (!initializationRef.current) {
+ // Check if initialFormData has meaningful content (not just empty strings)
+ const hasMeaningfulData = initialFormData && (
+ initialFormData.modelName ||
+ (initialFormData.tools && initialFormData.tools.length > 0)
+ );
+
+ console.log('🔍 Checking initialFormData:', {
+ hasData: !!initialFormData,
+ modelName: initialFormData?.modelName,
+ tools: initialFormData?.tools?.length,
+ systemPrompt: initialFormData?.systemPrompt?.substring(0, 50) + '...',
+ hasMeaningfulData
+ });
+
+ if (hasMeaningfulData) {
+ // Use existing form data to build graph
+ try {
+ const { nodes: initialNodes, edges: initialEdges } = convertFormDataToGraph(initialFormData as AgentFormData);
+ setNodes(initialNodes);
+ setEdges(initialEdges);
+ initializationRef.current = true;
+ setIsInitialized(true);
+ } catch (error) {
+ console.error("Error initializing visual builder:", error);
+ // Fallback to default graph on error
+ const { nodes: defaultNodes, edges: defaultEdges } = createDefaultGraph();
+ setNodes(defaultNodes);
+ setEdges(defaultEdges);
+ initializationRef.current = true;
+ setIsInitialized(true);
+ }
+ } else {
+ // Create default graph with Basic Info, LLM, System Prompt, and Output nodes
+ try {
+ console.log('📊 Creating default graph (no meaningful initial data)');
+ const { nodes: defaultNodes, edges: defaultEdges } = createDefaultGraph();
+ console.log('✅ Default graph created:', defaultNodes.length, 'nodes,', defaultEdges.length, 'edges');
+ console.log('📍 Nodes:', defaultNodes.map(n => `${n.type} (${n.id})`));
+ setNodes(defaultNodes);
+ setEdges(defaultEdges);
+ initializationRef.current = true;
+ setIsInitialized(true);
+ } catch (error) {
+ console.error("Error creating default graph:", error);
+ initializationRef.current = true;
+ setIsInitialized(true);
+ }
+ }
+ }
+ }, [initialFormData]);
+
+ const onNodesChange = useCallback(
+ (changes: NodeChange[]) => setNodes((nds) => applyNodeChanges(changes, nds) as VisualNode[]),
+ [setNodes]
+ );
+
+ const onEdgesChange = useCallback(
+ (changes: EdgeChange[]) => setEdges((eds) => applyEdgeChanges(changes, eds) as VisualEdge[]),
+ [setEdges]
+ );
+
+ const onConnect = useCallback(
+ (params: { source: string; target: string; sourceHandle?: string | null; targetHandle?: string | null }) => {
+ const connection = {
+ source: params.source,
+ target: params.target,
+ sourceHandle: params.sourceHandle ?? null,
+ targetHandle: params.targetHandle ?? null,
+ };
+ setEdges((eds) => addEdge(connection, eds));
+ },
+ [setEdges]
+ );
+
+ const onNodeAdd = useCallback((nodeType: string) => {
+ try {
+ const newNode: Node = {
+ id: generateNodeId(nodeType),
+ type: nodeType,
+ position: calculateNodePosition(nodes, nodeType),
+ data: getDefaultNodeData(nodeType),
+ };
+ setNodes((nds) => [...nds, newNode as VisualNode]);
+ } catch (error) {
+ console.error("Error adding node:", error);
+ }
+ }, [nodes]);
+
+ const onNodeUpdate = useCallback((nodeId: string, data: Record) => {
+ try {
+ setNodes((nds) =>
+ nds.map((node) =>
+ node.id === nodeId ? { ...node, data: { ...node.data, ...data } } : node
+ )
+ );
+ } catch (error) {
+ console.error("Error updating node:", error);
+ }
+ }, []);
+
+ // Memoize basic info to prevent unnecessary re-renders
+ const basicInfo = useMemo(() => ({
+ name: initialFormData?.name || "visual-agent",
+ namespace: initialFormData?.namespace || "default",
+ description: initialFormData?.description || "Agent created with visual builder",
+ }), [initialFormData?.name, initialFormData?.namespace, initialFormData?.description]);
+
+ // Validate graph whenever nodes or edges change (debounced)
+ React.useEffect(() => {
+ if (!isInitialized) return;
+
+ const timeoutId = setTimeout(() => {
+ try {
+ const result = validateVisualGraph(nodes, edges, basicInfo);
+ setValidationResult(result);
+ onValidationChange(result.errors);
+
+ // Convert graph to agent data and notify parent
+ if (onGraphDataChange && nodes.length > 0) {
+ try {
+ const agentData = convertGraphToAgentData(nodes, edges, basicInfo);
+ onGraphDataChange(agentData);
+ } catch (error) {
+ console.error("Error converting graph to agent data:", error);
+ // Don't call onGraphDataChange if there's an error
+ }
+ }
+ } catch (error) {
+ console.error("Error in validation:", error);
+ // Set a safe validation result
+ const safeResult = {
+ isValid: false,
+ errors: { general: "Validation error occurred" },
+ warnings: []
+ };
+ setValidationResult(safeResult);
+ onValidationChange(safeResult.errors);
+ }
+ }, 300); // Debounce validation by 300ms
+
+ return () => clearTimeout(timeoutId);
+ }, [nodes, edges, basicInfo, onValidationChange, onGraphDataChange, isInitialized]);
+
+ const onNodeSelect = useCallback((nodeId: string | null) => {
+ setSelectedNode(nodeId);
+ }, []);
+
+ const onNodesSelect = useCallback((nodeIds: string[]) => {
+ setSelectedNodes(nodeIds);
+ }, []);
+
+ const onEdgeSelect = useCallback((edgeIds: string[]) => {
+ setSelectedEdges(edgeIds);
+ }, []);
+
+ // Handle deletion of selected nodes and edges
+ const handleDelete = useCallback(() => {
+ // Delete multiple selected nodes
+ if (selectedNodes.length > 0) {
+ setNodes((nds) => nds.filter((node) => !selectedNodes.includes(node.id)));
+ setEdges((eds) => eds.filter(
+ (edge) => !selectedNodes.includes(edge.source) && !selectedNodes.includes(edge.target)
+ ));
+ setSelectedNodes([]);
+ setSelectedNode(null);
+ }
+ // Delete single selected node
+ else if (selectedNode) {
+ setNodes((nds) => nds.filter((node) => node.id !== selectedNode));
+ setEdges((eds) => eds.filter(
+ (edge) => edge.source !== selectedNode && edge.target !== selectedNode
+ ));
+ setSelectedNode(null);
+ }
+ // Delete selected edges
+ else if (selectedEdges.length > 0) {
+ setEdges((eds) => eds.filter((edge) => !selectedEdges.includes(edge.id)));
+ setSelectedEdges([]);
+ }
+ }, [selectedNode, selectedNodes, selectedEdges]);
+
+ // Keyboard event handler for Delete/Backspace keys
+ React.useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ // Check if Delete or Backspace key is pressed
+ if (event.key === 'Delete' || event.key === 'Backspace') {
+ // Prevent default backspace navigation
+ const target = event.target as HTMLElement;
+ const isInputField = target.tagName === 'INPUT' ||
+ target.tagName === 'TEXTAREA' ||
+ target.isContentEditable;
+
+ // Only delete if not typing in an input field
+ if (!isInputField) {
+ event.preventDefault();
+ handleDelete();
+ }
+ }
+ };
+
+ window.addEventListener('keydown', handleKeyDown);
+ return () => window.removeEventListener('keydown', handleKeyDown);
+ }, [handleDelete]);
+
+ // Safety check to prevent React errors
+ if (!isInitialized) {
+ return (
+
+
+
Initializing Visual Builder...
+
+
+ );
+ }
+
+ if (!nodes || !Array.isArray(nodes)) {
+ return (
+
+
+
Initializing Visual Builder...
+
+
+ );
+ }
+
+ // Show helpful message when no nodes are present
+ if (nodes.length === 0) {
+ return (
+
+
+
Welcome to the Visual Agent Builder! Start by dragging nodes from the library on the left.
+
+
+
+
+
+
+
No nodes yet
+
Add nodes from the library to get started
+
+
+
+
Properties
+
+ Select a node to configure its properties
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ );
+}
+
diff --git a/ui/src/components/agent-builder/nodes/BasicInfoNode.tsx b/ui/src/components/agent-builder/nodes/BasicInfoNode.tsx
new file mode 100644
index 000000000..49d6eee52
--- /dev/null
+++ b/ui/src/components/agent-builder/nodes/BasicInfoNode.tsx
@@ -0,0 +1,45 @@
+"use client";
+
+import * as React from "react";
+import { Handle, Position, NodeProps } from "@xyflow/react";
+import { Info } from "lucide-react";
+import type { BasicInfoNodeData } from "@/types";
+
+export function BasicInfoNode({ data, selected }: NodeProps) {
+ const nodeData = data as unknown as BasicInfoNodeData;
+
+ // Safety check to prevent rendering objects directly
+ if (!nodeData || typeof nodeData !== 'object') {
+ return null;
+ }
+
+ return (
+
+
+
+
+
{nodeData.name || 'Unnamed Agent'}
+
{nodeData.namespace || 'default'}
+ {nodeData.type && (
+
+ Type: {nodeData.type}
+
+ )}
+ {nodeData.description && (
+
+ {nodeData.description}
+
+ )}
+
+
+
+
+ );
+}
diff --git a/ui/src/components/agent-builder/nodes/LLMNode.tsx b/ui/src/components/agent-builder/nodes/LLMNode.tsx
new file mode 100644
index 000000000..6c642d15e
--- /dev/null
+++ b/ui/src/components/agent-builder/nodes/LLMNode.tsx
@@ -0,0 +1,41 @@
+"use client";
+
+import * as React from "react";
+import { Handle, Position, NodeProps } from "@xyflow/react";
+import { Brain } from "lucide-react";
+import type { LLMNodeData } from "@/types";
+
+export function LLMNode({ data, selected }: NodeProps) {
+ const nodeData = data as unknown as LLMNodeData;
+
+ // Safety check to prevent rendering objects directly
+ if (!nodeData || typeof nodeData !== 'object') {
+ return null;
+ }
+
+ return (
+
+
+
+
+
{String(nodeData.modelName || 'Select Model')}
+
{String(nodeData.provider || 'No provider')}
+
+
+
+
+
+ );
+}
diff --git a/ui/src/components/agent-builder/nodes/OutputNode.tsx b/ui/src/components/agent-builder/nodes/OutputNode.tsx
new file mode 100644
index 000000000..f52eacd9d
--- /dev/null
+++ b/ui/src/components/agent-builder/nodes/OutputNode.tsx
@@ -0,0 +1,41 @@
+"use client";
+
+import * as React from "react";
+import { Handle, Position, NodeProps } from "@xyflow/react";
+import { Send } from "lucide-react";
+import type { OutputNodeData } from "@/types";
+
+export function OutputNode({ data, selected }: NodeProps) {
+ const nodeData = data as unknown as OutputNodeData;
+
+ // Safety check to prevent rendering objects directly
+ if (!nodeData || typeof nodeData !== 'object') {
+ return null;
+ }
+
+ const formatDisplay = nodeData.format || 'json';
+ const streamingText = nodeData.streaming ? 'Streaming' : 'Non-streaming';
+
+ return (
+
+
+
+ {formatDisplay}
+ {nodeData.streaming !== undefined && (
+ • {streamingText}
+ )}
+
+
+
+ );
+}
diff --git a/ui/src/components/agent-builder/nodes/SystemPromptNode.tsx b/ui/src/components/agent-builder/nodes/SystemPromptNode.tsx
new file mode 100644
index 000000000..ca33de58c
--- /dev/null
+++ b/ui/src/components/agent-builder/nodes/SystemPromptNode.tsx
@@ -0,0 +1,48 @@
+"use client";
+
+import * as React from "react";
+import { Handle, Position, NodeProps } from "@xyflow/react";
+import { FileText } from "lucide-react";
+import type { SystemPromptNodeData } from "@/types";
+
+export function SystemPromptNode({ data, selected }: NodeProps) {
+ const nodeData = data as unknown as SystemPromptNodeData;
+
+ // Safety check to prevent rendering objects directly
+ if (!nodeData || typeof nodeData !== 'object') {
+ return null;
+ }
+ const truncatedPrompt = nodeData.systemPrompt
+ ? String(nodeData.systemPrompt).length > 50
+ ? String(nodeData.systemPrompt).substring(0, 50) + '...'
+ : String(nodeData.systemPrompt)
+ : 'No prompt set';
+
+ return (
+
+
+
+
+
System Prompt
+
+ {truncatedPrompt}
+
+
+
+
+
+
+ );
+}
diff --git a/ui/src/components/agent-builder/nodes/ToolNode.tsx b/ui/src/components/agent-builder/nodes/ToolNode.tsx
new file mode 100644
index 000000000..2d73ffbdf
--- /dev/null
+++ b/ui/src/components/agent-builder/nodes/ToolNode.tsx
@@ -0,0 +1,73 @@
+"use client";
+
+import * as React from "react";
+import { Handle, Position, NodeProps } from "@xyflow/react";
+import { Wrench } from "lucide-react";
+import type { ToolNodeData } from "@/types";
+
+export function ToolNode({ data, selected }: NodeProps) {
+ const nodeData = data as unknown as ToolNodeData;
+
+ // Safety check to prevent rendering objects directly
+ if (!nodeData || typeof nodeData !== 'object') {
+ return null;
+ }
+
+ const toolCount = nodeData.tools?.length || 0;
+ const toolSummary = toolCount > 0
+ ? `${toolCount} tool${toolCount !== 1 ? 's' : ''}`
+ : 'No tools';
+
+ // Get first few tool names for display
+ const getToolNames = () => {
+ if (!nodeData.tools || nodeData.tools.length === 0) return null;
+
+ const names: string[] = [];
+ nodeData.tools.slice(0, 2).forEach(tool => {
+ if (tool.type === 'McpServer' && tool.mcpServer) {
+ if (tool.mcpServer.toolNames && tool.mcpServer.toolNames.length > 0) {
+ names.push(tool.mcpServer.toolNames[0]);
+ }
+ } else if (tool.type === 'Agent' && tool.agent) {
+ names.push(tool.agent.name);
+ }
+ });
+
+ return names.length > 0 ? names.join(', ') : null;
+ };
+
+ const toolNames = getToolNames();
+
+ return (
+
+
+
+
+
Tools
+
+ {toolSummary}
+
+ {toolNames && (
+
+ {toolNames}
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/ui/src/components/agent-builder/properties/BasicInfoPropertyEditor.tsx b/ui/src/components/agent-builder/properties/BasicInfoPropertyEditor.tsx
new file mode 100644
index 000000000..f65f8329c
--- /dev/null
+++ b/ui/src/components/agent-builder/properties/BasicInfoPropertyEditor.tsx
@@ -0,0 +1,87 @@
+"use client";
+
+import * as React from "react";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { NamespaceCombobox } from "@/components/NamespaceCombobox";
+import type { BasicInfoNodeData } from "@/types";
+
+interface BasicInfoPropertyEditorProps {
+ data: BasicInfoNodeData;
+ onUpdate: (data: Record) => void;
+}
+
+export function BasicInfoPropertyEditor({ data, onUpdate }: BasicInfoPropertyEditorProps) {
+ const nodeData = data as BasicInfoPropertyEditorProps['data'];
+
+ const handleChange = (field: string, value: unknown) => {
+ onUpdate({ ...nodeData, [field]: value });
+ };
+
+ return (
+
+
+
+
handleChange('name', e.target.value)}
+ placeholder="Enter agent name..."
+ className="text-sm h-9"
+ />
+
+ Must be a valid Kubernetes resource name
+
+
+
+
+
+
handleChange('namespace', value)}
+ placeholder="Select namespace..."
+ />
+
+ The Kubernetes namespace for this agent
+
+
+
+
+
+
+
+ {nodeData.type === 'BYO'
+ ? 'Bring your own containerized agent'
+ : 'Use a model-driven declarative agent'}
+
+
+
+
+
+ );
+}
diff --git a/ui/src/components/agent-builder/properties/LLMPropertyEditor.tsx b/ui/src/components/agent-builder/properties/LLMPropertyEditor.tsx
new file mode 100644
index 000000000..91164f699
--- /dev/null
+++ b/ui/src/components/agent-builder/properties/LLMPropertyEditor.tsx
@@ -0,0 +1,143 @@
+"use client";
+
+import * as React from "react";
+import { Label } from "@/components/ui/label";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Loader2 } from "lucide-react";
+import { useAgents } from "@/components/AgentsProvider";
+import { k8sRefUtils } from "@/lib/k8sUtils";
+import type { LLMNodeData } from "@/types";
+
+interface LLMPropertyEditorProps {
+ data: LLMNodeData;
+ onUpdate: (data: Record) => void;
+ agentNamespace?: string;
+}
+
+export function LLMPropertyEditor({ data, onUpdate, agentNamespace }: LLMPropertyEditorProps) {
+ const nodeData = data as LLMPropertyEditorProps['data'];
+ const { models, loading } = useAgents();
+
+ const handleModelSelect = (modelRef: string) => {
+ const selectedModel = models.find(m => m.ref === modelRef);
+ if (selectedModel && isModelSelectable(selectedModel.ref)) {
+ // Extract provider from model name
+ const provider = extractProvider(selectedModel.model);
+
+ onUpdate({
+ ...nodeData,
+ modelConfigRef: selectedModel.ref,
+ modelName: selectedModel.model,
+ provider: provider,
+ });
+ }
+ };
+
+ const extractProvider = (modelName: string): string => {
+ if (!modelName) return 'OpenAI';
+
+ const lowerModel = modelName.toLowerCase();
+ if (lowerModel.startsWith('gpt-') || lowerModel.startsWith('o1-')) {
+ return 'OpenAI';
+ } else if (lowerModel.startsWith('claude-')) {
+ return 'Anthropic';
+ } else if (lowerModel.includes('gemini')) {
+ return 'Gemini';
+ } else if (lowerModel.includes('llama') || lowerModel.includes('mistral')) {
+ return 'Ollama';
+ }
+ return 'OpenAI';
+ };
+
+ const getModelNamespace = (modelRef: string): string => {
+ try {
+ return k8sRefUtils.fromRef(modelRef).namespace;
+ } catch {
+ return 'default';
+ }
+ };
+
+ const isModelSelectable = (modelRef: string): boolean => {
+ if (!agentNamespace) return true;
+ const modelNamespace = getModelNamespace(modelRef);
+ return modelNamespace === agentNamespace;
+ };
+
+ return (
+
+
+
+ {loading ? (
+
+
+ Loading models...
+
+ ) : (
+ <>
+
+ {models.length === 0 && (
+
+ No models available. Please create a model first.
+
+ )}
+ >
+ )}
+
+ {agentNamespace
+ ? `Only models from the ${agentNamespace} namespace are selectable.`
+ : 'Select the LLM model for this agent'
+ }
+
+
+
+ {nodeData.provider && (
+
+
+
+ {nodeData.provider}
+
+
+ Auto-detected from model selection
+
+
+ )}
+
+ );
+}
diff --git a/ui/src/components/agent-builder/properties/NodeProperties.tsx b/ui/src/components/agent-builder/properties/NodeProperties.tsx
new file mode 100644
index 000000000..0345cc745
--- /dev/null
+++ b/ui/src/components/agent-builder/properties/NodeProperties.tsx
@@ -0,0 +1,161 @@
+"use client";
+
+import * as React from "react";
+import { PropertyEditor } from "./PropertyEditor";
+import { getValidationSummary } from "@/lib/agent-builder/validation";
+import type { NodePropertiesProps } from "@/types";
+import { AlertCircle, CheckCircle, Loader2, ChevronDown, ChevronUp } from "lucide-react";
+import { Button } from "@/components/ui/button";
+
+export function NodeProperties({ selectedNode, nodes, onNodeUpdate, validationResult, onCreateAgent, isSubmitting }: NodePropertiesProps) {
+ const node = nodes.find(n => n.id === selectedNode);
+ const [showWarnings, setShowWarnings] = React.useState(false);
+ const [showErrors, setShowErrors] = React.useState(false);
+
+ const hasWarnings = validationResult && validationResult.warnings.length > 0;
+ const hasErrors = validationResult && !validationResult.isValid && Object.keys(validationResult.errors).length > 0;
+
+ return (
+
+
+
Properties
+
+ {!node ? (
+
+ Select a node to configure its properties
+
+ ) : (
+ <>
+
+
Node Type
+
{node.type}
+
+
onNodeUpdate(node.id, data)}
+ nodes={nodes}
+ />
+ >
+ )}
+
+
+ {/* Validation errors and warnings - Compact Side by Side */}
+ {(hasWarnings || hasErrors) && (
+
+ {/* Compact header row when both collapsed */}
+ {!showWarnings && !showErrors && (
+
+ {hasErrors && (
+
+ )}
+ {hasWarnings && (
+
+ )}
+
+ )}
+
+ {/* Expanded errors section */}
+ {showErrors && hasErrors && (
+
+
+
+ {Object.entries(validationResult!.errors).map(([key, error]) => (
+
+ • {error}
+
+ ))}
+
+
+ )}
+
+ {/* Expanded warnings section */}
+ {showWarnings && hasWarnings && (
+
+
+
+ {validationResult!.warnings.map((warning, index) => (
+
+ • {warning}
+
+ ))}
+
+
+ )}
+
+ )}
+
+ {/* Validation status - Only show when valid (no errors/warnings) */}
+ {validationResult && validationResult.isValid && (
+
+
+
+
+ {getValidationSummary(validationResult)}
+
+
+
+ )}
+
+ {/* Create Agent Button */}
+ {onCreateAgent && (
+
+
+
+ )}
+
+ );
+}
diff --git a/ui/src/components/agent-builder/properties/OutputPropertyEditor.tsx b/ui/src/components/agent-builder/properties/OutputPropertyEditor.tsx
new file mode 100644
index 000000000..a44b0b92a
--- /dev/null
+++ b/ui/src/components/agent-builder/properties/OutputPropertyEditor.tsx
@@ -0,0 +1,51 @@
+"use client";
+
+import * as React from "react";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+
+interface OutputPropertyEditorProps {
+ data: {
+ format: 'text' | 'json' | 'markdown';
+ template?: string;
+ };
+ onUpdate: (data: Record) => void;
+}
+
+export function OutputPropertyEditor({ data, onUpdate }: OutputPropertyEditorProps) {
+ const nodeData = data as OutputPropertyEditorProps['data'];
+
+ const handleChange = (field: string, value: unknown) => {
+ onUpdate({ ...nodeData, [field]: value });
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/ui/src/components/agent-builder/properties/PropertyEditor.tsx b/ui/src/components/agent-builder/properties/PropertyEditor.tsx
new file mode 100644
index 000000000..427f8c7c5
--- /dev/null
+++ b/ui/src/components/agent-builder/properties/PropertyEditor.tsx
@@ -0,0 +1,48 @@
+"use client";
+
+import * as React from "react";
+import { BasicInfoPropertyEditor } from "./BasicInfoPropertyEditor";
+import { LLMPropertyEditor } from "./LLMPropertyEditor";
+import { SystemPromptPropertyEditor } from "./SystemPromptPropertyEditor";
+import { ToolPropertyEditor } from "./ToolPropertyEditor";
+import { OutputPropertyEditor } from "./OutputPropertyEditor";
+import type {
+ BasicInfoNodeData,
+ LLMNodeData,
+ SystemPromptNodeData,
+ ToolNodeData,
+ OutputNodeData,
+ VisualNode
+} from "@/types";
+
+interface PropertyEditorProps {
+ nodeType: string;
+ data: Record;
+ onUpdate: (data: Record) => void;
+ nodes?: VisualNode[];
+}
+
+export function PropertyEditor({ nodeType, data, onUpdate, nodes }: PropertyEditorProps) {
+ // Find the basic-info node to get the agent namespace
+ const basicInfoNode = nodes?.find(n => n.type === 'basic-info');
+ const agentNamespace = basicInfoNode?.data?.namespace as string | undefined;
+
+ switch (nodeType) {
+ case 'basic-info':
+ return ;
+ case 'llm':
+ return ;
+ case 'system-prompt':
+ return ;
+ case 'tool':
+ return ;
+ case 'output':
+ return ;
+ default:
+ return (
+
+ No properties available for this node type
+
+ );
+ }
+}
diff --git a/ui/src/components/agent-builder/properties/SystemPromptPropertyEditor.tsx b/ui/src/components/agent-builder/properties/SystemPromptPropertyEditor.tsx
new file mode 100644
index 000000000..8523f6c14
--- /dev/null
+++ b/ui/src/components/agent-builder/properties/SystemPromptPropertyEditor.tsx
@@ -0,0 +1,35 @@
+"use client";
+
+import * as React from "react";
+import { Textarea } from "@/components/ui/textarea";
+import { Label } from "@/components/ui/label";
+
+interface SystemPromptPropertyEditorProps {
+ data: {
+ systemPrompt: string;
+ };
+ onUpdate: (data: Record) => void;
+}
+
+export function SystemPromptPropertyEditor({ data, onUpdate }: SystemPromptPropertyEditorProps) {
+ const nodeData = data as SystemPromptPropertyEditorProps['data'];
+
+ const handleChange = (field: string, value: unknown) => {
+ onUpdate({ ...nodeData, [field]: value });
+ };
+
+ return (
+
+ );
+}
diff --git a/ui/src/components/agent-builder/properties/ToolPropertyEditor.tsx b/ui/src/components/agent-builder/properties/ToolPropertyEditor.tsx
new file mode 100644
index 000000000..3ca892989
--- /dev/null
+++ b/ui/src/components/agent-builder/properties/ToolPropertyEditor.tsx
@@ -0,0 +1,258 @@
+"use client";
+
+import * as React from "react";
+import { useState, useEffect } from "react";
+import { Label } from "@/components/ui/label";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { Plus, X, FunctionSquare, Loader2 } from "lucide-react";
+import type { Tool, ToolNodeData, AgentResponse, ToolsResponse } from "@/types";
+import { SelectToolsDialog } from "@/components/create/SelectToolsDialog";
+import { getAgents } from "@/app/actions/agents";
+import { getTools } from "@/app/actions/tools";
+import {
+ isAgentTool,
+ isMcpTool,
+ getToolIdentifier,
+ getToolDisplayName,
+ getToolDescription
+} from "@/lib/toolUtils";
+import KagentLogo from "@/components/kagent-logo";
+import { k8sRefUtils } from "@/lib/k8sUtils";
+
+interface ToolPropertyEditorProps {
+ data: ToolNodeData;
+ onUpdate: (data: Record) => void;
+}
+
+export function ToolPropertyEditor({ data, onUpdate }: ToolPropertyEditorProps) {
+ const nodeData = data as ToolPropertyEditorProps['data'];
+ const [showToolSelector, setShowToolSelector] = useState(false);
+ const [availableAgents, setAvailableAgents] = useState([]);
+ const [availableTools, setAvailableTools] = useState([]);
+ const [loadingData, setLoadingData] = useState(true);
+
+ const handleChange = (field: string, value: unknown) => {
+ onUpdate({ ...nodeData, [field]: value });
+ };
+
+ // Fetch available tools and agents
+ useEffect(() => {
+ const fetchData = async () => {
+ setLoadingData(true);
+
+ try {
+ const [agentsResponse, toolsResponse] = await Promise.all([
+ getAgents(),
+ getTools()
+ ]);
+
+ // Handle agents
+ if (!agentsResponse.error && agentsResponse.data) {
+ setAvailableAgents(agentsResponse.data);
+ } else {
+ console.error("Failed to fetch agents:", agentsResponse.error);
+ }
+ setAvailableTools(toolsResponse);
+ } catch (error) {
+ console.error("Failed to fetch data:", error);
+ } finally {
+ setLoadingData(false);
+ }
+ };
+
+ fetchData();
+ }, []);
+
+ const handleToolSelect = (newSelectedTools: Tool[]) => {
+ handleChange('tools', newSelectedTools);
+ setShowToolSelector(false);
+ };
+
+ const handleRemoveTool = (toolIdentifier: string, mcpToolName?: string) => {
+ const selectedTools = nodeData.tools || [];
+ let updatedTools: Tool[] = [];
+
+ if (mcpToolName) {
+ // Remove specific MCP tool
+ updatedTools = selectedTools.map((tool: Tool) => {
+ if (getToolIdentifier(tool) === toolIdentifier && isMcpTool(tool)) {
+ const mcpTool = tool as Tool;
+ const updatedToolNames = mcpTool.mcpServer?.toolNames.filter(
+ (name: string) => name !== mcpToolName
+ ) || [];
+
+ if (updatedToolNames.length === 0) {
+ return null;
+ }
+
+ return {
+ ...tool,
+ mcpServer: {
+ ...mcpTool.mcpServer!,
+ toolNames: updatedToolNames,
+ },
+ };
+ }
+ return tool;
+ }).filter((tool): tool is Tool => tool !== null);
+ } else {
+ // Remove entire tool/agent
+ updatedTools = selectedTools.filter(
+ (tool: Tool) => getToolIdentifier(tool) !== toolIdentifier
+ );
+ }
+
+ handleChange('tools', updatedTools);
+ };
+
+ const renderSelectedTools = () => {
+ const selectedTools = nodeData.tools || [];
+
+ return (
+
+ {selectedTools.flatMap((agentTool: Tool) => {
+ const parentToolIdentifier = getToolIdentifier(agentTool);
+
+ if (isMcpTool(agentTool)) {
+ const mcpTool = agentTool as Tool;
+ return mcpTool.mcpServer?.toolNames.map((mcpToolName: string) => {
+ const toolIdentifierForDisplay = `${parentToolIdentifier}::${mcpToolName}`;
+ const displayName = mcpToolName;
+
+ // Get tool description from DB
+ let displayDescription = "Description not available.";
+ const toolFromDB = availableTools.find(server => {
+ const { name } = k8sRefUtils.fromRef(server.server_name);
+ return name === mcpTool.mcpServer?.name && server.id === mcpToolName;
+ });
+
+ if (toolFromDB) {
+ displayDescription = toolFromDB.description;
+ }
+
+ const Icon = FunctionSquare;
+ const iconColor = "text-blue-400";
+
+ return (
+
+
+
+
+
+
+
+ {displayName}
+ {displayDescription}
+
+
+
+
+
+
+
+
+
+ );
+ }) || [];
+ } else {
+ const displayName = getToolDisplayName(agentTool);
+ const displayDescription = getToolDescription(agentTool, availableTools);
+
+ let CurrentIcon: React.ElementType;
+ let currentIconColor: string;
+
+ if (isAgentTool(agentTool)) {
+ CurrentIcon = KagentLogo;
+ currentIconColor = "text-green-500";
+ } else {
+ CurrentIcon = FunctionSquare;
+ currentIconColor = "text-yellow-500";
+ }
+
+ return [(
+
+
+
+
+
+
+
+ {displayName}
+ {displayDescription}
+
+
+
+
+
+
+
+
+
+ )];
+ }
+ })}
+
+ );
+ };
+
+ return (
+
+
+
+
+
+
+ {nodeData.tools && nodeData.tools.length > 0 && (
+
+
+ {renderSelectedTools()}
+
+ )}
+
+
+
+ );
+}
diff --git a/ui/src/components/onboarding/OnboardingWizard.tsx b/ui/src/components/onboarding/OnboardingWizard.tsx
index f6399214f..dc01105d4 100644
--- a/ui/src/components/onboarding/OnboardingWizard.tsx
+++ b/ui/src/components/onboarding/OnboardingWizard.tsx
@@ -3,8 +3,8 @@
import React, { useState } from 'react';
import { Card } from '@/components/ui/card';
import { toast } from 'sonner';
-import { useAgents, AgentFormData } from "@/components/AgentsProvider";
-import type { Tool } from "@/types";
+import { useAgents } from "@/components/AgentsProvider";
+import type { Tool, AgentFormData } from "@/types";
import { WelcomeStep } from './steps/WelcomeStep';
import { ModelConfigStep } from './steps/ModelConfigStep';
import { AgentSetupStep, AgentSetupFormData } from './steps/AgentSetupStep';
diff --git a/ui/src/lib/agent-builder/formConverter.ts b/ui/src/lib/agent-builder/formConverter.ts
new file mode 100644
index 000000000..97a82118c
--- /dev/null
+++ b/ui/src/lib/agent-builder/formConverter.ts
@@ -0,0 +1,170 @@
+import type { Tool, ToolNodeData, VisualNode, AgentFormData } from "@/types";
+
+/**
+ * Convert Tool array to ToolNodeData array
+ * @param tools Array of tools
+ * @returns Array of ToolNodeData
+ */
+export function convertToolsToNodes(tools: Tool[]): ToolNodeData[] {
+ if (!tools || tools.length === 0) {
+ return [];
+ }
+
+ // Group tools by server/agent reference
+ const groupedTools: Record = {};
+
+ tools.forEach(tool => {
+ let key = 'default';
+
+ if (tool.type === 'McpServer' && tool.mcpServer) {
+ key = tool.mcpServer.name || 'default';
+ } else if (tool.type === 'Agent' && tool.agent) {
+ key = tool.agent.name || 'default';
+ }
+
+ if (!groupedTools[key]) {
+ groupedTools[key] = [];
+ }
+ groupedTools[key].push(tool);
+ });
+
+ // Convert grouped tools to ToolNodeData
+ return Object.entries(groupedTools).map(([serverRef, toolList]) => ({
+ serverRef,
+ tools: toolList,
+ }));
+}
+
+/**
+ * Convert ToolNodeData array back to Tool array
+ * @param nodeData Array of ToolNodeData
+ * @returns Array of tools
+ */
+export function convertNodesToTools(nodeData: ToolNodeData[]): Tool[] {
+ const allTools: Tool[] = [];
+
+ nodeData.forEach(node => {
+ if (node.tools && Array.isArray(node.tools)) {
+ allTools.push(...node.tools);
+ }
+ });
+
+ return allTools;
+}
+
+/**
+ * Merge multiple node data into single AgentFormData
+ * @param nodes All visual nodes
+ * @returns Partial AgentFormData with merged data
+ */
+export function mergeNodeData(nodes: VisualNode[]): Partial {
+ const result: Partial = {
+ tools: [],
+ };
+
+ nodes.forEach(node => {
+ switch (node.type) {
+ case 'basic-info':
+ result.name = (node.data.name as string) || result.name;
+ result.namespace = (node.data.namespace as string) || result.namespace;
+ result.description = (node.data.description as string) || result.description;
+ break;
+
+ case 'system-prompt':
+ result.systemPrompt = (node.data.systemPrompt as string) || result.systemPrompt;
+ break;
+
+ case 'llm':
+ result.modelName = (node.data.modelName as string) || result.modelName;
+ result.stream = (node.data.stream as boolean) !== false;
+ break;
+
+ case 'tool':
+ const tools = node.data.tools as Tool[];
+ if (tools && Array.isArray(tools)) {
+ result.tools = [...(result.tools || []), ...tools];
+ }
+ break;
+
+ case 'output':
+ if (node.data.streaming !== undefined) {
+ result.stream = node.data.streaming as boolean;
+ }
+ break;
+ }
+ });
+
+ return result;
+}
+
+/**
+ * Extract basic info from nodes
+ * @param nodes All visual nodes
+ * @returns Basic info object
+ */
+export function extractBasicInfo(nodes: VisualNode[]): {
+ name: string;
+ namespace: string;
+ description: string;
+} {
+ const basicInfoNode = nodes.find(node => node.type === 'basic-info');
+
+ if (basicInfoNode) {
+ return {
+ name: (basicInfoNode.data.name as string) || '',
+ namespace: (basicInfoNode.data.namespace as string) || 'default',
+ description: (basicInfoNode.data.description as string) || '',
+ };
+ }
+
+ return {
+ name: '',
+ namespace: 'default',
+ description: '',
+ };
+}
+
+/**
+ * Check if form data is complete enough to create an agent
+ * @param formData Agent form data
+ * @returns True if complete, false otherwise
+ */
+export function isFormDataComplete(formData: Partial): boolean {
+ return !!(
+ formData.name &&
+ formData.namespace &&
+ formData.modelName &&
+ formData.systemPrompt
+ );
+}
+
+/**
+ * Sanitize tool data to ensure proper structure
+ * @param tools Array of tools
+ * @returns Sanitized array of tools
+ */
+export function sanitizeTools(tools: Tool[]): Tool[] {
+ if (!Array.isArray(tools)) {
+ return [];
+ }
+
+ return tools.filter(tool => {
+ // Ensure tool has a valid type
+ if (!tool.type || (tool.type !== 'McpServer' && tool.type !== 'Agent')) {
+ return false;
+ }
+
+ // For McpServer type, ensure mcpServer data exists
+ if (tool.type === 'McpServer') {
+ return !!(tool.mcpServer && tool.mcpServer.name);
+ }
+
+ // For Agent type, ensure agent data exists
+ if (tool.type === 'Agent') {
+ return !!(tool.agent && tool.agent.name);
+ }
+
+ return false;
+ });
+}
+
diff --git a/ui/src/lib/agent-builder/graphConverter.ts b/ui/src/lib/agent-builder/graphConverter.ts
new file mode 100644
index 000000000..69a17a71e
--- /dev/null
+++ b/ui/src/lib/agent-builder/graphConverter.ts
@@ -0,0 +1,264 @@
+import type {
+ AgentFormData,
+ VisualNode,
+ VisualEdge,
+ LLMNodeData,
+ SystemPromptNodeData,
+ ToolNodeData,
+ OutputNodeData,
+ BasicInfoNodeData
+} from "@/types";
+import { generateNodeId } from "./utils";
+
+/**
+ * Converts AgentFormData to Visual Graph (nodes + edges)
+ * @param formData Agent form data
+ * @returns Object containing nodes and edges arrays
+ */
+export function convertFormDataToGraph(formData: AgentFormData): {
+ nodes: VisualNode[];
+ edges: VisualEdge[];
+} {
+ const nodes: VisualNode[] = [];
+ const edges: VisualEdge[] = [];
+ let yPosition = 100;
+ const ySpacing = 200;
+
+ // 1. Create Basic Info Node
+ const basicInfoId = generateNodeId('basic-info');
+ nodes.push({
+ id: basicInfoId,
+ type: 'basic-info',
+ position: { x: 100, y: yPosition },
+ data: {
+ name: formData.name || '',
+ namespace: formData.namespace || 'default',
+ description: formData.description || '',
+ type: formData.type || 'Declarative',
+ } as BasicInfoNodeData,
+ });
+
+ yPosition += ySpacing;
+
+ // 2. Create System Prompt Node (if exists)
+ let systemPromptId: string | null = null;
+ if (formData.systemPrompt) {
+ systemPromptId = generateNodeId('system-prompt');
+ nodes.push({
+ id: systemPromptId,
+ type: 'system-prompt',
+ position: { x: 100, y: yPosition },
+ data: {
+ systemPrompt: formData.systemPrompt,
+ } as SystemPromptNodeData,
+ });
+
+ // Connect basic-info to system-prompt
+ edges.push({
+ id: `edge-${basicInfoId}-${systemPromptId}`,
+ source: basicInfoId,
+ target: systemPromptId,
+ animated: false,
+ });
+
+ yPosition += ySpacing;
+ }
+
+ // 3. Create LLM Node (always create it, even if no model is selected yet)
+ const llmId = generateNodeId('llm');
+ nodes.push({
+ id: llmId,
+ type: 'llm',
+ position: { x: 100, y: yPosition },
+ data: {
+ modelConfigRef: formData.modelName || '',
+ modelName: formData.modelName || '',
+ provider: formData.modelName ? extractProvider(formData.modelName) : '',
+ stream: formData.stream !== false,
+ } as LLMNodeData,
+ });
+
+ // Connect previous node to llm
+ const prevNodeForLlm = systemPromptId || basicInfoId;
+ edges.push({
+ id: `edge-${prevNodeForLlm}-${llmId}`,
+ source: prevNodeForLlm,
+ target: llmId,
+ animated: false,
+ });
+
+ yPosition += ySpacing;
+
+ // 4. Create Tool Nodes (if tools exist)
+ let lastToolId: string | null = null;
+ if (formData.tools && formData.tools.length > 0) {
+ const toolId = generateNodeId('tool');
+ nodes.push({
+ id: toolId,
+ type: 'tool',
+ position: { x: 100, y: yPosition },
+ data: {
+ serverRef: '',
+ tools: formData.tools,
+ } as ToolNodeData,
+ });
+
+ lastToolId = toolId;
+
+ // Connect previous node to tool
+ const prevNodeForTool = llmId || systemPromptId || basicInfoId;
+ edges.push({
+ id: `edge-${prevNodeForTool}-${toolId}`,
+ source: prevNodeForTool,
+ target: toolId,
+ animated: false,
+ });
+
+ yPosition += ySpacing;
+ }
+
+ // 5. Create Output Node (always create one)
+ const outputId = generateNodeId('output');
+ nodes.push({
+ id: outputId,
+ type: 'output',
+ position: { x: 100, y: yPosition },
+ data: {
+ format: 'json',
+ streaming: formData.stream !== false,
+ } as OutputNodeData,
+ });
+
+ // Connect to output node
+ const prevNodeForOutput = lastToolId || llmId || systemPromptId || basicInfoId;
+ edges.push({
+ id: `edge-${prevNodeForOutput}-${outputId}`,
+ source: prevNodeForOutput,
+ target: outputId,
+ animated: false,
+ });
+
+ return { nodes, edges };
+}
+
+/**
+ * Converts Visual Graph to AgentFormData
+ * @param nodes All nodes in the graph
+ * @param edges All edges in the graph
+ * @param basicInfo Basic agent information
+ * @returns AgentFormData object
+ */
+export function convertGraphToAgentData(
+ nodes: VisualNode[],
+ edges: VisualEdge[],
+ basicInfo: { name: string; namespace: string; description: string }
+): AgentFormData {
+ const formData: AgentFormData = {
+ name: basicInfo.name,
+ namespace: basicInfo.namespace,
+ description: basicInfo.description,
+ type: 'Declarative',
+ tools: [],
+ stream: true,
+ };
+
+ // Extract data from each node type
+ nodes.forEach(node => {
+ switch (node.type) {
+ case 'basic-info':
+ const basicData = node.data as BasicInfoNodeData;
+ formData.name = basicData.name || formData.name;
+ formData.namespace = basicData.namespace || formData.namespace;
+ formData.description = basicData.description || formData.description;
+ formData.type = basicData.type || 'Declarative';
+ break;
+
+ case 'system-prompt':
+ const promptData = node.data as SystemPromptNodeData;
+ formData.systemPrompt = promptData.systemPrompt || '';
+ break;
+
+ case 'llm':
+ const llmData = node.data as LLMNodeData;
+ formData.modelName = llmData.modelConfigRef || llmData.modelName;
+ formData.stream = llmData.stream !== false;
+ break;
+
+ case 'tool':
+ const toolData = node.data as ToolNodeData;
+ if (toolData.tools && Array.isArray(toolData.tools)) {
+ formData.tools = [...formData.tools, ...toolData.tools];
+ }
+ break;
+
+ case 'output':
+ const outputData = node.data as OutputNodeData;
+ if (outputData.streaming !== undefined) {
+ formData.stream = outputData.streaming;
+ }
+ break;
+ }
+ });
+
+ return formData;
+}
+
+/**
+ * Helper function to extract provider from model name
+ * @param modelName Full model name or ref
+ * @returns Provider name
+ */
+function extractProvider(modelName: string): string {
+ if (!modelName) return '';
+
+ // If it's a ref like "default/gpt-4", extract just the model name
+ const parts = modelName.split('/');
+ const actualModelName = parts.length > 1 ? parts[parts.length - 1] : modelName;
+
+ // Determine provider based on model name patterns
+ if (actualModelName.startsWith('gpt-') || actualModelName.startsWith('o1-')) {
+ return 'OpenAI';
+ } else if (actualModelName.startsWith('claude-')) {
+ return 'Anthropic';
+ } else if (actualModelName.includes('gemini')) {
+ return 'Gemini';
+ } else if (actualModelName.includes('llama') || actualModelName.includes('mistral')) {
+ return 'Ollama';
+ }
+
+ return 'OpenAI'; // Default fallback
+}
+
+/**
+ * Merge node data from multiple nodes of the same type
+ * @param nodes Array of nodes to merge
+ * @returns Merged data
+ */
+export function mergeNodesByType(nodes: VisualNode[], nodeType: string): Record {
+ const matchingNodes = nodes.filter(node => node.type === nodeType);
+
+ if (matchingNodes.length === 0) {
+ return {};
+ }
+
+ // For most node types, just use the first one
+ if (matchingNodes.length === 1) {
+ return matchingNodes[0].data;
+ }
+
+ // For tool nodes, merge all tools together
+ if (nodeType === 'tool') {
+ const allTools: unknown[] = [];
+ matchingNodes.forEach(node => {
+ const toolData = node.data as ToolNodeData;
+ if (toolData.tools && Array.isArray(toolData.tools)) {
+ allTools.push(...toolData.tools);
+ }
+ });
+ return { tools: allTools };
+ }
+
+ // For other types, use the first node
+ return matchingNodes[0].data;
+}
+
diff --git a/ui/src/lib/agent-builder/utils.ts b/ui/src/lib/agent-builder/utils.ts
new file mode 100644
index 000000000..106e1df85
--- /dev/null
+++ b/ui/src/lib/agent-builder/utils.ts
@@ -0,0 +1,268 @@
+import type { VisualNode, VisualEdge, NodeData } from "@/types";
+
+/**
+ * Generate unique node ID
+ * @param nodeType Type of the node
+ * @returns Unique node ID
+ */
+export function generateNodeId(nodeType: string): string {
+ const timestamp = Date.now();
+ const random = Math.floor(Math.random() * 1000);
+ return `${nodeType}-${timestamp}-${random}`;
+}
+
+/**
+ * Calculate position for new node using grid layout
+ * @param existingNodes Existing nodes in the graph
+ * @param nodeType Type of the node being added (reserved for future use)
+ * @returns Position coordinates {x, y}
+ */
+export function calculateNodePosition(
+ existingNodes: VisualNode[],
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ nodeType: string
+): { x: number; y: number } {
+ // Grid layout configuration
+ const HORIZONTAL_SPACING = 300;
+ const VERTICAL_SPACING = 200;
+ const START_X = 100;
+ const START_Y = 100;
+
+ // Calculate position based on total nodes
+ const totalNodes = existingNodes.length;
+ const row = Math.floor(totalNodes / 3);
+ const col = totalNodes % 3;
+
+ return {
+ x: START_X + (col * HORIZONTAL_SPACING),
+ y: START_Y + (row * VERTICAL_SPACING),
+ };
+}
+
+/**
+ * Get default data for node type
+ * @param nodeType Type of the node
+ * @returns Default node data
+ */
+export function getDefaultNodeData(nodeType: string): NodeData {
+ switch (nodeType) {
+ case 'basic-info':
+ return {
+ name: 'my-agent',
+ namespace: 'default',
+ description: 'Agent created with visual builder',
+ type: 'Declarative' as const,
+ };
+ case 'llm':
+ return {
+ modelConfigRef: '',
+ modelName: '',
+ provider: '',
+ stream: true,
+ };
+ case 'system-prompt':
+ return {
+ systemPrompt: "You're a helpful agent, made by the kagent team.\n\n# Instructions\n- If user question is unclear, ask for clarification before running any tools\n- Always be helpful and friendly\n- If you don't know how to answer the question DO NOT make things up, tell the user \"Sorry, I don't know how to answer that\" and ask them to clarify the question further\n\n# Response format:\n- ALWAYS format your response as Markdown",
+ };
+ case 'tool':
+ return {
+ serverRef: '',
+ tools: [],
+ };
+ case 'output':
+ return {
+ format: 'json',
+ template: '',
+ streaming: true,
+ };
+ default:
+ return { label: nodeType };
+ }
+}
+
+/**
+ * Create default connected graph with Basic Info, LLM, System Prompt, and Output nodes
+ * @returns Object containing default nodes and edges arrays
+ */
+export function createDefaultGraph(): { nodes: VisualNode[], edges: VisualEdge[] } {
+ const basicInfoId = generateNodeId('basic-info');
+ const llmId = generateNodeId('llm');
+ const systemPromptId = generateNodeId('system-prompt');
+ const outputId = generateNodeId('output');
+
+ const nodes: VisualNode[] = [
+ {
+ id: basicInfoId,
+ type: 'basic-info',
+ position: { x: 600, y: 50 },
+ data: getDefaultNodeData('basic-info'),
+ },
+ {
+ id: llmId,
+ type: 'llm',
+ position: { x: 600, y: 180 },
+ data: getDefaultNodeData('llm'),
+ },
+ {
+ id: systemPromptId,
+ type: 'system-prompt',
+ position: { x: 600, y: 310 },
+ data: getDefaultNodeData('system-prompt'),
+ },
+ {
+ id: outputId,
+ type: 'output',
+ position: { x: 600, y: 440 },
+ data: getDefaultNodeData('output'),
+ },
+ ];
+
+ const edges: VisualEdge[] = [
+ {
+ id: `${basicInfoId}-${llmId}`,
+ source: basicInfoId,
+ target: llmId,
+ type: 'smoothstep',
+ },
+ {
+ id: `${llmId}-${systemPromptId}`,
+ source: llmId,
+ target: systemPromptId,
+ type: 'smoothstep',
+ },
+ {
+ id: `${systemPromptId}-${outputId}`,
+ source: systemPromptId,
+ target: outputId,
+ type: 'smoothstep',
+ },
+ ];
+
+ console.log('🔧 Creating default graph with nodes:', nodes.map(n => n.type));
+ console.log('🔗 Creating edges:', edges.map(e => `${e.source} → ${e.target}`));
+
+ return { nodes, edges };
+}
+
+/**
+ * Detect cycles in graph using DFS
+ * @param nodes All nodes in the graph
+ * @param edges All edges in the graph
+ * @returns True if cycles exist, false otherwise
+ */
+export function detectCycles(nodes: VisualNode[], edges: VisualEdge[]): boolean {
+ const adjacencyList = new Map();
+ const visited = new Set();
+ const recursionStack = new Set();
+
+ // Build adjacency list
+ nodes.forEach(node => {
+ adjacencyList.set(node.id, []);
+ });
+
+ edges.forEach(edge => {
+ const neighbors = adjacencyList.get(edge.source) || [];
+ neighbors.push(edge.target);
+ adjacencyList.set(edge.source, neighbors);
+ });
+
+ // DFS helper function
+ function hasCycleDFS(nodeId: string): boolean {
+ visited.add(nodeId);
+ recursionStack.add(nodeId);
+
+ const neighbors = adjacencyList.get(nodeId) || [];
+ for (const neighbor of neighbors) {
+ if (!visited.has(neighbor)) {
+ if (hasCycleDFS(neighbor)) {
+ return true;
+ }
+ } else if (recursionStack.has(neighbor)) {
+ return true;
+ }
+ }
+
+ recursionStack.delete(nodeId);
+ return false;
+ }
+
+ // Check each node
+ for (const node of nodes) {
+ if (!visited.has(node.id)) {
+ if (hasCycleDFS(node.id)) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+}
+
+/**
+ * Find orphaned nodes (nodes with no connections)
+ * @param nodes All nodes in the graph
+ * @param edges All edges in the graph
+ * @returns Array of orphaned node IDs
+ */
+export function findOrphanedNodes(nodes: VisualNode[], edges: VisualEdge[]): string[] {
+ const connectedNodes = new Set();
+
+ // Mark all nodes that have connections
+ edges.forEach(edge => {
+ connectedNodes.add(edge.source);
+ connectedNodes.add(edge.target);
+ });
+
+ // Find nodes that are not connected
+ const orphanedNodes = nodes
+ .filter(node => !connectedNodes.has(node.id))
+ .map(node => node.id);
+
+ return orphanedNodes;
+}
+
+/**
+ * Get node by ID
+ * @param nodes All nodes in the graph
+ * @param nodeId ID of the node to find
+ * @returns Node or undefined
+ */
+export function getNodeById(nodes: VisualNode[], nodeId: string): VisualNode | undefined {
+ return nodes.find(node => node.id === nodeId);
+}
+
+/**
+ * Get nodes by type
+ * @param nodes All nodes in the graph
+ * @param nodeType Type to filter by
+ * @returns Array of nodes of the specified type
+ */
+export function getNodesByType(nodes: VisualNode[], nodeType: string): VisualNode[] {
+ return nodes.filter(node => node.type === nodeType);
+}
+
+/**
+ * Validate node data structure
+ * @param nodeType Type of the node
+ * @param data Node data to validate
+ * @returns True if valid, false otherwise
+ */
+export function isValidNodeData(nodeType: string, data: NodeData): boolean {
+ if (!data || typeof data !== 'object') {
+ return false;
+ }
+
+ switch (nodeType) {
+ case 'basic-info':
+ return typeof data.name === 'string' && typeof data.namespace === 'string';
+ case 'llm':
+ return typeof data.modelName === 'string' && typeof data.provider === 'string';
+ case 'system-prompt':
+ return typeof data.systemPrompt === 'string';
+ case 'tool':
+ return Array.isArray(data.tools);
+ default:
+ return true;
+ }
+}
+
diff --git a/ui/src/lib/agent-builder/validation.ts b/ui/src/lib/agent-builder/validation.ts
new file mode 100644
index 000000000..4872dd4e0
--- /dev/null
+++ b/ui/src/lib/agent-builder/validation.ts
@@ -0,0 +1,255 @@
+import type {
+ VisualNode,
+ VisualEdge,
+ VisualBuilderValidationResult,
+ LLMNodeData,
+ SystemPromptNodeData,
+ BasicInfoNodeData
+} from "@/types";
+import { detectCycles, findOrphanedNodes, getNodesByType } from "./utils";
+import { isResourceNameValid } from "@/lib/utils";
+
+/**
+ * Validates complete visual graph
+ * @param nodes All nodes in the graph
+ * @param edges All edges in the graph
+ * @param basicInfo Basic agent information
+ * @returns Validation result with errors and warnings
+ */
+export function validateVisualGraph(
+ nodes: VisualNode[],
+ edges: VisualEdge[],
+ basicInfo: { name: string; namespace: string; description: string }
+): VisualBuilderValidationResult {
+ const errors: Record = {};
+ const warnings: string[] = [];
+
+ // If no nodes, return early with basic validation
+ if (!nodes || nodes.length === 0) {
+ errors.general = "No nodes added to the graph. Add at least one node to get started.";
+ return { isValid: false, errors, warnings };
+ }
+
+ // Validate basic info
+ const basicInfoErrors = validateBasicInfo(basicInfo);
+ Object.assign(errors, basicInfoErrors);
+
+ // Validate required nodes
+ const requiredNodeErrors = validateRequiredNodes(nodes);
+ Object.assign(errors, requiredNodeErrors);
+
+ // Validate individual nodes
+ const nodeErrors = validateNodes(nodes);
+ Object.assign(errors, nodeErrors);
+
+ // Validate graph structure
+ const structureWarnings = validateGraphStructure(nodes, edges);
+ warnings.push(...structureWarnings);
+
+ // Check for cycles
+ if (detectCycles(nodes, edges)) {
+ warnings.push("Graph contains cycles. This may cause unexpected behavior.");
+ }
+
+ // Check for orphaned nodes (except basic-info which can be standalone)
+ const orphanedNodes = findOrphanedNodes(nodes, edges);
+ const orphanedNonBasicNodes = orphanedNodes.filter(nodeId => {
+ const node = nodes.find(n => n.id === nodeId);
+ return node && node.type !== 'basic-info';
+ });
+
+ if (orphanedNonBasicNodes.length > 0) {
+ warnings.push(`${orphanedNonBasicNodes.length} node(s) are not connected. Consider connecting them or removing them.`);
+ }
+
+ const isValid = Object.keys(errors).length === 0;
+
+ return { isValid, errors, warnings };
+}
+
+/**
+ * Validate basic agent information
+ */
+function validateBasicInfo(basicInfo: { name: string; namespace: string; description: string }): Record {
+ const errors: Record = {};
+
+ if (!basicInfo.name || basicInfo.name.trim() === '') {
+ errors.name = "Agent name is required";
+ } else if (!isResourceNameValid(basicInfo.name)) {
+ errors.name = "Agent name must be a valid Kubernetes resource name (lowercase alphanumeric, hyphens, max 63 chars)";
+ }
+
+ if (!basicInfo.namespace || basicInfo.namespace.trim() === '') {
+ errors.namespace = "Namespace is required";
+ } else if (!isResourceNameValid(basicInfo.namespace)) {
+ errors.namespace = "Namespace must be a valid Kubernetes resource name";
+ }
+
+ return errors;
+}
+
+/**
+ * Validate required nodes exist
+ */
+function validateRequiredNodes(nodes: VisualNode[]): Record {
+ const errors: Record = {};
+
+ // Check for LLM node (required)
+ const llmNodes = getNodesByType(nodes, 'llm');
+ if (llmNodes.length === 0) {
+ errors.llm = "Add an LLM node to configure the model";
+ } else if (llmNodes.length > 1) {
+ errors.llm = "Only one LLM model configuration is allowed";
+ }
+
+ // Check for system prompt node (required)
+ const systemPromptNodes = getNodesByType(nodes, 'system-prompt');
+ if (systemPromptNodes.length === 0) {
+ errors.systemPrompt = "Add a System Prompt node to define agent behavior";
+ }
+
+ return errors;
+}
+
+/**
+ * Validate individual nodes
+ */
+function validateNodes(nodes: VisualNode[]): Record {
+ const errors: Record = {};
+
+ nodes.forEach((node, index) => {
+ if (!node.type) {
+ errors[`node_${index}`] = `Node ${node.id} has no type`;
+ return;
+ }
+
+ switch (node.type) {
+ case 'basic-info':
+ const basicInfoErrors = validateBasicInfoNode(node);
+ Object.assign(errors, basicInfoErrors);
+ break;
+
+ case 'llm':
+ const llmErrors = validateLLMNode(node);
+ Object.assign(errors, llmErrors);
+ break;
+
+ case 'system-prompt':
+ const promptErrors = validateSystemPromptNode(node);
+ Object.assign(errors, promptErrors);
+ break;
+
+ case 'tool':
+ // Tool validation is optional - tools can be empty
+ break;
+
+ case 'output':
+ // Output validation is optional
+ break;
+
+ default:
+ errors[`node_${index}`] = `Unknown node type: ${node.type}`;
+ }
+ });
+
+ return errors;
+}
+
+/**
+ * Validate basic info node
+ */
+function validateBasicInfoNode(node: VisualNode): Record {
+ const errors: Record = {};
+ const data = node.data as BasicInfoNodeData;
+
+ // Only validate if the node actually has data fields (not just checking existence)
+ if (data.name !== undefined && (!data.name || data.name.trim() === '')) {
+ errors[`${node.id}_name`] = "Basic info node: Agent name is required";
+ }
+
+ if (data.namespace !== undefined && (!data.namespace || data.namespace.trim() === '')) {
+ errors[`${node.id}_namespace`] = "Basic info node: Namespace is required";
+ }
+
+ return errors;
+}
+
+/**
+ * Validate LLM node
+ */
+function validateLLMNode(node: VisualNode): Record {
+ const errors: Record = {};
+ const data = node.data as LLMNodeData;
+
+ if (!data.modelName || data.modelName.trim() === '') {
+ errors.model = "LLM node: Model must be selected";
+ }
+
+ if (!data.modelConfigRef || data.modelConfigRef.trim() === '') {
+ errors[`${node.id}_modelRef`] = "LLM node: Model configuration reference is required";
+ }
+
+ return errors;
+}
+
+/**
+ * Validate system prompt node
+ */
+function validateSystemPromptNode(node: VisualNode): Record {
+ const errors: Record = {};
+ const data = node.data as SystemPromptNodeData;
+
+ if (!data.systemPrompt || data.systemPrompt.trim() === '') {
+ errors.systemPrompt = "System prompt cannot be empty";
+ }
+
+ return errors;
+}
+
+/**
+ * Validate graph structure
+ */
+function validateGraphStructure(
+ nodes: VisualNode[],
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ edges: VisualEdge[]
+): string[] {
+ const warnings: string[] = [];
+
+ // Tools are optional - no warning needed if not configured
+ // Users can create agents without tools
+
+ // Check for multiple system prompts
+ const systemPromptNodes = getNodesByType(nodes, 'system-prompt');
+ if (systemPromptNodes.length > 1) {
+ warnings.push("Multiple system prompts detected. Only the first one will be used.");
+ }
+
+ // Check for multiple LLM nodes
+ const llmNodes = getNodesByType(nodes, 'llm');
+ if (llmNodes.length > 1) {
+ warnings.push("Multiple LLM configurations detected. Only one is allowed.");
+ }
+
+ return warnings;
+}
+
+/**
+ * Returns human-readable validation summary
+ */
+export function getValidationSummary(result: VisualBuilderValidationResult): string {
+ if (result.isValid) {
+ return "✓ Agent configuration is valid";
+ }
+
+ const errorCount = Object.keys(result.errors).length;
+ const warningCount = result.warnings.length;
+
+ let summary = `✗ Found ${errorCount} error${errorCount !== 1 ? 's' : ''}`;
+ if (warningCount > 0) {
+ summary += ` and ${warningCount} warning${warningCount !== 1 ? 's' : ''}`;
+ }
+
+ return summary;
+}
+
diff --git a/ui/src/types/index.ts b/ui/src/types/index.ts
index 4a4dff7b4..77be95a6f 100644
--- a/ui/src/types/index.ts
+++ b/ui/src/types/index.ts
@@ -1,3 +1,6 @@
+import * as React from 'react';
+import type { NodeChange, EdgeChange } from '@xyflow/react';
+
export type ChatStatus = "ready" | "thinking" | "error" | "submitted" | "working" | "input_required" | "auth_required" | "processing_tools" | "generating_response";
export interface ModelConfig {
@@ -386,3 +389,207 @@ export interface DiscoveredTool {
name: string;
description: string;
}
+
+// ============================================================================
+// VISUAL AGENT BUILDER TYPES
+// ============================================================================
+
+/**
+ * Agent form data structure used for creating/editing agents
+ */
+export interface AgentFormData {
+ name: string;
+ namespace: string;
+ description: string;
+ type?: AgentType;
+ // Declarative fields
+ systemPrompt?: string;
+ modelName?: string;
+ tools: Tool[];
+ stream?: boolean;
+ // BYO fields
+ byoImage?: string;
+ byoCmd?: string;
+ byoArgs?: string[];
+ // Shared deployment optional fields
+ replicas?: number;
+ imagePullSecrets?: Array<{ name: string }>;
+ volumes?: unknown[];
+ volumeMounts?: unknown[];
+ labels?: Record;
+ annotations?: Record;
+ env?: EnvVar[];
+ imagePullPolicy?: string;
+}
+
+/**
+ * Base node data interface
+ */
+export interface NodeData {
+ label?: string;
+ [key: string]: unknown;
+}
+
+/**
+ * Visual node type (React Flow node with typed data)
+ */
+export type VisualNode = {
+ id: string;
+ type?: string;
+ position: { x: number; y: number };
+ data: NodeData;
+ selected?: boolean;
+ dragging?: boolean;
+};
+
+/**
+ * Visual edge type (React Flow edge)
+ */
+export type VisualEdge = {
+ id: string;
+ source: string;
+ target: string;
+ sourceHandle?: string | null;
+ targetHandle?: string | null;
+ type?: string;
+ animated?: boolean;
+ style?: Record;
+};
+
+// Node Data Types for each visual node type
+
+/**
+ * Basic info node data
+ */
+export interface BasicInfoNodeData extends NodeData {
+ name: string;
+ namespace: string;
+ description: string;
+ type: AgentType;
+}
+
+/**
+ * System prompt node data
+ */
+export interface SystemPromptNodeData extends NodeData {
+ systemPrompt: string;
+}
+
+/**
+ * LLM model configuration node data
+ */
+export interface LLMNodeData extends NodeData {
+ modelConfigRef: string;
+ modelName: string;
+ provider: string;
+ temperature?: number;
+ maxTokens?: number;
+ topP?: number;
+ stream?: boolean;
+}
+
+/**
+ * Tool node data
+ */
+export interface ToolNodeData extends NodeData {
+ serverRef?: string;
+ tools: Tool[];
+}
+
+/**
+ * Output formatting node data
+ */
+export interface OutputNodeData extends NodeData {
+ format: 'json' | 'text' | 'markdown';
+ template?: string;
+ streaming?: boolean;
+}
+
+// Validation Types
+
+/**
+ * Visual builder validation result
+ */
+export interface VisualBuilderValidationResult {
+ isValid: boolean;
+ errors: Record;
+ warnings: string[];
+}
+
+// Component Props Types
+
+/**
+ * Visual Agent Builder component props
+ */
+export interface VisualAgentBuilderProps {
+ onValidationChange: (errors: Record) => void;
+ onGraphDataChange?: (data: AgentFormData) => void;
+ initialFormData?: Partial;
+ onCreateAgent?: () => void;
+ isSubmitting?: boolean;
+}
+
+/**
+ * Canvas component props
+ */
+export interface CanvasProps {
+ nodes: VisualNode[];
+ edges: VisualEdge[];
+ onNodesChange: (changes: NodeChange[]) => void;
+ onEdgesChange: (changes: EdgeChange[]) => void;
+ onConnect: (connection: { source: string; target: string; sourceHandle?: string | null; targetHandle?: string | null }) => void;
+ onNodeSelect: (nodeId: string | null) => void;
+ onNodesSelect: (nodeIds: string[]) => void;
+ onEdgeSelect: (edgeIds: string[]) => void;
+ onDelete: () => void;
+}
+
+/**
+ * Node library component props
+ */
+export interface NodeLibraryProps {
+ onNodeAdd: (nodeType: string) => void;
+ availableTypes?: readonly string[];
+}
+
+/**
+ * Node properties panel component props
+ */
+export interface NodePropertiesProps {
+ selectedNode: string | null;
+ nodes: VisualNode[];
+ onNodeUpdate: (nodeId: string, data: Record) => void;
+ validationResult?: VisualBuilderValidationResult | null;
+ onCreateAgent?: () => void;
+ isSubmitting?: boolean;
+}
+
+/**
+ * Node type definition for node library
+ */
+export interface NodeTypeDefinition {
+ type: string;
+ label: string;
+ icon: React.ComponentType<{ className?: string }>; // React component type for icons
+ color: string;
+ description: string;
+ category: 'core' | 'advanced';
+}
+
+// Constants
+
+/**
+ * MVP node types (subset for initial release)
+ */
+export const MVP_NODE_TYPES = [
+ 'basic-info',
+ 'system-prompt',
+ 'llm',
+ 'tool',
+ 'output'
+] as const;
+
+/**
+ * MVP node type union
+ */
+export type MVPNodeType = typeof MVP_NODE_TYPES[number];