diff --git a/packages/components/agent-builder/README.md b/packages/components/agent-builder/README.md new file mode 100644 index 00000000..407502cc --- /dev/null +++ b/packages/components/agent-builder/README.md @@ -0,0 +1,87 @@ +# @openassistant/agent-builder + +> Visual workflow builder for composing OpenAssistant tools into agents with React Flow. + +## Features + +- πŸ“¦ Automatically imports every tool that ships with OpenAssistant (plots, geoda, duckdb, osm, places, …) and makes them available as drag-and-drop nodes. +- 🧱 Provides custom metadata nodes (name, description, result, error) so you can describe the agent contract right on the canvas. +- πŸ•ΈοΈ Uses [React Flow](https://reactflow.dev/) to let you draw DAGs that represent the execution graph for the experimental AI SDK v5 `Agent` class. +- 🧾 Generates a rich JSON schema (tools, metadata, adjacency, topological order, zod-powered parameter schemas) with a single `Create Agent` click. +- 🎨 Modern Tailwind-based UI with search, grouping, and real-time schema preview. + +## Installation + +```bash +yarn add @openassistant/agent-builder +# or +npm install @openassistant/agent-builder +``` + +This package ships as an ES module + CommonJS bundle and expects `react`, `react-dom`, and `reactflow` as peer dependencies. + +## Quick start + +```tsx +import { AgentBuilder, defaultToolRegistry } from '@openassistant/agent-builder'; +import '@openassistant/agent-builder/dist/index.css'; + +export function BuilderExample() { + return ( +
+ { + // Persist the schema or hydrate an Agent instance. + console.log(schema); + }} + /> +
+ ); +} +``` + +By default the builder loads every OpenAssistant tool it can find. You can limit or extend the catalog by passing your own `tools` prop (array of `AgentBuilderTool`). + +## JSON schema + +Click **Create Agent** to produce a schema shaped like: + +```json +{ + "agent": { + "name": "Spatial Analyst Agent", + "description": "Runs Moran scatterplot after DuckDB query", + "result": "GeoJSON feature collection", + "error": "Surface upstream DuckDB errors" + }, + "workflow": { + "order": ["meta_agentName_ab1", "tool_duckdb_1", "tool_moran_2"], + "edges": [{ "id": "e1", "source": "tool_duckdb_1", "target": "tool_moran_2" }], + "adjacency": { "tool_duckdb_1": ["tool_moran_2"] } + }, + "tools": [ + { + "nodeId": "tool_duckdb_1", + "name": "duckdbQuery", + "description": "Run SQL queries against DuckDB", + "parametersSchema": { "...": "zod-based JSON schema" } + } + ], + "customNodes": [ + { "nodeId": "meta_agentName_ab1", "type": "agentName", "value": "Spatial Analyst Agent" } + ] +} +``` + +You can feed this JSON directly into downstream orchestration layers to instantiate the AI SDK Agent or to persist/share templates. + +## Customization tips + +- `customNodes`: provide your own metadata node definitions (label, description, placeholder). +- `initialNodes` / `initialEdges`: hydrate the canvas from a previously saved schema. +- Use `buildAgentSchema(nodes, edges)` if you need to generate the schema outside of the built-in button. + +## License + +MIT – see the root repository for details. diff --git a/packages/components/agent-builder/esbuild.config.mjs b/packages/components/agent-builder/esbuild.config.mjs new file mode 100644 index 00000000..0c8936d5 --- /dev/null +++ b/packages/components/agent-builder/esbuild.config.mjs @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: MIT +// Copyright contributors to the openassistant project + +import { + createBaseConfig, + buildFormat, + createWatchMode, +} from '../../../esbuild.config.mjs'; +import tailwindPlugin from 'esbuild-plugin-tailwindcss'; + +const isStart = process.argv.includes('--start'); +const isWatch = process.argv.includes('--watch'); + +const baseConfig = createBaseConfig({ + entryPoints: ['src/index.ts'], + external: [ + 'react', + 'react-dom', + 'reactflow', + 'clsx', + 'tailwindcss', + 'zod', + 'zod-to-json-schema', + '@openassistant/utils', + '@openassistant/duckdb', + '@openassistant/geoda', + '@openassistant/h3', + '@openassistant/map', + '@openassistant/osm', + '@openassistant/places', + '@openassistant/plots', + ], + loader: { + '.js': 'jsx', + '.ts': 'tsx', + '.css': 'css', + '.svg': 'file', + '.png': 'file', + }, + jsx: 'automatic', + plugins: [ + tailwindPlugin({ + config: './tailwind.config.js', + }), + ], + define: { + 'process.env.NODE_ENV': isStart ? '"development"' : '"production"', + }, + mainFields: ['module', 'main'], + resolveExtensions: ['.js', '.jsx', '.ts', '.tsx'], + nodePaths: ['node_modules'], +}); + +if (isWatch) { + const esmConfig = { + ...baseConfig, + format: 'esm', + outfile: 'dist/index.esm.js', + }; + const cjsConfig = { + ...baseConfig, + format: 'cjs', + outfile: 'dist/index.cjs.js', + platform: 'node', + target: ['es2017'], + }; + + await createWatchMode(esmConfig); + await createWatchMode(cjsConfig); +} else { + Promise.all([ + buildFormat(baseConfig, 'esm', 'dist/index.esm.js'), + buildFormat(baseConfig, 'cjs', 'dist/index.cjs.js'), + ]).catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/packages/components/agent-builder/package.json b/packages/components/agent-builder/package.json new file mode 100644 index 00000000..0bb0febc --- /dev/null +++ b/packages/components/agent-builder/package.json @@ -0,0 +1,64 @@ +{ + "name": "@openassistant/agent-builder", + "version": "1.0.0-alpha.0", + "author": "Xun Li", + "description": "Visual agent workflow builder for OpenAssistant tools", + "main": "./dist/index.cjs.js", + "types": "./dist/index.d.ts", + "module": "./dist/index.esm.js", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.esm.js", + "require": "./dist/index.cjs.js" + }, + "./dist/index.css": "./dist/index.esm.css", + "./dist/index.cjs.js": "./dist/index.cjs.js", + "./dist/index.esm.js": "./dist/index.esm.js" + }, + "scripts": { + "build": "node ../../../node_modules/typescript/bin/tsc -p tsconfig.json && node esbuild.config.mjs", + "watch": "node esbuild.config.mjs --watch", + "prepublishOnly": "yarn build", + "lint": "eslint src --ext .js,.jsx,.ts,.tsx" + }, + "keywords": [ + "agent", + "workflow", + "react-flow", + "openassistant" + ], + "license": "MIT", + "files": [ + "dist", + "src", + "README.md", + "package.json" + ], + "dependencies": { + "@openassistant/duckdb": "workspace:*", + "@openassistant/geoda": "workspace:*", + "@openassistant/h3": "workspace:*", + "@openassistant/map": "workspace:*", + "@openassistant/osm": "workspace:*", + "@openassistant/places": "workspace:*", + "@openassistant/plots": "workspace:*", + "@openassistant/utils": "workspace:*", + "clsx": "^2.1.1", + "reactflow": "^11.10.4", + "tailwindcss": "^3.4.17", + "zod": "^3.25.0", + "zod-to-json-schema": "^3.24.1" + }, + "peerDependencies": { + "react": ">=18.2", + "react-dom": ">=18.2" + }, + "devDependencies": { + "esbuild-plugin-tailwindcss": "^1.2.1" + }, + "publishConfig": { + "access": "public" + }, + "gitHead": "232930873ca397af1dbaa234a00c5d27dba29a26" +} diff --git a/packages/components/agent-builder/src/AgentBuilder.tsx b/packages/components/agent-builder/src/AgentBuilder.tsx new file mode 100644 index 00000000..bd895167 --- /dev/null +++ b/packages/components/agent-builder/src/AgentBuilder.tsx @@ -0,0 +1,496 @@ +// SPDX-License-Identifier: MIT +// Copyright contributors to the openassistant project + +import { + useCallback, + useMemo, + useRef, + useState, + type DragEvent, + type MouseEvent, +} from 'react'; +import ReactFlow, { + addEdge, + Background, + Connection, + Controls, + MarkerType, + MiniMap, + type Edge, + type ReactFlowInstance, + type XYPosition, + useEdgesState, + useNodesState, +} from 'reactflow'; +import 'reactflow/dist/style.css'; +import './index.css'; +import clsx from 'clsx'; +import { generateId } from '@openassistant/utils'; +import { ToolNode } from './components/ToolNode'; +import { MetaNode } from './components/MetaNode'; +import { AgentBuilderProvider, createNodeDataUpdater } from './context/AgentBuilderContext'; +import { buildAgentSchema } from './utils/schema'; +import { defaultToolRegistry } from './utils/tool-registry'; +import { DEFAULT_CUSTOM_NODES } from './constants'; +import type { + AgentBuilderNode, + AgentBuilderNodeData, + AgentBuilderProps, + AgentBuilderSchema, + AgentBuilderTool, + AgentMetaNodeType, + CustomNodeDefinition, + DraggedNodeBlueprint, + ToolNodeData, + MetaNodeData, +} from './types'; + +const nodeTypes = { + toolNode: ToolNode, + metaNode: MetaNode, +} as const; + +const APPLICATION_MIME = 'application/reactflow'; + +const normalizeTools = (tools: AgentBuilderTool[]) => { + return tools.map((tool) => ({ + ...tool, + label: tool.tool.name, + })); +}; + +const createNodeFromBlueprint = ( + blueprint: DraggedNodeBlueprint, + position: XYPosition, + tools: AgentBuilderTool[], + customNodes: CustomNodeDefinition[], + currentNodes: AgentBuilderNode[] +): AgentBuilderNode | null => { + if (blueprint.kind === 'tool') { + const tool = tools.find((item) => item.id === blueprint.toolId); + if (!tool) { + return null; + } + return { + id: `tool_${tool.id}_${generateId()}`, + type: 'toolNode', + position, + data: { + label: tool.tool.name, + description: tool.shortDescription ?? tool.tool.description, + category: tool.category, + tool: tool.tool, + } satisfies ToolNodeData, + }; + } + + const definition = customNodes.find( + (node) => node.type === blueprint.metaType + ); + + if (!definition) { + return null; + } + + const alreadyExists = currentNodes.some( + (node) => + node.type === 'metaNode' && + (node.data as MetaNodeData).type === definition.type + ); + + if (alreadyExists) { + return null; + } + + return { + id: `meta_${definition.type}_${generateId()}`, + type: 'metaNode', + position, + data: { + label: definition.label, + description: definition.description, + placeholder: definition.placeholder, + type: definition.type, + value: '', + } satisfies MetaNodeData, + }; +}; + +const useGroupedTools = (tools: AgentBuilderTool[], search: string) => { + return useMemo(() => { + const lowered = search.toLowerCase(); + const filtered = tools.filter((tool) => { + if (!lowered) { + return true; + } + return ( + tool.tool.name.toLowerCase().includes(lowered) || + tool.tool.description.toLowerCase().includes(lowered) + ); + }); + const groups = filtered.reduce>( + (acc, tool) => { + if (!acc[tool.category]) { + acc[tool.category] = []; + } + acc[tool.category].push(tool); + return acc; + }, + {} + ); + + return Object.entries(groups) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([category, groupedTools]) => ({ + category, + tools: groupedTools.sort((a, b) => + a.tool.name.localeCompare(b.tool.name) + ), + })); + }, [tools, search]); +}; + +export const AgentBuilder = ({ + tools = defaultToolRegistry, + customNodes = DEFAULT_CUSTOM_NODES, + initialNodes = [], + initialEdges = [], + onCreateAgent, +}: AgentBuilderProps) => { + const normalizedTools = useMemo(() => normalizeTools(tools), [tools]); + const [nodes, setNodes, onNodesChange] = + useNodesState(initialNodes); + const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); + const [search, setSearch] = useState(''); + const [schemaPreview, setSchemaPreview] = useState( + null + ); + const [errorMessage, setErrorMessage] = useState(null); + const reactFlowWrapper = useRef(null); + const [reactFlowInstance, setReactFlowInstance] = + useState(null); + const groupedTools = useGroupedTools(normalizedTools, search); + + const onConnect = useCallback( + (connection: Connection | Edge) => { + setEdges((current) => + addEdge( + { + ...connection, + animated: true, + style: { stroke: '#6366f1', strokeWidth: 2 }, + }, + current + ) + ); + }, + [setEdges] + ); + + const onDragOver = useCallback((event: DragEvent) => { + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + }, []); + + const onDrop = useCallback( + (event: DragEvent) => { + event.preventDefault(); + if (!reactFlowWrapper.current || !reactFlowInstance) { + return; + } + const raw = event.dataTransfer.getData(APPLICATION_MIME); + if (!raw) { + return; + } + + let blueprint: DraggedNodeBlueprint | null = null; + try { + blueprint = JSON.parse(raw) as DraggedNodeBlueprint; + } catch { + return; + } + + const bounds = reactFlowWrapper.current.getBoundingClientRect(); + const position = reactFlowInstance.project({ + x: event.clientX - bounds.left, + y: event.clientY - bounds.top, + }); + const newNode = createNodeFromBlueprint( + blueprint, + position, + normalizedTools, + customNodes, + nodes + ); + + if (!newNode) { + setErrorMessage( + blueprint.kind === 'meta' + ? 'This custom node already exists.' + : 'Unable to add this node.' + ); + return; + } + + setErrorMessage(null); + setNodes((current) => current.concat(newNode)); + }, + [ + customNodes, + normalizedTools, + nodes, + reactFlowInstance, + setNodes, + setErrorMessage, + ] + ); + + const handleDragStart = + (payload: DraggedNodeBlueprint) => (event: DragEvent) => { + event.dataTransfer.setData(APPLICATION_MIME, JSON.stringify(payload)); + event.dataTransfer.effectAllowed = 'move'; + }; + + const handleCreateAgent = useCallback(() => { + try { + const schema = buildAgentSchema(nodes, edges); + setSchemaPreview(schema); + setErrorMessage(null); + onCreateAgent?.(schema); + } catch (error) { + setSchemaPreview(null); + setErrorMessage( + error instanceof Error ? error.message : 'Unable to build agent schema.' + ); + } + }, [edges, nodes, onCreateAgent]); + + const updateNodeData = useMemo( + () => createNodeDataUpdater(setNodes), + [setNodes] + ); + + const isMetaNodeUsed = useCallback( + (type: AgentMetaNodeType) => { + return nodes.some( + (node) => + node.type === 'metaNode' && (node.data as MetaNodeData).type === type + ); + }, + [nodes] + ); + + const handleNodeShortcut = ( + payload: DraggedNodeBlueprint, + event: MouseEvent + ) => { + event.preventDefault(); + if (!reactFlowInstance) { + return; + } + const position = reactFlowInstance.project({ x: 120, y: 80 }); + const newNode = createNodeFromBlueprint( + payload, + position, + normalizedTools, + customNodes, + nodes + ); + if (!newNode) { + setErrorMessage( + payload.kind === 'meta' + ? 'This custom node already exists.' + : 'Unable to add this node.' + ); + return; + } + setErrorMessage(null); + setNodes((current) => current.concat(newNode)); + }; + + const nodeShortcutHandler = + (payload: DraggedNodeBlueprint) => (event: MouseEvent) => + handleNodeShortcut(payload, event); + + return ( + +
+
+ + +
+
+ + + + + node.type === 'metaNode' ? '#fbbf24' : '#6366f1' + } + nodeColor={(node) => + node.type === 'metaNode' ? '#fef3c7' : '#eef2ff' + } + /> + +
+
+
+
+
+ ); +}; diff --git a/packages/components/agent-builder/src/components/MetaNode.tsx b/packages/components/agent-builder/src/components/MetaNode.tsx new file mode 100644 index 00000000..a1478102 --- /dev/null +++ b/packages/components/agent-builder/src/components/MetaNode.tsx @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: MIT +// Copyright contributors to the openassistant project + +import type { NodeProps } from 'reactflow'; +import { Handle, Position } from 'reactflow'; +import type { MetaNodeData } from '../types'; +import { useAgentBuilder } from '../context/AgentBuilderContext'; + +export const MetaNode = ({ id, data }: NodeProps) => { + const { updateNodeData } = useAgentBuilder(); + + return ( +
+
+

{data.label}

+

{data.description}

+
+