mirror of
https://github.com/FlowiseAI/Flowise.git
synced 2026-10-12 02:02:17 +00:00
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:
@@ -5,6 +5,7 @@
|
||||
# dependencies
|
||||
**/node_modules
|
||||
**/package-lock.json
|
||||
!**/examples/package-lock.json
|
||||
**/yarn.lock
|
||||
|
||||
## logs
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
dist
|
||||
node_modules
|
||||
build
|
||||
*.config.ts
|
||||
*.config.js
|
||||
vite.config.ts
|
||||
examples/dist
|
||||
examples/vite.config.ts
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
# Source files
|
||||
/src
|
||||
/tests
|
||||
/*.ts
|
||||
/*.tsx
|
||||
tsconfig.json
|
||||
vite.config.ts
|
||||
|
||||
# Dev files
|
||||
.turbo
|
||||
node_modules
|
||||
|
||||
# Keep dist
|
||||
!dist
|
||||
@@ -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
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"printWidth": 140,
|
||||
"singleQuote": true,
|
||||
"jsxSingleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"tabWidth": 4,
|
||||
"semi": false,
|
||||
"endOfLine": "auto"
|
||||
}
|
||||
@@ -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` |
|
||||
@@ -0,0 +1,82 @@
|
||||
# @flowise/agentflow
|
||||
|
||||
[](https://www.npmjs.com/package/@flowise/agentflow)
|
||||
[](./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
|
||||
@@ -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=
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
+2977
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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'],
|
||||
},
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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' }}> *</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' }}> *</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
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
]
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
<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)
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
Generated
+39871
-39284
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user