feat(agentflow): add initial implementation of @flowise/agentflow package (#5733)

* feat(agentflow): add initial implementation of @flowise/agentflow package

- Introduced core files including package.json, README.md, and architecture documentation.
- Added ESLint, Prettier, and TypeScript configurations for code quality and formatting.
- Implemented basic structure with components, hooks, and utilities for building AI agent workflows.
- Included example usage and development setup in the examples directory.
- Established dependency management with pnpm-lock.yaml updates.

This commit lays the groundwork for the agentflow package, enabling further development of features and functionalities.

* address gemini review comments and fix format failure from ci/cd

* lint issue from ci/cd

* refactor to remove duplicates in agentflow, lint update

* Track package-lock.json for agentflow examples

* fix whitespace in examples lock file
This commit is contained in:
Jocelyn Lin
2026-02-11 16:27:28 -08:00
committed by GitHub
parent 420907210a
commit 4b1b8ba376
96 changed files with 51270 additions and 39284 deletions
+1
View File
@@ -5,6 +5,7 @@
# dependencies
**/node_modules
**/package-lock.json
!**/examples/package-lock.json
**/yarn.lock
## logs
+8
View File
@@ -0,0 +1,8 @@
dist
node_modules
build
*.config.ts
*.config.js
vite.config.ts
examples/dist
examples/vite.config.ts
+147
View File
@@ -0,0 +1,147 @@
const features = ['canvas', 'generator', 'node-editor', 'node-palette']
const crossFeatureRules = features.map((feature) => ({
target: `./src/features/${feature}`,
from: './src/features',
except: [`./${feature}`],
message: 'Features cannot import from other features. Move shared logic to core.'
}))
module.exports = {
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:react/jsx-runtime',
'plugin:react-hooks/recommended',
'plugin:jsx-a11y/recommended',
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended'
],
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
ecmaFeatures: {
jsx: true
}
},
settings: {
react: {
version: 'detect'
}
},
plugins: ['react', 'react-hooks', '@typescript-eslint', 'unused-imports', 'jsx-a11y', 'simple-import-sort', 'import'],
ignorePatterns: ['dist', 'node_modules', 'build', 'vite.config.ts', 'examples/dist', '**/*.json'],
rules: {
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': 'off',
'no-unused-vars': 'off',
'unused-imports/no-unused-imports': 'warn',
'unused-imports/no-unused-vars': [
'warn',
{
vars: 'all',
varsIgnorePattern: '^_',
args: 'after-used',
argsIgnorePattern: '^_'
}
],
'no-console': ['warn', { allow: ['warn', 'error', 'info'] }],
'react/prop-types': 'off',
'react/react-in-jsx-scope': 'off',
'simple-import-sort/imports': [
'error',
{
groups: [
// Side effect imports
['^\\u0000'],
// React and React-related packages first
['^react', '^react-dom'],
// Other external packages (starting with @ or letter)
['^@?\\w'],
// Internal packages (using @ alias)
['^@/'],
// Parent imports (../)
['^\\.\\.(?!/?$)', '^\\.\\./?$'],
// Same directory imports (./)
['^\\./(?=.*/)(?!/?$)', '^\\.(?!/?$)', '^\\./?$'],
// Type imports
['^.+\\.?(css|scss)$']
]
}
],
'simple-import-sort/exports': 'error',
'import/first': 'error',
'import/newline-after-import': 'error',
'import/no-duplicates': 'error',
'prettier/prettier': 'error',
// Architectural boundary enforcement
'import/no-restricted-paths': [
'error',
{
zones: [
// atoms/ can only import from core/types (not from features, infrastructure, or core utils)
{
target: './src/atoms',
from: './src/features',
message: 'Atoms cannot import from features. Keep atoms dumb and reusable.'
},
{
target: './src/atoms',
from: './src/infrastructure',
message: 'Atoms cannot import from infrastructure. Use props instead of contexts.'
},
{
target: './src/atoms',
from: './src/core',
except: ['./types'],
message: 'Atoms can only import types from core/types, not utilities or business logic.'
},
// core/ cannot import from anything (leaf node)
{
target: './src/core',
from: './src/atoms',
message: 'Core is a leaf node and cannot import from atoms.'
},
{
target: './src/core',
from: './src/features',
message: 'Core is a leaf node and cannot import from features.'
},
{
target: './src/core',
from: './src/infrastructure',
message: 'Core is a leaf node and cannot import from infrastructure.'
},
// infrastructure/ can only import from core/
{
target: './src/infrastructure',
from: './src/atoms',
message: 'Infrastructure cannot import from atoms. Move shared code to core.'
},
{
target: './src/infrastructure',
from: './src/features',
message: 'Infrastructure cannot import from features. Move shared code to core.'
},
// features/ cannot import from other features (prevent cross-feature dependencies)
...crossFeatureRules
]
}
]
},
env: {
browser: true,
es2021: true,
node: true
},
overrides: [
{
files: ['examples/**/*.{js,jsx,ts,tsx}'],
rules: {
'no-console': 'off',
'@typescript-eslint/no-non-null-assertion': 'off'
}
}
]
}
+14
View File
@@ -0,0 +1,14 @@
# Source files
/src
/tests
/*.ts
/*.tsx
tsconfig.json
vite.config.ts
# Dev files
.turbo
node_modules
# Keep dist
!dist
+10
View File
@@ -0,0 +1,10 @@
dist
node_modules
build
*.config.ts
*.config.js
vite.config.ts
examples/dist
examples/vite.config.ts
pnpm-lock.yaml
package-lock.json
+9
View File
@@ -0,0 +1,9 @@
{
"printWidth": 140,
"singleQuote": true,
"jsxSingleQuote": true,
"trailingComma": "none",
"tabWidth": 4,
"semi": false,
"endOfLine": "auto"
}
+361
View File
@@ -0,0 +1,361 @@
# @flowise/agentflow - Architecture
This document describes the internal architecture of the `@flowise/agentflow` package.
## Overview
The package follows a **Domain-Driven Modular Architecture** with clear separation of concerns. The goal is to separate low-level UI primitives from high-level business logic to ensure high reusability and easy testing.
```
src/
├── index.ts # Public Package API ("Public Face")
├── Agentflow.tsx # Main component
├── AgentflowProvider.tsx # Root provider (composition)
├── useAgentflow.ts # Primary public hook
│
├── atoms/ # ⚛️ UI Primitives ("What it looks like" (Dumb))
├── features/ # 🧩 Domain Features ("What it does" (Smart))
├── core/ # 🧠 Business Logic ("Business Rules" (Types/Logic))
└── infrastructure/ # 🌐 External Services ("Outside World" (API/Storage))
```
---
## Directory Structure
### `atoms/` - UI Primitives
**"What it looks like."** Tiny, irreducible UI components with no business logic. These are the building blocks used by features.
```
atoms/
├── MainCard.tsx # Styled card wrapper
├── Input.tsx # Basic input component
├── ConfirmDialog.tsx # Confirmation dialog with context
├── NodeInputHandler.tsx # Form input for node properties
└── index.ts # Central export
```
**Rules:**
- Must be "dumb" and stateless
- No business logic
- No API calls
- Stateless or minimal local state
- Imported by features, never the reverse
- **Forbidden**: Importing from `features/` or `infrastructure/` (except types from `core/types` for prop definitions)
**Goal:** 100% visual consistency.
---
### `features/` - Domain Features
**"What it does."** Complex, self-contained domain modules (silos) that house the application's core functionality. Each feature owns its components, hooks, and utilities.
```
features/
├── canvas/ # Core ReactFlow canvas
│ ├── containers/ # Smart components with state/logic
│ │ ├── AgentFlowNode.tsx
│ │ ├── AgentFlowEdge.tsx
│ │ ├── StickyNote.tsx
│ │ ├── IterationNode.tsx
│ │ └── index.ts
│ ├── components/ # Presentational components
│ │ ├── AgentflowHeader.tsx
│ │ ├── ConnectionLine.tsx
│ │ ├── NodeToolbarActions.tsx
│ │ ├── NodeStatusIndicator.tsx
│ │ ├── NodeInfoDialog.tsx
│ │ ├── NodeModelConfigs.tsx
│ │ └── index.ts
│ ├── hooks/ # Canvas-related hooks
│ │ ├── useFlowNodes.ts
│ │ ├── useFlowHandlers.ts
│ │ ├── useDragAndDrop.ts
│ │ └── index.ts
│ ├── styled.ts # Styled components
│ ├── nodeIcons.tsx # Icon utilities
│ ├── canvas.css # Co-located styles
│ └── index.ts # Public API
│
├── node-palette/ # Add nodes drawer
│ ├── AddNodesDrawer.tsx
│ ├── StyledFab.tsx
│ ├── search.ts # Feature-specific utility (private)
│ └── index.ts # Exports: AddNodesDrawer, StyledFab
│
├── generator/ # AI flow generation
│ ├── GenerateFlowDialog.tsx
│ ├── SuggestionChips.tsx
│ └── index.ts # Exports: GenerateFlowDialog
│
└── node-editor/ # Node property editing
├── EditNodeDialog.tsx
└── index.ts # Exports: EditNodeDialog
```
**Rules:**
- Each feature has an `index.ts` gatekeeper
- **Features never import from other features directly** - If `canvas` needs logic from `generator`, move that logic to `core/`
- Feature-specific utils stay in the feature folder
- Styles are co-located with their feature
- Use `containers/` for smart components (with state, hooks, side effects)
- Use `components/` for presentational components (props in, JSX out)
**Goal:** High cohesion. Should be able to delete a feature folder without breaking others.
---
### `core/` - Business Logic
**"The Brain."** Framework-agnostic logic, types, and utilities. No React, no UI - pure TypeScript. Contains validation schemas, node registries, domain constants, and shared types.
```
core/
├── types/ # Global interfaces (Node, Edge, Flow)
│ └── index.ts
├── node-config/ # Node configuration (icons, colors, default types)
│ ├── nodeIcons.ts # AGENTFLOW_ICONS, DEFAULT_AGENTFLOW_NODES
│ ├── nodeIconUtils.ts # getAgentflowIcon, getNodeColor
│ └── index.ts
├── node-catalog/ # Node catalog and filtering logic
│ ├── nodeFilters.ts # filterNodesByComponents, isAgentflowNode, groupNodesByCategory
│ └── index.ts
├── validation/ # Flow validation logic
│ ├── flowValidation.ts # validateFlow, validateNode
│ └── index.ts
├── utils/ # Generic utilities
│ ├── nodeFactory.ts # initNode, getUniqueNodeId, getUniqueNodeLabel
│ ├── connectionValidation.ts # isValidConnectionAgentflowV2
│ ├── flowExport.ts # generateExportFlowData
│ └── index.ts
└── index.ts # Barrel export (use sparingly)
```
**Rules:**
- **No React components allowed** - Pure TypeScript only
- No browser-specific APIs if possible
- No side effects
- Pure functions where possible
- Can be tested in isolation
**Goal:** To be the framework-agnostic source of truth.
---
### `infrastructure/` - External Services
**"The Outside World."** Handles communication with external systems: data persistence, network requests, and global state management.
```
infrastructure/
├── api/ # API client layer (network requests)
│ ├── client.ts # Axios factory
│ ├── nodes.ts # Nodes API endpoints
│ ├── chatflows.ts # Chatflows API endpoints
│ ├── hooks/
│ │ └── useApi.ts # API hook (co-located)
│ └── index.ts
│
└── store/ # State management
├── ApiContext.tsx # API client context
├── ConfigContext.tsx # Configuration context
├── AgentflowContext.tsx # Flow state context
├── useFlowInstance.ts # ReactFlow instance hook
└── index.ts
```
**Rules:**
- All external communication goes here
- Contexts are internal (composed in AgentflowProvider)
- API hooks live with their API modules
**Goal:** To wrap external dependencies so they can be easily swapped or mocked in tests.
---
## Dependency Flow
**Dependencies must only flow downwards.**
```
┌─────────────────────────────────────────────────────────┐
│ Root Files (Public API) │
│ (index.ts, Agentflow.tsx, AgentflowProvider.tsx) │
│ "The Public Face" │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ features/ │
│ (canvas, node-palette, generator, node-editor) │
│ "What it does" │
└─────────────────────────────────────────────────────────┘
│ │
▼ ▼
┌──────────────────┐ ┌────────────────────────┐
│ atoms/ │ │ infrastructure/ │
│ (UI primitives) │ │ (api, store) │
│ "What it looks │ │ "The Outside World" │
│ like" │ │ │
└──────────────────┘ └────────────────────────┘
│ │
└──────────────┬─────────────────────┘
▼
┌─────────────────────┐
│ core/ │
│ (types, constants, │
│ validation, etc.) │
│ "The Brain" │
└─────────────────────┘
```
**Import Rules:**
- `features` → `atoms`, `infrastructure`, `core` ✅
- `infrastructure` → `core` ✅
- `atoms` → `core/types` only (for type definitions) ✅
- `core` → nothing (leaf node) ✅
- **Atoms and Core are "leaf" nodes** - they cannot import from `features/` or `infrastructure/`
---
## Gatekeeper Pattern
Each module exposes only what's needed via its `index.ts`:
```typescript
// features/canvas/index.ts
// ✅ Public API
export const nodeTypes = { ... }
export const edgeTypes = { ... }
export { ConnectionLine, AgentflowHeader, createHeaderProps }
export { useFlowNodes, useFlowHandlers, useDragAndDrop }
// Container components are re-exported for advanced usage
export { AgentFlowNode, AgentFlowEdge, StickyNote, IterationNode }
// ❌ Internal sub-components stay private within containers/components
```
This enables:
- **Encapsulation**: Internal refactoring doesn't break consumers
- **Tree-shaking**: Bundlers can eliminate unused code
- **Clarity**: Clear contract of what each module provides
---
## Adding a New Feature
1. Create folder under `features/`:
```
features/my-feature/
├── containers/ # Smart components (optional)
│ └── MyContainer.tsx
├── components/ # Presentational components (optional)
│ └── MyComponent.tsx
├── hooks/ # Feature-specific hooks (optional)
│ └── useMyHook.ts
├── helper.ts # Feature-specific util
└── index.ts # Public exports only
```
2. Export only what's needed:
```typescript
// index.ts
export { MyContainer } from './containers'
export { MyComponent } from './components'
export { useMyHook } from './hooks'
export type { MyComponentProps } from './components'
```
3. Import from infrastructure/core, never from other features:
```typescript
// ✅ Good
import { useApiContext } from '../../infrastructure/store'
import type { NodeData } from '../../core/types'
// ❌ Bad - cross-feature import
import { AgentFlowNode } from '../canvas/containers/AgentFlowNode'
```
---
## Root Files (`src/*.ts`)
**"The Public Face."** The entry point of the package.
- **`AgentflowProvider.tsx`**: Injects infrastructure (stores/api) into the app
- **`Agentflow.tsx`**: The primary component users will drop into their apps
- **`useAgentflow.ts`**: Primary public hook for accessing flow state and actions
- **`index.ts`**: The "Barrel" that exports the public API for npm
---
## Public API
The package exposes a minimal public API via `src/index.ts`:
```typescript
// Components
export { Agentflow } from './Agentflow'
export { AgentflowProvider } from './AgentflowProvider'
// Hooks
export { useAgentflow } from './useAgentflow'
export { useApiContext, useConfigContext, useAgentflowContext } from './infrastructure/store'
// Types
export type { AgentflowProps, FlowData, NodeData, ... } from './core/types'
// Utilities (for advanced usage)
export { AGENTFLOW_ICONS, validateFlow, ... } from './core/...'
```
Everything else is internal implementation detail.
---
## Development Principles
### Barrel Exports
Every directory must have an `index.ts` that acts as a gatekeeper.
**✅ Good:**
```typescript
import { Button } from '@atoms'
import { useFlowHandlers } from '@features/canvas'
```
**❌ Bad:**
```typescript
// Never deep-link into files
import { Button } from '@atoms/Button/Button'
import { useFlowHandlers } from '@features/canvas/hooks/useFlowHandlers'
```
### Prop Drilling vs. Context
- **Atoms**: Use props for all configuration
- **Features**: Use context/state for shared data
### Naming Convention
| Type | Convention | Example |
| ----------- | ------------------------- | ------------------------------------- |
| Component | PascalCase.tsx | `AgentFlowNode.tsx` |
| Hook | camelCase.ts (use prefix) | `useFlowInstance.ts` |
| Logic/Types | camelCase.ts | `flowValidation.ts`, `nodeFilters.ts` |
| Styles | kebab-case (co-located) | `canvas.css` |
+82
View File
@@ -0,0 +1,82 @@
# @flowise/agentflow
[![Version](https://img.shields.io/npm/v/@flowise/agentflow)](https://www.npmjs.com/package/@flowise/agentflow)
[![License](https://img.shields.io/badge/license-SEE%20LICENSE-blue)](./LICENSE.md)
> Embeddable React component for building and visualizing AI agent workflows
## ⚠️ Status
**This package is currently under active development.**
- 🚧 Components are not yet fully functional
- ❌ End-to-end functionality is not complete
- 🔄 Features are still being implemented and tested
- ⚡ APIs may change before stable release
- 📝 Documentation is being updated as development progresses
**Cannot be used in production. For development and testing purposes only.**
## Overview
`@flowise/agentflow` is a React-based flow editor for creating AI agent workflows. It provides a visual canvas built on ReactFlow for connecting AI agents, LLMs, tools, and logic nodes.
## Installation
```bash
pnpm add @flowise/agentflow
```
**Peer Dependencies:**
```bash
pnpm add react react-dom @mui/material @mui/icons-material @emotion/react @emotion/styled reactflow
```
## Basic Usage
```tsx
import { Agentflow } from '@flowise/agentflow'
import '@flowise/agentflow/flowise.css'
export default function App() {
return (
<div style={{ width: '100vw', height: '100vh' }}>
<Agentflow instanceUrl='http://localhost:3000' token='your-api-key' />
</div>
)
}
```
## Development
```bash
# Install dependencies
pnpm install
# Build the package
pnpm build
# Run examples
cd examples && pnpm install && pnpm dev
```
Visit the [examples](./examples) directory for more usage patterns.
## Documentation
- [ARCHITECTURE.md](./ARCHITECTURE.md) - Internal architecture and design patterns
- [Examples](./examples/README.md) - Usage examples and demos
## Contributing
This package follows a feature-based architecture with clear separation of concerns. See [ARCHITECTURE.md](./ARCHITECTURE.md) for details on the project structure and development guidelines.
## License
See [LICENSE.md](./LICENSE.md) for details.
---
Part of the [Flowise](https://github.com/FlowiseAI/Flowise) ecosystem
+7
View File
@@ -0,0 +1,7 @@
# Flowise Instance Configuration
VITE_INSTANCE_URL=http://localhost:3000
# API Key (required for authenticated API endpoints)
# Important: Use an API Key, NOT a user authentication token
# Get this from: Flowise UI → Settings → API Keys → Create New Key
VITE_API_TOKEN=
+148
View File
@@ -0,0 +1,148 @@
# @flowise/agentflow Examples
This folder demonstrates various usage patterns of the `@flowise/agentflow` package.
## Setup
1. First, build the agentflow package from the root:
```bash
cd packages/agentflow
pnpm build
```
2. Install dependencies for this example:
```bash
cd examples
pnpm install
```
3. Start the development server:
```bash
pnpm dev
```
4. Open http://localhost:5174 in your browser
## Configuration
The examples app uses environment variables for configuration. To set up:
1. Copy the `.env.example` file to `.env`:
```bash
cp .env.example .env
```
2. Edit `.env` to configure your Flowise instance:
```bash
# Flowise Instance Configuration
VITE_INSTANCE_URL=http://localhost:3000
# API Key (required for authenticated endpoints)
VITE_API_TOKEN=your-api-key-here
```
3. **Get your API Key from Flowise:**
- Open your Flowise instance (http://localhost:3000)
- Go to **Settings** → **API Keys**
- Click **Create New Key**
- Copy the generated key and paste it in `.env`
⚠️ **Important:** Use an **API Key**, not a user authentication token (JWT).
4. Restart the dev server to apply changes:
```bash
pnpm dev
```
**Environment Variables:**
- `VITE_INSTANCE_URL`: Base URL of your Flowise instance (default: `http://localhost:3000`)
- `VITE_API_TOKEN`: Flowise API Key for programmatic access (required for authenticated endpoints)
**Note**: The `.env` file is gitignored and will not be committed to version control. Add your actual API key to `.env`, not `.env.example`.
### Troubleshooting Authentication
**Getting 401 Unauthorized errors?**
Common causes:
1. **Using wrong token type** ❌
- Don't use: User authentication JWT tokens (long tokens starting with `eyJhbGc...`)
- Use: API Keys (shorter tokens like `9CnKuLRHbEY...`)
2. **Token not loaded**
- Restart dev server after editing `.env`: `pnpm dev`
- Check browser console for: `[BasicExample] Environment check`
3. **Invalid API Key**
- Regenerate key in Flowise: Settings → API Keys → Create New Key
- Copy the new key to `.env`
4. **CORS issues**
- Ensure Flowise allows requests from `http://localhost:5174`
- Check Flowise CORS configuration
## Examples
### Basic Usage (`App.tsx`)
The default export shows:
- Basic canvas rendering with `<Agentflow>` component
- Using the `ref` to access imperative methods (`validate`, `fitView`, `getFlow`, `clear`)
- Handling `onFlowChange` and `onSave` callbacks
### Additional Examples
| Example | File | Description |
| ----------------------- | ------------------------------- | ------------------------------------------------------------------------------------ |
| **Multi-Node Flow** | `MultiNodeFlow.tsx` | Complete translation agent flow with multiple connected nodes showing gradient edges |
| **Dark Mode** | `DarkModeExample.tsx` | Theme toggle demonstrating light/dark mode support |
| **Status Indicators** | `StatusIndicatorsExample.tsx` | Node execution states (running, finished, error, stopped) with animated loader |
| **Custom UI** | `CustomUIExample.tsx` | Custom header and node palette using render props |
| **All Node Types** | `AllNodeTypesExample.tsx` | Visual catalog of all 15 available node types with colors and icons |
| **Filtered Components** | `FilteredComponentsExample.tsx` | Restricting available nodes with preset configurations |
## Switching Examples
Use the dropdown selector at the top of the page to switch between examples. All examples are lazy-loaded for better performance.
## Node Types Reference
| Node Type | Color | Description |
| ------------------------- | ------- | -------------------------- |
| `startAgentflow` | #7EE787 | Entry point for the flow |
| `llmAgentflow` | #64B5F6 | Large Language Model node |
| `agentAgentflow` | #4DD0E1 | AI Agent with tools |
| `conditionAgentflow` | #FFB938 | Conditional branching |
| `conditionAgentAgentflow` | #ff8fab | Agent-based condition |
| `humanInputAgentflow` | #6E6EFD | Human approval required |
| `loopAgentflow` | #FFA07A | Loop iteration |
| `iterationAgentflow` | #9C89B8 | Iteration container |
| `directReplyAgentflow` | #4DDBBB | Send response to user |
| `customFunctionAgentflow` | #E4B7FF | Custom JavaScript function |
| `toolAgentflow` | #d4a373 | External tool integration |
| `retrieverAgentflow` | #b8bedd | Vector store retrieval |
| `httpAgentflow` | #FF7F7F | HTTP API request |
| `executeFlowAgentflow` | #a3b18a | Execute another flow |
| `stickyNoteAgentflow` | #fee440 | Documentation note |
## Requirements
The examples work best with a running Flowise instance at `http://localhost:3000` for the node API. Without it:
- The canvas will render
- Initial flow data will display
- Node palette may be empty (nodes load from API)
For standalone testing, examples include mock flow data.
+23
View File
@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@flowise/agentflow - Basic Example</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #root {
width: 100%;
height: 100%;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "agentflow-basic-example",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.10.0",
"@emotion/styled": "^11.10.0",
"@mui/material": "^5.15.0",
"@mui/icons-material": "^5.15.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"reactflow": "^11.5.0"
},
"devDependencies": {
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.0.0",
"typescript": "^5.0.0",
"vite": "^5.0.0"
}
}
+187
View File
@@ -0,0 +1,187 @@
/**
* Example App with Dropdown Selector
*
* Select different examples from the dropdown to see various SDK usage patterns.
*/
import { type ComponentType, lazy, Suspense, useState } from 'react'
import { instanceUrl, token } from './config'
import {
AllNodeTypesExampleProps,
BasicExampleProps,
CustomUIExampleProps,
DarkModeExampleProps,
FilteredComponentsExampleProps,
MultiNodeFlowProps,
StatusIndicatorsExampleProps
} from './demos'
import { PropsDisplay } from './PropsDisplay'
const examples: Array<{
id: string
name: string
description: string
props: object
component: ComponentType
}> = [
{
id: 'basic',
name: 'Basic Usage',
description: 'Simple canvas with imperative methods',
props: BasicExampleProps,
component: lazy(() => import('./demos/BasicExample').then((m) => ({ default: m.BasicExample })))
},
{
id: 'multi-node',
name: 'Multi-Node Flow',
description: 'Complete flow with gradient edges',
props: MultiNodeFlowProps,
component: lazy(() => import('./demos/MultiNodeFlow').then((m) => ({ default: m.MultiNodeFlow })))
},
{
id: 'dark-mode',
name: 'Dark Mode',
description: 'Light/dark theme toggle',
props: DarkModeExampleProps,
component: lazy(() => import('./demos/DarkModeExample').then((m) => ({ default: m.DarkModeExample })))
},
{
id: 'status',
name: 'Status Indicators',
description: 'Node execution states with animations',
props: StatusIndicatorsExampleProps,
component: lazy(() => import('./demos/StatusIndicatorsExample').then((m) => ({ default: m.StatusIndicatorsExample })))
},
{
id: 'custom-ui',
name: 'Custom UI',
description: 'Custom header and palette via render props',
props: CustomUIExampleProps,
component: lazy(() => import('./demos/CustomUIExample').then((m) => ({ default: m.CustomUIExample })))
},
{
id: 'all-nodes',
name: 'All Node Types',
description: 'Visual catalog of all 15 node types',
props: AllNodeTypesExampleProps,
component: lazy(() => import('./demos/AllNodeTypesExample').then((m) => ({ default: m.AllNodeTypesExample })))
},
{
id: 'filtered',
name: 'Filtered Components',
description: 'Restrict available nodes with presets',
props: FilteredComponentsExampleProps,
component: lazy(() => import('./demos/FilteredComponentsExample').then((m) => ({ default: m.FilteredComponentsExample })))
}
]
type ExampleId = (typeof examples)[number]['id']
function isExampleId(id: string): id is ExampleId {
return examples.some((e) => e.id === id)
}
function LoadingFallback() {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
color: '#666',
fontSize: '14px'
}}
>
Loading example...
</div>
)
}
export default function App() {
const [selectedExample, setSelectedExample] = useState<ExampleId>('basic')
// Config loaded from environment variables
const currentExample = examples.find((e) => e.id === selectedExample)
// Replace placeholder values with actual runtime values
const actualProps = currentExample?.props
? Object.fromEntries(
Object.entries(currentExample.props).map(([key, value]) => {
if (key === 'instanceUrl' && typeof value === 'string' && value.includes('environment variables')) {
return [key, instanceUrl]
}
if (key === 'token' && typeof value === 'string' && value.includes('environment variables')) {
return [key, token ? `${token.substring(0, 20)}...` : 'undefined']
}
return [key, value]
})
)
: {}
const Component = currentExample?.component
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
{/* Example Selector Header */}
<div
style={{
padding: '12px 16px',
background: '#fff',
borderBottom: '1px solid #e0e0e0',
display: 'flex',
alignItems: 'center',
gap: '16px',
flexShrink: 0
}}
>
<label htmlFor='example-select' style={{ fontWeight: 600, fontSize: '14px', color: '#333' }}>
Example:
</label>
<select
id='example-select'
value={selectedExample}
onChange={(e) => {
const value = e.target.value
if (isExampleId(value)) setSelectedExample(value)
}}
style={{
padding: '8px 12px',
fontSize: '14px',
border: '1px solid #e0e0e0',
borderRadius: '6px',
background: '#fff',
cursor: 'pointer',
minWidth: '200px'
}}
>
{examples.map((example) => (
<option key={example.id} value={example.id}>
{example.name}
</option>
))}
</select>
{currentExample && <span style={{ color: '#666', fontSize: '13px' }}>{currentExample.description}</span>}
{/* Instance URL Display */}
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: '12px' }}>
<span style={{ fontSize: '12px', color: '#999' }}>{instanceUrl}</span>
</div>
</div>
{/* Props Display Section */}
{currentExample && actualProps && (
<PropsDisplay
exampleName={currentExample.name}
props={actualProps as Record<string, string | boolean>}
exampleId={selectedExample}
/>
)}
{/* Example Content */}
<div style={{ flex: 1, overflow: 'hidden' }}>
<Suspense fallback={<LoadingFallback />}>{Component ? <Component /> : null}</Suspense>
</div>
</div>
)
}
@@ -0,0 +1,123 @@
/**
* PropsDisplay Component
*
* Displays Agentflow component props in an expandable accordion format
*/
import { useEffect, useState } from 'react'
interface PropsDisplayProps {
exampleName: string
props: Record<string, string | boolean>
exampleId: string
}
export function PropsDisplay({ exampleName, props, exampleId }: PropsDisplayProps) {
const [showProps, setShowProps] = useState(false)
// Auto-expand props when example changes
useEffect(() => {
setShowProps(true)
}, [exampleId])
return (
<div
key={exampleId}
style={{
background: '#f8f9fa',
borderBottom: '1px solid #e0e0e0',
flexShrink: 0
}}
>
{/* Accordion Header */}
<button
onClick={() => setShowProps(!showProps)}
style={{
width: '100%',
padding: '12px 16px',
display: 'flex',
alignItems: 'center',
gap: '8px',
background: showProps ? '#fff' : 'transparent',
border: 'none',
borderBottom: showProps ? '1px solid #e0e0e0' : 'none',
cursor: 'pointer',
textAlign: 'left',
transition: 'background 0.2s'
}}
onMouseEnter={(e) => {
if (!showProps) e.currentTarget.style.background = '#f0f0f0'
}}
onMouseLeave={(e) => {
if (!showProps) e.currentTarget.style.background = 'transparent'
}}
>
<span style={{ fontSize: '14px', color: '#666' }}>{showProps ? '▼' : '▶'}</span>
<h3 style={{ margin: 0, fontSize: '14px', fontWeight: 600, color: '#333' }}>Agentflow Props</h3>
<span
style={{
fontSize: '11px',
background: '#e3f2fd',
color: '#1976d2',
padding: '2px 8px',
borderRadius: '12px',
fontWeight: 500
}}
>
{Object.keys(props).length} props
</span>
</button>
{/* Accordion Content */}
{showProps && (
<div
style={{
padding: '16px',
maxHeight: '300px',
overflow: 'auto'
}}
>
<div
style={{
background: '#fff',
border: '1px solid #e0e0e0',
borderRadius: '8px',
padding: '16px',
fontSize: '13px',
fontFamily: 'monospace'
}}
>
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word', color: '#333' }}>
{`<Agentflow\n${Object.entries(props)
.map(([key, value], index, arr) => {
const isLast = index === arr.length - 1
let displayValue: string
if (typeof value === 'boolean') {
displayValue = `{${value}}`
} else if (typeof value === 'string') {
// Check if it's an expression (starts with { or contains =>)
if (value.startsWith('{') || value.includes('=>')) {
displayValue = value
} else {
displayValue = `"${value}"`
}
} else {
displayValue = `"${String(value)}"`
}
return ` ${key}=${displayValue}${isLast ? '' : '\n'}`
})
.join('')}\n/>`}
</pre>
</div>
<div style={{ marginTop: '12px', fontSize: '12px', color: '#666' }}>
ℹ️ These are the main props used in the <strong>{exampleName}</strong> example. See the source code for complete
implementation details.
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,5 @@
/**
* Application configuration from environment variables
*/
export const instanceUrl = import.meta.env.VITE_INSTANCE_URL || 'http://localhost:3000'
export const token = import.meta.env.VITE_API_TOKEN || undefined
@@ -0,0 +1,259 @@
/**
* All Node Types Example
*
* Showcases all available node types with their distinct colors and icons.
* Useful for understanding the visual vocabulary of Agentflow.
*/
import { useRef } from 'react'
import type { AgentFlowInstance, FlowData } from '@flowise/agentflow'
import { Agentflow } from '@flowise/agentflow'
import { instanceUrl, token } from '../config'
// Showcase all node types in a grid layout
const allNodesFlow: FlowData = {
nodes: [
// Row 1: Core Flow Nodes
{
id: 'startAgentflow_0',
type: 'agentflowNode',
position: { x: 50, y: 50 },
data: {
id: 'startAgentflow_0',
name: 'startAgentflow',
label: 'Start',
color: '#7EE787',
hideInput: true,
outputAnchors: [{ id: 'startAgentflow_0-output-0', name: 'start', label: 'Start', type: 'start' }]
}
},
{
id: 'llmAgentflow_0',
type: 'agentflowNode',
position: { x: 250, y: 50 },
data: {
id: 'llmAgentflow_0',
name: 'llmAgentflow',
label: 'LLM',
color: '#64B5F6',
outputAnchors: [{ id: 'llmAgentflow_0-output-0', name: 'output', label: 'Output', type: 'string' }]
}
},
{
id: 'agentAgentflow_0',
type: 'agentflowNode',
position: { x: 450, y: 50 },
data: {
id: 'agentAgentflow_0',
name: 'agentAgentflow',
label: 'Agent',
color: '#4DD0E1',
outputAnchors: [{ id: 'agentAgentflow_0-output-0', name: 'output', label: 'Output', type: 'string' }]
}
},
{
id: 'directReplyAgentflow_0',
type: 'agentflowNode',
position: { x: 650, y: 50 },
data: {
id: 'directReplyAgentflow_0',
name: 'directReplyAgentflow',
label: 'Direct Reply',
color: '#4DDBBB',
outputAnchors: [{ id: 'directReplyAgentflow_0-output-0', name: 'output', label: 'Output', type: 'string' }]
}
},
// Row 2: Control Flow Nodes
{
id: 'conditionAgentflow_0',
type: 'agentflowNode',
position: { x: 50, y: 180 },
data: {
id: 'conditionAgentflow_0',
name: 'conditionAgentflow',
label: 'Condition',
color: '#FFB938',
outputAnchors: [
{ id: 'conditionAgentflow_0-output-0', name: 'true', label: 'True', type: 'boolean' },
{ id: 'conditionAgentflow_0-output-1', name: 'false', label: 'False', type: 'boolean' }
]
}
},
{
id: 'conditionAgentAgentflow_0',
type: 'agentflowNode',
position: { x: 250, y: 180 },
data: {
id: 'conditionAgentAgentflow_0',
name: 'conditionAgentAgentflow',
label: 'Condition Agent',
color: '#ff8fab',
outputAnchors: [{ id: 'conditionAgentAgentflow_0-output-0', name: 'output', label: 'Output', type: 'string' }]
}
},
{
id: 'loopAgentflow_0',
type: 'agentflowNode',
position: { x: 450, y: 180 },
data: {
id: 'loopAgentflow_0',
name: 'loopAgentflow',
label: 'Loop',
color: '#FFA07A',
outputAnchors: [{ id: 'loopAgentflow_0-output-0', name: 'output', label: 'Output', type: 'string' }]
}
},
{
id: 'iterationAgentflow_0',
type: 'agentflowNode',
position: { x: 650, y: 180 },
data: {
id: 'iterationAgentflow_0',
name: 'iterationAgentflow',
label: 'Iteration',
color: '#9C89B8',
outputAnchors: [{ id: 'iterationAgentflow_0-output-0', name: 'output', label: 'Output', type: 'string' }]
}
},
// Row 3: Interactive & Tool Nodes
{
id: 'humanInputAgentflow_0',
type: 'agentflowNode',
position: { x: 50, y: 310 },
data: {
id: 'humanInputAgentflow_0',
name: 'humanInputAgentflow',
label: 'Human Input',
color: '#6E6EFD',
outputAnchors: [
{ id: 'humanInputAgentflow_0-output-0', name: 'proceed', label: 'Proceed', type: 'string' },
{ id: 'humanInputAgentflow_0-output-1', name: 'reject', label: 'Reject', type: 'string' }
]
}
},
{
id: 'toolAgentflow_0',
type: 'agentflowNode',
position: { x: 250, y: 310 },
data: {
id: 'toolAgentflow_0',
name: 'toolAgentflow',
label: 'Tool',
color: '#d4a373',
outputAnchors: [{ id: 'toolAgentflow_0-output-0', name: 'output', label: 'Output', type: 'string' }]
}
},
{
id: 'retrieverAgentflow_0',
type: 'agentflowNode',
position: { x: 450, y: 310 },
data: {
id: 'retrieverAgentflow_0',
name: 'retrieverAgentflow',
label: 'Retriever',
color: '#b8bedd',
outputAnchors: [{ id: 'retrieverAgentflow_0-output-0', name: 'output', label: 'Output', type: 'string' }]
}
},
{
id: 'customFunctionAgentflow_0',
type: 'agentflowNode',
position: { x: 650, y: 310 },
data: {
id: 'customFunctionAgentflow_0',
name: 'customFunctionAgentflow',
label: 'Custom Function',
color: '#E4B7FF',
outputAnchors: [{ id: 'customFunctionAgentflow_0-output-0', name: 'output', label: 'Output', type: 'string' }]
}
},
// Row 4: Integration Nodes
{
id: 'httpAgentflow_0',
type: 'agentflowNode',
position: { x: 50, y: 440 },
data: {
id: 'httpAgentflow_0',
name: 'httpAgentflow',
label: 'HTTP Request',
color: '#FF7F7F',
outputAnchors: [{ id: 'httpAgentflow_0-output-0', name: 'output', label: 'Output', type: 'string' }]
}
},
{
id: 'executeFlowAgentflow_0',
type: 'agentflowNode',
position: { x: 250, y: 440 },
data: {
id: 'executeFlowAgentflow_0',
name: 'executeFlowAgentflow',
label: 'Execute Flow',
color: '#a3b18a',
outputAnchors: [{ id: 'executeFlowAgentflow_0-output-0', name: 'output', label: 'Output', type: 'string' }]
}
},
{
id: 'stickyNoteAgentflow_0',
type: 'agentflowNode',
position: { x: 450, y: 440 },
data: {
id: 'stickyNoteAgentflow_0',
name: 'stickyNoteAgentflow',
label: 'Sticky Note',
color: '#fee440',
outputAnchors: []
}
}
],
edges: [],
viewport: { x: 0, y: 0, zoom: 0.85 }
}
export function AllNodeTypesExample() {
// Config loaded from environment variables
const agentflowRef = useRef<AgentFlowInstance>(null)
return (
<div style={{ width: '100%', height: '100vh', display: 'flex', flexDirection: 'column' }}>
{/* Info Header */}
<div
style={{
padding: '16px 20px',
background: '#fff',
borderBottom: '1px solid #e0e0e0'
}}
>
<h2 style={{ margin: 0, fontSize: '18px', fontWeight: 600 }}>All Node Types</h2>
<p style={{ margin: '8px 0 0', color: '#666', fontSize: '14px' }}>
Showcasing all available node types with their colors and icons. Hover over nodes to see the output handles.
</p>
</div>
{/* Canvas */}
<div style={{ flex: 1 }}>
<Agentflow
ref={agentflowRef}
instanceUrl={instanceUrl}
token={token ?? undefined}
flow={allNodesFlow}
showDefaultHeader={false}
readOnly={true}
/>
</div>
</div>
)
}
export const AllNodeTypesExampleProps = {
instanceUrl: instanceUrl,
token: token,
flow: 'FlowData (15 node types)',
readOnly: true,
showDefaultHeader: false,
enableGenerator: false
}
@@ -0,0 +1,124 @@
/**
* Basic Example
*
* Demonstrates basic @flowise/agentflow usage with imperative methods.
*/
import { useRef, useState } from 'react'
import type { AgentFlowInstance, FlowData, ValidationResult } from '@flowise/agentflow'
import { Agentflow } from '@flowise/agentflow'
import { instanceUrl, token } from '../config'
// Example flow data
const initialFlow: FlowData = {
nodes: [
{
id: 'startAgentflow_0',
type: 'agentflowNode',
position: { x: 100, y: 100 },
data: {
id: 'startAgentflow_0',
name: 'startAgentflow',
label: 'Start',
color: '#7EE787',
hideInput: true,
outputAnchors: [{ id: 'startAgentflow_0-output-0', name: 'start', label: 'Start', type: 'start' }]
}
}
],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 }
}
export function BasicExample() {
// Config loaded from environment variables
const agentflowRef = useRef<AgentFlowInstance>(null)
const [validationResult, setValidationResult] = useState<ValidationResult | null>(null)
const handleFlowChange = (flow: FlowData) => {
console.log('Flow changed:', flow)
}
const handleSave = (flow: FlowData) => {
console.log('Flow saved:', flow)
alert('Flow saved! Check console for data.')
}
const handleValidate = () => {
if (agentflowRef.current) {
const result = agentflowRef.current.validate()
setValidationResult(result)
console.log('Validation result:', result)
}
}
const handleFitView = () => {
if (agentflowRef.current) {
agentflowRef.current.fitView()
}
}
const handleGetFlow = () => {
if (agentflowRef.current) {
const flow = agentflowRef.current.getFlow()
console.log('Current flow:', flow)
alert('Flow data logged to console!')
}
}
const handleClear = () => {
if (agentflowRef.current) {
agentflowRef.current.clear()
}
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{/* Toolbar */}
<div
style={{
padding: '10px 16px',
borderBottom: '1px solid #e0e0e0',
display: 'flex',
alignItems: 'center',
gap: '10px',
background: '#fafafa'
}}
>
<button onClick={handleValidate}>Validate</button>
<button onClick={handleFitView}>Fit View</button>
<button onClick={handleGetFlow}>Get Flow</button>
<button onClick={handleClear}>Clear</button>
{validationResult && (
<span style={{ marginLeft: '20px', color: validationResult.valid ? 'green' : 'red' }}>
{validationResult.valid ? '✓ Valid' : `✗ ${validationResult.errors.length} error(s)`}
</span>
)}
</div>
{/* Canvas */}
<div style={{ flex: 1 }}>
<Agentflow
ref={agentflowRef}
instanceUrl={instanceUrl}
token={token ?? undefined}
flow={initialFlow}
onFlowChange={handleFlowChange}
onSave={handleSave}
showDefaultHeader={true}
/>
</div>
</div>
)
}
export const BasicExampleProps = {
instanceUrl: '{from environment variables}',
token: '{from environment variables}',
flow: 'FlowData',
onFlowChange: '(flow: FlowData) => void',
onSave: '(flow: FlowData) => void',
showDefaultHeader: true
}
@@ -0,0 +1,284 @@
/**
* Custom UI Example
*
* Demonstrates how to customize the header and node palette
* using render props for full control over the UI.
*/
import { useRef, useState } from 'react'
import type { AgentFlowInstance, FlowData, HeaderRenderProps, PaletteRenderProps } from '@flowise/agentflow'
import { Agentflow } from '@flowise/agentflow'
import { instanceUrl, token } from '../config'
const initialFlow: FlowData = {
nodes: [
{
id: 'startAgentflow_0',
type: 'agentflowNode',
position: { x: 300, y: 200 },
data: {
id: 'startAgentflow_0',
name: 'startAgentflow',
label: 'Start',
color: '#7EE787',
hideInput: true,
outputAnchors: [{ id: 'startAgentflow_0-output-0', name: 'start', label: 'Start', type: 'start' }]
}
}
],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 }
}
// Custom header component
function CustomHeader({ flowName, isDirty, onSave, onExport, onValidate }: HeaderRenderProps) {
const [validationStatus, setValidationStatus] = useState<'valid' | 'invalid' | null>(null)
const handleValidate = () => {
const result = onValidate()
setValidationStatus(result.valid ? 'valid' : 'invalid')
setTimeout(() => setValidationStatus(null), 3000)
}
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 20px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: '#fff'
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<span style={{ fontSize: '20px' }}>🤖</span>
<span style={{ fontWeight: 600, fontSize: '16px' }}>{flowName}</span>
{isDirty && (
<span
style={{
background: 'rgba(255,255,255,0.2)',
padding: '2px 8px',
borderRadius: '12px',
fontSize: '12px'
}}
>
Unsaved
</span>
)}
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={handleValidate}
style={{
padding: '8px 16px',
background:
validationStatus === 'valid' ? '#4CAF50' : validationStatus === 'invalid' ? '#f44336' : 'rgba(255,255,255,0.2)',
color: '#fff',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
transition: 'all 0.2s'
}}
>
{validationStatus === 'valid' ? '✓ Valid' : validationStatus === 'invalid' ? '✗ Invalid' : 'Validate'}
</button>
<button
onClick={onExport}
style={{
padding: '8px 16px',
background: 'rgba(255,255,255,0.2)',
color: '#fff',
border: 'none',
borderRadius: '6px',
cursor: 'pointer'
}}
>
Export JSON
</button>
<button
onClick={onSave}
style={{
padding: '8px 16px',
background: '#fff',
color: '#764ba2',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontWeight: 600
}}
>
Save Flow
</button>
</div>
</div>
)
}
// Custom palette component
function CustomPalette({ availableNodes, onAddNode }: PaletteRenderProps) {
const [search, setSearch] = useState('')
const [hoveredNode, setHoveredNode] = useState<string | null>(null)
const filteredNodes = availableNodes.filter(
(node) => node.label.toLowerCase().includes(search.toLowerCase()) || node.name.toLowerCase().includes(search.toLowerCase())
)
// Group nodes by category
const categories = filteredNodes.reduce((acc, node) => {
const category = node.category || 'Other'
if (!acc[category]) acc[category] = []
acc[category].push(node)
return acc
}, {} as Record<string, typeof availableNodes>)
return (
<div
style={{
width: '280px',
background: '#f8f9fa',
borderRight: '1px solid #e0e0e0',
display: 'flex',
flexDirection: 'column',
height: '100%'
}}
>
{/* Search */}
<div style={{ padding: '16px' }}>
<input
type='text'
placeholder='Search nodes...'
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{
width: '100%',
padding: '10px 12px',
border: '1px solid #e0e0e0',
borderRadius: '8px',
fontSize: '14px',
outline: 'none'
}}
/>
</div>
{/* Node List */}
<div style={{ flex: 1, overflow: 'auto', padding: '0 16px 16px' }}>
{Object.entries(categories).map(([category, nodes]) => (
<div key={category} style={{ marginBottom: '16px' }}>
<div
style={{
fontSize: '11px',
fontWeight: 600,
textTransform: 'uppercase',
color: '#9e9e9e',
marginBottom: '8px',
letterSpacing: '0.5px'
}}
>
{category}
</div>
{nodes.map((node) => (
<div
key={node.name}
role='button'
tabIndex={0}
onClick={() => onAddNode(node.name)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onAddNode(node.name)
}
}}
onMouseEnter={() => setHoveredNode(node.name)}
onMouseLeave={() => setHoveredNode(null)}
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '10px 12px',
marginBottom: '4px',
background: hoveredNode === node.name ? '#fff' : 'transparent',
border: `1px solid ${hoveredNode === node.name ? '#e0e0e0' : 'transparent'}`,
borderRadius: '8px',
cursor: 'pointer',
transition: 'all 0.15s'
}}
>
<div
style={{
width: '32px',
height: '32px',
borderRadius: '8px',
background: node.color || '#9e9e9e',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontSize: '14px'
}}
>
{node.label.charAt(0)}
</div>
<div>
<div style={{ fontWeight: 500, fontSize: '14px', color: '#333' }}>{node.label}</div>
{node.description && (
<div
style={{
fontSize: '12px',
color: '#9e9e9e',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '180px'
}}
>
{node.description}
</div>
)}
</div>
</div>
))}
</div>
))}
</div>
</div>
)
}
export function CustomUIExample() {
// Config loaded from environment variables
const agentflowRef = useRef<AgentFlowInstance>(null)
return (
<div style={{ width: '100%', height: '100vh' }}>
<Agentflow
ref={agentflowRef}
instanceUrl={instanceUrl}
token={token ?? undefined}
flow={initialFlow}
renderHeader={(props) => <CustomHeader {...props} />}
renderNodePalette={(props) => <CustomPalette {...props} />}
showDefaultHeader={false}
showDefaultPalette={false}
onSave={(flow) => {
console.log('Saving flow:', flow)
alert('Flow saved! Check console.')
}}
/>
</div>
)
}
export const CustomUIExampleProps = {
instanceUrl: '{from environment variables}',
token: '{from environment variables}',
flow: 'FlowData',
renderHeader: '(props: HeaderRenderProps) => ReactNode',
renderNodePalette: '(props: PaletteRenderProps) => ReactNode',
showDefaultHeader: false,
showDefaultPalette: false,
onSave: '(flow: FlowData) => void'
}
@@ -0,0 +1,158 @@
/**
* Dark Mode Example
*
* Demonstrates the agentflow canvas with dark theme styling.
* The nodes and edges automatically adapt to the dark theme.
*/
import { useRef, useState } from 'react'
import type { AgentFlowInstance, FlowData } from '@flowise/agentflow'
import { Agentflow } from '@flowise/agentflow'
import CssBaseline from '@mui/material/CssBaseline'
import { createTheme, ThemeProvider } from '@mui/material/styles'
import { instanceUrl, token } from '../config'
const sampleFlow: FlowData = {
nodes: [
{
id: 'startAgentflow_0',
type: 'agentflowNode',
position: { x: 100, y: 150 },
data: {
id: 'startAgentflow_0',
name: 'startAgentflow',
label: 'Start',
color: '#7EE787',
hideInput: true,
outputAnchors: [{ id: 'startAgentflow_0-output-0', name: 'start', label: 'Start', type: 'start' }]
}
},
{
id: 'agentAgentflow_0',
type: 'agentflowNode',
position: { x: 400, y: 150 },
data: {
id: 'agentAgentflow_0',
name: 'agentAgentflow',
label: 'AI Assistant',
color: '#4DD0E1',
outputAnchors: [{ id: 'agentAgentflow_0-output-0', name: 'output', label: 'Output', type: 'string' }],
inputs: {
agentModel: 'chatAnthropic',
agentModelConfig: {
modelName: 'claude-3-5-sonnet'
}
}
}
},
{
id: 'directReplyAgentflow_0',
type: 'agentflowNode',
position: { x: 700, y: 150 },
data: {
id: 'directReplyAgentflow_0',
name: 'directReplyAgentflow',
label: 'Reply',
color: '#4DDBBB',
outputAnchors: [{ id: 'directReplyAgentflow_0-output-0', name: 'output', label: 'Output', type: 'string' }]
}
}
],
edges: [
{
id: 'edge-1',
source: 'startAgentflow_0',
sourceHandle: 'startAgentflow_0-output-0',
target: 'agentAgentflow_0',
targetHandle: 'agentAgentflow_0',
type: 'agentflowEdge',
data: { sourceColor: '#7EE787', targetColor: '#4DD0E1' }
},
{
id: 'edge-2',
source: 'agentAgentflow_0',
sourceHandle: 'agentAgentflow_0-output-0',
target: 'directReplyAgentflow_0',
targetHandle: 'directReplyAgentflow_0',
type: 'agentflowEdge',
data: { sourceColor: '#4DD0E1', targetColor: '#4DDBBB' }
}
],
viewport: { x: 0, y: 0, zoom: 1 }
}
export function DarkModeExample() {
// Config loaded from environment variables
const agentflowRef = useRef<AgentFlowInstance>(null)
const [isDark, setIsDark] = useState(true)
const darkTheme = createTheme({
palette: {
mode: isDark ? 'dark' : 'light',
background: {
default: isDark ? '#1a1a2e' : '#f5f5f5',
paper: isDark ? '#16213e' : '#ffffff'
},
primary: {
main: '#4DD0E1'
}
}
})
return (
<ThemeProvider theme={darkTheme}>
<CssBaseline />
<div style={{ width: '100%', height: '100vh', display: 'flex', flexDirection: 'column' }}>
{/* Theme Toggle */}
<div
style={{
padding: '12px 16px',
background: isDark ? '#16213e' : '#fff',
borderBottom: `1px solid ${isDark ? '#2a2a4a' : '#e0e0e0'}`,
display: 'flex',
alignItems: 'center',
gap: '12px'
}}
>
<span style={{ color: isDark ? '#fff' : '#333', fontWeight: 500 }}>Theme:</span>
<button
onClick={() => setIsDark(!isDark)}
style={{
padding: '8px 16px',
background: isDark ? '#4DD0E1' : '#333',
color: isDark ? '#000' : '#fff',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontWeight: 500
}}
>
{isDark ? 'Switch to Light' : 'Switch to Dark'}
</button>
</div>
{/* Canvas */}
<div style={{ flex: 1 }}>
<Agentflow
ref={agentflowRef}
instanceUrl={instanceUrl}
token={token ?? undefined}
flow={sampleFlow}
theme={isDark ? 'dark' : 'light'}
showDefaultHeader={false}
/>
</div>
</div>
</ThemeProvider>
)
}
export const DarkModeExampleProps = {
instanceUrl: '{from environment variables}',
token: '{from environment variables}',
flow: 'FlowData (sample flow)',
theme: '{isDark ? "dark" : "light"}',
showDefaultHeader: false
}
@@ -0,0 +1,162 @@
/**
* Filtered Components Example
*
* Demonstrates how to restrict which node types are available in the palette.
* Useful for creating simplified or specialized workflows.
*/
import { useRef, useState } from 'react'
import type { AgentFlowInstance, FlowData } from '@flowise/agentflow'
import { Agentflow } from '@flowise/agentflow'
import { instanceUrl, token } from '../config'
const initialFlow: FlowData = {
nodes: [
{
id: 'startAgentflow_0',
type: 'agentflowNode',
position: { x: 200, y: 200 },
data: {
id: 'startAgentflow_0',
name: 'startAgentflow',
label: 'Start',
color: '#7EE787',
hideInput: true,
outputAnchors: [{ id: 'startAgentflow_0-output-0', name: 'start', label: 'Start', type: 'start' }]
}
}
],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 }
}
// Different preset configurations
const presets = {
full: {
name: 'Full Access',
description: 'All nodes available',
components: undefined // undefined = all nodes
},
simple: {
name: 'Simple Chat',
description: 'Basic LLM flow nodes only',
components: ['llmAgentflow', 'directReplyAgentflow']
},
agent: {
name: 'Agent Builder',
description: 'Agent and tool nodes',
components: ['agentAgentflow', 'toolAgentflow', 'retrieverAgentflow', 'directReplyAgentflow']
},
conditional: {
name: 'Conditional Flow',
description: 'Branching and control flow',
components: ['conditionAgentflow', 'conditionAgentAgentflow', 'loopAgentflow', 'humanInputAgentflow', 'llmAgentflow']
},
integration: {
name: 'Integration',
description: 'External services and functions',
components: ['httpAgentflow', 'customFunctionAgentflow', 'executeFlowAgentflow', 'directReplyAgentflow']
}
}
type PresetKey = keyof typeof presets
export function FilteredComponentsExample() {
// Config loaded from environment variables
const agentflowRef = useRef<AgentFlowInstance>(null)
const [selectedPreset, setSelectedPreset] = useState<PresetKey>('full')
const [key, setKey] = useState(0) // Used to force re-render when preset changes
const handlePresetChange = (preset: PresetKey) => {
setSelectedPreset(preset)
setKey((k) => k + 1) // Force re-mount to apply new components filter
}
const currentPreset = presets[selectedPreset]
return (
<div style={{ width: '100%', height: '100vh', display: 'flex', flexDirection: 'column' }}>
{/* Preset Selector */}
<div
style={{
padding: '16px 20px',
background: '#fff',
borderBottom: '1px solid #e0e0e0'
}}
>
<div style={{ marginBottom: '12px' }}>
<h3 style={{ margin: 0, fontSize: '16px', fontWeight: 600 }}>Select Component Preset</h3>
<p style={{ margin: '4px 0 0', color: '#666', fontSize: '13px' }}>
The <code>components</code> prop restricts which node types appear in the palette.
</p>
</div>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
{(Object.keys(presets) as PresetKey[]).map((key) => {
const preset = presets[key]
const isSelected = selectedPreset === key
return (
<button
key={key}
onClick={() => handlePresetChange(key)}
style={{
padding: '10px 16px',
background: isSelected ? '#1976d2' : '#f5f5f5',
color: isSelected ? '#fff' : '#333',
border: `1px solid ${isSelected ? '#1976d2' : '#e0e0e0'}`,
borderRadius: '8px',
cursor: 'pointer',
textAlign: 'left',
transition: 'all 0.15s'
}}
>
<div style={{ fontWeight: 600, fontSize: '14px' }}>{preset.name}</div>
<div style={{ fontSize: '12px', opacity: 0.8, marginTop: '2px' }}>{preset.description}</div>
</button>
)
})}
</div>
{/* Show current filter */}
<div
style={{
marginTop: '12px',
padding: '10px 12px',
background: '#f8f9fa',
borderRadius: '6px',
fontSize: '13px'
}}
>
<strong>components=</strong>
{currentPreset.components ? (
<code style={{ color: '#1976d2' }}>{JSON.stringify(currentPreset.components)}</code>
) : (
<span style={{ color: '#666' }}>undefined (all nodes)</span>
)}
</div>
</div>
{/* Canvas - re-mounts when key changes */}
<div style={{ flex: 1 }} key={key}>
<Agentflow
ref={agentflowRef}
instanceUrl={instanceUrl}
token={token ?? undefined}
flow={initialFlow}
components={currentPreset.components}
showDefaultHeader={false}
/>
</div>
</div>
)
}
export const FilteredComponentsExampleProps = {
instanceUrl: '{from environment variables}',
token: '{from environment variables}',
flow: 'FlowData',
components: 'string[] (preset-based)',
showDefaultHeader: false
}
@@ -0,0 +1,171 @@
/**
* Multi-Node Flow Example
*
* Demonstrates a complete flow with multiple connected nodes,
* showing the styled edges with gradient colors.
*/
import { useRef } from 'react'
import type { AgentFlowInstance, FlowData } from '@flowise/agentflow'
import { Agentflow } from '@flowise/agentflow'
import { instanceUrl, token } from '../config'
// A complete translation agent flow
const translationFlow: FlowData = {
nodes: [
{
id: 'startAgentflow_0',
type: 'agentflowNode',
position: { x: 100, y: 200 },
data: {
id: 'startAgentflow_0',
name: 'startAgentflow',
label: 'Start',
color: '#7EE787',
hideInput: true,
outputAnchors: [{ id: 'startAgentflow_0-output-0', name: 'start', label: 'Start', type: 'start' }]
}
},
{
id: 'llmAgentflow_0',
type: 'agentflowNode',
position: { x: 400, y: 100 },
data: {
id: 'llmAgentflow_0',
name: 'llmAgentflow',
label: 'Translator',
color: '#64B5F6',
outputAnchors: [{ id: 'llmAgentflow_0-output-0', name: 'output', label: 'Output', type: 'string' }],
inputs: {
llmModel: 'chatGoogleGenerativeAI',
llmModelConfig: {
modelName: 'gemini-2.0-flash'
}
}
}
},
{
id: 'conditionAgentflow_0',
type: 'agentflowNode',
position: { x: 700, y: 100 },
data: {
id: 'conditionAgentflow_0',
name: 'conditionAgentflow',
label: 'Check Quality',
color: '#FFB938',
outputAnchors: [
{ id: 'conditionAgentflow_0-output-0', name: 'true', label: 'Pass', type: 'boolean' },
{ id: 'conditionAgentflow_0-output-1', name: 'false', label: 'Fail', type: 'boolean' }
]
}
},
{
id: 'directReplyAgentflow_0',
type: 'agentflowNode',
position: { x: 1000, y: 50 },
data: {
id: 'directReplyAgentflow_0',
name: 'directReplyAgentflow',
label: 'Send Response',
color: '#4DDBBB',
outputAnchors: [{ id: 'directReplyAgentflow_0-output-0', name: 'output', label: 'Output', type: 'string' }]
}
},
{
id: 'humanInputAgentflow_0',
type: 'agentflowNode',
position: { x: 1000, y: 200 },
data: {
id: 'humanInputAgentflow_0',
name: 'humanInputAgentflow',
label: 'Review Required',
color: '#6E6EFD',
outputAnchors: [
{ id: 'humanInputAgentflow_0-output-0', name: 'proceed', label: 'Proceed', type: 'string' },
{ id: 'humanInputAgentflow_0-output-1', name: 'reject', label: 'Reject', type: 'string' }
]
}
}
],
edges: [
{
id: 'edge-start-translator',
source: 'startAgentflow_0',
sourceHandle: 'startAgentflow_0-output-0',
target: 'llmAgentflow_0',
targetHandle: 'llmAgentflow_0',
type: 'agentflowEdge',
data: {
sourceColor: '#7EE787',
targetColor: '#64B5F6'
}
},
{
id: 'edge-translator-condition',
source: 'llmAgentflow_0',
sourceHandle: 'llmAgentflow_0-output-0',
target: 'conditionAgentflow_0',
targetHandle: 'conditionAgentflow_0',
type: 'agentflowEdge',
data: {
sourceColor: '#64B5F6',
targetColor: '#FFB938'
}
},
{
id: 'edge-condition-reply',
source: 'conditionAgentflow_0',
sourceHandle: 'conditionAgentflow_0-output-0',
target: 'directReplyAgentflow_0',
targetHandle: 'directReplyAgentflow_0',
type: 'agentflowEdge',
data: {
sourceColor: '#FFB938',
targetColor: '#4DDBBB',
edgeLabel: '0'
}
},
{
id: 'edge-condition-human',
source: 'conditionAgentflow_0',
sourceHandle: 'conditionAgentflow_0-output-1',
target: 'humanInputAgentflow_0',
targetHandle: 'humanInputAgentflow_0',
type: 'agentflowEdge',
data: {
sourceColor: '#FFB938',
targetColor: '#6E6EFD',
edgeLabel: '1'
}
}
],
viewport: { x: 0, y: 0, zoom: 0.8 }
}
export function MultiNodeFlow() {
// Config loaded from environment variables
const agentflowRef = useRef<AgentFlowInstance>(null)
return (
<div style={{ width: '100%', height: '100vh' }}>
<Agentflow
ref={agentflowRef}
instanceUrl={instanceUrl}
token={token ?? undefined}
flow={translationFlow}
showDefaultHeader={true}
onFlowChange={(flow) => console.log('Flow changed:', flow.nodes.length, 'nodes')}
/>
</div>
)
}
export const MultiNodeFlowProps = {
instanceUrl: '{from environment variables}',
token: '{from environment variables}',
flow: 'FlowData (multiple nodes)',
showDefaultHeader: true,
onFlowChange: '(flow: FlowData) => void'
}
@@ -0,0 +1,257 @@
/**
* Status Indicators Example
*
* Demonstrates node status indicators showing execution state:
* - INPROGRESS: Spinning loader
* - FINISHED: Green checkmark
* - ERROR: Red exclamation
* - STOPPED/TERMINATED: Stop icons
*/
import { useRef, useState } from 'react'
import type { AgentFlowInstance, FlowData, FlowNode } from '@flowise/agentflow'
import { Agentflow } from '@flowise/agentflow'
import { instanceUrl, token } from '../config'
const createFlowWithStatuses = (): FlowData => ({
nodes: [
{
id: 'startAgentflow_0',
type: 'agentflowNode',
position: { x: 100, y: 200 },
data: {
id: 'startAgentflow_0',
name: 'startAgentflow',
label: 'Start',
color: '#7EE787',
hideInput: true,
status: 'FINISHED',
outputAnchors: [{ id: 'startAgentflow_0-output-0', name: 'start', label: 'Start', type: 'start' }]
}
},
{
id: 'llmAgentflow_0',
type: 'agentflowNode',
position: { x: 350, y: 100 },
data: {
id: 'llmAgentflow_0',
name: 'llmAgentflow',
label: 'Processing...',
color: '#64B5F6',
status: 'INPROGRESS',
outputAnchors: [{ id: 'llmAgentflow_0-output-0', name: 'output', label: 'Output', type: 'string' }]
}
},
{
id: 'llmAgentflow_1',
type: 'agentflowNode',
position: { x: 350, y: 300 },
data: {
id: 'llmAgentflow_1',
name: 'llmAgentflow',
label: 'Failed Task',
color: '#64B5F6',
status: 'ERROR',
error: 'API rate limit exceeded. Please try again later.',
outputAnchors: [{ id: 'llmAgentflow_1-output-0', name: 'output', label: 'Output', type: 'string' }]
}
},
{
id: 'agentAgentflow_0',
type: 'agentflowNode',
position: { x: 600, y: 100 },
data: {
id: 'agentAgentflow_0',
name: 'agentAgentflow',
label: 'Completed',
color: '#4DD0E1',
status: 'FINISHED',
outputAnchors: [{ id: 'agentAgentflow_0-output-0', name: 'output', label: 'Output', type: 'string' }]
}
},
{
id: 'humanInputAgentflow_0',
type: 'agentflowNode',
position: { x: 600, y: 300 },
data: {
id: 'humanInputAgentflow_0',
name: 'humanInputAgentflow',
label: 'Stopped',
color: '#6E6EFD',
status: 'STOPPED',
outputAnchors: [{ id: 'humanInputAgentflow_0-output-0', name: 'proceed', label: 'Proceed', type: 'string' }]
}
},
{
id: 'directReplyAgentflow_0',
type: 'agentflowNode',
position: { x: 850, y: 200 },
data: {
id: 'directReplyAgentflow_0',
name: 'directReplyAgentflow',
label: 'Pending',
color: '#4DDBBB',
// No status = pending/not yet executed
outputAnchors: [{ id: 'directReplyAgentflow_0-output-0', name: 'output', label: 'Output', type: 'string' }]
}
}
],
edges: [
{
id: 'e1',
source: 'startAgentflow_0',
sourceHandle: 'startAgentflow_0-output-0',
target: 'llmAgentflow_0',
targetHandle: 'llmAgentflow_0',
type: 'agentflowEdge',
data: { sourceColor: '#7EE787', targetColor: '#64B5F6' }
},
{
id: 'e2',
source: 'startAgentflow_0',
sourceHandle: 'startAgentflow_0-output-0',
target: 'llmAgentflow_1',
targetHandle: 'llmAgentflow_1',
type: 'agentflowEdge',
data: { sourceColor: '#7EE787', targetColor: '#64B5F6' }
},
{
id: 'e3',
source: 'llmAgentflow_0',
sourceHandle: 'llmAgentflow_0-output-0',
target: 'agentAgentflow_0',
targetHandle: 'agentAgentflow_0',
type: 'agentflowEdge',
data: { sourceColor: '#64B5F6', targetColor: '#4DD0E1' }
},
{
id: 'e4',
source: 'llmAgentflow_1',
sourceHandle: 'llmAgentflow_1-output-0',
target: 'humanInputAgentflow_0',
targetHandle: 'humanInputAgentflow_0',
type: 'agentflowEdge',
data: { sourceColor: '#64B5F6', targetColor: '#6E6EFD' }
}
],
viewport: { x: 0, y: 0, zoom: 0.9 }
})
export function StatusIndicatorsExample() {
// Config loaded from environment variables
const agentflowRef = useRef<AgentFlowInstance>(null)
const [flow, setFlow] = useState<FlowData>(createFlowWithStatuses)
const simulateExecution = () => {
// Reset all statuses
const newNodes: FlowNode[] = flow.nodes.map((node) => ({
...node,
data: {
...node.data,
status: undefined,
error: undefined
}
}))
setFlow({ ...flow, nodes: newNodes })
// Simulate step-by-step execution
const statuses: Array<{ nodeId: string; status: 'INPROGRESS' | 'FINISHED' | 'ERROR' }> = [
{ nodeId: 'startAgentflow_0', status: 'INPROGRESS' },
{ nodeId: 'startAgentflow_0', status: 'FINISHED' },
{ nodeId: 'llmAgentflow_0', status: 'INPROGRESS' },
{ nodeId: 'llmAgentflow_1', status: 'INPROGRESS' },
{ nodeId: 'llmAgentflow_0', status: 'FINISHED' },
{ nodeId: 'llmAgentflow_1', status: 'ERROR' },
{ nodeId: 'agentAgentflow_0', status: 'INPROGRESS' },
{ nodeId: 'agentAgentflow_0', status: 'FINISHED' }
]
statuses.forEach(({ nodeId, status }, index) => {
setTimeout(() => {
setFlow((prev) => ({
...prev,
nodes: prev.nodes.map((node) =>
node.id === nodeId
? {
...node,
data: {
...node.data,
status,
error: status === 'ERROR' ? 'Simulated error for demo' : undefined
}
}
: node
)
}))
}, (index + 1) * 800)
})
}
return (
<div style={{ width: '100%', height: '100vh', display: 'flex', flexDirection: 'column' }}>
{/* Controls */}
<div
style={{
padding: '12px 16px',
background: '#fff',
borderBottom: '1px solid #e0e0e0',
display: 'flex',
alignItems: 'center',
gap: '16px'
}}
>
<span style={{ fontWeight: 600 }}>Status Indicators Demo</span>
<button
onClick={simulateExecution}
style={{
padding: '8px 16px',
background: '#4CAF50',
color: '#fff',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontWeight: 500
}}
>
Simulate Execution
</button>
<button
onClick={() => setFlow(createFlowWithStatuses())}
style={{
padding: '8px 16px',
background: '#9e9e9e',
color: '#fff',
border: 'none',
borderRadius: '6px',
cursor: 'pointer'
}}
>
Reset
</button>
<span style={{ color: '#666', fontSize: '14px' }}>Hover over error nodes to see error messages</span>
</div>
{/* Canvas */}
<div style={{ flex: 1 }}>
<Agentflow
ref={agentflowRef}
instanceUrl={instanceUrl}
token={token ?? undefined}
flow={flow}
showDefaultHeader={false}
readOnly={true}
/>
</div>
</div>
)
}
export const StatusIndicatorsExampleProps = {
instanceUrl: '{from environment variables}',
token: '{from environment variables}',
flow: 'FlowData (status indicators)',
showDefaultHeader: false,
readOnly: true
}
@@ -0,0 +1,7 @@
export * from './AllNodeTypesExample'
export * from './BasicExample'
export * from './CustomUIExample'
export * from './DarkModeExample'
export * from './FilteredComponentsExample'
export * from './MultiNodeFlow'
export * from './StatusIndicatorsExample'
+25
View File
@@ -0,0 +1,25 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import CssBaseline from '@mui/material/CssBaseline'
import { createTheme, ThemeProvider } from '@mui/material/styles'
import App from './App'
// Import the agentflow CSS (from source for development)
import '../../src/features/canvas/canvas.css'
const theme = createTheme({
palette: {
mode: 'light'
}
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ThemeProvider theme={theme}>
<CssBaseline />
<App />
</ThemeProvider>
</React.StrictMode>
)
+10
View File
@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_INSTANCE_URL: string
readonly VITE_API_TOKEN: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src", "*.tsx", "*.ts", "../src/**/*.ts", "../src/**/*.tsx"]
}
@@ -0,0 +1,25 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
root: __dirname,
resolve: {
alias: {
// Use the source files directly for development
'@flowise/agentflow': path.resolve(__dirname, '../src'),
},
},
server: {
port: 5174,
// Watch the parent src directory for changes
watch: {
// Include the agentflow source directory
ignored: ['!**/packages/agentflow/src/**'],
},
},
optimizeDeps: {
include: ['react', 'react-dom', '@mui/material', 'reactflow'],
},
})
+64
View File
@@ -0,0 +1,64 @@
{
"name": "@flowise/agentflow",
"version": "0.0.0-dev.1",
"description": "Embeddable Agentflow component for React applications",
"license": "SEE LICENSE IN LICENSE.md",
"main": "./dist/index.umd.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.umd.js",
"types": "./dist/index.d.ts"
},
"./flowise.css": "./dist/flowise.css"
},
"files": [
"dist"
],
"sideEffects": [
"*.css"
],
"scripts": {
"build": "tsc && vite build",
"clean": "rimraf dist",
"dev": "vite",
"dev:example": "vite --config examples/vite.config.ts",
"format": "prettier --write \"{src,examples}/**/*.{ts,tsx,js,jsx,json,css,md}\"",
"format:check": "prettier --check \"{src,examples}/**/*.{ts,tsx,js,jsx,json,css,md}\"",
"lint": "eslint \"{src,examples/src}/**/*.{js,jsx,ts,tsx}\"",
"lint:fix": "eslint \"{src,examples/src}/**/*.{js,jsx,ts,tsx}\" --fix",
"nuke": "rimraf dist node_modules .turbo"
},
"peerDependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"@mui/material": "^5.15.0",
"@mui/icons-material": "^5.0.0",
"@emotion/react": "^11.10.0",
"@emotion/styled": "^11.10.0",
"reactflow": "^11.5.0"
},
"dependencies": {
"axios": "^1.7.2",
"lodash": "^4.17.21",
"@tabler/icons-react": "^3.7.0",
"uuid": "^9.0.1"
},
"devDependencies": {
"@types/lodash": "^4.14.195",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@types/uuid": "^9.0.0",
"@typescript-eslint/eslint-plugin": "^8.18.2",
"@typescript-eslint/parser": "^8.18.2",
"@vitejs/plugin-react": "^4.2.0",
"eslint-plugin-import": "^2.29.0",
"eslint-plugin-simple-import-sort": "^12.0.0",
"rimraf": "^5.0.5",
"typescript": "^5.4.5",
"vite": "^5.0.2",
"vite-plugin-dts": "^3.7.0"
}
}
+286
View File
@@ -0,0 +1,286 @@
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'
import ReactFlow, { Background, BackgroundVariant, Controls, MiniMap, ReactFlowProvider, useEdgesState, useNodesState } from 'reactflow'
import { IconSparkles } from '@tabler/icons-react'
import type { AgentFlowInstance, AgentflowProps, FlowData, FlowEdge, FlowNode } from './core/types'
import {
AgentflowHeader,
ConnectionLine,
createHeaderProps,
edgeTypes,
nodeTypes,
useDragAndDrop,
useFlowHandlers,
useFlowNodes
} from './features/canvas'
import { GenerateFlowDialog } from './features/generator'
import { AddNodesDrawer, StyledFab } from './features/node-palette'
import { useAgentflowContext } from './infrastructure/store'
import { AgentflowProvider } from './AgentflowProvider'
import { useAgentflow } from './useAgentflow'
import 'reactflow/dist/style.css'
import './features/canvas/canvas.css'
/**
* Internal canvas component that uses the contexts
*/
function AgentflowCanvas({
initialFlow,
readOnly,
onFlowChange,
onSave,
onFlowGenerated,
showDefaultHeader = true,
enableGenerator = true,
showDefaultPalette = true,
renderHeader,
renderNodePalette
}: {
initialFlow?: FlowData
readOnly?: boolean
onFlowChange?: (flow: FlowData) => void
onSave?: (flow: FlowData) => void
onFlowGenerated?: (flow: FlowData) => void
showDefaultHeader?: boolean
showDefaultPalette?: boolean
enableGenerator?: boolean
renderHeader?: AgentflowProps['renderHeader']
renderNodePalette?: AgentflowProps['renderNodePalette']
}) {
const { state, setNodes, setEdges, setDirty, setReactFlowInstance } = useAgentflowContext()
const agentflow = useAgentflow()
const reactFlowWrapper = useRef<HTMLDivElement>(null)
const [nodes, setLocalNodes, onNodesChange] = useNodesState(initialFlow?.nodes || [])
const [edges, setLocalEdges, onEdgesChange] = useEdgesState(initialFlow?.edges || [])
const [showGenerateDialog, setShowGenerateDialog] = useState(false)
// Load available nodes
const { availableNodes } = useFlowNodes()
// Sync local state with context
useEffect(() => {
setNodes(nodes as FlowNode[])
}, [nodes, setNodes])
useEffect(() => {
setEdges(edges as FlowEdge[])
}, [edges, setEdges])
// Flow handlers
const { handleConnect, handleNodesChange, handleEdgesChange, handleAddNode } = useFlowHandlers({
nodes: nodes as FlowNode[],
edges: edges as FlowEdge[],
setLocalNodes: setLocalNodes as React.Dispatch<React.SetStateAction<FlowNode[]>>,
setLocalEdges: setLocalEdges as React.Dispatch<React.SetStateAction<FlowEdge[]>>,
onNodesChange,
onEdgesChange,
onFlowChange,
availableNodes
})
// Drag and drop handlers
const { handleDragOver, handleDrop } = useDragAndDrop({
nodes: nodes as FlowNode[],
setLocalNodes: setLocalNodes as React.Dispatch<React.SetStateAction<FlowNode[]>>,
reactFlowWrapper: reactFlowWrapper as React.RefObject<HTMLDivElement>
})
// Handle generated flow from dialog
const handleFlowGenerated = useCallback(
(generatedNodes: FlowData['nodes'], generatedEdges: FlowData['edges']) => {
setLocalNodes(generatedNodes)
setLocalEdges(generatedEdges)
setDirty(true)
if (onFlowGenerated) {
onFlowGenerated({
nodes: generatedNodes,
edges: generatedEdges,
viewport: { x: 0, y: 0, zoom: 1 }
})
}
},
[setLocalNodes, setLocalEdges, setDirty, onFlowGenerated]
)
// Handle save
const handleSave = useCallback(() => {
if (onSave) {
onSave(agentflow.getFlow())
}
}, [onSave, agentflow])
// Header props
const headerProps = createHeaderProps(
state.chatflow?.name || 'Untitled',
state.isDirty,
handleSave,
agentflow.toJSON,
agentflow.validate
)
// Palette props
const paletteProps = {
availableNodes,
onAddNode: handleAddNode
}
return (
<div className='agentflow-container'>
{/* Header */}
{renderHeader ? renderHeader(headerProps) : showDefaultHeader ? <AgentflowHeader {...headerProps} readOnly={readOnly} /> : null}
<div className='agentflow-main'>
{/* Node Palette - only render if custom renderNodePalette is provided */}
{renderNodePalette && renderNodePalette(paletteProps)}
{/* Canvas */}
<div className='agentflow-canvas' ref={reactFlowWrapper} onDragOver={handleDragOver} onDrop={handleDrop}>
{/* Add Nodes Drawer - floating button */}
{!readOnly && showDefaultPalette && (
<AddNodesDrawer nodes={availableNodes} onNodeClick={(node) => handleAddNode(node.name)} />
)}
{/* Generate Flow Button */}
{!readOnly && enableGenerator && (
<StyledFab
gradient
size='small'
aria-label='generate'
title='Generate Agentflow'
onClick={() => setShowGenerateDialog(true)}
sx={{
position: 'absolute',
left: 70,
top: 20,
zIndex: 1000
}}
>
<IconSparkles />
</StyledFab>
)}
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={handleNodesChange}
onEdgesChange={handleEdgesChange}
onConnect={handleConnect}
onInit={setReactFlowInstance}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
connectionLineComponent={ConnectionLine}
fitView
nodesDraggable={!readOnly}
nodesConnectable={!readOnly}
elementsSelectable={!readOnly}
>
<Controls />
<MiniMap />
<Background variant={BackgroundVariant.Dots} gap={12} size={1} />
</ReactFlow>
</div>
</div>
{/* Generate Flow Dialog */}
<GenerateFlowDialog open={showGenerateDialog} onClose={() => setShowGenerateDialog(false)} onGenerated={handleFlowGenerated} />
</div>
)
}
/**
* Main Agentflow component.
* Renders an embeddable agentflow canvas with optional header and node palette.
*
* @example
* ```tsx
* import { Agentflow } from '@flowise/agentflow'
* import '@flowise/agentflow/flowise.css'
*
* function App() {
* return (
* <Agentflow
* instanceUrl="https://flowise-url.com"
* token="your-auth-token"
* components={['agentAgentflow', 'llmAgentflow']}
* />
* )
* }
* ```
*/
export const Agentflow = forwardRef<AgentFlowInstance, AgentflowProps>(function Agentflow(props, ref) {
const {
instanceUrl,
token,
flow,
components,
onFlowChange,
onSave,
onFlowGenerated,
theme = 'system',
readOnly = false,
enableGenerator = true,
renderHeader,
renderNodePalette,
showDefaultHeader = true,
showDefaultPalette = true
} = props
return (
<AgentflowProvider
instanceUrl={instanceUrl}
token={token}
theme={theme}
components={components}
readOnly={readOnly}
initialFlow={flow}
>
<ReactFlowProvider>
<AgentflowCanvasWithRef
ref={ref}
initialFlow={flow}
readOnly={readOnly}
onFlowChange={onFlowChange}
onSave={onSave}
onFlowGenerated={onFlowGenerated}
enableGenerator={enableGenerator}
showDefaultHeader={showDefaultHeader}
showDefaultPalette={showDefaultPalette}
renderHeader={renderHeader}
renderNodePalette={renderNodePalette}
/>
</ReactFlowProvider>
</AgentflowProvider>
)
})
/**
* Canvas component with ref forwarding
*/
const AgentflowCanvasWithRef = forwardRef<
AgentFlowInstance,
{
initialFlow?: FlowData
readOnly?: boolean
onFlowChange?: (flow: FlowData) => void
onSave?: (flow: FlowData) => void
onFlowGenerated?: (flow: FlowData) => void
showDefaultHeader?: boolean
showDefaultPalette?: boolean
enableGenerator?: boolean
renderHeader?: AgentflowProps['renderHeader']
renderNodePalette?: AgentflowProps['renderNodePalette']
}
>(function AgentflowCanvasWithRef(props, ref) {
const agentflow = useAgentflow()
// Expose imperative methods via ref
useImperativeHandle(ref, () => agentflow, [agentflow])
return <AgentflowCanvas {...props} />
})
export default Agentflow
@@ -0,0 +1,48 @@
import { ReactNode } from 'react'
import { ReactFlowProvider } from 'reactflow'
import type { FlowData } from './core/types'
import { AgentflowStateProvider, ApiProvider, ConfigProvider } from './infrastructure/store'
interface AgentflowProviderProps {
/** Base URL of the Flowise server */
instanceUrl: string
/** Authentication token for API calls */
token?: string
/** Theme override */
theme?: 'light' | 'dark' | 'system'
/** Array of allowed node component names */
components?: string[]
/** Whether the canvas is read-only */
readOnly?: boolean
/** Initial flow data */
initialFlow?: FlowData
/** Children to render */
children: ReactNode
}
/**
* Provider component that wraps the entire Agentflow application.
* Sets up all required contexts for API access, configuration, and state management.
*/
export function AgentflowProvider({
instanceUrl,
token,
theme = 'system',
components,
readOnly = false,
initialFlow,
children
}: AgentflowProviderProps) {
return (
<ReactFlowProvider>
<ApiProvider instanceUrl={instanceUrl} token={token}>
<ConfigProvider theme={theme} components={components} readOnly={readOnly}>
<AgentflowStateProvider initialFlow={initialFlow}>{children}</AgentflowStateProvider>
</ConfigProvider>
</ApiProvider>
</ReactFlowProvider>
)
}
export default AgentflowProvider
@@ -0,0 +1,115 @@
import { createContext, ReactNode, useCallback, useContext, useState } from 'react'
import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from '@mui/material'
export interface ConfirmState {
show: boolean
title: string
description: string
confirmButtonName: string
cancelButtonName: string
}
interface ConfirmContextValue {
confirm: (payload: Partial<ConfirmState>) => Promise<boolean>
}
const ConfirmContext = createContext<ConfirmContextValue | null>(null)
let resolveCallback: (value: boolean) => void
/**
* Hook to show confirmation dialogs
* @example
* const { confirm } = useConfirm()
* const confirmed = await confirm({ title: 'Delete?', description: 'Are you sure?' })
*/
export function useConfirm(): ConfirmContextValue {
const context = useContext(ConfirmContext)
if (!context) {
throw new Error('useConfirm must be used within a ConfirmProvider')
}
return context
}
export interface ConfirmProviderProps {
children: ReactNode
}
/**
* Provider component for confirmation dialogs
*/
export function ConfirmProvider({ children }: ConfirmProviderProps) {
const [confirmState, setConfirmState] = useState<ConfirmState>({
show: false,
title: 'Confirm',
description: '',
confirmButtonName: 'Confirm',
cancelButtonName: 'Cancel'
})
const closeConfirm = useCallback(() => {
setConfirmState((prev) => ({ ...prev, show: false }))
}, [])
const onConfirm = useCallback(() => {
closeConfirm()
resolveCallback(true)
}, [closeConfirm])
const onCancel = useCallback(() => {
closeConfirm()
resolveCallback(false)
}, [closeConfirm])
const confirm = useCallback((payload: Partial<ConfirmState>): Promise<boolean> => {
setConfirmState((prev) => ({
...prev,
...payload,
show: true
}))
return new Promise((resolve) => {
resolveCallback = resolve
})
}, [])
return (
<ConfirmContext.Provider value={{ confirm }}>
{children}
{confirmState.show && (
<Dialog
fullWidth
maxWidth='xs'
open={confirmState.show}
onClose={onCancel}
aria-labelledby='confirm-dialog-title'
aria-describedby='confirm-dialog-description'
>
<DialogTitle sx={{ fontSize: '1rem' }} id='confirm-dialog-title'>
{confirmState.title}
</DialogTitle>
<DialogContent>
<span>{confirmState.description}</span>
</DialogContent>
<DialogActions>
<Button onClick={onCancel}>{confirmState.cancelButtonName}</Button>
<Button variant='contained' onClick={onConfirm}>
{confirmState.confirmButtonName}
</Button>
</DialogActions>
</Dialog>
)}
</ConfirmContext.Provider>
)
}
export default ConfirmDialog
/**
* Standalone confirm dialog component for portal-based rendering
*/
export function ConfirmDialog() {
// This component is now handled by the ConfirmProvider
// Kept for backwards compatibility
return null
}
+73
View File
@@ -0,0 +1,73 @@
import { ChangeEvent, useState } from 'react'
import { IconButton, InputAdornment, SxProps, TextField, Theme } from '@mui/material'
import { IconEye, IconEyeOff } from '@tabler/icons-react'
import type { InputParam } from '../core/types'
export interface InputProps {
inputParam?: InputParam
value?: string
placeholder?: string
disabled?: boolean
onChange?: (value: string) => void
sx?: SxProps<Theme>
}
/**
* Basic input component for text, password, and number inputs
*/
export function Input({ inputParam, value = '', placeholder, disabled = false, onChange, sx }: InputProps) {
const [showPassword, setShowPassword] = useState(false)
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
const newValue = event.target.value
onChange?.(newValue)
}
const getInputType = () => {
if (inputParam?.type === 'password') {
return showPassword ? 'text' : 'password'
}
if (inputParam?.type === 'number') {
return 'number'
}
return 'text'
}
const isMultiline = Boolean(inputParam?.rows && inputParam.rows > 1)
return (
<TextField
fullWidth
size='small'
disabled={disabled}
type={getInputType()}
multiline={isMultiline}
rows={isMultiline ? inputParam?.rows : undefined}
placeholder={placeholder || inputParam?.placeholder}
value={value}
onChange={handleChange}
sx={{
mt: 1,
...sx
}}
InputProps={{
endAdornment: inputParam?.type === 'password' && (
<InputAdornment position='end'>
<IconButton
aria-label='toggle password visibility'
onClick={() => setShowPassword(!showPassword)}
edge='end'
size='small'
>
{showPassword ? <IconEyeOff size={18} /> : <IconEye size={18} />}
</IconButton>
</InputAdornment>
)
}}
/>
)
}
export default Input
+80
View File
@@ -0,0 +1,80 @@
import { forwardRef, ReactNode } from 'react'
import { Card, CardContent, CardHeader, Divider, SxProps, Theme, Typography } from '@mui/material'
const headerSX = {
'& .MuiCardHeader-action': { mr: 0 }
}
export interface MainCardProps {
border?: boolean
boxShadow?: boolean
maxWidth?: 'full' | 'sm' | 'md'
children?: ReactNode
content?: boolean
contentClass?: string
contentSX?: SxProps<Theme>
darkTitle?: boolean
secondary?: ReactNode
shadow?: string
sx?: SxProps<Theme>
title?: ReactNode
}
/**
* Custom main card component for wrapping content
*/
export const MainCard = forwardRef<HTMLDivElement, MainCardProps>(function MainCard(
{
boxShadow,
children,
content = true,
contentClass = '',
contentSX = {
px: 2,
py: 0
},
darkTitle,
maxWidth = 'full',
secondary,
shadow,
sx = {},
title,
border,
..._others
},
ref
) {
return (
<Card
ref={ref}
sx={{
background: 'transparent',
':hover': {
boxShadow: boxShadow ? shadow || '0 2px 14px 0 rgb(32 40 45 / 8%)' : 'inherit'
},
maxWidth: maxWidth === 'sm' ? '800px' : maxWidth === 'md' ? '960px' : '1280px',
mx: 'auto',
border: border === false ? 'none' : border ? '1px solid' : undefined,
...(sx as object)
}}
>
{/* card header and action */}
{!darkTitle && title && <CardHeader sx={headerSX} title={title} action={secondary} />}
{darkTitle && title && <CardHeader sx={headerSX} title={<Typography variant='h3'>{title}</Typography>} action={secondary} />}
{/* content & header divider */}
{title && <Divider />}
{/* card content */}
{content && (
<CardContent sx={contentSX} className={contentClass}>
{children}
</CardContent>
)}
{!content && children}
</Card>
)
})
export default MainCard
@@ -0,0 +1,217 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Handle, Position, useUpdateNodeInternals } from 'reactflow'
import { Box, FormControlLabel, IconButton, MenuItem, Select, Switch, TextField, Tooltip, TooltipProps, Typography } from '@mui/material'
import { styled, useTheme } from '@mui/material/styles'
import { tooltipClasses } from '@mui/material/Tooltip'
import { IconArrowsMaximize, IconVariable } from '@tabler/icons-react'
import type { InputAnchor, InputParam, NodeData } from '../core/types'
const CustomWidthTooltip = styled(({ className, ...props }: TooltipProps) => <Tooltip {...props} classes={{ popper: className }} />)({
[`& .${tooltipClasses.tooltip}`]: {
maxWidth: 500
}
})
export interface NodeInputHandlerProps {
inputAnchor?: InputAnchor
inputParam?: InputParam
data: NodeData
disabled?: boolean
isAdditionalParams?: boolean
disablePadding?: boolean
onDataChange?: (params: { inputParam: InputParam; newValue: unknown }) => void
}
/**
* Simplified input handler for agentflow nodes
* Handles basic input types: string, number, password, boolean, options
*/
export function NodeInputHandler({
inputAnchor,
inputParam,
data,
disabled = false,
isAdditionalParams = false,
disablePadding = false,
onDataChange
}: NodeInputHandlerProps) {
const theme = useTheme()
const ref = useRef<HTMLDivElement>(null)
const updateNodeInternals = useUpdateNodeInternals()
const [position, setPosition] = useState(0)
const handleDataChange = useCallback(
(newValue: unknown) => {
if (inputParam && data.inputs) {
data.inputs[inputParam.name] = newValue
onDataChange?.({ inputParam, newValue })
}
},
[inputParam, data, onDataChange]
)
useEffect(() => {
if (ref.current && ref.current.offsetTop && ref.current.clientHeight) {
setPosition(ref.current.offsetTop + ref.current.clientHeight / 2)
updateNodeInternals(data.id)
}
}, [data.id, ref, updateNodeInternals])
useEffect(() => {
updateNodeInternals(data.id)
}, [data.id, position, updateNodeInternals])
const renderInput = () => {
if (!inputParam) return null
const value = data.inputs?.[inputParam.name] ?? inputParam.default ?? ''
switch (inputParam.type) {
case 'string':
case 'password':
case 'number':
return (
<TextField
fullWidth
size='small'
disabled={disabled}
type={inputParam.type === 'password' ? 'password' : inputParam.type === 'number' ? 'number' : 'text'}
multiline={!!inputParam.rows && inputParam.rows > 1}
rows={inputParam.rows || undefined}
placeholder={inputParam.placeholder}
value={value}
onChange={(e) => handleDataChange(e.target.value)}
sx={{ mt: 1 }}
/>
)
case 'boolean':
return (
<FormControlLabel
control={<Switch disabled={disabled} checked={!!value} onChange={(e) => handleDataChange(e.target.checked)} />}
label=''
sx={{ mt: 1 }}
/>
)
case 'options':
return (
<Select
fullWidth
size='small'
disabled={disabled}
value={value || ''}
onChange={(e) => handleDataChange(e.target.value)}
sx={{ mt: 1 }}
>
{inputParam.options?.map((option) => (
<MenuItem
key={typeof option === 'string' ? option : option.name}
value={typeof option === 'string' ? option : option.name}
>
{typeof option === 'string' ? option : option.label}
</MenuItem>
))}
</Select>
)
default:
// For unsupported types, render a basic text field
return (
<TextField
fullWidth
size='small'
disabled={disabled}
placeholder={inputParam.placeholder}
value={typeof value === 'string' ? value : JSON.stringify(value)}
onChange={(e) => handleDataChange(e.target.value)}
sx={{ mt: 1 }}
/>
)
}
}
return (
<div ref={ref}>
{inputAnchor && (
<>
<CustomWidthTooltip placement='left' title={inputAnchor.type}>
<Handle
type='target'
position={Position.Left}
key={inputAnchor.id}
id={inputAnchor.id}
style={{
height: 10,
width: 10,
backgroundColor: data.selected ? theme.palette.primary.main : theme.palette.text.secondary,
top: position
}}
/>
</CustomWidthTooltip>
<Box sx={{ p: 2 }}>
<Typography>
{inputAnchor.label}
{!inputAnchor.optional && <span style={{ color: 'red' }}>&nbsp;*</span>}
</Typography>
</Box>
</>
)}
{((inputParam && !inputParam.additionalParams) || isAdditionalParams) && (
<>
{inputParam?.acceptVariable && !isAdditionalParams && (
<CustomWidthTooltip placement='left' title={inputParam.type}>
<Handle
type='target'
position={Position.Left}
key={inputParam.id}
id={inputParam.id}
style={{
height: 10,
width: 10,
backgroundColor: data.selected ? theme.palette.primary.main : theme.palette.text.secondary,
top: position
}}
/>
</CustomWidthTooltip>
)}
<Box sx={{ p: disablePadding ? 0 : 2 }}>
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}>
<Typography>
{inputParam?.label}
{!inputParam?.optional && <span style={{ color: 'red' }}>&nbsp;*</span>}
</Typography>
<div style={{ flexGrow: 1 }} />
{inputParam?.acceptVariable && inputParam?.type === 'string' && (
<Tooltip title='Type {{ to select variables'>
<IconVariable size={20} style={{ color: 'teal' }} />
</Tooltip>
)}
{((inputParam?.type === 'string' && inputParam?.rows) || inputParam?.type === 'code') && (
<IconButton
size='small'
sx={{
height: 25,
width: 25,
ml: 0.5
}}
title='Expand'
color='primary'
>
<IconArrowsMaximize />
</IconButton>
)}
</div>
{renderInput()}
</Box>
</>
)}
</div>
)
}
export default NodeInputHandler
+5
View File
@@ -0,0 +1,5 @@
// UI Components - Internal design system
export { ConfirmDialog, ConfirmProvider, useConfirm } from './ConfirmDialog'
export { Input } from './Input'
export { MainCard, type MainCardProps } from './MainCard'
export { NodeInputHandler } from './NodeInputHandler'
+8
View File
@@ -0,0 +1,8 @@
// Core exports - Framework-agnostic logic
// NOTE: Import directly from submodules for tree-shaking
export * from './node-catalog'
export * from './node-config'
export * from './types'
export * from './utils'
export * from './validation'
@@ -0,0 +1,2 @@
// Node catalog - Filtering and grouping available nodes
export { filterNodesByComponents, groupNodesByCategory, isAgentflowNode } from './nodeFilters'
@@ -0,0 +1,42 @@
import { DEFAULT_AGENTFLOW_NODES } from '../node-config'
import type { NodeData } from '../types'
/**
* Filter nodes based on allowed components list
* @param allNodes - All available nodes from API
* @param allowedComponents - Array of allowed node names (optional)
* @returns Filtered array of nodes
*/
export function filterNodesByComponents(allNodes: NodeData[], allowedComponents?: string[]): NodeData[] {
// If no filter specified, return all agentflow nodes
if (!allowedComponents || allowedComponents.length === 0) {
return allNodes.filter((node) => DEFAULT_AGENTFLOW_NODES.includes(node.name))
}
// Always include startAgentflow - it's required
const requiredNodes = ['startAgentflow']
const allowed = new Set([...requiredNodes, ...allowedComponents])
return allNodes.filter((node) => allowed.has(node.name))
}
/**
* Check if a node type is an agentflow node
*/
export function isAgentflowNode(nodeName: string): boolean {
return DEFAULT_AGENTFLOW_NODES.includes(nodeName)
}
/**
* Group nodes by category
*/
export function groupNodesByCategory(nodes: NodeData[]): Record<string, NodeData[]> {
return nodes.reduce((acc, node) => {
const category = node.category || 'Other'
if (!acc[category]) {
acc[category] = []
}
acc[category].push(node)
return acc
}, {} as Record<string, NodeData[]>)
}
@@ -0,0 +1,5 @@
// Node configuration - icons, colors, and default node types
export { AGENTFLOW_ICONS, type AgentflowIcon, DEFAULT_AGENTFLOW_NODES } from './nodeIcons'
// Node icon utility functions
export { getAgentflowIcon, getNodeColor } from './nodeIconUtils'
@@ -0,0 +1,15 @@
import { AGENTFLOW_ICONS, type AgentflowIcon } from './nodeIcons'
/**
* Get icon config for a node by name
*/
export function getAgentflowIcon(name: string): AgentflowIcon | undefined {
return AGENTFLOW_ICONS.find((icon) => icon.name === name)
}
/**
* Get color for a node by name
*/
export function getNodeColor(name: string): string {
return getAgentflowIcon(name)?.color || '#9e9e9e'
}
@@ -0,0 +1,128 @@
import type { ComponentType } from 'react'
import {
IconArrowsSplit,
IconFunctionFilled,
IconLibrary,
IconMessageCircleFilled,
IconNote,
IconPlayerPlayFilled,
IconRelationOneToManyFilled,
IconRepeat,
IconReplaceUser,
IconRobot,
IconSparkles,
IconSubtask,
IconTools,
IconVectorBezier2,
IconWorld
} from '@tabler/icons-react'
// Using 'any' for icon props to be compatible with tabler-icons
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type IconComponent = ComponentType<any>
export interface AgentflowIcon {
name: string
icon: IconComponent
color: string
}
export const AGENTFLOW_ICONS: AgentflowIcon[] = [
{
name: 'conditionAgentflow',
icon: IconArrowsSplit,
color: '#FFB938'
},
{
name: 'startAgentflow',
icon: IconPlayerPlayFilled,
color: '#7EE787'
},
{
name: 'llmAgentflow',
icon: IconSparkles,
color: '#64B5F6'
},
{
name: 'agentAgentflow',
icon: IconRobot,
color: '#4DD0E1'
},
{
name: 'humanInputAgentflow',
icon: IconReplaceUser,
color: '#6E6EFD'
},
{
name: 'loopAgentflow',
icon: IconRepeat,
color: '#FFA07A'
},
{
name: 'directReplyAgentflow',
icon: IconMessageCircleFilled,
color: '#4DDBBB'
},
{
name: 'customFunctionAgentflow',
icon: IconFunctionFilled,
color: '#E4B7FF'
},
{
name: 'toolAgentflow',
icon: IconTools,
color: '#d4a373'
},
{
name: 'retrieverAgentflow',
icon: IconLibrary,
color: '#b8bedd'
},
{
name: 'conditionAgentAgentflow',
icon: IconSubtask,
color: '#ff8fab'
},
{
name: 'stickyNoteAgentflow',
icon: IconNote,
color: '#fee440'
},
{
name: 'httpAgentflow',
icon: IconWorld,
color: '#FF7F7F'
},
{
name: 'iterationAgentflow',
icon: IconRelationOneToManyFilled,
color: '#9C89B8'
},
{
name: 'executeFlowAgentflow',
icon: IconVectorBezier2,
color: '#a3b18a'
}
]
/**
* Default node types that are always available
*/
export const DEFAULT_AGENTFLOW_NODES = [
'startAgentflow',
'llmAgentflow',
'agentAgentflow',
'conditionAgentflow',
'conditionAgentAgentflow',
'humanInputAgentflow',
'loopAgentflow',
'directReplyAgentflow',
'customFunctionAgentflow',
'toolAgentflow',
'retrieverAgentflow',
'stickyNoteAgentflow',
'httpAgentflow',
'iterationAgentflow',
'executeFlowAgentflow'
]
+302
View File
@@ -0,0 +1,302 @@
import type { ReactNode } from 'react'
import type { ReactFlowInstance } from 'reactflow'
import type { AxiosInstance } from 'axios'
// ============================================================================
// Flow Data Types
// ============================================================================
export interface Viewport {
x: number
y: number
zoom: number
}
export interface FlowNode {
id: string
type: string
position: { x: number; y: number }
data: NodeData
parentNode?: string
extent?: 'parent'
selected?: boolean
dragging?: boolean
width?: number
height?: number
}
export interface FlowEdge {
id: string
source: string
target: string
sourceHandle?: string
targetHandle?: string
type: string
data?: EdgeData
selected?: boolean
animated?: boolean
}
export interface FlowData {
nodes: FlowNode[]
edges: FlowEdge[]
viewport?: Viewport
}
export interface FlowConfig {
id?: string
name?: string
deployed?: boolean
isPublic?: boolean
type?: 'AGENTFLOW'
}
// ============================================================================
// Node & Edge Data Types
// ============================================================================
export interface NodeData {
id: string
name: string
label: string
type?: string
category?: string
description?: string
version?: number
baseClasses?: string[]
inputs?: Record<string, unknown>
outputs?: NodeOutput[]
inputAnchors?: InputAnchor[]
outputAnchors?: OutputAnchor[]
inputParams?: InputParam[]
// Visual properties
color?: string
icon?: string
selected?: boolean
hideInput?: boolean
// Status properties
status?: 'INPROGRESS' | 'FINISHED' | 'ERROR' | 'STOPPED' | 'TERMINATED'
error?: string
warning?: string
hint?: string
[key: string]: unknown
}
export interface NodeInput {
label: string
name: string
type: string
optional?: boolean
}
export interface NodeOutput {
label: string
name: string
type: string
}
export interface InputAnchor {
id: string
name: string
label: string
type: string
optional?: boolean
description?: string
}
export interface OutputAnchor {
id: string
name: string
label: string
type: string
}
export interface InputParam {
id: string
name: string
label: string
type: string
default?: unknown
optional?: boolean
options?: Array<{ label: string; name: string } | string>
placeholder?: string
rows?: number
description?: string
acceptVariable?: boolean
additionalParams?: boolean
display?: boolean
}
export interface EdgeData {
sourceColor?: string
targetColor?: string
edgeLabel?: string
isHumanInput?: boolean
[key: string]: unknown
}
// ============================================================================
// Validation Types
// ============================================================================
export interface ValidationResult {
valid: boolean
errors: ValidationError[]
}
export interface ValidationError {
nodeId?: string
edgeId?: string
message: string
type: 'error' | 'warning'
}
// ============================================================================
// Render Props Types
// ============================================================================
export interface HeaderRenderProps {
flowName: string
isDirty: boolean
onSave: () => void
onExport: () => void
onValidate: () => ValidationResult
}
export interface PaletteRenderProps {
availableNodes: NodeData[]
onAddNode: (nodeType: string, position?: { x: number; y: number }) => void
}
// ============================================================================
// Component Props Types
// ============================================================================
export interface AgentflowProps {
/** Base URL of the Flowise server (e.g., "https://flowise-url.com") */
instanceUrl: string
/** Authentication token for API calls */
token?: string
/** Initial flow data to render */
flow?: FlowData
/** Flow ID for loading existing flow */
flowId?: string
/** Array of allowed node component names to show in palette */
components?: string[]
/** Callback when flow changes */
onFlowChange?: (flow: FlowData) => void
/** Callback when flow is saved */
onSave?: (flow: FlowData) => void
/** Theme override */
theme?: 'light' | 'dark' | 'system'
/** Whether the canvas is read-only */
readOnly?: boolean
/** Custom header renderer - receives save/export handlers */
renderHeader?: (props: HeaderRenderProps) => ReactNode
/** Custom node palette renderer - receives available nodes */
renderNodePalette?: (props: PaletteRenderProps) => ReactNode
/** Whether to show default header (ignored if renderHeader provided) */
showDefaultHeader?: boolean
/** Whether to show default node palette (ignored if renderNodePalette provided) */
showDefaultPalette?: boolean
/** Enable the AI flow generator feature (default: true) */
enableGenerator?: boolean
/** Callback when flow is generated via AI */
onFlowGenerated?: (flow: FlowData) => void
}
// ============================================================================
// Hook Return Types
// ============================================================================
export interface AgentFlowInstance {
/** Get current flow data as serializable object */
getFlow(): FlowData
/** Convert flow to JSON string */
toJSON(): string
/** Validate the current flow */
validate(): ValidationResult
/** Fit view to show all nodes */
fitView(): void
/** Get the underlying ReactFlow instance */
getReactFlowInstance(): ReactFlowInstance | null
/** Programmatically add a node */
addNode(nodeData: Partial<FlowNode>): void
/** Clear all nodes and edges */
clear(): void
}
// ============================================================================
// Context Types
// ============================================================================
export interface ApiContextValue {
client: AxiosInstance
instanceUrl: string
}
export interface ConfigContextValue {
theme: 'light' | 'dark' | 'system'
components?: string[]
readOnly: boolean
isDarkMode: boolean
}
export interface AgentflowState {
nodes: FlowNode[]
edges: FlowEdge[]
chatflow: FlowConfig | null
isDirty: boolean
reactFlowInstance: ReactFlowInstance | null
}
export type AgentflowAction =
| { type: 'SET_NODES'; payload: FlowNode[] }
| { type: 'SET_EDGES'; payload: FlowEdge[] }
| { type: 'SET_CHATFLOW'; payload: FlowConfig | null }
| { type: 'SET_DIRTY'; payload: boolean }
| { type: 'SET_REACTFLOW_INSTANCE'; payload: ReactFlowInstance | null }
| { type: 'RESET' }
// ============================================================================
// API Types
// ============================================================================
export interface Chatflow {
id: string
name: string
flowData: string
deployed?: boolean
isPublic?: boolean
apikeyid?: string
chatbotConfig?: string
createdDate: string
updatedDate: string
type?: string
}
export interface ApiResponse<T> {
data: T
status: number
}
@@ -0,0 +1,61 @@
import type { FlowEdge, FlowNode } from '../types'
/**
* Check if a connection is valid for AgentFlow v2
*/
export function isValidConnectionAgentflowV2(
connection: { source: string; target: string },
nodes: FlowNode[],
edges: FlowEdge[]
): boolean {
const { source, target } = connection
// Prevent self connections
if (source === target) {
return false
}
// Check if this connection would create a cycle
if (wouldCreateCycle(source, target, edges)) {
return false
}
return true
}
/**
* Check if adding an edge would create a cycle in the graph
*/
function wouldCreateCycle(sourceId: string, targetId: string, edges: FlowEdge[]): boolean {
if (sourceId === targetId) {
return true
}
// Build directed graph from existing edges
const graph: Record<string, string[]> = {}
edges.forEach((edge) => {
if (!graph[edge.source]) graph[edge.source] = []
graph[edge.source].push(edge.target)
})
// Check if there's a path from target to source
const visited = new Set<string>()
function hasPath(current: string, destination: string): boolean {
if (current === destination) return true
if (visited.has(current)) return false
visited.add(current)
const neighbors = graph[current] || []
for (const neighbor of neighbors) {
if (hasPath(neighbor, destination)) {
return true
}
}
return false
}
return hasPath(targetId, sourceId)
}
@@ -0,0 +1,23 @@
import type { FlowEdge, FlowNode } from '../types'
/**
* Generate export-friendly flow data (strips sensitive info)
*/
export function generateExportFlowData(flowData: { nodes: FlowNode[]; edges: FlowEdge[] }): { nodes: FlowNode[]; edges: FlowEdge[] } {
const nodes = flowData.nodes.map((node) => ({
...node,
selected: false,
data: {
...node.data,
// Remove credential IDs for export
credential: undefined
}
}))
const edges = flowData.edges.map((edge) => ({
...edge,
selected: false
}))
return { nodes, edges }
}
@@ -0,0 +1,8 @@
// Node factory - Node ID generation, labeling, and initialization
export { getUniqueNodeId, getUniqueNodeLabel, initializeDefaultNodeData, initNode } from './nodeFactory'
// Connection validation utilities
export { isValidConnectionAgentflowV2 } from './connectionValidation'
// Flow export utilities
export { generateExportFlowData } from './flowExport'
@@ -0,0 +1,137 @@
import type { FlowNode, NodeData } from '../types'
/**
* Generate a unique node ID based on existing nodes
*/
export function getUniqueNodeId(nodeData: NodeData, nodes: FlowNode[]): string {
let suffix = 0
let baseId = `${nodeData.name}_${suffix}`
while (nodes.some((node) => node.id === baseId)) {
suffix += 1
baseId = `${nodeData.name}_${suffix}`
}
return baseId
}
/**
* Generate a unique node label based on existing nodes
*/
export function getUniqueNodeLabel(nodeData: NodeData, nodes: FlowNode[]): string {
if (nodeData.type === 'StickyNote') return nodeData.label
if (nodeData.name === 'startAgentflow') return nodeData.label
let suffix = 0
let baseId = `${nodeData.name}_${suffix}`
while (nodes.some((node) => node.id === baseId)) {
suffix += 1
baseId = `${nodeData.name}_${suffix}`
}
return `${nodeData.label} ${suffix}`
}
/**
* Initialize default values for node parameters
*/
export function initializeDefaultNodeData(nodeParams: Array<{ name: string; default?: unknown }>): Record<string, unknown> {
const initialValues: Record<string, unknown> = {}
for (const input of nodeParams) {
initialValues[input.name] = input.default ?? ''
}
return initialValues
}
/**
* Create output anchors for agentflow nodes
*/
function createAgentFlowOutputs(nodeData: NodeData, newNodeId: string): Array<{ id: string; label: string; name: string }> {
if ((nodeData as Record<string, unknown>).hideOutput) return []
if (nodeData.outputs?.length) {
return nodeData.outputs.map((_, index) => ({
id: `${newNodeId}-output-${index}`,
label: nodeData.label,
name: nodeData.name
}))
}
return [
{
id: `${newNodeId}-output-${nodeData.name}`,
label: nodeData.label,
name: nodeData.name
}
]
}
/**
* Initialize a node with proper anchors and default values
*/
export function initNode(nodeData: NodeData, newNodeId: string, isAgentflow = true): NodeData {
const inputAnchors: Array<{ id: string; name: string; label: string; type: string }> = []
const inputParams: Array<{ id: string; name: string; label: string; type: string; default?: unknown; optional?: boolean }> = []
// Get input definitions from inputParams if available, otherwise use inputAnchors
const inputDefs = nodeData.inputParams || nodeData.inputAnchors || []
const whitelistTypes = [
'asyncOptions',
'asyncMultiOptions',
'options',
'multiOptions',
'array',
'datagrid',
'string',
'number',
'boolean',
'password',
'json',
'code',
'date',
'file',
'folder',
'tabs',
'conditionFunction'
]
// Process inputs
for (const input of inputDefs) {
const newInput = {
...input,
id: `${newNodeId}-input-${input.name}-${input.type}`
}
if (whitelistTypes.includes(input.type)) {
inputParams.push(newInput)
} else {
inputAnchors.push(newInput)
}
}
// Initialize outputs
const outputAnchors = isAgentflow ? createAgentFlowOutputs(nodeData, newNodeId) : []
// Initialize default input values
const initialInputs: Record<string, unknown> = {}
for (const param of inputParams) {
if (param.default !== undefined) {
initialInputs[param.name] = param.default
}
}
// Create initialized node data
const initializedData: NodeData = {
...nodeData,
id: newNodeId,
inputs: { ...initialInputs, ...(nodeData.inputs || {}) },
inputAnchors: inputAnchors as NodeData['inputAnchors'],
inputParams: inputParams as NodeData['inputParams'],
outputAnchors: outputAnchors as NodeData['outputAnchors']
}
return initializedData
}
@@ -0,0 +1,166 @@
import type { FlowEdge, FlowNode, ValidationError, ValidationResult } from '../types'
/**
* Validate the flow structure
*/
export function validateFlow(nodes: FlowNode[], edges: FlowEdge[]): ValidationResult {
const errors: ValidationError[] = []
// Check for empty flow
if (nodes.length === 0) {
errors.push({
message: 'Flow is empty - add at least one node',
type: 'error'
})
return { valid: false, errors }
}
// Check for start node
const startNode = nodes.find((n) => n.data.name === 'startAgentflow')
if (!startNode) {
errors.push({
message: 'Flow must have a start node',
type: 'error'
})
}
// Check for multiple start nodes
const startNodes = nodes.filter((n) => n.data.name === 'startAgentflow')
if (startNodes.length > 1) {
errors.push({
message: 'Flow can only have one start node',
type: 'error'
})
}
// Check for disconnected nodes
const nonStickyNodes = nodes.filter((n) => n.data.name !== 'stickyNoteAgentflow')
nonStickyNodes.forEach((node) => {
const hasIncoming = edges.some((e) => e.target === node.id)
const hasOutgoing = edges.some((e) => e.source === node.id)
// Start node should only have outgoing connections
if (node.data.name === 'startAgentflow') {
if (!hasOutgoing) {
errors.push({
nodeId: node.id,
message: 'Start node must have at least one outgoing connection',
type: 'warning'
})
}
return
}
// End nodes don't need outgoing connections
const isEndNode = ['directReplyAgentflow'].includes(node.data.name)
if (!hasIncoming) {
errors.push({
nodeId: node.id,
message: `Node "${node.data.label || node.data.name}" has no incoming connections`,
type: 'warning'
})
}
if (!isEndNode && !hasOutgoing) {
errors.push({
nodeId: node.id,
message: `Node "${node.data.label || node.data.name}" has no outgoing connections`,
type: 'warning'
})
}
})
// Check for cycles (should be handled during connection, but double-check)
const hasCycle = detectCycle(nodes, edges)
if (hasCycle) {
errors.push({
message: 'Flow contains a cycle - this may cause infinite loops',
type: 'error'
})
}
return {
valid: errors.filter((e) => e.type === 'error').length === 0,
errors
}
}
/**
* Detect if there's a cycle in the graph
*/
function detectCycle(nodes: FlowNode[], edges: FlowEdge[]): boolean {
// Build adjacency list
const graph: Record<string, string[]> = {}
nodes.forEach((node) => {
graph[node.id] = []
})
edges.forEach((edge) => {
if (graph[edge.source]) {
graph[edge.source].push(edge.target)
}
})
// DFS with colors: 0 = white (unvisited), 1 = gray (in progress), 2 = black (done)
const colors: Record<string, number> = {}
nodes.forEach((node) => {
colors[node.id] = 0
})
function dfs(nodeId: string): boolean {
colors[nodeId] = 1 // Mark as in progress
for (const neighbor of graph[nodeId] || []) {
if (colors[neighbor] === 1) {
// Back edge found - cycle detected
return true
}
if (colors[neighbor] === 0 && dfs(neighbor)) {
return true
}
}
colors[nodeId] = 2 // Mark as done
return false
}
// Run DFS from all unvisited nodes
for (const node of nodes) {
if (colors[node.id] === 0 && dfs(node.id)) {
return true
}
}
return false
}
/**
* Check if a specific node is valid
*/
export function validateNode(node: FlowNode, _edges: FlowEdge[]): ValidationError[] {
const errors: ValidationError[] = []
// Check required fields
if (!node.data.name) {
errors.push({
nodeId: node.id,
message: 'Node is missing a name',
type: 'error'
})
}
// Check required inputs (if defined)
const inputParams = node.data.inputParams || []
for (const param of inputParams) {
if (!param.optional && !node.data.inputs?.[param.name]) {
errors.push({
nodeId: node.id,
message: `Required input "${param.label || param.name}" is missing`,
type: 'warning'
})
}
}
return errors
}
@@ -0,0 +1,2 @@
// Flow validation utilities
export { validateFlow, validateNode } from './flowValidation'
@@ -0,0 +1,322 @@
/* Agentflow Component Styles */
.agentflow-container {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
}
/* Header */
.agentflow-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: #fff;
border-bottom: 1px solid #e0e0e0;
min-height: 56px;
}
.agentflow-title {
font-size: 16px;
font-weight: 600;
color: #333;
}
.agentflow-header-actions {
display: flex;
gap: 8px;
}
.agentflow-header-actions button {
padding: 8px 16px;
border: 1px solid #e0e0e0;
border-radius: 4px;
background: #fff;
color: #333;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.agentflow-header-actions button:hover {
background: #f5f5f5;
border-color: #bdbdbd;
}
.agentflow-header-actions button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Main layout */
.agentflow-main {
display: flex;
flex: 1;
overflow: hidden;
}
/* Node Palette */
.agentflow-palette {
width: 240px;
background: #fafafa;
border-right: 1px solid #e0e0e0;
overflow-y: auto;
padding: 16px 0;
}
.agentflow-palette-title {
padding: 0 16px 12px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
color: #757575;
}
.agentflow-palette-item {
padding: 10px 16px;
margin: 0 8px 4px;
background: #fff;
border: 1px solid #e0e0e0;
border-left-width: 4px;
border-radius: 4px;
font-size: 13px;
color: #333;
cursor: pointer;
transition: all 0.2s;
}
.agentflow-palette-item:hover {
background: #f5f5f5;
border-color: #bdbdbd;
}
/* Canvas */
.agentflow-canvas {
flex: 1;
background: #f8f9fa;
}
.agentflow-canvas .react-flow {
width: 100%;
height: 100%;
}
/* ReactFlow customizations */
.agentflow-canvas .react-flow__node {
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
/* Content-sized nodes: width/height follow content (no fixed size) */
.agentflow-canvas .react-flow__node-agentflowNode,
.agentflow-canvas .react-flow__node-stickyNote {
width: fit-content !important;
height: fit-content !important;
min-width: 0 !important;
min-height: 0 !important;
}
.agentflow-canvas .react-flow__edge-path {
stroke-width: 2;
}
.agentflow-canvas .react-flow__controls {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border-radius: 8px;
overflow: hidden;
}
.agentflow-canvas .react-flow__minimap {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border-radius: 8px;
overflow: hidden;
}
.agentflow-canvas .react-flow__background {
background: #f8f9fa;
}
/* Dark theme support */
.agentflow-container.dark {
background: #1a1a1a;
}
.agentflow-container.dark .agentflow-header {
background: #2d2d2d;
border-color: #404040;
}
.agentflow-container.dark .agentflow-title {
color: #fff;
}
.agentflow-container.dark .agentflow-header-actions button {
background: #2d2d2d;
border-color: #404040;
color: #fff;
}
.agentflow-container.dark .agentflow-header-actions button:hover {
background: #404040;
}
.agentflow-container.dark .agentflow-palette {
background: #252525;
border-color: #404040;
}
.agentflow-container.dark .agentflow-palette-title {
color: #9e9e9e;
}
.agentflow-container.dark .agentflow-palette-item {
background: #2d2d2d;
border-color: #404040;
color: #fff;
}
.agentflow-container.dark .agentflow-palette-item:hover {
background: #404040;
}
.agentflow-container.dark .agentflow-canvas {
background: #1a1a1a;
}
.agentflow-container.dark .react-flow__background {
background: #1a1a1a;
}
/* Node styles (placeholder - will be enhanced with actual components) */
.agentflow-node {
padding: 12px;
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 8px;
min-width: 180px;
}
.agentflow-node-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.agentflow-node-icon {
width: 24px;
height: 24px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
}
.agentflow-node-label {
font-size: 14px;
font-weight: 500;
color: #333;
}
.agentflow-node-content {
font-size: 12px;
color: #666;
}
/* Handles */
.agentflow-handle {
width: 12px;
height: 12px;
border-radius: 50%;
background: #fff;
border: 2px solid #9e9e9e;
}
.agentflow-handle-connected {
border-color: #4caf50;
background: #4caf50;
}
/* Edge styles */
.agentflow-edge {
stroke-width: 2;
}
.agentflow-edge-label {
font-size: 11px;
fill: #666;
}
/* Sticky note */
.agentflow-sticky-note {
padding: 12px;
background: #fee440;
border: none;
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
min-width: 150px;
min-height: 100px;
}
.agentflow-sticky-note textarea {
width: 100%;
height: 100%;
border: none;
background: transparent;
resize: none;
font-family: inherit;
font-size: 13px;
}
/* Iteration node */
.agentflow-iteration-node {
background: rgba(156, 137, 184, 0.1);
border: 2px dashed #9c89b8;
border-radius: 12px;
padding: 20px;
min-width: 300px;
min-height: 200px;
}
/* Animations */
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.agentflow-node-running {
animation: pulse 1.5s ease-in-out infinite;
}
.spin-animation {
animation: spin 1s linear infinite;
}
/* Responsive */
@media (max-width: 768px) {
.agentflow-palette {
width: 200px;
}
.agentflow-palette-item {
padding: 8px 12px;
font-size: 12px;
}
}
@@ -0,0 +1,52 @@
import type { HeaderRenderProps, ValidationResult } from '../../../core/types'
export interface AgentflowHeaderProps extends HeaderRenderProps {
readOnly?: boolean
}
/**
* Default header component for the Agentflow canvas
*/
export function AgentflowHeader({ flowName, isDirty, readOnly, onSave }: AgentflowHeaderProps) {
return (
<div className='agentflow-header'>
<span className='agentflow-title'>
{flowName}
{isDirty && ' *'}
</span>
<div className='agentflow-header-actions'>
<button onClick={onSave} disabled={readOnly}>
Save
</button>
</div>
</div>
)
}
/**
* Creates header props from agentflow state and handlers
*/
export function createHeaderProps(
flowName: string,
isDirty: boolean,
onSave: () => void,
toJSON: () => string,
validate: () => ValidationResult
): HeaderRenderProps {
return {
flowName,
isDirty,
onSave,
onExport: () => {
const json = toJSON()
const blob = new Blob([json], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'flow.json'
a.click()
URL.revokeObjectURL(url)
},
onValidate: validate
}
}
@@ -0,0 +1,106 @@
import { memo } from 'react'
import { EdgeLabelRenderer, getBezierPath, Position, useStore } from 'reactflow'
import { useTheme } from '@mui/material/styles'
import { AGENTFLOW_ICONS } from '../../../core'
interface EdgeLabelProps {
transform: string
isHumanInput?: boolean
label?: string
color: string
}
function EdgeLabel({ transform, isHumanInput, label, color }: EdgeLabelProps) {
return (
<div
style={{
position: 'absolute',
background: 'transparent',
left: isHumanInput ? 20 : 10,
paddingTop: 1,
color: color,
fontSize: '0.5rem',
fontWeight: 700,
transform,
zIndex: 1000
}}
className='nodrag nopan'
>
{label}
</div>
)
}
export interface ConnectionLineProps {
fromX: number
fromY: number
toX: number
toY: number
fromPosition: Position
toPosition: Position
}
/**
* Connection line component for rendering active connections while dragging
*/
function ConnectionLineComponent({ fromX, fromY, toX, toY, fromPosition, toPosition }: ConnectionLineProps) {
const [edgePath] = getBezierPath({
sourceX: fromX,
sourceY: fromY,
sourcePosition: fromPosition,
targetX: toX,
targetY: toY,
targetPosition: toPosition
})
const connectionHandleId = useStore((state) => state.connectionHandleId) as string | null
const theme = useTheme()
const nodeName = (connectionHandleId || '').split('_')[0] || ''
const isLabelVisible = nodeName === 'humanInputAgentflow' || nodeName === 'conditionAgentflow' || nodeName === 'conditionAgentAgentflow'
const getEdgeLabel = (): string | undefined => {
let edgeLabel: string | undefined = undefined
if (nodeName === 'conditionAgentflow' || nodeName === 'conditionAgentAgentflow') {
const _edgeLabel = connectionHandleId?.split('-').pop() || '0'
edgeLabel = (isNaN(Number(_edgeLabel)) ? 0 : _edgeLabel).toString()
}
if (nodeName === 'humanInputAgentflow') {
const _edgeLabel = connectionHandleId?.split('-').pop() || '0'
edgeLabel = (isNaN(Number(_edgeLabel)) ? 0 : _edgeLabel).toString()
edgeLabel = edgeLabel === '0' ? 'proceed' : 'reject'
}
return edgeLabel
}
const color =
AGENTFLOW_ICONS.find((icon) => icon.name === (connectionHandleId || '').split('_')[0] || '')?.color ?? theme.palette.primary.main
return (
<g>
<path fill='none' stroke={color} strokeWidth={1.5} className='animated' d={edgePath} />
<g transform={`translate(${toX - 10}, ${toY - 10}) scale(0.8)`}>
<path stroke='none' d='M0 0h24v24H0z' fill='none' />
<path
d='M12 2c5.523 0 10 4.477 10 10a10 10 0 0 1 -20 0c0 -5.523 4.477 -10 10 -10m-.293 6.293a1 1 0 0 0 -1.414 0l-.083 .094a1 1 0 0 0 .083 1.32l2.292 2.293l-2.292 2.293a1 1 0 0 0 1.414 1.414l3 -3a1 1 0 0 0 0 -1.414z'
fill={color}
/>
</g>
{isLabelVisible && (
<EdgeLabelRenderer>
<EdgeLabel
color={color}
isHumanInput={nodeName === 'humanInputAgentflow'}
label={getEdgeLabel()}
transform={`translate(-50%, 0%) translate(${fromX}px,${fromY}px)`}
/>
</EdgeLabelRenderer>
)}
</g>
)
}
export const ConnectionLine = memo(ConnectionLineComponent)
export default ConnectionLine
@@ -0,0 +1,51 @@
import { memo } from 'react'
import type { NodeData } from '../../../core/types'
import { renderNodeIcon } from '../nodeIcons'
export interface NodeIconProps {
data: NodeData
instanceUrl: string
}
function NodeIconComponent({ data, instanceUrl }: NodeIconProps) {
if (data.color && !data.icon) {
return (
<div
style={{
width: 40,
height: 40,
borderRadius: '15px',
backgroundColor: data.color,
cursor: 'grab',
display: 'flex',
justifyContent: 'center',
alignItems: 'center'
}}
>
{renderNodeIcon(data)}
</div>
)
}
return (
<div
style={{
width: 40,
height: 40,
borderRadius: '50%',
backgroundColor: 'white',
cursor: 'grab',
overflow: 'hidden'
}}
>
<img
style={{ width: '100%', height: '100%', padding: 5, objectFit: 'contain' }}
src={`${instanceUrl}/api/v1/node-icon/${data.name}`}
alt={data.name}
/>
</div>
)
}
export const NodeIcon = memo(NodeIconComponent)
@@ -0,0 +1,38 @@
import { memo } from 'react'
import { Dialog, DialogContent, DialogTitle, Typography } from '@mui/material'
export interface NodeInfoDialogProps {
open: boolean
onClose: () => void
label: string
name: string
nodeId: string
description?: string
}
/**
* Dialog showing node information
*/
function NodeInfoDialogComponent({ open, onClose, label, name, nodeId, description }: NodeInfoDialogProps) {
return (
<Dialog open={open} onClose={onClose} maxWidth='sm' fullWidth>
<DialogTitle>{label}</DialogTitle>
<DialogContent>
<Typography variant='body2' color='text.secondary'>
<strong>Name:</strong> {name}
</Typography>
<Typography variant='body2' color='text.secondary' sx={{ mt: 1 }}>
<strong>ID:</strong> {nodeId}
</Typography>
{description && (
<Typography variant='body2' color='text.secondary' sx={{ mt: 1 }}>
<strong>Description:</strong> {description}
</Typography>
)}
</DialogContent>
</Dialog>
)
}
export const NodeInfoDialog = memo(NodeInfoDialogComponent)
@@ -0,0 +1,42 @@
import { memo } from 'react'
import { Handle, Position } from 'reactflow'
export interface NodeInputHandleProps {
nodeId: string
nodeColor: string
hidden?: boolean
}
function NodeInputHandleComponent({ nodeId, nodeColor, hidden }: NodeInputHandleProps) {
if (hidden) return null
return (
<Handle
type='target'
position={Position.Left}
id={nodeId}
style={{
width: 5,
height: 20,
backgroundColor: 'transparent',
border: 'none',
position: 'absolute',
left: -2
}}
>
<div
style={{
width: 5,
height: 20,
backgroundColor: nodeColor,
position: 'absolute',
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)'
}}
/>
</Handle>
)
}
export const NodeInputHandle = memo(NodeInputHandleComponent)
@@ -0,0 +1,65 @@
import { memo } from 'react'
import { Box, Typography } from '@mui/material'
import { useApiContext, useConfigContext } from '../../../infrastructure/store'
interface ModelConfig {
model?: string
config?: { modelName?: string; model?: string }
}
export interface NodeModelConfigsProps {
inputs?: Record<string, unknown>
}
/**
* Displays model configuration badges on a node
*/
function NodeModelConfigsComponent({ inputs }: NodeModelConfigsProps) {
const { instanceUrl } = useApiContext()
const { isDarkMode } = useConfigContext()
if (!inputs) return null
const modelConfigs: ModelConfig[] = [
{ model: inputs.llmModel as string, config: inputs.llmModelConfig as ModelConfig['config'] },
{ model: inputs.agentModel as string, config: inputs.agentModelConfig as ModelConfig['config'] },
{ model: inputs.conditionAgentModel as string, config: inputs.conditionAgentModelConfig as ModelConfig['config'] }
]
const validConfigs = modelConfigs.filter((item) => item.model && item.config)
if (validConfigs.length === 0) return null
return (
<>
{validConfigs.map((item, index) => (
<Box key={`model-${index}`} sx={{ display: 'flex', gap: 1, mt: 1 }}>
<Box
sx={{
backgroundColor: isDarkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(255, 255, 255, 0.9)',
borderRadius: '16px',
width: 'max-content',
height: 24,
pl: 1,
pr: 1,
display: 'flex',
justifyContent: 'center',
alignItems: 'center'
}}
>
<img
style={{ width: 20, height: 20, objectFit: 'contain' }}
src={`${instanceUrl}/api/v1/node-icon/${item.model}`}
alt={item.model as string}
/>
<Typography sx={{ fontSize: '0.7rem', ml: 0.5 }}>{item.config?.modelName || item.config?.model}</Typography>
</Box>
</Box>
))}
</>
)
}
export const NodeModelConfigs = memo(NodeModelConfigsComponent)
@@ -0,0 +1,86 @@
import type { RefObject } from 'react'
import { memo } from 'react'
import { Handle, Position, useUpdateNodeInternals } from 'reactflow'
import { useTheme } from '@mui/material/styles'
import { IconCircleChevronRightFilled } from '@tabler/icons-react'
import type { OutputAnchor } from '../../../core/types'
export interface NodeOutputHandlesProps {
outputAnchors: OutputAnchor[]
nodeColor: string
isHovered: boolean
nodeRef: RefObject<HTMLDivElement | null>
nodeId: string
}
/**
* Calculate the minimum height needed for a node based on output anchor count
*/
export function getMinimumNodeHeight(outputCount: number): number {
return Math.max(60, outputCount * 20 + 40)
}
function NodeOutputHandlesComponent({ outputAnchors, nodeColor, isHovered, nodeRef, nodeId }: NodeOutputHandlesProps) {
const theme = useTheme()
const updateNodeInternals = useUpdateNodeInternals()
const getAnchorPosition = (index: number) => {
const currentHeight = nodeRef.current?.clientHeight || 0
const spacing = currentHeight / (outputAnchors.length + 1)
const anchorPosition = spacing * (index + 1)
if (anchorPosition > 0) {
updateNodeInternals(nodeId)
}
return anchorPosition
}
return (
<>
{outputAnchors.map((outputAnchor, index) => (
<Handle
type='source'
position={Position.Right}
key={outputAnchor.id}
id={outputAnchor.id}
style={{
height: 20,
width: 20,
top: getAnchorPosition(index),
backgroundColor: 'transparent',
border: 'none',
position: 'absolute',
right: -10,
opacity: isHovered ? 1 : 0,
transition: 'opacity 0.2s'
}}
>
<div
style={{
position: 'absolute',
width: 20,
height: 20,
borderRadius: '50%',
backgroundColor: theme.palette.background.paper,
pointerEvents: 'none'
}}
/>
<IconCircleChevronRightFilled
size={20}
color={nodeColor}
style={{
pointerEvents: 'none',
position: 'relative',
zIndex: 1
}}
/>
</Handle>
))}
</>
)
}
export const NodeOutputHandles = memo(NodeOutputHandlesComponent)
@@ -0,0 +1,110 @@
import { memo } from 'react'
import CancelIcon from '@mui/icons-material/Cancel'
import StopCircleIcon from '@mui/icons-material/StopCircle'
import { Avatar, Tooltip } from '@mui/material'
import { useTheme } from '@mui/material/styles'
import { IconAlertCircleFilled, IconCheck, IconExclamationMark, IconLoader } from '@tabler/icons-react'
export type NodeStatus = 'INPROGRESS' | 'FINISHED' | 'ERROR' | 'STOPPED' | 'TERMINATED'
export interface NodeStatusIndicatorProps {
status?: NodeStatus
error?: string
}
export interface NodeWarningIndicatorProps {
message: string
}
/**
* Status indicator badge shown on the top-right of a node
*/
function NodeStatusIndicatorComponent({ status, error }: NodeStatusIndicatorProps) {
const theme = useTheme()
if (!status) return null
const getStatusBackgroundColor = (status: NodeStatus) => {
switch (status) {
case 'ERROR':
return theme.palette.error.dark
case 'INPROGRESS':
return theme.palette.warning.dark
case 'STOPPED':
case 'TERMINATED':
return theme.palette.error.main
case 'FINISHED':
return theme.palette.success.dark
default:
return theme.palette.primary.dark
}
}
const renderStatusIcon = () => {
switch (status) {
case 'INPROGRESS':
return <IconLoader className='spin-animation' />
case 'ERROR':
return <IconExclamationMark />
case 'TERMINATED':
return <CancelIcon sx={{ color: getStatusBackgroundColor(status), fontSize: 16 }} />
case 'STOPPED':
return <StopCircleIcon sx={{ color: getStatusBackgroundColor(status), fontSize: 16 }} />
default:
return <IconCheck />
}
}
return (
<Tooltip title={status === 'ERROR' ? error || 'Error' : ''}>
<Avatar
variant='rounded'
sx={{
width: 22,
height: 22,
fontSize: '1rem',
borderRadius: '50%',
background: status === 'STOPPED' || status === 'TERMINATED' ? 'white' : getStatusBackgroundColor(status),
color: 'white',
ml: 2,
position: 'absolute',
top: -10,
right: -10
}}
>
{renderStatusIcon()}
</Avatar>
</Tooltip>
)
}
/**
* Warning indicator badge shown on the top-left of a node
*/
function NodeWarningIndicatorComponent({ message }: NodeWarningIndicatorProps) {
if (!message) return null
return (
<Tooltip placement='right-start' title={<span style={{ whiteSpace: 'pre-line' }}>{message}</span>}>
<Avatar
variant='rounded'
sx={{
width: 22,
height: 22,
fontSize: '1rem',
borderRadius: '50%',
background: 'white',
position: 'absolute',
top: -10,
left: -10
}}
>
<IconAlertCircleFilled color='orange' />
</Avatar>
</Tooltip>
)
}
export const NodeStatusIndicator = memo(NodeStatusIndicatorComponent)
export const NodeWarningIndicator = memo(NodeWarningIndicatorComponent)
@@ -0,0 +1,71 @@
import { memo } from 'react'
import { Position } from 'reactflow'
import { ButtonGroup, IconButton } from '@mui/material'
import { useTheme } from '@mui/material/styles'
import { IconCopy, IconInfoCircle, IconTrash } from '@tabler/icons-react'
import { useAgentflowContext, useConfigContext } from '../../../infrastructure/store'
import { StyledNodeToolbar } from '../styled'
export interface NodeToolbarActionsProps {
nodeId: string
nodeName: string
isVisible: boolean
onInfoClick?: () => void
}
/**
* Toolbar with action buttons for a node (duplicate, delete, info)
*/
function NodeToolbarActionsComponent({ nodeId, nodeName, isVisible, onInfoClick }: NodeToolbarActionsProps) {
const theme = useTheme()
const { isDarkMode } = useConfigContext()
const { deleteNode, duplicateNode } = useAgentflowContext()
return (
<StyledNodeToolbar position={Position.Top} offset={5} align='end' isVisible={isVisible}>
<ButtonGroup sx={{ gap: 1 }} variant='outlined' aria-label='Node actions'>
{nodeName !== 'startAgentflow' && (
<IconButton
size='small'
title='Duplicate'
onClick={() => duplicateNode(nodeId)}
sx={{
color: isDarkMode ? 'white' : theme.palette.grey[600],
'&:hover': { color: theme.palette.primary.main }
}}
>
<IconCopy size={20} />
</IconButton>
)}
<IconButton
size='small'
title='Delete'
onClick={() => deleteNode(nodeId)}
sx={{
color: isDarkMode ? 'white' : theme.palette.grey[600],
'&:hover': { color: theme.palette.error.main }
}}
>
<IconTrash size={20} />
</IconButton>
{onInfoClick && (
<IconButton
size='small'
title='Info'
onClick={onInfoClick}
sx={{
color: isDarkMode ? 'white' : theme.palette.grey[600],
'&:hover': { color: theme.palette.info.main }
}}
>
<IconInfoCircle size={20} />
</IconButton>
)}
</ButtonGroup>
</StyledNodeToolbar>
)
}
export const NodeToolbarActions = memo(NodeToolbarActionsComponent)
@@ -0,0 +1,10 @@
export type { AgentflowHeaderProps } from './AgentflowHeader'
export { AgentflowHeader, createHeaderProps } from './AgentflowHeader'
export { ConnectionLine } from './ConnectionLine'
export { NodeIcon } from './NodeIcon'
export { NodeInfoDialog } from './NodeInfoDialog'
export { NodeInputHandle } from './NodeInputHandle'
export { NodeModelConfigs } from './NodeModelConfigs'
export { getMinimumNodeHeight, NodeOutputHandles } from './NodeOutputHandles'
export { NodeStatusIndicator, NodeWarningIndicator } from './NodeStatusIndicator'
export { NodeToolbarActions } from './NodeToolbarActions'
@@ -0,0 +1,207 @@
import { memo, useState } from 'react'
import { EdgeLabelRenderer, getBezierPath, Position } from 'reactflow'
import { IconX } from '@tabler/icons-react'
import type { EdgeData } from '../../../core/types'
import { useAgentflowContext } from '../../../infrastructure/store'
interface EdgeLabelProps {
transform: string
isHumanInput?: boolean
label: string
color: string
}
function EdgeLabel({ transform, isHumanInput, label, color }: EdgeLabelProps) {
return (
<div
style={{
position: 'absolute',
background: 'transparent',
left: isHumanInput ? 10 : 0,
paddingTop: 1,
color: color,
fontSize: '0.5rem',
fontWeight: 700,
transform,
zIndex: 1000
}}
className='nodrag nopan'
>
{label}
</div>
)
}
const foreignObjectSize = 40
export interface AgentFlowEdgeProps {
id: string
sourceX: number
sourceY: number
targetX: number
targetY: number
sourcePosition: Position
targetPosition: Position
data?: EdgeData
markerEnd?: string
selected?: boolean
}
/**
* Agent Flow Edge component for rendering edges in the canvas
*/
function AgentFlowEdgeComponent({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
data,
markerEnd,
selected
}: AgentFlowEdgeProps) {
const [isHovered, setIsHovered] = useState(false)
const { deleteEdge, setDirty } = useAgentflowContext()
const onEdgeClick = (evt: React.MouseEvent) => {
evt.stopPropagation()
deleteEdge(id)
setDirty(true)
}
const xEqual = sourceX === targetX
const yEqual = sourceY === targetY
const [edgePath, edgeCenterX, edgeCenterY] = getBezierPath({
// we need this little hack in order to display the gradient for a straight line
sourceX: xEqual ? sourceX + 0.0001 : sourceX,
sourceY: yEqual ? sourceY + 0.0001 : sourceY,
sourcePosition,
targetX,
targetY,
targetPosition
})
const gradientId = `edge-gradient-${id}`
return (
<>
<defs>
<linearGradient id={gradientId}>
<stop offset='0%' stopColor={data?.sourceColor || '#ae53ba'} />
<stop offset='100%' stopColor={data?.targetColor || '#2a8af6'} />
</linearGradient>
</defs>
<path
id={`${id}-selector`}
className='agent-flow-edge-selector'
style={{
stroke: 'transparent',
strokeWidth: 15,
fill: 'none',
cursor: 'pointer'
}}
d={edgePath}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
/>
<path
id={id}
className='agent-flow-edge'
style={{
strokeWidth: selected ? 3 : 2,
stroke: `url(#${gradientId})`,
filter: selected ? 'drop-shadow(0 0 3px rgba(0,0,0,0.3))' : 'none',
cursor: 'pointer',
opacity: selected ? 1 : 0.75,
fill: 'none'
}}
d={edgePath}
markerEnd={markerEnd}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
/>
{data?.edgeLabel && (
<EdgeLabelRenderer>
<EdgeLabel
isHumanInput={data?.isHumanInput}
color={data?.sourceColor || '#ae53ba'}
label={data.edgeLabel}
transform={`translate(-50%, 0%) translate(${sourceX}px,${sourceY}px)`}
/>
</EdgeLabelRenderer>
)}
{isHovered && (
<foreignObject
width={foreignObjectSize}
height={foreignObjectSize}
x={edgeCenterX - foreignObjectSize / 2}
y={edgeCenterY - foreignObjectSize / 2}
className='edgebutton-foreignobject'
requiredExtensions='http://www.w3.org/1999/xhtml'
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
pointerEvents: 'all'
}}
>
<button
className='edgebutton'
onClick={onEdgeClick}
style={{
width: '12px',
height: '12px',
background: `linear-gradient(to right, ${data?.sourceColor || '#ae53ba'}, ${
data?.targetColor || '#2a8af6'
})`,
border: 'none',
borderRadius: '50%',
cursor: 'pointer',
fontSize: '10px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
boxShadow: '0 0 4px rgba(0,0,0,0.3)',
transition: 'all 0.2s ease-in-out',
padding: '2px'
}}
onMouseOver={(e) => {
e.currentTarget.style.transform = 'scale(1.2)'
e.currentTarget.style.boxShadow = '0 0 8px rgba(0,0,0,0.4)'
}}
onFocus={(e) => {
e.currentTarget.style.transform = 'scale(1.2)'
e.currentTarget.style.boxShadow = '0 0 8px rgba(0,0,0,0.4)'
}}
onMouseOut={(e) => {
e.currentTarget.style.transform = 'scale(1)'
e.currentTarget.style.boxShadow = '0 0 4px rgba(0,0,0,0.3)'
}}
onBlur={(e) => {
e.currentTarget.style.transform = 'scale(1)'
e.currentTarget.style.boxShadow = '0 0 4px rgba(0,0,0,0.3)'
}}
>
<IconX stroke={2} size='12' color='white' />
</button>
</div>
</foreignObject>
)}
</>
)
}
export const AgentFlowEdge = memo(AgentFlowEdgeComponent)
export default AgentFlowEdge
@@ -0,0 +1,140 @@
import { memo, useEffect, useRef, useState } from 'react'
import { useUpdateNodeInternals } from 'reactflow'
import { Box, Typography } from '@mui/material'
import type { NodeData } from '../../../core/types'
import { useApiContext, useConfigContext } from '../../../infrastructure/store'
import { NodeIcon } from '../components/NodeIcon'
import { NodeInfoDialog } from '../components/NodeInfoDialog'
import { NodeInputHandle } from '../components/NodeInputHandle'
import { NodeModelConfigs } from '../components/NodeModelConfigs'
import { getMinimumNodeHeight, NodeOutputHandles } from '../components/NodeOutputHandles'
import { NodeStatusIndicator, NodeWarningIndicator } from '../components/NodeStatusIndicator'
import { NodeToolbarActions } from '../components/NodeToolbarActions'
import { useNodeColors } from '../hooks/useNodeColors'
import { CardWrapper } from '../styled'
export interface AgentFlowNodeProps {
data: NodeData
}
/**
* Agent Flow Node component for rendering nodes in the canvas
*/
function AgentFlowNodeComponent({ data }: AgentFlowNodeProps) {
const { isDarkMode } = useConfigContext()
const { instanceUrl } = useApiContext()
const ref = useRef<HTMLDivElement>(null)
const updateNodeInternals = useUpdateNodeInternals()
const [isHovered, setIsHovered] = useState(false)
const [warningMessage, setWarningMessage] = useState('')
const [showInfoDialog, setShowInfoDialog] = useState(false)
const { nodeColor, stateColor, backgroundColor } = useNodeColors({
nodeColor: data.color,
selected: data.selected,
isDarkMode,
isHovered
})
const outputAnchors = data.outputAnchors ?? []
const minHeight = getMinimumNodeHeight(outputAnchors.length)
useEffect(() => {
if (ref.current) {
setTimeout(() => {
updateNodeInternals(data.id)
}, 10)
}
}, [data, ref, updateNodeInternals])
useEffect(() => {
if (data.warning) {
setWarningMessage(data.warning)
} else {
setWarningMessage('')
}
}, [data.name, data.version, data.warning])
return (
<div
ref={ref}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
style={{ position: 'relative', width: 'fit-content' }}
>
<NodeToolbarActions
nodeId={data.id}
nodeName={data.name}
isVisible={data.selected || isHovered}
onInfoClick={() => setShowInfoDialog(true)}
/>
<CardWrapper
content={false}
sx={{
width: 'max-content',
borderColor: stateColor,
borderWidth: '1px',
boxShadow: data.selected ? `0 0 0 1px ${stateColor} !important` : 'none',
minHeight,
height: 'auto',
backgroundColor,
display: 'flex',
alignItems: 'center',
px: '14px',
'&:hover': {
boxShadow: data.selected ? `0 0 0 1px ${stateColor} !important` : 'none'
}
}}
border={false}
>
<NodeStatusIndicator status={data.status} error={data.error} />
<NodeWarningIndicator message={warningMessage} />
<Box sx={{ width: 'max-content', flexShrink: 0 }}>
<NodeInputHandle nodeId={data.id} nodeColor={nodeColor} hidden={data.hideInput} />
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}>
<Box style={{ padding: 10 }}>
<NodeIcon data={data} instanceUrl={instanceUrl} />
</Box>
<Box>
<Typography
sx={{
fontSize: '0.85rem',
fontWeight: 500
}}
>
{data.label}
</Typography>
<NodeModelConfigs inputs={data.inputs} />
</Box>
</div>
<NodeOutputHandles
outputAnchors={outputAnchors}
nodeColor={nodeColor}
isHovered={isHovered}
nodeRef={ref}
nodeId={data.id}
/>
</Box>
</CardWrapper>
<NodeInfoDialog
open={showInfoDialog}
onClose={() => setShowInfoDialog(false)}
label={data.label}
name={data.name}
nodeId={data.id}
description={data.description}
/>
</div>
)
}
export const AgentFlowNode = memo(AgentFlowNodeComponent)
export default AgentFlowNode
@@ -0,0 +1,177 @@
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { Background, NodeResizer, NodeToolbar, useUpdateNodeInternals } from 'reactflow'
import { Box, Typography } from '@mui/material'
import { useTheme } from '@mui/material/styles'
import type { NodeData } from '../../../core/types'
import { useAgentflowContext, useApiContext, useConfigContext } from '../../../infrastructure/store'
import { NodeIcon } from '../components/NodeIcon'
import { NodeInfoDialog } from '../components/NodeInfoDialog'
import { NodeInputHandle } from '../components/NodeInputHandle'
import { getMinimumNodeHeight, NodeOutputHandles } from '../components/NodeOutputHandles'
import { NodeStatusIndicator } from '../components/NodeStatusIndicator'
import { NodeToolbarActions } from '../components/NodeToolbarActions'
import { useNodeColors } from '../hooks/useNodeColors'
import { CardWrapper } from '../styled'
export interface IterationNodeProps {
data: NodeData
}
/**
* Iteration Node component for loop/iteration nodes in the canvas
*/
function IterationNodeComponent({ data }: IterationNodeProps) {
const theme = useTheme()
const { isDarkMode } = useConfigContext()
const { instanceUrl } = useApiContext()
const { state } = useAgentflowContext()
const ref = useRef<HTMLDivElement>(null)
const reactFlowWrapper = useRef<HTMLDivElement>(null)
const updateNodeInternals = useUpdateNodeInternals()
const [isHovered, setIsHovered] = useState(false)
const [showInfoDialog, setShowInfoDialog] = useState(false)
const [cardDimensions, setCardDimensions] = useState({
width: '300px',
height: '250px'
})
const { nodeColor, stateColor, backgroundColor } = useNodeColors({
nodeColor: data.color,
selected: data.selected,
isDarkMode,
isHovered
})
const outputAnchors = data.outputAnchors ?? []
const minHeight = Math.max(getMinimumNodeHeight(outputAnchors.length), 250)
useEffect(() => {
if (state.reactFlowInstance) {
const node = state.reactFlowInstance.getNodes().find((n) => n.id === data.id)
if (node && node.width && node.height) {
setCardDimensions({
width: `${node.width}px`,
height: `${node.height}px`
})
}
}
}, [state.reactFlowInstance, data.id])
useEffect(() => {
if (ref.current) {
setTimeout(() => {
updateNodeInternals(data.id)
}, 10)
}
}, [data, ref, updateNodeInternals])
const onResizeEnd = useCallback(
(e: unknown, params: { width: number; height: number }) => {
if (!ref.current) return
setCardDimensions({
width: `${params.width}px`,
height: `${params.height}px`
})
},
[ref]
)
return (
<div ref={ref} onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)}>
<NodeToolbar align='start' isVisible={true}>
<Box style={{ display: 'flex', alignItems: 'center', flexDirection: 'row' }}>
<NodeIcon data={data} instanceUrl={instanceUrl} />
<Typography
sx={{
fontSize: '0.85rem',
fontWeight: 500,
ml: 1
}}
>
{data.label}
</Typography>
</Box>
</NodeToolbar>
<NodeToolbarActions
nodeId={data.id}
nodeName={data.name}
isVisible={data.selected || isHovered}
onInfoClick={() => setShowInfoDialog(true)}
/>
<NodeResizer minWidth={300} minHeight={minHeight} onResizeEnd={onResizeEnd} />
<CardWrapper
content={false}
sx={{
borderColor: stateColor,
borderWidth: '1px',
boxShadow: data.selected ? `0 0 0 1px ${stateColor} !important` : 'none',
minHeight,
minWidth: 300,
width: cardDimensions.width,
height: cardDimensions.height,
backgroundColor,
display: 'flex',
'&:hover': {
boxShadow: data.selected ? `0 0 0 1px ${stateColor} !important` : 'none'
}
}}
border={false}
>
<NodeStatusIndicator status={data.status} error={data.error} />
<Box sx={{ width: '100%' }}>
<NodeInputHandle nodeId={data.id} nodeColor={nodeColor} hidden={data.hideInput} />
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}>
<Box
sx={{
height: `calc(${cardDimensions.height} - 20px)`,
width: `${cardDimensions.width}`,
overflow: 'hidden',
position: 'relative',
borderRadius: '10px'
}}
>
<div
ref={reactFlowWrapper}
style={{
width: '100%',
height: '100%',
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: theme.palette.background.default
}}
>
<Background color='#aaa' gap={16} />
</div>
</Box>
</div>
<NodeOutputHandles
outputAnchors={outputAnchors}
nodeColor={nodeColor}
isHovered={isHovered}
nodeRef={ref}
nodeId={data.id}
/>
</Box>
</CardWrapper>
<NodeInfoDialog
open={showInfoDialog}
onClose={() => setShowInfoDialog(false)}
label={data.label}
name={data.name}
nodeId={data.id}
description={data.description}
/>
</div>
)
}
export const IterationNode = memo(IterationNodeComponent)
export default IterationNode
@@ -0,0 +1,81 @@
import { memo, useRef, useState } from 'react'
import { Box, TextField } from '@mui/material'
import type { NodeData } from '../../../core/types'
import { useConfigContext } from '../../../infrastructure/store'
import { NodeToolbarActions } from '../components/NodeToolbarActions'
import { useNodeColors } from '../hooks/useNodeColors'
import { CardWrapper } from '../styled'
export interface StickyNoteProps {
data: NodeData
}
/**
* Sticky Note node component for adding notes to the canvas
*/
function StickyNoteComponent({ data }: StickyNoteProps) {
const { isDarkMode } = useConfigContext()
const ref = useRef<HTMLDivElement>(null)
const [inputParam] = data.inputParams || []
const [isHovered, setIsHovered] = useState(false)
const { stateColor, backgroundColor } = useNodeColors({
nodeColor: data.color,
selected: data.selected,
isDarkMode,
isHovered
})
return (
<div ref={ref} onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)}>
<NodeToolbarActions nodeId={data.id} nodeName={data.name} isVisible={data.selected || isHovered} />
<CardWrapper
content={false}
sx={{
borderColor: stateColor,
borderWidth: '1px',
boxShadow: data.selected ? `0 0 0 1px ${stateColor} !important` : 'none',
minHeight: 60,
height: 'auto',
backgroundColor,
display: 'flex',
alignItems: 'center',
'&:hover': {
boxShadow: data.selected ? `0 0 0 1px ${stateColor} !important` : 'none'
}
}}
border={false}
>
<Box>
<TextField
key={data.id}
multiline
rows={3}
placeholder={inputParam?.placeholder || 'Add a note...'}
value={data.inputs?.[inputParam?.name || 'note'] ?? inputParam?.default ?? ''}
onChange={(e) => {
if (data.inputs && inputParam) {
data.inputs[inputParam.name] = e.target.value
}
}}
sx={{
'& .MuiInputBase-root': {
background: 'transparent',
border: 'none'
},
'& .MuiOutlinedInput-notchedOutline': {
border: 'none'
}
}}
/>
</Box>
</CardWrapper>
</div>
)
}
export const StickyNote = memo(StickyNoteComponent)
export default StickyNote
@@ -0,0 +1,4 @@
export { AgentFlowEdge } from './AgentFlowEdge'
export { AgentFlowNode } from './AgentFlowNode'
export { IterationNode } from './IterationNode'
export { StickyNote } from './StickyNote'
@@ -0,0 +1,4 @@
export { useDragAndDrop } from './useDragAndDrop'
export { useFlowHandlers } from './useFlowHandlers'
export { useFlowNodes } from './useFlowNodes'
export { useNodeColors } from './useNodeColors'
@@ -0,0 +1,69 @@
import { RefObject, useCallback } from 'react'
import { useReactFlow } from 'reactflow'
import { getUniqueNodeId, initNode } from '../../../core'
import type { FlowNode, NodeData } from '../../../core/types'
import { useAgentflowContext } from '../../../infrastructure/store'
interface UseDragAndDropProps {
nodes: FlowNode[]
setLocalNodes: React.Dispatch<React.SetStateAction<FlowNode[]>>
reactFlowWrapper: RefObject<HTMLDivElement>
}
/**
* Hook for handling drag and drop of nodes onto the canvas
*/
export function useDragAndDrop({ nodes, setLocalNodes, reactFlowWrapper }: UseDragAndDropProps) {
const { setDirty } = useAgentflowContext()
const reactFlowInstance = useReactFlow()
// Handle drag over for drop zone
const handleDragOver = useCallback((event: React.DragEvent) => {
event.preventDefault()
event.dataTransfer.dropEffect = 'move'
}, [])
// Handle drop from AddNodesDrawer
const handleDrop = useCallback(
(event: React.DragEvent) => {
event.preventDefault()
const nodeDataStr = event.dataTransfer.getData('application/reactflow')
if (!nodeDataStr) return
try {
const nodeData = JSON.parse(nodeDataStr) as NodeData
// Get drop position relative to the canvas
const reactFlowBounds = reactFlowWrapper.current?.getBoundingClientRect()
if (!reactFlowBounds) return
const position = reactFlowInstance.screenToFlowPosition({
x: event.clientX - reactFlowBounds.left,
y: event.clientY - reactFlowBounds.top
})
const newId = getUniqueNodeId(nodeData, nodes)
const initializedData = initNode(nodeData, newId, true)
const newNode: FlowNode = {
id: newId,
type: 'agentflowNode',
position,
data: initializedData
}
setLocalNodes((nds) => [...nds, newNode])
setDirty(true)
} catch (error) {
console.error('[Agentflow] Failed to parse dropped node data:', error)
}
},
[nodes, reactFlowInstance, setLocalNodes, setDirty, reactFlowWrapper]
)
return {
handleDragOver,
handleDrop
}
}
@@ -0,0 +1,145 @@
import { useCallback, useRef } from 'react'
import { addEdge, Connection, EdgeChange, NodeChange } from 'reactflow'
import { getNodeColor, getUniqueNodeId, initNode, isValidConnectionAgentflowV2 } from '../../../core'
import type { FlowData, FlowEdge, FlowNode, NodeData } from '../../../core/types'
import { useAgentflowContext } from '../../../infrastructure/store'
interface UseFlowHandlersProps {
nodes: FlowNode[]
edges: FlowEdge[]
setLocalNodes: React.Dispatch<React.SetStateAction<FlowNode[]>>
setLocalEdges: React.Dispatch<React.SetStateAction<FlowEdge[]>>
onNodesChange: (changes: NodeChange[]) => void
onEdgesChange: (changes: EdgeChange[]) => void
onFlowChange?: (flow: FlowData) => void
availableNodes: NodeData[]
}
/**
* Hook for handling flow connection and change events
*/
export function useFlowHandlers({
nodes,
edges,
setLocalNodes,
setLocalEdges,
onNodesChange,
onEdgesChange,
onFlowChange,
availableNodes
}: UseFlowHandlersProps) {
const { setDirty } = useAgentflowContext()
// Ref to store onFlowChange callback to avoid stale closures
const onFlowChangeRef = useRef(onFlowChange)
onFlowChangeRef.current = onFlowChange
// Handle connection
const handleConnect = useCallback(
(params: Connection) => {
if (!params.source || !params.target) {
return
}
if (!isValidConnectionAgentflowV2({ source: params.source, target: params.target }, nodes, edges)) {
return
}
const sourceNode = nodes.find((n) => n.id === params.source)
const targetNode = nodes.find((n) => n.id === params.target)
const sourceColor = getNodeColor(sourceNode?.data?.name || '')
const targetColor = getNodeColor(targetNode?.data?.name || '')
setLocalEdges(
(eds) =>
addEdge(
{
...params,
type: 'agentflowEdge',
data: {
sourceColor,
targetColor
}
},
eds
) as FlowEdge[]
)
setDirty(true)
},
[nodes, edges, setLocalEdges, setDirty]
)
// Handle node changes
const handleNodesChange = useCallback(
(changes: NodeChange[]) => {
onNodesChange(changes)
// Only set dirty and notify for meaningful changes (not selection or internal dimension updates)
const meaningfulChanges = changes.filter((c) => c.type !== 'select' && c.type !== 'dimensions')
if (meaningfulChanges.length > 0) {
setDirty(true)
// Use setTimeout to ensure state has updated before notifying
setTimeout(
() =>
onFlowChangeRef.current?.({
nodes,
edges,
viewport: { x: 0, y: 0, zoom: 1 }
}),
0
)
}
},
[onNodesChange, setDirty, nodes, edges]
)
// Handle edge changes
const handleEdgesChange = useCallback(
(changes: EdgeChange[]) => {
onEdgesChange(changes)
// Only set dirty and notify for meaningful changes (not selection)
const meaningfulChanges = changes.filter((c) => c.type !== 'select')
if (meaningfulChanges.length > 0) {
setDirty(true)
setTimeout(
() =>
onFlowChangeRef.current?.({
nodes,
edges,
viewport: { x: 0, y: 0, zoom: 1 }
}),
0
)
}
},
[onEdgesChange, setDirty, nodes, edges]
)
// Handle add node from palette
const handleAddNode = useCallback(
(nodeType: string, position?: { x: number; y: number }) => {
const nodeData = availableNodes.find((n) => n.name === nodeType)
if (!nodeData) return
const newId = getUniqueNodeId(nodeData, nodes)
const initializedData = initNode(nodeData, newId, true)
const newNode: FlowNode = {
id: newId,
type: 'agentflowNode',
position: position || { x: 100, y: 100 },
data: initializedData
}
setLocalNodes((nds) => [...nds, newNode])
setDirty(true)
},
[availableNodes, nodes, setLocalNodes, setDirty]
)
return {
handleConnect,
handleNodesChange,
handleEdgesChange,
handleAddNode
}
}
@@ -0,0 +1,45 @@
import { useEffect, useState } from 'react'
import type { NodeData } from '../../../core/types'
import { useApiContext, useConfigContext } from '../../../infrastructure/store'
/**
* Hook for loading and filtering available agentflow nodes from the API
*/
export function useFlowNodes() {
const { nodesApi } = useApiContext()
const { components: allowedComponents } = useConfigContext()
const [availableNodes, setAvailableNodes] = useState<NodeData[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
const loadNodes = async () => {
setIsLoading(true)
setError(null)
let agentflowNodes: NodeData[] = []
try {
const allNodes = await nodesApi.getAllNodes()
// Filter to only agentflow nodes
agentflowNodes = allNodes.filter((node) => node.category === 'Agent Flows')
} catch (err) {
console.warn('[Agentflow] Failed to load nodes from API:', err)
setError(err instanceof Error ? err : new Error('Failed to load nodes'))
}
// Apply component filter
if (allowedComponents && allowedComponents.length > 0) {
const allowed = new Set(['startAgentflow', ...allowedComponents])
setAvailableNodes(agentflowNodes.filter((n) => allowed.has(n.name)))
} else {
setAvailableNodes(agentflowNodes)
}
setIsLoading(false)
}
loadNodes()
}, [nodesApi, allowedComponents])
return { availableNodes, isLoading, error }
}
@@ -0,0 +1,37 @@
import { useMemo } from 'react'
import { alpha, darken, lighten } from '@mui/material/styles'
const DEFAULT_NODE_COLOR = '#666666'
export interface UseNodeColorsOptions {
nodeColor?: string
selected?: boolean
isDarkMode: boolean
isHovered: boolean
}
export interface UseNodeColorsReturn {
nodeColor: string
stateColor: string
backgroundColor: string
}
export function useNodeColors({ nodeColor: rawColor, selected, isDarkMode, isHovered }: UseNodeColorsOptions): UseNodeColorsReturn {
const nodeColor = rawColor || DEFAULT_NODE_COLOR
const stateColor = useMemo(() => {
if (selected) return nodeColor
if (isHovered) return alpha(nodeColor, 0.8)
return alpha(nodeColor, 0.5)
}, [nodeColor, selected, isHovered])
const backgroundColor = useMemo(() => {
if (isDarkMode) {
return isHovered ? darken(nodeColor, 0.7) : darken(nodeColor, 0.8)
}
return isHovered ? lighten(nodeColor, 0.8) : lighten(nodeColor, 0.9)
}, [nodeColor, isDarkMode, isHovered])
return { nodeColor, stateColor, backgroundColor }
}
@@ -0,0 +1,30 @@
// Canvas Feature - Public API
// Container components (with state/logic)
import { AgentFlowEdge, AgentFlowNode, IterationNode, StickyNote } from './containers'
import './canvas.css'
// Presentational components
export type { AgentflowHeaderProps } from './components'
export { AgentflowHeader, ConnectionLine, createHeaderProps } from './components'
// Hooks
export { useDragAndDrop, useFlowHandlers, useFlowNodes } from './hooks'
// Node and edge type registrations for ReactFlow
export const nodeTypes = {
agentflowNode: AgentFlowNode,
stickyNote: StickyNote,
iteration: IterationNode
}
export const edgeTypes = {
agentflowEdge: AgentFlowEdge
}
// Re-export container components
export { AgentFlowEdge, AgentFlowNode, IterationNode, StickyNote }
// Utilities and styled components
export { getBuiltInAnthropicToolIcon, getBuiltInGeminiToolIcon, getBuiltInOpenAIToolIcon, renderNodeIcon } from './nodeIcons'
export { CardWrapper, StyledNodeToolbar } from './styled'
@@ -0,0 +1,60 @@
import { IconBrandGoogle, IconBrowserCheck, IconCode, IconPhoto, IconWorldWww } from '@tabler/icons-react'
import { AGENTFLOW_ICONS } from '../../core'
import type { NodeData } from '../../core/types'
/**
* Renders the icon for an agentflow node based on its name
*/
export function renderNodeIcon(node: NodeData) {
const foundIcon = AGENTFLOW_ICONS.find((icon) => icon.name === node.name)
if (!foundIcon) return null
const IconComponent = foundIcon.icon
return <IconComponent size={24} color='white' />
}
/**
* Returns the icon component for OpenAI built-in tools
*/
export function getBuiltInOpenAIToolIcon(toolName: string) {
switch (toolName) {
case 'web_search_preview':
return <IconWorldWww size={14} color='white' />
case 'code_interpreter':
return <IconCode size={14} color='white' />
case 'image_generation':
return <IconPhoto size={14} color='white' />
default:
return null
}
}
/**
* Returns the icon component for Gemini built-in tools
*/
export function getBuiltInGeminiToolIcon(toolName: string) {
switch (toolName) {
case 'urlContext':
return <IconWorldWww size={14} color='white' />
case 'googleSearch':
return <IconBrandGoogle size={14} color='white' />
case 'codeExecution':
return <IconCode size={14} color='white' />
default:
return null
}
}
/**
* Returns the icon component for Anthropic built-in tools
*/
export function getBuiltInAnthropicToolIcon(toolName: string) {
switch (toolName) {
case 'web_search_20250305':
return <IconWorldWww size={14} color='white' />
case 'web_fetch_20250910':
return <IconBrowserCheck size={14} color='white' />
default:
return null
}
}
@@ -0,0 +1,25 @@
import type { ComponentType } from 'react'
import { NodeToolbar, type NodeToolbarProps } from 'reactflow'
import { styled } from '@mui/material/styles'
import { MainCard, type MainCardProps } from '../../atoms'
export const CardWrapper: ComponentType<MainCardProps> = styled(MainCard)(({ theme }) => ({
background: (theme.palette as unknown as { card?: { main?: string } }).card?.main || theme.palette.background.paper,
color: theme.palette.text.primary,
border: 'solid 1px',
borderRadius: '8px',
width: 'max-content',
height: 'auto',
padding: '10px',
boxShadow: 'none'
}))
export const StyledNodeToolbar: ComponentType<NodeToolbarProps> = styled(NodeToolbar)(({ theme }) => ({
backgroundColor: (theme.palette as unknown as { card?: { main?: string } }).card?.main || theme.palette.background.paper,
color: theme.palette.text.primary,
padding: '5px',
borderRadius: '10px',
boxShadow: '0 2px 14px 0 rgb(32 40 45 / 8%)'
}))
@@ -0,0 +1,289 @@
import { memo, useCallback, useEffect, useState } from 'react'
import {
Alert,
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControl,
InputLabel,
LinearProgress,
MenuItem,
OutlinedInput,
Select,
Typography
} from '@mui/material'
import { useTheme } from '@mui/material/styles'
import { IconSparkles } from '@tabler/icons-react'
import type { FlowData } from '../../core/types'
import { useApiContext, useConfigContext } from '../../infrastructure/store'
import { defaultSuggestions, SuggestionChips } from './SuggestionChips'
export interface GenerateFlowDialogProps {
/** Whether the dialog is open */
open: boolean
/** Callback when dialog is closed */
onClose: () => void
/** Callback when flow is generated successfully */
onGenerated: (nodes: FlowData['nodes'], edges: FlowData['edges']) => void
}
interface ChatModel {
name: string
label: string
description?: string
}
/**
* Generate Flow Dialog - AI-powered flow generation
*/
function GenerateFlowDialogComponent({ open, onClose, onGenerated }: GenerateFlowDialogProps) {
const theme = useTheme()
const { chatflowsApi, instanceUrl } = useApiContext()
const { isDarkMode: _isDarkMode } = useConfigContext()
const [prompt, setPrompt] = useState('')
const [loading, setLoading] = useState(false)
const [progress, setProgress] = useState(0)
const [error, setError] = useState<string | null>(null)
const [chatModels, setChatModels] = useState<ChatModel[]>([])
const [selectedModel, setSelectedModel] = useState<string>('')
const [loadingModels, setLoadingModels] = useState(false)
// Load chat models when dialog opens
useEffect(() => {
if (open && chatModels.length === 0) {
loadChatModels()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
// Clear state when dialog closes
useEffect(() => {
if (!open) {
setPrompt('')
setProgress(0)
setError(null)
}
}, [open])
// Fake progress animation
useEffect(() => {
let timer: ReturnType<typeof setInterval>
if (loading) {
setProgress(0)
timer = setInterval(() => {
setProgress((prev) => {
if (prev >= 95) {
clearInterval(timer)
return 95
}
// Speed up in the middle, slow at the beginning and end
const increment = prev < 30 ? 3 : prev < 60 ? 5 : prev < 80 ? 2 : 0.5
return Math.min(prev + increment, 95)
})
}, 500)
} else {
setProgress(100)
}
return () => {
if (timer) clearInterval(timer)
}
}, [loading])
const loadChatModels = async () => {
try {
setLoadingModels(true)
const models = await chatflowsApi.getChatModels()
setChatModels(models)
// Select first model by default
if (models.length > 0 && !selectedModel) {
setSelectedModel(models[0].name)
}
} catch (err) {
console.error('Failed to load chat models:', err)
setError('Failed to load chat models. Please try again.')
} finally {
setLoadingModels(false)
}
}
const handleSuggestionSelect = useCallback((suggestion: { text: string }) => {
setPrompt(suggestion.text)
setError(null)
}, [])
const handleGenerate = async () => {
if (!prompt.trim() || !selectedModel) return
try {
setLoading(true)
setError(null)
const result = await chatflowsApi.generateAgentflow({
question: prompt.trim(),
selectedChatModel: {
name: selectedModel
}
})
if (result.nodes && result.edges) {
onGenerated(result.nodes, result.edges)
onClose()
} else {
setError('Failed to generate flow. Please try again.')
}
} catch (err: unknown) {
const errorMessage =
err instanceof Error
? err.message
: (err as { response?: { data?: { message?: string } } })?.response?.data?.message ||
'Failed to generate flow. Please try again.'
setError(errorMessage)
} finally {
setLoading(false)
}
}
const isGenerateDisabled = loading || !prompt.trim() || !selectedModel
return (
<Dialog
fullWidth
maxWidth={loading ? 'sm' : 'md'}
open={open}
onClose={loading ? undefined : onClose}
aria-labelledby='generate-flow-dialog-title'
>
<DialogTitle sx={{ fontSize: '1rem' }} id='generate-flow-dialog-title'>
What would you like to build?
</DialogTitle>
<DialogContent>
{loading ? (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'column',
py: 4
}}
>
<Typography variant='h5' sx={{ mt: 2 }}>
Generating your Agentflow...
</Typography>
<Box sx={{ width: '100%', mt: 3 }}>
<LinearProgress
variant='determinate'
value={progress}
sx={{
height: 10,
borderRadius: 5,
'& .MuiLinearProgress-bar': {
background: 'linear-gradient(45deg, #FF6B6B 30%, #FF8E53 90%)',
borderRadius: 5
}
}}
/>
<Typography variant='body2' color='text.secondary' align='center' sx={{ mt: 1 }}>
{`${Math.round(progress)}%`}
</Typography>
</Box>
</Box>
) : (
<>
<Typography color='text.secondary'>
Enter your prompt to generate an agentflow. Performance may vary with different models. Only nodes and edges are
generated, you will need to fill in the input fields for each node.
</Typography>
<SuggestionChips suggestions={defaultSuggestions} onSelect={handleSuggestionSelect} disabled={loading} />
<OutlinedInput
sx={{ mt: 2, width: '100%' }}
type='text'
multiline
rows={8}
disabled={loading}
value={prompt}
placeholder='Describe your agent here'
onChange={(e) => {
setPrompt(e.target.value)
setError(null)
}}
/>
<FormControl fullWidth sx={{ mt: 2 }}>
<InputLabel id='model-select-label'>
Select model to generate agentflow <span style={{ color: 'red' }}>*</span>
</InputLabel>
<Select
labelId='model-select-label'
id='model-select'
value={selectedModel}
label='Select model to generate agentflow *'
onChange={(e) => setSelectedModel(e.target.value)}
disabled={loading || loadingModels}
>
{chatModels.map((model) => (
<MenuItem key={model.name} value={model.name}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<img
src={`${instanceUrl}/api/v1/node-icon/${model.name}`}
alt={model.label}
style={{ width: 24, height: 24, borderRadius: '50%' }}
onError={(e) => {
;(e.target as HTMLImageElement).style.display = 'none'
}}
/>
{model.label}
</Box>
</MenuItem>
))}
</Select>
</FormControl>
{error && (
<Alert severity='error' sx={{ mt: 2 }}>
{error}
</Alert>
)}
</>
)}
</DialogContent>
<DialogActions sx={{ pb: 3, pr: 3 }}>
{!loading && (
<>
<Button onClick={onClose} color='inherit'>
Cancel
</Button>
<Button
variant='contained'
onClick={handleGenerate}
disabled={isGenerateDisabled}
startIcon={<IconSparkles size={20} />}
sx={{
background: 'linear-gradient(45deg, #FF6B6B 30%, #FF8E53 90%)',
'&:hover': { background: 'linear-gradient(45deg, #FF8E53 30%, #FF6B6B 90%)' },
'&:disabled': {
background: theme.palette.action.disabledBackground
}
}}
>
Generate
</Button>
</>
)}
</DialogActions>
</Dialog>
)
}
export const GenerateFlowDialog = memo(GenerateFlowDialogComponent)
export default GenerateFlowDialog
@@ -0,0 +1,77 @@
import { memo } from 'react'
import { Box, Button } from '@mui/material'
import { useConfigContext } from '../../infrastructure/store'
export interface Suggestion {
text: string
id?: string
}
export interface SuggestionChipsProps {
/** List of suggestions to display */
suggestions: Suggestion[]
/** Callback when a suggestion is clicked */
onSelect: (suggestion: Suggestion) => void
/** Whether the chips are disabled */
disabled?: boolean
}
const defaultSuggestions: Suggestion[] = [
{ id: '1', text: 'An agent that can autonomously search the web and generate report' },
{ id: '2', text: 'Summarize a document' },
{ id: '3', text: 'Generate response to user queries and send it to Slack' },
{ id: '4', text: 'A team of agents that can handle all customer queries' }
]
/**
* Suggestion chips for the generate flow dialog
*/
function SuggestionChipsComponent({ suggestions = defaultSuggestions, onSelect, disabled = false }: SuggestionChipsProps) {
const { isDarkMode } = useConfigContext()
return (
<Box
sx={{
display: 'block',
flexDirection: 'row',
width: '100%',
mt: 3
}}
>
{suggestions.map((suggestion, index) => (
<Button
key={suggestion.id || index}
size='small'
disabled={disabled}
sx={{
textTransform: 'none',
mr: 1,
mb: 1,
borderRadius: '16px',
border: 'none',
backgroundColor: isDarkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.03)',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
'&:hover': {
backgroundColor: isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.06)',
boxShadow: '0 4px 8px rgba(0,0,0,0.15)'
},
'&:disabled': {
opacity: 0.5
}
}}
variant='contained'
color='inherit'
onClick={() => onSelect(suggestion)}
>
{suggestion.text}
</Button>
))}
</Box>
)
}
export const SuggestionChips = memo(SuggestionChipsComponent)
export { defaultSuggestions }
export default SuggestionChips
@@ -0,0 +1,3 @@
// Generator Feature - Public API
export type { GenerateFlowDialogProps } from './GenerateFlowDialog'
export { GenerateFlowDialog } from './GenerateFlowDialog'
@@ -0,0 +1,244 @@
import { memo, useEffect, useRef, useState } from 'react'
import { useUpdateNodeInternals } from 'reactflow'
import { Avatar, Box, ButtonBase, Dialog, DialogContent, Stack, TextField, Typography } from '@mui/material'
import { useTheme } from '@mui/material/styles'
import { IconCheck, IconInfoCircle, IconPencil, IconX } from '@tabler/icons-react'
import { NodeInputHandler } from '../../atoms'
import type { InputParam, NodeData } from '../../core/types'
import { useAgentflowContext, useConfigContext } from '../../infrastructure/store'
export interface EditNodeDialogProps {
show: boolean
dialogProps: {
inputParams?: InputParam[]
data?: NodeData
disabled?: boolean
}
onCancel: () => void
}
/**
* Dialog for editing node properties
*/
function EditNodeDialogComponent({ show, dialogProps, onCancel }: EditNodeDialogProps) {
const theme = useTheme()
const { isDarkMode } = useConfigContext()
const { state: _state, updateNodeData } = useAgentflowContext()
const nodeNameRef = useRef<HTMLInputElement>(null)
const updateNodeInternals = useUpdateNodeInternals()
const [inputParams, setInputParams] = useState<InputParam[]>([])
const [data, setData] = useState<NodeData | null>(null)
const [isEditingNodeName, setEditingNodeName] = useState(false)
const [nodeName, setNodeName] = useState('')
const onNodeLabelChange = () => {
if (!data || !nodeNameRef.current) return
const newLabel = nodeNameRef.current.value
updateNodeData(data.id, { label: newLabel })
setData({ ...data, label: newLabel })
updateNodeInternals(data.id)
}
const onCustomDataChange = ({ inputParam, newValue }: { inputParam: InputParam; newValue: unknown }) => {
if (!data) return
const updatedInputs = {
...data.inputs,
[inputParam.name]: newValue
}
updateNodeData(data.id, { inputs: updatedInputs })
setData({ ...data, inputs: updatedInputs })
}
useEffect(() => {
if (dialogProps.inputParams) {
setInputParams(dialogProps.inputParams)
}
if (dialogProps.data) {
setData(dialogProps.data)
if (dialogProps.data.label) setNodeName(dialogProps.data.label)
}
return () => {
setInputParams([])
setData(null)
}
}, [dialogProps])
if (!show) return null
return (
<Dialog
onClose={onCancel}
open={show}
fullWidth
maxWidth='sm'
aria-labelledby='edit-node-dialog-title'
aria-describedby='edit-node-dialog-description'
>
<DialogContent>
{data && data.name && (
<Box sx={{ width: '100%' }}>
{!isEditingNodeName ? (
<Stack flexDirection='row' sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Typography
sx={{
ml: 2,
textOverflow: 'ellipsis',
overflow: 'hidden',
whiteSpace: 'nowrap'
}}
variant='h4'
>
{nodeName}
</Typography>
{data?.id && (
<ButtonBase title='Edit Name' sx={{ borderRadius: '50%' }}>
<Avatar
variant='rounded'
sx={{
width: 30,
height: 30,
transition: 'all .2s ease-in-out',
ml: 1,
background: theme.palette.secondary.light,
color: theme.palette.secondary.dark,
'&:hover': {
background: theme.palette.secondary.dark,
color: theme.palette.secondary.light
}
}}
onClick={() => setEditingNodeName(true)}
>
<IconPencil stroke={1.5} size='1rem' />
</Avatar>
</ButtonBase>
)}
</Stack>
) : (
<Stack flexDirection='row' sx={{ width: '100%' }}>
<TextField
size='small'
sx={{
width: '100%',
ml: 2
}}
inputRef={nodeNameRef}
defaultValue={nodeName}
onKeyDown={(e) => {
if (e.key === 'Enter' && nodeNameRef.current) {
setNodeName(nodeNameRef.current.value)
onNodeLabelChange()
setEditingNodeName(false)
} else if (e.key === 'Escape') {
setEditingNodeName(false)
}
}}
/>
<ButtonBase title='Save Name' sx={{ borderRadius: '50%' }}>
<Avatar
variant='rounded'
sx={{
width: 30,
height: 30,
transition: 'all .2s ease-in-out',
background: theme.palette.success.light,
color: theme.palette.success.dark,
ml: 1,
'&:hover': {
background: theme.palette.success.dark,
color: theme.palette.success.light
}
}}
onClick={() => {
if (nodeNameRef.current) {
setNodeName(nodeNameRef.current.value)
onNodeLabelChange()
setEditingNodeName(false)
}
}}
>
<IconCheck stroke={1.5} size='1rem' />
</Avatar>
</ButtonBase>
<ButtonBase title='Cancel' sx={{ borderRadius: '50%' }}>
<Avatar
variant='rounded'
sx={{
width: 30,
height: 30,
transition: 'all .2s ease-in-out',
background: theme.palette.error.light,
color: theme.palette.error.dark,
ml: 1,
'&:hover': {
background: theme.palette.error.dark,
color: theme.palette.error.light
}
}}
onClick={() => setEditingNodeName(false)}
>
<IconX stroke={1.5} size='1rem' />
</Avatar>
</ButtonBase>
</Stack>
)}
</Box>
)}
{data?.hint && (
<Stack
direction='row'
alignItems='center'
sx={{
ml: 2,
backgroundColor: isDarkMode ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.03)',
borderRadius: '8px',
mr: 2,
px: 1.5,
py: 1,
mt: 1,
mb: 1,
border: `1px solid ${isDarkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.08)'}`
}}
>
<IconInfoCircle size='1rem' stroke={1.5} color={theme.palette.info.main} style={{ marginRight: '6px' }} />
<Typography
variant='caption'
color='text.secondary'
sx={{
fontStyle: 'italic',
lineHeight: 1.2
}}
>
{data.hint}
</Typography>
</Stack>
)}
{data &&
inputParams
.filter((inputParam) => inputParam.display !== false)
.map((inputParam, index) => (
<NodeInputHandler
disabled={dialogProps.disabled}
key={index}
inputParam={inputParam}
data={data}
isAdditionalParams={true}
onDataChange={onCustomDataChange}
/>
))}
</DialogContent>
</Dialog>
)
}
export const EditNodeDialog = memo(EditNodeDialogComponent)
export default EditNodeDialog
@@ -0,0 +1,3 @@
// Node Editor Feature - Public API
export type { EditNodeDialogProps } from './EditNodeDialog'
export { EditNodeDialog } from './EditNodeDialog'
@@ -0,0 +1,403 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
import {
Accordion,
AccordionDetails,
AccordionSummary,
Box,
Chip,
ClickAwayListener,
Divider,
Fade,
InputAdornment,
List,
ListItem,
ListItemAvatar,
ListItemButton,
ListItemText,
OutlinedInput,
Paper,
Popper,
Stack,
Typography
} from '@mui/material'
import { useTheme } from '@mui/material/styles'
import { IconMinus, IconPlus, IconSearch, IconX } from '@tabler/icons-react'
import { MainCard } from '../../atoms'
import { AGENTFLOW_ICONS } from '../../core'
import type { NodeData } from '../../core/types'
import { useApiContext, useConfigContext } from '../../infrastructure/store'
import { debounce, groupNodesByCategory, searchNodes } from './search'
import { StyledFab } from './StyledFab'
export interface AddNodesDrawerProps {
/** Available nodes to display */
nodes: NodeData[]
/** Callback when a node drag starts */
onDragStart?: (event: React.DragEvent, node: NodeData) => void
/** Callback when a node is clicked (alternative to drag) */
onNodeClick?: (node: NodeData) => void
}
/**
* Add Nodes Drawer - Slide-out panel with draggable nodes
*/
function AddNodesDrawerComponent({ nodes, onDragStart, onNodeClick }: AddNodesDrawerProps) {
const theme = useTheme()
const { instanceUrl } = useApiContext()
const { isDarkMode: _isDarkMode } = useConfigContext()
const [searchValue, setSearchValue] = useState('')
const [filteredNodes, setFilteredNodes] = useState<Record<string, NodeData[]>>({})
const [open, setOpen] = useState(false)
const [categoryExpanded, setCategoryExpanded] = useState<Record<string, boolean>>({})
const anchorRef = useRef<HTMLButtonElement>(null)
const prevOpen = useRef(open)
// Group nodes by category
const groupNodes = useCallback((nodeList: NodeData[], expandAll = false) => {
const grouped = groupNodesByCategory(nodeList)
setFilteredNodes(grouped)
// Set category expansion state
const expanded: Record<string, boolean> = {}
Object.keys(grouped).forEach((category) => {
expanded[category] = expandAll
})
// Always expand 'Agent Flows' by default
if (expanded['Agent Flows'] !== undefined) {
expanded['Agent Flows'] = true
}
setCategoryExpanded(expanded)
}, [])
// Debounced search
const debouncedSearch = useMemo(
() =>
debounce((value: string) => {
if (value.trim()) {
const results = searchNodes(nodes, value)
groupNodes(results, true) // Expand all when searching
} else {
groupNodes(nodes, false)
}
}, 300),
[nodes, groupNodes]
)
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value
setSearchValue(value)
debouncedSearch(value)
}
const handleClearSearch = () => {
setSearchValue('')
groupNodes(nodes, false)
}
const handleToggle = () => {
setOpen((prevOpen) => !prevOpen)
}
const handleClose = (event: Event | React.SyntheticEvent) => {
if (anchorRef.current && anchorRef.current.contains(event.target as HTMLElement)) {
return
}
setOpen(false)
}
const handleAccordionChange = (category: string) => (_event: React.SyntheticEvent, isExpanded: boolean) => {
setCategoryExpanded((prev) => ({
...prev,
[category]: isExpanded
}))
}
const handleDragStart = (event: React.DragEvent, node: NodeData) => {
event.dataTransfer.setData('application/reactflow', JSON.stringify(node))
event.dataTransfer.effectAllowed = 'move'
onDragStart?.(event, node)
}
const handleNodeClick = (node: NodeData) => {
onNodeClick?.(node)
}
const renderIcon = (node: NodeData) => {
const foundIcon = AGENTFLOW_ICONS.find((icon) => icon.name === node.name)
if (foundIcon) {
const IconComponent = foundIcon.icon
return <IconComponent size={30} color={node.color} />
}
return null
}
// Initialize nodes on mount
useEffect(() => {
if (nodes.length > 0) {
groupNodes(nodes, false)
}
}, [nodes, groupNodes])
// Focus management
useEffect(() => {
if (prevOpen.current === true && open === false) {
anchorRef.current?.focus()
}
prevOpen.current = open
}, [open])
return (
<>
<StyledFab
ref={anchorRef}
size='small'
color='primary'
aria-label='add'
title='Add Node'
onClick={handleToggle}
sx={{
position: 'absolute',
left: 20,
top: 20,
zIndex: 1000
}}
>
{open ? <IconMinus /> : <IconPlus />}
</StyledFab>
<Popper
placement='bottom-start'
open={open}
anchorEl={anchorRef.current}
role={undefined}
transition
disablePortal
modifiers={[
{
name: 'offset',
options: {
offset: [0, 14]
}
}
]}
sx={{ zIndex: 1000 }}
>
{({ TransitionProps }) => (
<Fade {...TransitionProps} timeout={200}>
<Paper elevation={16}>
<ClickAwayListener onClickAway={handleClose}>
<MainCard
border={false}
sx={{
boxShadow: theme.shadows[16],
width: 370,
maxWidth: '90vw'
}}
>
<Box sx={{ p: 2 }}>
<Stack>
<Typography variant='h4'>Add Nodes</Typography>
</Stack>
<OutlinedInput
sx={{ width: '100%', pr: 2, pl: 2, my: 2 }}
id='input-search-node'
value={searchValue}
onChange={handleSearchChange}
placeholder='Search nodes'
startAdornment={
<InputAdornment position='start'>
<IconSearch stroke={1.5} size='1rem' color={theme.palette.grey[500]} />
</InputAdornment>
}
endAdornment={
searchValue && (
<InputAdornment
position='end'
sx={{
cursor: 'pointer',
color: theme.palette.grey[500],
'&:hover': {
color: theme.palette.grey[900]
}
}}
onClick={handleClearSearch}
title='Clear Search'
>
<IconX stroke={1.5} size='1rem' />
</InputAdornment>
)
}
aria-describedby='search-helper-text'
inputProps={{
'aria-label': 'search nodes'
}}
/>
<Divider />
</Box>
<Box
sx={{
maxHeight: 'calc(100vh - 300px)',
overflowY: 'auto',
overflowX: 'hidden'
}}
>
<Box sx={{ p: 2, pt: 0 }}>
<List
sx={{
width: '100%',
py: 0,
borderRadius: '10px',
'& .MuiListItemSecondaryAction-root': {
top: 22
},
'& .MuiDivider-root': {
my: 0
}
}}
>
{Object.keys(filteredNodes)
.sort()
.map((category) => (
<Accordion
expanded={categoryExpanded[category] || false}
onChange={handleAccordionChange(category)}
key={category}
disableGutters
sx={{
'&:before': { display: 'none' },
boxShadow: 'none'
}}
>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
aria-controls={`nodes-accordian-${category}`}
id={`nodes-accordian-header-${category}`}
>
<Typography variant='h5'>{category}</Typography>
</AccordionSummary>
<AccordionDetails sx={{ p: 0 }}>
{filteredNodes[category].map((node, index) => (
<div
key={node.name}
onDragStart={(event) => handleDragStart(event, node)}
draggable
>
<ListItemButton
sx={{
p: 0,
borderRadius: '8px',
cursor: 'grab',
'&:active': { cursor: 'grabbing' }
}}
onClick={() => handleNodeClick(node)}
>
<ListItem alignItems='center'>
{node.color && !node.icon ? (
<ListItemAvatar>
<div
style={{
width: 50,
height: 'auto',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
{renderIcon(node)}
</div>
</ListItemAvatar>
) : (
<ListItemAvatar>
<div
style={{
width: 50,
height: 50,
borderRadius: '50%',
backgroundColor: 'white'
}}
>
<img
style={{
width: '100%',
height: '100%',
padding: 10,
objectFit: 'contain'
}}
alt={node.name}
src={`${instanceUrl}/api/v1/node-icon/${node.name}`}
/>
</div>
</ListItemAvatar>
)}
<ListItemText
sx={{ ml: 1 }}
primary={
<div
style={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center'
}}
>
<span>{node.label}</span>
{typeof node.badge === 'string' &&
node.badge && (
<>
&nbsp;
<Chip
sx={{
width: 'max-content',
fontWeight: 700,
fontSize: '0.65rem',
background:
node.badge ===
'DEPRECATING'
? theme.palette
.warning.main
: theme.palette
.success.main,
color:
node.badge !==
'DEPRECATING'
? 'white'
: 'inherit'
}}
size='small'
label={node.badge}
/>
</>
)}
</div>
}
secondary={node.description}
/>
</ListItem>
</ListItemButton>
{index === filteredNodes[category].length - 1 ? null : <Divider />}
</div>
))}
</AccordionDetails>
</Accordion>
))}
</List>
</Box>
</Box>
</MainCard>
</ClickAwayListener>
</Paper>
</Fade>
)}
</Popper>
</>
)
}
export const AddNodesDrawer = memo(AddNodesDrawerComponent)
export default AddNodesDrawer
@@ -0,0 +1,32 @@
import type { ComponentType } from 'react'
import { Fab, FabProps } from '@mui/material'
import { styled } from '@mui/material/styles'
export interface StyledFabProps extends FabProps {
gradient?: boolean
}
/**
* Styled floating action button with hover effects
* Supports gradient background for special actions like Generate
*/
export const StyledFab: ComponentType<StyledFabProps> = styled(Fab, {
shouldForwardProp: (prop) => prop !== 'gradient'
})<StyledFabProps>(({ theme, color = 'primary', gradient }) => ({
color: 'white',
backgroundColor: gradient
? undefined
: theme.palette[color as 'primary' | 'secondary' | 'error' | 'warning' | 'info' | 'success']?.main || theme.palette.primary.main,
background: gradient ? 'linear-gradient(45deg, #FF6B6B 30%, #FF8E53 90%)' : undefined,
'&:hover': {
backgroundColor: gradient
? undefined
: theme.palette[color as 'primary' | 'secondary' | 'error' | 'warning' | 'info' | 'success']?.main ||
theme.palette.primary.main,
background: gradient ? 'linear-gradient(45deg, #FF8E53 30%, #FF6B6B 90%)' : undefined,
backgroundImage: gradient ? undefined : 'linear-gradient(rgb(0 0 0/10%) 0 0)'
}
}))
export default StyledFab
@@ -0,0 +1,4 @@
// Node Palette Feature - Public API
export type { AddNodesDrawerProps } from './AddNodesDrawer'
export { AddNodesDrawer } from './AddNodesDrawer'
export { StyledFab } from './StyledFab'
@@ -0,0 +1,126 @@
import type { NodeData } from '../../core'
import { groupNodesByCategory } from '../../core'
export { groupNodesByCategory }
/**
* Calculate fuzzy search score between search term and target text
* Higher scores indicate better matches
*/
export function fuzzyScore(searchTerm: string, text: string): number {
const search = ((searchTerm ?? '') + '').trim().toLowerCase()
if (!search) return 0
const target = ((text ?? '') + '').toLowerCase()
let score = 0
let searchIndex = 0
let firstMatchIndex = -1
let lastMatchIndex = -1
let consecutiveMatches = 0
// Check for exact substring match
const exactMatchIndex = target.indexOf(search)
if (exactMatchIndex !== -1) {
score = 1000
// Bonus for match at start of string
if (exactMatchIndex === 0) {
score += 200
}
// Bonus for match at start of word
else if (target[exactMatchIndex - 1] === ' ' || target[exactMatchIndex - 1] === '-' || target[exactMatchIndex - 1] === '_') {
score += 100
}
// Penalty for how far into the string the match is
score -= exactMatchIndex * 2
// Penalty for length difference (shorter target = better match)
score -= (target.length - search.length) * 3
return score
}
// Fuzzy matching with character-by-character scoring
for (let i = 0; i < target.length && searchIndex < search.length; i++) {
if (target[i] === search[searchIndex]) {
// Base score for character match
score += 10
// Bonus for consecutive matches
if (lastMatchIndex === i - 1) {
consecutiveMatches++
score += 5 + consecutiveMatches * 2 // Increasing bonus for longer sequences
} else {
consecutiveMatches = 0
}
// Bonus for match at start of string
if (i === 0) {
score += 20
}
// Bonus for match after space or special character (word boundary)
if (i > 0 && (target[i - 1] === ' ' || target[i - 1] === '-' || target[i - 1] === '_')) {
score += 15
}
if (firstMatchIndex === -1) firstMatchIndex = i
lastMatchIndex = i
searchIndex++
}
}
// Return 0 if not all characters were matched
if (searchIndex < search.length) {
return 0
}
// Penalty for length difference (favor shorter targets)
score -= Math.max(0, target.length - search.length) * 2
// Penalty for gaps between first/last matched span
const span = lastMatchIndex - firstMatchIndex + 1
const gaps = Math.max(0, span - search.length)
score -= gaps * 3
return score
}
/**
* Score and sort nodes by fuzzy search relevance
*/
export function searchNodes(nodes: NodeData[], searchValue: string): NodeData[] {
// Return all nodes unsorted if search is empty
if (!searchValue || searchValue.trim() === '') {
return nodes
}
// Calculate fuzzy scores for each node
const nodesWithScores = nodes.map((nd) => {
const nameScore = fuzzyScore(searchValue, nd.name)
const labelScore = fuzzyScore(searchValue, nd.label)
const categoryScore = fuzzyScore(searchValue, nd.category || '') * 0.5 // Lower weight for category
const descriptionScore = fuzzyScore(searchValue, nd.description || '') * 0.3 // Even lower for description
const maxScore = Math.max(nameScore, labelScore, categoryScore, descriptionScore)
return { node: nd, score: maxScore }
})
// Filter nodes with score > 0 and sort by score (highest first)
return nodesWithScores
.filter((item) => item.score > 0)
.sort((a, b) => b.score - a.score)
.map((item) => item.node)
}
/**
* Debounce function for search input
*/
export function debounce<T extends (...args: Parameters<T>) => ReturnType<T>>(func: T, wait: number): (...args: Parameters<T>) => void {
let timeoutId: ReturnType<typeof setTimeout> | null = null
return function (...args: Parameters<T>) {
if (timeoutId) {
clearTimeout(timeoutId)
}
timeoutId = setTimeout(() => {
func(...args)
}, wait)
}
}
+55
View File
@@ -0,0 +1,55 @@
// ===========================================
// @flowise/agentflow - Public API
// ===========================================
// Main component
export { Agentflow, default as AgentflowDefault } from './Agentflow'
// Root provider
export { AgentflowProvider } from './AgentflowProvider'
// Primary hook
export { useAgentflow } from './useAgentflow'
// Context hooks (for advanced usage)
export { useAgentflowContext, useApiContext, useConfigContext } from './infrastructure/store'
// Types
export type {
// Instance
AgentFlowInstance,
// Main props
AgentflowProps,
AgentflowState,
// Context
ApiContextValue,
ApiResponse,
// API
Chatflow,
ConfigContextValue,
EdgeData,
FlowConfig,
// Flow data
FlowData,
FlowEdge,
FlowNode,
// Render props
HeaderRenderProps,
InputAnchor,
InputParam,
// Node data
NodeData,
NodeInput,
NodeOutput,
OutputAnchor,
PaletteRenderProps,
ValidationError,
// Validation
ValidationResult,
Viewport
} from './core/types'
// Utilities (for advanced usage)
export { filterNodesByComponents, isAgentflowNode } from './core/node-catalog'
export { AGENTFLOW_ICONS, DEFAULT_AGENTFLOW_NODES, getAgentflowIcon, getNodeColor } from './core/node-config'
export { validateFlow } from './core/validation'
@@ -0,0 +1,107 @@
import type { AxiosInstance } from 'axios'
import type { Chatflow, FlowData } from '../../core/types'
/**
* Create chatflows API functions bound to a client instance
*/
export function createChatflowsApi(client: AxiosInstance) {
return {
/**
* Get all chatflows
*/
getAllChatflows: async (): Promise<Chatflow[]> => {
const response = await client.get('/chatflows')
return response.data
},
/**
* Get a specific chatflow by ID
*/
getChatflow: async (id: string): Promise<Chatflow> => {
const response = await client.get(`/chatflows/${id}`)
return response.data
},
/**
* Create a new chatflow
*/
createChatflow: async (data: { name: string; flowData: FlowData | string; type?: string }): Promise<Chatflow> => {
const flowData = typeof data.flowData === 'string' ? data.flowData : JSON.stringify(data.flowData)
const response = await client.post('/chatflows', {
name: data.name,
flowData,
type: data.type || 'AGENTFLOW'
})
return response.data
},
/**
* Update an existing chatflow
*/
updateChatflow: async (
id: string,
data: Partial<{
name: string
flowData: FlowData | string
deployed: boolean
isPublic: boolean
chatbotConfig: string
}>
): Promise<Chatflow> => {
const updateData = { ...data }
if (data.flowData && typeof data.flowData !== 'string') {
updateData.flowData = JSON.stringify(data.flowData)
}
const response = await client.put(`/chatflows/${id}`, updateData)
return response.data
},
/**
* Delete a chatflow
*/
deleteChatflow: async (id: string): Promise<void> => {
await client.delete(`/chatflows/${id}`)
},
/**
* Generate an agentflow using AI
*/
generateAgentflow: async (data: {
question: string
selectedChatModel: Record<string, unknown>
}): Promise<{
nodes: FlowData['nodes']
edges: FlowData['edges']
}> => {
const response = await client.post('/agentflowv2-generator/generate', data)
return response.data
},
/**
* Get available chat models for generation
*/
getChatModels: async (): Promise<
Array<{
name: string
label: string
description?: string
category?: string
inputParams?: Array<{
name: string
label: string
type: string
optional?: boolean
default?: unknown
}>
}>
> => {
const response = await client.get('/assistants/chatmodels')
return response.data
}
}
}
export type ChatflowsApi = ReturnType<typeof createChatflowsApi>
@@ -0,0 +1,49 @@
import axios, { AxiosInstance } from 'axios'
/**
* Creates a configured axios client for API calls
* @param instanceUrl - Base URL of the Flowise server
* @param token - Authentication token (optional)
*/
export function createApiClient(instanceUrl: string, token?: string): AxiosInstance {
const headers: Record<string, string> = {
'Content-Type': 'application/json'
}
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
const client = axios.create({
baseURL: `${instanceUrl}/api/v1`,
headers
})
// Add request interceptor for debugging
client.interceptors.request.use(
(config) => {
return config
},
(error) => Promise.reject(error)
)
// Add response interceptor for error handling
client.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
console.error('[Agentflow] 401 Authentication error:', {
url: error.config?.url,
message: error.response?.data?.message || error.message,
hasToken: !!token,
tokenLength: token?.length
})
}
return Promise.reject(error)
}
)
return client
}
export type { AxiosInstance }
@@ -0,0 +1,50 @@
import { useCallback, useState } from 'react'
interface UseApiState<T> {
data: T | null
loading: boolean
error: Error | null
}
interface UseApiReturn<T> extends UseApiState<T> {
execute: () => Promise<T | null>
reset: () => void
}
/**
* Hook for managing API call state
* @param apiFunc - The API function to call
*/
export function useApi<T>(apiFunc: () => Promise<T>): UseApiReturn<T> {
const [state, setState] = useState<UseApiState<T>>({
data: null,
loading: false,
error: null
})
const execute = useCallback(async (): Promise<T | null> => {
setState((prev) => ({ ...prev, loading: true, error: null }))
try {
const data = await apiFunc()
setState({ data, loading: false, error: null })
return data
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error))
setState({ data: null, loading: false, error: err })
return null
}
}, [apiFunc])
const reset = useCallback(() => {
setState({ data: null, loading: false, error: null })
}, [])
return {
...state,
execute,
reset
}
}
export default useApi
@@ -0,0 +1,5 @@
// API infrastructure - External data layer
export { type ChatflowsApi, createChatflowsApi } from './chatflows'
export { createApiClient } from './client'
export { useApi } from './hooks/useApi'
export { createNodesApi, type NodesApi } from './nodes'
@@ -0,0 +1,35 @@
import type { AxiosInstance } from 'axios'
import type { NodeData } from '../../core/types'
/**
* Create nodes API functions bound to a client instance
*/
export function createNodesApi(client: AxiosInstance) {
return {
/**
* Get all available nodes
*/
getAllNodes: async (): Promise<NodeData[]> => {
const response = await client.get('/nodes')
return response.data
},
/**
* Get a specific node by name
*/
getNodeByName: async (name: string): Promise<NodeData> => {
const response = await client.get(`/nodes/${name}`)
return response.data
},
/**
* Get node icon URL
*/
getNodeIconUrl: (instanceUrl: string, nodeName: string): string => {
return `${instanceUrl}/api/v1/node-icon/${nodeName}`
}
}
}
export type NodesApi = ReturnType<typeof createNodesApi>
@@ -0,0 +1,210 @@
import { createContext, Dispatch, ReactNode, useCallback, useContext, useReducer } from 'react'
import type { ReactFlowInstance } from 'reactflow'
import type { AgentflowAction, AgentflowState, FlowConfig, FlowData, FlowEdge, FlowNode } from '../../core/types'
// Initial state
const initialState: AgentflowState = {
nodes: [],
edges: [],
chatflow: null,
isDirty: false,
reactFlowInstance: null
}
// Node types that size to content; strip stored width/height so they stay content-sized
const CONTENT_SIZED_NODE_TYPES = new Set(['agentFlow', 'stickyNote'])
function normalizeNodes(nodes: FlowNode[]): FlowNode[] {
return nodes.map((node) => {
if (CONTENT_SIZED_NODE_TYPES.has(node.type)) {
const { width: _width, height: _height, ...rest } = node
return rest as FlowNode
}
return node
})
}
// Reducer
function agentflowReducer(state: AgentflowState, action: AgentflowAction): AgentflowState {
switch (action.type) {
case 'SET_NODES':
return { ...state, nodes: normalizeNodes(action.payload) }
case 'SET_EDGES':
return { ...state, edges: action.payload }
case 'SET_CHATFLOW':
return { ...state, chatflow: action.payload }
case 'SET_DIRTY':
return { ...state, isDirty: action.payload }
case 'SET_REACTFLOW_INSTANCE':
return { ...state, reactFlowInstance: action.payload }
case 'RESET':
return initialState
default:
return state
}
}
// Context value interface
export interface AgentflowContextValue {
state: AgentflowState
dispatch: Dispatch<AgentflowAction>
// Convenience methods
setNodes: (nodes: FlowNode[]) => void
setEdges: (edges: FlowEdge[]) => void
setChatflow: (chatflow: FlowConfig | null) => void
setDirty: (dirty: boolean) => void
setReactFlowInstance: (instance: ReactFlowInstance | null) => void
// Node operations
deleteNode: (nodeId: string) => void
duplicateNode: (nodeId: string) => void
updateNodeData: (nodeId: string, data: Partial<FlowNode['data']>) => void
// Edge operations
deleteEdge: (edgeId: string) => void
// Flow operations
getFlowData: () => FlowData
reset: () => void
}
const AgentflowContext = createContext<AgentflowContextValue | null>(null)
interface AgentflowStateProviderProps {
children: ReactNode
initialFlow?: FlowData
}
export function AgentflowStateProvider({ children, initialFlow }: AgentflowStateProviderProps) {
const [state, dispatch] = useReducer(agentflowReducer, {
...initialState,
nodes: normalizeNodes(initialFlow?.nodes || []),
edges: initialFlow?.edges || []
})
// Convenience setters
const setNodes = useCallback((nodes: FlowNode[]) => {
dispatch({ type: 'SET_NODES', payload: nodes })
}, [])
const setEdges = useCallback((edges: FlowEdge[]) => {
dispatch({ type: 'SET_EDGES', payload: edges })
}, [])
const setChatflow = useCallback((chatflow: FlowConfig | null) => {
dispatch({ type: 'SET_CHATFLOW', payload: chatflow })
}, [])
const setDirty = useCallback((dirty: boolean) => {
dispatch({ type: 'SET_DIRTY', payload: dirty })
}, [])
const setReactFlowInstance = useCallback((instance: ReactFlowInstance | null) => {
dispatch({ type: 'SET_REACTFLOW_INSTANCE', payload: instance })
}, [])
// Node operations
const deleteNode = useCallback(
(nodeId: string) => {
const newNodes = state.nodes.filter((node) => node.id !== nodeId)
const newEdges = state.edges.filter((edge) => edge.source !== nodeId && edge.target !== nodeId)
dispatch({ type: 'SET_NODES', payload: newNodes })
dispatch({ type: 'SET_EDGES', payload: newEdges })
dispatch({ type: 'SET_DIRTY', payload: true })
},
[state.nodes, state.edges]
)
const duplicateNode = useCallback(
(nodeId: string) => {
const nodeToDuplicate = state.nodes.find((node) => node.id === nodeId)
if (!nodeToDuplicate) return
const newNode: FlowNode = {
...nodeToDuplicate,
id: `${nodeToDuplicate.id}_copy_${Date.now()}`,
position: {
x: nodeToDuplicate.position.x + 50,
y: nodeToDuplicate.position.y + 50
},
data: { ...nodeToDuplicate.data }
}
dispatch({ type: 'SET_NODES', payload: [...state.nodes, newNode] })
dispatch({ type: 'SET_DIRTY', payload: true })
},
[state.nodes]
)
const updateNodeData = useCallback(
(nodeId: string, data: Partial<FlowNode['data']>) => {
const newNodes = state.nodes.map((node) => {
if (node.id === nodeId) {
return {
...node,
data: { ...node.data, ...data }
}
}
return node
})
dispatch({ type: 'SET_NODES', payload: newNodes })
dispatch({ type: 'SET_DIRTY', payload: true })
},
[state.nodes]
)
// Edge operations
const deleteEdge = useCallback(
(edgeId: string) => {
const newEdges = state.edges.filter((edge) => edge.id !== edgeId)
dispatch({ type: 'SET_EDGES', payload: newEdges })
dispatch({ type: 'SET_DIRTY', payload: true })
},
[state.edges]
)
// Get flow data
const getFlowData = useCallback((): FlowData => {
const viewport = state.reactFlowInstance?.getViewport() || { x: 0, y: 0, zoom: 1 }
return {
nodes: state.nodes,
edges: state.edges,
viewport
}
}, [state.nodes, state.edges, state.reactFlowInstance])
// Reset
const reset = useCallback(() => {
dispatch({ type: 'RESET' })
}, [])
const value: AgentflowContextValue = {
state,
dispatch,
setNodes,
setEdges,
setChatflow,
setDirty,
setReactFlowInstance,
deleteNode,
duplicateNode,
updateNodeData,
deleteEdge,
getFlowData,
reset
}
return <AgentflowContext.Provider value={value}>{children}</AgentflowContext.Provider>
}
export function useAgentflowContext(): AgentflowContextValue {
const context = useContext(AgentflowContext)
if (!context) {
throw new Error('useAgentflowContext must be used within AgentflowProvider')
}
return context
}
export { AgentflowContext }
@@ -0,0 +1,47 @@
import { createContext, ReactNode, useContext, useMemo } from 'react'
import type { AxiosInstance } from 'axios'
import { type ChatflowsApi, createApiClient, createChatflowsApi, createNodesApi, type NodesApi } from '../api'
interface ApiContextValue {
client: AxiosInstance
instanceUrl: string
nodesApi: NodesApi
chatflowsApi: ChatflowsApi
}
const ApiContext = createContext<ApiContextValue | null>(null)
interface ApiProviderProps {
instanceUrl: string
token?: string
children: ReactNode
}
export function ApiProvider({ instanceUrl, token, children }: ApiProviderProps) {
const value = useMemo(() => {
const client = createApiClient(instanceUrl, token)
const nodesApi = createNodesApi(client)
const chatflowsApi = createChatflowsApi(client)
return {
client,
instanceUrl,
nodesApi,
chatflowsApi
}
}, [instanceUrl, token])
return <ApiContext.Provider value={value}>{children}</ApiContext.Provider>
}
export function useApiContext(): ApiContextValue {
const context = useContext(ApiContext)
if (!context) {
throw new Error('useApiContext must be used within AgentflowProvider')
}
return context
}
export { ApiContext }
@@ -0,0 +1,55 @@
import { createContext, ReactNode, useContext, useEffect, useMemo, useState } from 'react'
import type { ConfigContextValue } from '../../core/types'
const ConfigContext = createContext<ConfigContextValue | null>(null)
interface ConfigProviderProps {
theme?: 'light' | 'dark' | 'system'
components?: string[]
readOnly?: boolean
children: ReactNode
}
export function ConfigProvider({ theme = 'system', components, readOnly = false, children }: ConfigProviderProps) {
const [isDarkMode, setIsDarkMode] = useState(false)
// Determine dark mode based on theme setting
useEffect(() => {
if (theme === 'dark') {
setIsDarkMode(true)
} else if (theme === 'light') {
setIsDarkMode(false)
} else {
// system preference
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
setIsDarkMode(mediaQuery.matches)
const handler = (e: MediaQueryListEvent) => setIsDarkMode(e.matches)
mediaQuery.addEventListener('change', handler)
return () => mediaQuery.removeEventListener('change', handler)
}
}, [theme])
const value = useMemo<ConfigContextValue>(
() => ({
theme,
components,
readOnly,
isDarkMode
}),
[theme, components, readOnly, isDarkMode]
)
return <ConfigContext.Provider value={value}>{children}</ConfigContext.Provider>
}
export function useConfigContext(): ConfigContextValue {
const context = useContext(ConfigContext)
if (!context) {
throw new Error('useConfigContext must be used within AgentflowProvider')
}
return context
}
export { ConfigContext }
@@ -0,0 +1,6 @@
// Store infrastructure - Internal state management
// These are internal and composed in AgentflowProvider
export { AgentflowStateProvider, useAgentflowContext } from './AgentflowContext'
export { ApiProvider, useApiContext } from './ApiContext'
export { ConfigProvider, useConfigContext } from './ConfigContext'
+121
View File
@@ -0,0 +1,121 @@
import { useCallback, useMemo } from 'react'
import type { AgentFlowInstance, FlowData, FlowNode, ValidationResult } from './core/types'
import { validateFlow } from './core/validation'
import { useAgentflowContext } from './infrastructure/store'
/**
* Hook for programmatic access to the Agentflow instance.
* Provides methods for getting flow data, validation, and canvas manipulation.
*
* @example
* ```tsx
* function ControlPanel() {
* const agentflow = useAgentflow()
*
* const handleSave = () => {
* const flow = agentflow.getFlow()
* console.log('Saving flow:', flow)
* }
*
* const handleValidate = () => {
* const result = agentflow.validate()
* if (!result.valid) {
* console.error('Validation errors:', result.errors)
* }
* }
*
* return (
* <div>
* <button onClick={handleSave}>Save</button>
* <button onClick={handleValidate}>Validate</button>
* <button onClick={() => agentflow.fitView()}>Fit View</button>
* </div>
* )
* }
* ```
*/
export function useAgentflow(): AgentFlowInstance {
const { state, setNodes, setEdges, setDirty, getFlowData } = useAgentflowContext()
const { nodes, edges, reactFlowInstance } = state
/**
* Get the current flow data as a serializable object
*/
const getFlow = useCallback((): FlowData => {
return getFlowData()
}, [getFlowData])
/**
* Convert the current flow to a JSON string
*/
const toJSON = useCallback((): string => {
return JSON.stringify(getFlowData(), null, 2)
}, [getFlowData])
/**
* Validate the current flow structure
*/
const validate = useCallback((): ValidationResult => {
return validateFlow(nodes, edges)
}, [nodes, edges])
/**
* Fit the canvas view to show all nodes
*/
const fitView = useCallback((): void => {
reactFlowInstance?.fitView({ padding: 0.2, duration: 200 })
}, [reactFlowInstance])
/**
* Get the underlying ReactFlow instance
*/
const getReactFlowInstance = useCallback(() => {
return reactFlowInstance
}, [reactFlowInstance])
/**
* Programmatically add a new node to the canvas
*/
const addNode = useCallback(
(nodeData: Partial<FlowNode>): void => {
const newNode: FlowNode = {
id: nodeData.id || `node_${Date.now()}`,
type: nodeData.type || 'agentFlow',
position: nodeData.position || { x: 100, y: 100 },
data: nodeData.data || { name: '', label: '' },
...nodeData
} as FlowNode
setNodes([...nodes, newNode])
setDirty(true)
},
[nodes, setNodes, setDirty]
)
/**
* Clear all nodes and edges from the canvas
*/
const clear = useCallback((): void => {
setNodes([])
setEdges([])
setDirty(true)
}, [setNodes, setEdges, setDirty])
// Return memoized instance
return useMemo<AgentFlowInstance>(
() => ({
getFlow,
toJSON,
validate,
fitView,
getReactFlowInstance,
addNode,
clear
}),
[getFlow, toJSON, validate, fitView, getReactFlowInstance, addNode, clear]
)
}
export default useAgentflow
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"declaration": true,
"declarationMap": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
+59
View File
@@ -0,0 +1,59 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import dts from 'vite-plugin-dts'
import { resolve } from 'path'
export default defineConfig({
plugins: [
react(),
dts({
insertTypesEntry: true,
include: ['src/**/*'],
exclude: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
}),
],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
name: 'FlowiseAgentflow',
formats: ['es', 'umd'],
fileName: (format) => `index.${format === 'es' ? 'js' : 'umd.js'}`,
},
rollupOptions: {
external: [
'react',
'react-dom',
'react/jsx-runtime',
'@mui/material',
'@mui/material/styles',
'@mui/icons-material',
'@emotion/react',
'@emotion/styled',
'reactflow',
],
output: {
globals: {
react: 'React',
'react-dom': 'ReactDOM',
'react/jsx-runtime': 'jsxRuntime',
'@mui/material': 'MaterialUI',
'@mui/material/styles': 'MaterialUIStyles',
'@emotion/react': 'emotionReact',
'@emotion/styled': 'emotionStyled',
reactflow: 'ReactFlow',
},
assetFileNames: (assetInfo) => {
if (assetInfo.name === 'style.css') return 'flowise.css'
return assetInfo.name || 'asset'
},
},
},
cssCodeSplit: false,
sourcemap: true,
},
})
+39871 -39284
View File
File diff suppressed because one or more lines are too long