feat: agent workflow builder (#2264)

* feat: implement WorkflowAgent and GraphExecutor for workflow management and execution

* refactor: workflow schemas and introduce WorkflowEngine

- Updated schemas in `schemas.py` to include new agent types and configurations.
- Created `WorkflowEngine` class in `workflow_engine.py` to manage workflow execution.
- Enhanced `StreamProcessor` to handle workflow-related data.
- Added new routes and utilities for managing workflows in the user API.
- Implemented validation and serialization functions for workflows.
- Established MongoDB collections and indexes for workflows and related entities.

* refactor: improve WorkflowAgent documentation and update type hints in WorkflowEngine

* feat: workflow builder and managing in frontend

- Added new endpoints for workflows in `endpoints.ts`.
- Implemented `getWorkflow`, `createWorkflow`, and `updateWorkflow` methods in `userService.ts`.
- Introduced new UI components for alerts, buttons, commands, dialogs, multi-select, popovers, and selects.
- Enhanced styling in `index.css` with new theme variables and animations.
- Refactored modal components for better layout and styling.
- Configured TypeScript paths and Vite aliases for cleaner imports.

* feat: add workflow preview component and related state management

- Implemented WorkflowPreview component for displaying workflow execution.
- Created WorkflowPreviewSlice for managing workflow preview state, including queries and execution steps.
- Added WorkflowMiniMap for visual representation of workflow nodes and their statuses.
- Integrated conversation handling with the ability to fetch answers and manage query states.
- Introduced reusable Sheet component for UI overlays.
- Updated Redux store to include workflowPreview reducer.

* feat: enhance workflow execution details and state management in WorkflowEngine and WorkflowPreview

* feat: enhance workflow components with improved UI and functionality

- Updated WorkflowPreview to allow text truncation for better display of long names.
- Enhanced BaseNode with connectable handles and improved styling for better visibility.
- Added MobileBlocker component to inform users about desktop requirements for the Workflow Builder.
- Introduced PromptTextArea component for improved variable insertion and search functionality, including upstream variable extraction and context addition.

* feat(workflow): add owner validation and graph version support

* fix: ruff lint

---------

Co-authored-by: Alex <a@tushynski.me>
This commit is contained in:
Siddhant Rai
2026-02-11 19:45:24 +05:30
committed by GitHub
parent 8353f9c649
commit 8ef321d784
52 changed files with 8634 additions and 222 deletions

View File

@@ -83,7 +83,11 @@ export default function AgentCard({
label: 'Edit',
onClick: (e: SyntheticEvent) => {
e.stopPropagation();
navigate(`/agents/edit/${agent.id}`);
if (agent.agent_type === 'workflow') {
navigate(`/agents/workflow/edit/${agent.id}`);
} else {
navigate(`/agents/edit/${agent.id}`);
}
},
variant: 'primary',
iconWidth: 14,

View File

@@ -5,6 +5,8 @@ import { useDispatch, useSelector } from 'react-redux';
import MessageInput from '../components/MessageInput';
import ConversationMessages from '../conversation/ConversationMessages';
import { Query } from '../conversation/conversationModels';
import { selectSelectedAgent } from '../preferences/preferenceSlice';
import { AppDispatch } from '../store';
import {
addQuery,
fetchPreviewAnswer,
@@ -14,8 +16,6 @@ import {
selectPreviewQueries,
selectPreviewStatus,
} from './agentPreviewSlice';
import { selectSelectedAgent } from '../preferences/preferenceSlice';
import { AppDispatch } from '../store';
export default function AgentPreview() {
const { t } = useTranslation();
@@ -112,7 +112,7 @@ export default function AgentPreview() {
}, [queries]);
return (
<div className="relative h-full w-full">
<div className="scrollbar-thin absolute inset-0 bottom-[180px] overflow-hidden px-4 pt-4 [&>div>div]:!w-full [&>div>div]:!max-w-none">
<div className="scrollbar-thin absolute inset-0 bottom-[180px] overflow-hidden px-4 pt-4 [&>div>div]:w-full! [&>div>div]:max-w-none!">
<ConversationMessages
handleQuestion={handleQuestion}
handleQuestionSubmission={handleQuestionSubmission}

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { useNavigate, useSearchParams } from 'react-router-dom';
@@ -19,6 +19,7 @@ import {
} from '../preferences/preferenceSlice';
import AgentCard from './AgentCard';
import { AgentSectionId, agentSectionsConfig } from './agents.config';
import AgentTypeModal from './components/AgentTypeModal';
import FolderCard from './FolderCard';
import { AgentFilterTab, useAgentSearch } from './hooks/useAgentSearch';
import { useAgentsFetch } from './hooks/useAgentsFetch';
@@ -43,14 +44,19 @@ export default function AgentsList() {
const folderIdFromUrl = searchParams.get('folder');
return folderIdFromUrl ? [folderIdFromUrl] : [];
});
const [showAgentTypeModal, setShowAgentTypeModal] = useState(false);
const [modalFolderId, setModalFolderId] = useState<string | null>(null);
// Sync folder path with URL
useEffect(() => {
const currentFolderInUrl = searchParams.get('folder');
const currentFolderId = folderPath.length > 0 ? folderPath[folderPath.length - 1] : null;
const currentFolderId =
folderPath.length > 0 ? folderPath[folderPath.length - 1] : null;
if (currentFolderId !== currentFolderInUrl) {
const newUrl = currentFolderId ? `/agents?folder=${currentFolderId}` : '/agents';
const newUrl = currentFolderId
? `/agents?folder=${currentFolderId}`
: '/agents';
navigate(newUrl, { replace: true });
}
}, [folderPath, searchParams, navigate]);
@@ -212,6 +218,8 @@ export default function AgentsList() {
onCreateFolder={handleSubmitNewFolder}
onDeleteFolder={handleDeleteFolder}
onRenameFolder={handleRenameFolder}
setModalFolderId={setModalFolderId}
setShowAgentTypeModal={setShowAgentTypeModal}
/>
))}
@@ -221,6 +229,12 @@ export default function AgentsList() {
<p className="text-sm">{t('agents.tryDifferentSearch')}</p>
</div>
)}
<AgentTypeModal
isOpen={showAgentTypeModal}
onClose={() => setShowAgentTypeModal(false)}
folderId={modalFolderId}
/>
</div>
);
}
@@ -238,6 +252,8 @@ interface AgentSectionProps {
onCreateFolder: (name: string, parentId?: string) => void;
onDeleteFolder: (id: string) => Promise<boolean>;
onRenameFolder: (id: string, name: string) => void;
setModalFolderId: (folderId: string | null) => void;
setShowAgentTypeModal: (show: boolean) => void;
}
function AgentSection({
@@ -253,6 +269,8 @@ function AgentSection({
onCreateFolder,
onDeleteFolder,
onRenameFolder,
setModalFolderId,
setShowAgentTypeModal,
}: AgentSectionProps) {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -280,13 +298,17 @@ function AgentSection({
const updateAgents = (updatedAgents: Agent[]) => {
dispatch(config.updateAction(updatedAgents));
};
const currentFolderDescendantIds = useMemo(() => {
if (config.id !== 'user' || !folders || currentFolderId === null) return null;
if (config.id !== 'user' || !folders || currentFolderId === null)
return null;
const getDescendants = (folderId: string): string[] => {
const children = folders.filter((f) => f.parent_id === folderId);
return children.flatMap((child) => [child.id, ...getDescendants(child.id)]);
return children.flatMap((child) => [
child.id,
...getDescendants(child.id),
]);
};
return new Set([currentFolderId, ...getDescendants(currentFolderId)]);
@@ -294,7 +316,9 @@ function AgentSection({
const folderHasMatchingAgents = useCallback(
(folderId: string): boolean => {
const directMatches = filteredAgents.some((a) => a.folder_id === folderId);
const directMatches = filteredAgents.some(
(a) => a.folder_id === folderId,
);
if (directMatches) return true;
const childFolders = (folders || []).filter(
(f) => f.parent_id === folderId,
@@ -314,7 +338,13 @@ function AgentSection({
return foldersAtLevel.filter((f) => folderHasMatchingAgents(f.id));
}
return foldersAtLevel;
}, [folders, currentFolderId, config.id, searchQuery, folderHasMatchingAgents]);
}, [
folders,
currentFolderId,
config.id,
searchQuery,
folderHasMatchingAgents,
]);
const unfolderedAgents = useMemo(() => {
if (config.id !== 'user' || !folders) return filteredAgents;
@@ -334,7 +364,14 @@ function AgentSection({
return filteredAgents.filter(
(a) => (a.folder_id || null) === currentFolderId,
);
}, [filteredAgents, folders, config.id, currentFolderId, searchQuery, currentFolderDescendantIds]);
}, [
filteredAgents,
folders,
config.id,
currentFolderId,
searchQuery,
currentFolderDescendantIds,
]);
const getAgentsForFolder = (folderId: string) => {
return filteredAgents.filter((a) => a.folder_id === folderId);
@@ -376,7 +413,10 @@ function AgentSection({
{config.showNewAgentButton && (
<button
className="bg-purple-30 hover:bg-violets-are-blue rounded-full px-4 py-2 text-sm text-white"
onClick={() => navigate('/agents/new')}
onClick={() => {
setModalFolderId(null);
setShowAgentTypeModal(true);
}}
>
{t('agents.newAgent')}
</button>
@@ -478,7 +518,7 @@ function AgentSection({
/>
) : (
<button
className="shrink-0 whitespace-nowrap rounded-full border border-[#E5E5E5] bg-white px-4 py-2 text-sm text-[#18181B] hover:bg-[#F5F5F5] dark:border-[#3A3A3A] dark:bg-[#2C2C2C] dark:text-white dark:hover:bg-[#383838]"
className="shrink-0 rounded-full border border-[#E5E5E5] bg-white px-4 py-2 text-sm whitespace-nowrap text-[#18181B] hover:bg-[#F5F5F5] dark:border-[#3A3A3A] dark:bg-[#2C2C2C] dark:text-white dark:hover:bg-[#383838]"
onClick={() => {
setIsCreatingFolder(true);
setTimeout(() => newFolderInputRef.current?.focus(), 0);
@@ -489,14 +529,11 @@ function AgentSection({
))}
{config.showNewAgentButton && (
<button
className="bg-purple-30 hover:bg-violets-are-blue shrink-0 whitespace-nowrap rounded-full px-4 py-2 text-sm text-white"
onClick={() =>
navigate(
currentFolderId
? `/agents/new?folder_id=${currentFolderId}`
: '/agents/new',
)
}
className="bg-purple-30 hover:bg-violets-are-blue shrink-0 rounded-full px-4 py-2 text-sm whitespace-nowrap text-white"
onClick={() => {
setModalFolderId(currentFolderId);
setShowAgentTypeModal(true);
}}
>
{t('agents.newAgent')}
</button>
@@ -551,13 +588,10 @@ function AgentSection({
{config.showNewAgentButton && !currentFolderId && (
<button
className="bg-purple-30 hover:bg-violets-are-blue ml-2 rounded-full px-4 py-2 text-sm text-white"
onClick={() =>
navigate(
currentFolderId
? `/agents/new?folder_id=${currentFolderId}`
: '/agents/new',
)
}
onClick={() => {
setModalFolderId(currentFolderId);
setShowAgentTypeModal(true);
}}
>
{t('agents.newAgent')}
</button>

View File

@@ -125,4 +125,3 @@ export default function FolderCard({
</>
);
}

View File

@@ -30,6 +30,7 @@ import Prompts from '../settings/Prompts';
import { UserToolType } from '../settings/types';
import AgentPreview from './AgentPreview';
import { Agent, ToolSummary } from './types';
import WorkflowBuilder from './workflow/WorkflowBuilder';
import type { Model } from '../models/types';
@@ -48,7 +49,9 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
const prompts = useSelector(selectPrompts);
const agentFolders = useSelector(selectAgentFolders);
const [validatedFolderId, setValidatedFolderId] = useState<string | null>(null);
const [validatedFolderId, setValidatedFolderId] = useState<string | null>(
null,
);
const [effectiveMode, setEffectiveMode] = useState(mode);
const [agent, setAgent] = useState<Agent>({
@@ -252,6 +255,9 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
if (agent.default_model_id) {
formData.append('default_model_id', agent.default_model_id);
}
if (agent.agent_type === 'workflow' && agent.workflow) {
formData.append('workflow', JSON.stringify(agent.workflow));
}
if (effectiveMode === 'new' && validatedFolderId) {
formData.append('folder_id', validatedFolderId);
@@ -359,6 +365,9 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
if (agent.default_model_id) {
formData.append('default_model_id', agent.default_model_id);
}
if (agent.agent_type === 'workflow' && agent.workflow) {
formData.append('workflow', JSON.stringify(agent.workflow));
}
if (effectiveMode === 'new' && validatedFolderId) {
formData.append('folder_id', validatedFolderId);
@@ -664,6 +673,11 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
<h1 className="text-eerie-black m-0 text-[32px] font-bold lg:text-[40px] dark:text-white">
{modeConfig[effectiveMode].heading}
</h1>
{agent.agent_type === 'workflow' && (
<div className="mt-4 w-full">
<WorkflowBuilder />
</div>
)}
<div className="flex flex-wrap items-center gap-1">
<button
className="text-purple-30 dark:text-light-gray mr-4 rounded-3xl py-2 text-sm font-medium dark:bg-transparent"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,93 @@
import { Bot, Workflow, X } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
interface AgentTypeModalProps {
isOpen: boolean;
onClose: () => void;
folderId?: string | null;
}
export default function AgentTypeModal({
isOpen,
onClose,
folderId,
}: AgentTypeModalProps) {
const navigate = useNavigate();
if (!isOpen) return null;
const handleSelect = (type: 'normal' | 'workflow') => {
if (type === 'workflow') {
navigate(
`/agents/workflow/new${folderId ? `?folder_id=${folderId}` : ''}`,
);
} else {
navigate(`/agents/new${folderId ? `?folder_id=${folderId}` : ''}`);
}
onClose();
};
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4"
onClick={onClose}
>
<div
className="relative w-full max-w-lg rounded-xl bg-white p-8 shadow-2xl dark:bg-[#1e1e1e]"
onClick={(e) => e.stopPropagation()}
>
<button
onClick={onClose}
className="absolute top-5 right-5 text-gray-400 transition-colors hover:text-gray-600 dark:hover:text-gray-200"
>
<X size={20} />
</button>
<h2 className="text-jet dark:text-bright-gray mb-3 text-2xl font-bold">
Create New Agent
</h2>
<p className="mb-8 text-sm text-gray-500 dark:text-gray-400">
Choose the type of agent you want to create
</p>
<div className="flex flex-col gap-4">
<button
onClick={() => handleSelect('normal')}
className="hover:border-purple-30 hover:bg-purple-30/5 dark:hover:border-purple-30 dark:hover:bg-purple-30/10 group flex items-start gap-5 rounded-xl border-2 border-gray-200 p-5 text-left transition-all dark:border-[#2E2F34]"
>
<div className="dark:bg-purple-30/20 bg-purple-30/10 text-purple-30 group-hover:bg-purple-30 flex h-14 w-14 shrink-0 items-center justify-center rounded-xl transition-colors group-hover:text-white dark:text-purple-300">
<Bot size={28} />
</div>
<div className="flex-1">
<h3 className="text-jet dark:text-bright-gray mb-2 text-lg font-semibold">
Classic Agent
</h3>
<p className="text-sm leading-relaxed text-gray-600 dark:text-gray-400">
Create a standard AI agent with a single model, tools, and
knowledge sources
</p>
</div>
</button>
<button
onClick={() => handleSelect('workflow')}
className="hover:border-violets-are-blue hover:bg-violets-are-blue/5 dark:hover:border-violets-are-blue dark:hover:bg-violets-are-blue/10 group flex items-start gap-5 rounded-xl border-2 border-gray-200 p-5 text-left transition-all dark:border-[#2E2F34]"
>
<div className="dark:bg-violets-are-blue/20 bg-violets-are-blue/10 text-violets-are-blue group-hover:bg-violets-are-blue flex h-14 w-14 shrink-0 items-center justify-center rounded-xl transition-colors group-hover:text-white dark:text-purple-300">
<Workflow size={28} />
</div>
<div className="flex-1">
<h3 className="text-jet dark:text-bright-gray mb-2 text-lg font-semibold">
Workflow Agent
</h3>
<p className="text-sm leading-relaxed text-gray-600 dark:text-gray-400">
Design complex multi-step workflows with different models,
conditional logic, and state management
</p>
</div>
</button>
</div>
</div>
</div>
);
}

View File

@@ -4,6 +4,7 @@ import AgentLogs from './AgentLogs';
import AgentsList from './AgentsList';
import NewAgent from './NewAgent';
import SharedAgent from './SharedAgent';
import WorkflowBuilder from './workflow/WorkflowBuilder';
export default function Agents() {
return (
@@ -13,6 +14,8 @@ export default function Agents() {
<Route path="/edit/:agentId" element={<NewAgent mode="edit" />} />
<Route path="/logs/:agentId" element={<AgentLogs />} />
<Route path="/shared/:agentId" element={<SharedAgent />} />
<Route path="/workflow/new" element={<WorkflowBuilder />} />
<Route path="/workflow/edit/:agentId" element={<WorkflowBuilder />} />
</Routes>
);
}

View File

@@ -35,6 +35,7 @@ export type Agent = {
models?: string[];
default_model_id?: string;
folder_id?: string;
workflow?: string;
};
export type AgentFolder = {
@@ -44,3 +45,5 @@ export type AgentFolder = {
created_at?: string;
updated_at?: string;
};
export * from './workflow';

View File

@@ -0,0 +1,49 @@
export type NodeType = 'start' | 'end' | 'agent' | 'note' | 'state';
export interface WorkflowEdge {
id: string;
source: string;
target: string;
sourceHandle?: string;
targetHandle?: string;
}
export interface WorkflowNode {
id: string;
type: NodeType;
title: string;
description?: string;
position: { x: number; y: number };
data: Record<string, any>;
}
export interface WorkflowDefinition {
id?: string;
name: string;
nodes: WorkflowNode[];
edges: WorkflowEdge[];
created_at?: string;
updated_at?: string;
}
export type ExecutionStatus = 'pending' | 'running' | 'completed' | 'failed';
export interface NodeExecutionLog {
node_id: string;
status: ExecutionStatus;
input_state: Record<string, any>;
output_state: Record<string, any>;
error?: string;
started_at: number;
completed_at?: number;
logs: string[];
}
export interface WorkflowRun {
workflow_id: string;
status: ExecutionStatus;
state: Record<string, any>;
node_logs: NodeExecutionLog[];
created_at: number;
completed_at?: number;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,611 @@
import {
Bot,
CheckCircle2,
Circle,
Database,
Flag,
Loader2,
MessageSquare,
Play,
StickyNote,
Workflow,
XCircle,
} from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { cn } from '@/lib/utils';
import ChevronDownIcon from '../../assets/chevron-down.svg';
import MessageInput from '../../components/MessageInput';
import ConversationBubble from '../../conversation/ConversationBubble';
import { Query } from '../../conversation/conversationModels';
import { AppDispatch } from '../../store';
import { WorkflowEdge, WorkflowNode } from '../types/workflow';
import {
addQuery,
fetchWorkflowPreviewAnswer,
handleWorkflowPreviewAbort,
resendQuery,
resetWorkflowPreview,
selectActiveNodeId,
selectWorkflowExecutionSteps,
selectWorkflowPreviewQueries,
selectWorkflowPreviewStatus,
WorkflowExecutionStep,
WorkflowQuery,
} from './workflowPreviewSlice';
interface WorkflowData {
name: string;
description?: string;
nodes: WorkflowNode[];
edges: WorkflowEdge[];
}
interface WorkflowPreviewProps {
workflowData: WorkflowData;
}
const NODE_ICONS: Record<string, React.ReactNode> = {
start: <Play className="h-3 w-3" />,
agent: <Bot className="h-3 w-3" />,
end: <Flag className="h-3 w-3" />,
note: <StickyNote className="h-3 w-3" />,
state: <Database className="h-3 w-3" />,
};
const NODE_COLORS: Record<string, string> = {
start: 'text-green-600 dark:text-green-400',
agent: 'text-purple-600 dark:text-purple-400',
end: 'text-gray-600 dark:text-gray-400',
note: 'text-yellow-600 dark:text-yellow-400',
state: 'text-blue-600 dark:text-blue-400',
};
function ExecutionDetails({
steps,
nodes,
isOpen,
onToggle,
stepRefs,
}: {
steps: WorkflowExecutionStep[];
nodes: WorkflowNode[];
isOpen: boolean;
onToggle: () => void;
stepRefs?: React.RefObject<Map<string, HTMLDivElement>>;
}) {
const completedSteps = steps.filter(
(s) => s.status === 'completed' || s.status === 'failed',
);
if (completedSteps.length === 0) return null;
const formatValue = (value: unknown): string => {
if (typeof value === 'string') return value;
return JSON.stringify(value, null, 2);
};
return (
<div className="mb-4 flex w-full flex-col flex-wrap items-start self-start lg:flex-nowrap">
<div className="my-2 flex flex-row items-center justify-center gap-3">
<div className="flex h-[26px] w-[30px] items-center justify-center">
<Workflow className="h-5 w-5 text-gray-600 dark:text-gray-400" />
</div>
<button className="flex flex-row items-center gap-2" onClick={onToggle}>
<p className="text-base font-semibold">
Execution Details
<span className="ml-1.5 text-sm font-normal text-gray-500 dark:text-gray-400">
({completedSteps.length}{' '}
{completedSteps.length === 1 ? 'step' : 'steps'})
</span>
</p>
<img
src={ChevronDownIcon}
alt="ChevronDown"
className={cn(
'h-4 w-4 transform transition-transform duration-200 dark:invert',
isOpen ? 'rotate-180' : '',
)}
/>
</button>
</div>
<div
className={cn(
'ml-3 grid w-full transition-all duration-300 ease-in-out',
isOpen ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0',
)}
>
<div className="overflow-hidden">
<div className="space-y-2 pr-2">
{completedSteps.map((step, stepIndex) => {
const node = nodes.find((n) => n.id === step.nodeId);
const displayName =
node?.title || node?.data?.title || step.nodeTitle;
const stateVars = step.stateSnapshot
? Object.entries(step.stateSnapshot).filter(
([key]) => !['query', 'chat_history'].includes(key),
)
: [];
const truncateText = (text: string, maxLength: number) => {
if (text.length <= maxLength) return text;
return text.slice(0, maxLength) + '...';
};
return (
<div
key={step.nodeId}
ref={(el) => {
if (el && stepRefs) stepRefs.current.set(step.nodeId, el);
}}
className="rounded-xl bg-[#F5F5F5] p-3 dark:bg-[#383838]"
>
<div className="flex items-center gap-2 text-sm">
<span className="flex h-5 w-5 shrink-0 items-center justify-center text-xs font-medium text-gray-500 dark:text-gray-400">
{stepIndex + 1}.
</span>
<div
className={cn(
'shrink-0',
NODE_COLORS[step.nodeType] || NODE_COLORS.state,
)}
>
{NODE_ICONS[step.nodeType] || (
<Circle className="h-3 w-3" />
)}
</div>
<span className="min-w-0 truncate font-medium text-gray-900 dark:text-white">
{displayName}
</span>
<div className="ml-auto shrink-0">
{step.status === 'completed' && (
<CheckCircle2 className="h-4 w-4 text-green-600 dark:text-green-400" />
)}
{step.status === 'failed' && (
<XCircle className="h-4 w-4 text-red-600 dark:text-red-400" />
)}
</div>
</div>
{(step.output || step.error || stateVars.length > 0) && (
<div className="mt-3 space-y-2 text-sm">
{step.output && (
<div className="rounded-lg bg-white p-2 dark:bg-[#2A2A2A]">
<span className="font-medium text-gray-600 dark:text-gray-400">
Output:{' '}
</span>
<span className="wrap-break-word whitespace-pre-wrap text-gray-900 dark:text-gray-100">
{truncateText(step.output, 300)}
</span>
</div>
)}
{step.error && (
<div className="rounded-lg bg-red-50 p-2 dark:bg-red-900/30">
<span className="font-medium text-red-700 dark:text-red-300">
Error:{' '}
</span>
<span className="wrap-break-word whitespace-pre-wrap text-red-800 dark:text-red-200">
{step.error}
</span>
</div>
)}
{stateVars.length > 0 && (
<div className="flex flex-wrap gap-2">
{stateVars.map(([key, value]) => (
<span
key={key}
className="inline-flex items-center rounded-lg bg-white px-2 py-1 text-xs dark:bg-[#2A2A2A]"
>
<span className="max-w-[100px] truncate font-medium text-gray-600 dark:text-gray-400">
{key}:
</span>
<span
className="ml-1 max-w-[200px] truncate text-gray-900 dark:text-gray-100"
title={formatValue(value)}
>
{truncateText(formatValue(value), 50)}
</span>
</span>
))}
</div>
)}
</div>
)}
</div>
);
})}
</div>
</div>
</div>
</div>
);
}
function WorkflowMiniMap({
nodes,
activeNodeId,
executionSteps,
onNodeClick,
}: {
nodes: WorkflowNode[];
activeNodeId: string | null;
executionSteps: WorkflowExecutionStep[];
onNodeClick?: (nodeId: string) => void;
}) {
const getNodeDisplayName = (node: WorkflowNode) => {
if (node.type === 'start') return 'Start';
if (node.type === 'end') return 'End';
return node.title || node.data?.title || node.type;
};
const getNodeSubtitle = (node: WorkflowNode) => {
if (node.type === 'agent' && node.data?.model_id) {
return node.data.model_id;
}
return null;
};
const getNodeStatus = (nodeId: string) => {
const step = executionSteps.find((s) => s.nodeId === nodeId);
return step?.status || 'pending';
};
const getStatusColor = (nodeId: string, nodeType: string) => {
const status = getNodeStatus(nodeId);
const isActive = nodeId === activeNodeId;
if (isActive) {
return 'ring-2 ring-purple-500 bg-purple-100 dark:bg-purple-900/50';
}
switch (status) {
case 'completed':
return 'bg-green-100 dark:bg-green-900/30 border-green-300 dark:border-green-700';
case 'running':
return 'bg-purple-100 dark:bg-purple-900/30 border-purple-300 dark:border-purple-700 animate-pulse';
case 'failed':
return 'bg-red-100 dark:bg-red-900/30 border-red-300 dark:border-red-700';
default:
if (nodeType === 'start') {
return 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800';
}
if (nodeType === 'agent') {
return 'bg-purple-50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-800';
}
if (nodeType === 'end') {
return 'bg-gray-50 dark:bg-gray-800 border-gray-200 dark:border-gray-700';
}
return 'bg-gray-50 dark:bg-gray-800 border-gray-200 dark:border-gray-700';
}
};
const sortedNodes = [...nodes].sort((a, b) => {
if (a.type === 'start') return -1;
if (b.type === 'start') return 1;
if (a.type === 'end') return 1;
if (b.type === 'end') return -1;
return (a.position?.y || 0) - (b.position?.y || 0);
});
const hasStepData = (nodeId: string) => {
const step = executionSteps.find((s) => s.nodeId === nodeId);
return step && (step.status === 'completed' || step.status === 'failed');
};
return (
<div className="space-y-1">
{sortedNodes.map((node, index) => (
<div key={node.id} className="relative">
{index < sortedNodes.length - 1 && (
<div className="absolute top-12 left-4 h-3 w-0.5 bg-gray-200 dark:bg-gray-700" />
)}
<button
onClick={() => hasStepData(node.id) && onNodeClick?.(node.id)}
disabled={!hasStepData(node.id)}
className={cn(
'flex h-12 w-full items-center gap-2 rounded-lg border px-3 text-xs transition-all',
getStatusColor(node.id, node.type),
hasStepData(node.id) && 'cursor-pointer hover:opacity-80',
)}
>
<div
className={cn(
'flex h-5 w-5 shrink-0 items-center justify-center rounded-full',
NODE_COLORS[node.type] || NODE_COLORS.state,
)}
>
{NODE_ICONS[node.type] || <Circle className="h-3 w-3" />}
</div>
<div className="min-w-0 flex-1 text-left">
<div className="truncate font-medium text-gray-700 dark:text-gray-200">
{getNodeDisplayName(node)}
</div>
{getNodeSubtitle(node) && (
<div className="truncate text-[10px] text-gray-500 dark:text-gray-400">
{getNodeSubtitle(node)}
</div>
)}
</div>
<div className="shrink-0">
{getNodeStatus(node.id) === 'running' && (
<Loader2 className="h-3 w-3 animate-spin text-purple-500" />
)}
{getNodeStatus(node.id) === 'completed' && (
<CheckCircle2 className="h-3 w-3 text-green-500" />
)}
{getNodeStatus(node.id) === 'failed' && (
<XCircle className="h-3 w-3 text-red-500" />
)}
</div>
</button>
</div>
))}
</div>
);
}
export default function WorkflowPreview({
workflowData,
}: WorkflowPreviewProps) {
const dispatch = useDispatch<AppDispatch>();
const queries = useSelector(selectWorkflowPreviewQueries) as WorkflowQuery[];
const status = useSelector(selectWorkflowPreviewStatus);
const executionSteps = useSelector(selectWorkflowExecutionSteps);
const activeNodeId = useSelector(selectActiveNodeId);
const [lastQueryReturnedErr, setLastQueryReturnedErr] = useState(false);
const [openDetailsIndex, setOpenDetailsIndex] = useState<number | null>(null);
const fetchStream = useRef<{ abort: () => void } | null>(null);
const stepRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const chatContainerRef = useRef<HTMLDivElement>(null);
const scrollToStep = useCallback(
(nodeId: string) => {
const lastQueryIndex = queries.length - 1;
if (lastQueryIndex >= 0) {
setOpenDetailsIndex(lastQueryIndex);
setTimeout(() => {
const stepEl = stepRefs.current.get(nodeId);
if (stepEl) {
stepEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, 100);
}
},
[queries.length],
);
const handleFetchAnswer = useCallback(
({ question, index }: { question: string; index?: number }) => {
const promise = dispatch(
fetchWorkflowPreviewAnswer({
question,
workflowData,
indx: index,
}),
);
fetchStream.current = promise;
},
[dispatch, workflowData],
);
const handleQuestion = useCallback(
({
question,
isRetry = false,
index = undefined,
}: {
question: string;
isRetry?: boolean;
index?: number;
}) => {
const trimmedQuestion = question.trim();
if (trimmedQuestion === '') return;
if (index !== undefined) {
if (!isRetry) dispatch(resendQuery({ index, prompt: trimmedQuestion }));
handleFetchAnswer({ question: trimmedQuestion, index });
} else {
if (!isRetry) {
const newQuery: Query = { prompt: trimmedQuestion };
dispatch(addQuery(newQuery));
}
handleFetchAnswer({ question: trimmedQuestion, index: undefined });
}
},
[dispatch, handleFetchAnswer],
);
const handleQuestionSubmission = (
question?: string,
updated?: boolean,
indx?: number,
) => {
if (updated === true && question !== undefined && indx !== undefined) {
handleQuestion({
question,
index: indx,
isRetry: false,
});
} else if (question && status !== 'loading') {
const currentInput = question.trim();
if (lastQueryReturnedErr && queries.length > 0) {
const lastQueryIndex = queries.length - 1;
handleQuestion({
question: currentInput,
isRetry: true,
index: lastQueryIndex,
});
} else {
handleQuestion({
question: currentInput,
isRetry: false,
index: undefined,
});
}
}
};
useEffect(() => {
dispatch(resetWorkflowPreview());
return () => {
if (fetchStream.current) fetchStream.current.abort();
handleWorkflowPreviewAbort();
dispatch(resetWorkflowPreview());
};
}, [dispatch]);
useEffect(() => {
if (queries.length > 0) {
const lastQuery = queries[queries.length - 1];
setLastQueryReturnedErr(!!lastQuery.error);
} else setLastQueryReturnedErr(false);
}, [queries]);
const lastQuerySteps =
queries.length > 0 ? queries[queries.length - 1].executionSteps || [] : [];
return (
<div className="dark:bg-raisin-black flex h-full flex-col bg-white">
<div className="border-light-silver dark:bg-raisin-black flex h-[77px] items-center justify-between border-b bg-white px-6 dark:border-[#3A3A3A]">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center rounded-full bg-gray-100 p-3 text-gray-600 dark:bg-[#2C2C2C] dark:text-gray-300">
<Play className="h-4 w-4" />
</div>
<div>
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
Preview
</h2>
<p className="max-w-md truncate text-xs text-gray-500 dark:text-gray-400">
{workflowData.name}
{workflowData.description && ` - ${workflowData.description}`}
</p>
</div>
</div>
{status === 'loading' && (
<span className="text-purple-30 dark:text-violets-are-blue flex items-center gap-1 text-xs">
<Loader2 className="h-3 w-3 animate-spin" />
Running
</span>
)}
</div>
<div className="flex min-h-0 flex-1">
<div className="flex w-64 shrink-0 flex-col border-r border-gray-200 dark:border-[#3A3A3A]">
<div className="flex items-center justify-between px-4 py-3">
<h3 className="text-xs font-semibold tracking-wider text-gray-500 uppercase dark:text-gray-400">
Workflow
</h3>
</div>
<div className="scrollbar-thin flex-1 overflow-y-auto p-3">
<WorkflowMiniMap
nodes={workflowData.nodes}
activeNodeId={activeNodeId}
executionSteps={
lastQuerySteps.length > 0 ? lastQuerySteps : executionSteps
}
onNodeClick={scrollToStep}
/>
</div>
</div>
<div className="relative flex min-w-0 flex-1 flex-col">
<div
ref={chatContainerRef}
className="scrollbar-thin absolute inset-0 bottom-[100px] overflow-y-auto px-4 pt-4"
>
{queries.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center">
<div className="mb-2 flex size-14 shrink-0 items-center justify-center rounded-xl bg-gray-100 dark:bg-[#2C2C2C]">
<MessageSquare className="size-6 text-gray-600 dark:text-gray-300" />
</div>
<p className="text-xl font-semibold text-gray-700 dark:text-gray-200">
Test the workflow
</p>
</div>
) : (
<div className="w-full">
{queries.map((query, index) => {
const querySteps = query.executionSteps || [];
const hasResponse = !!(query.response || query.error);
const isLastQuery = index === queries.length - 1;
const isOpen =
openDetailsIndex === index ||
(!hasResponse && isLastQuery && querySteps.length > 0);
return (
<div key={index}>
{/* Query bubble */}
<ConversationBubble
className={index === 0 ? 'mt-5' : ''}
message={query.prompt}
type="QUESTION"
handleUpdatedQuestionSubmission={
handleQuestionSubmission
}
questionNumber={index}
/>
{/* Execution Details */}
{querySteps.length > 0 && (
<ExecutionDetails
steps={querySteps}
nodes={workflowData.nodes}
isOpen={isOpen}
onToggle={() =>
setOpenDetailsIndex(
openDetailsIndex === index ? null : index,
)
}
stepRefs={isLastQuery ? stepRefs : undefined}
/>
)}
{/* Response bubble */}
{(query.response ||
query.thought ||
query.tool_calls) && (
<ConversationBubble
className={isLastQuery ? 'mb-32' : 'mb-7'}
message={query.response}
type="ANSWER"
thought={query.thought}
sources={query.sources}
toolCalls={query.tool_calls}
feedback={query.feedback}
isStreaming={status === 'loading' && isLastQuery}
/>
)}
{/* Error bubble */}
{query.error && (
<ConversationBubble
className={isLastQuery ? 'mb-32' : 'mb-7'}
message={query.error}
type="ERROR"
/>
)}
</div>
);
})}
</div>
)}
</div>
<div className="dark:bg-raisin-black absolute right-0 bottom-0 left-0 flex w-full flex-col gap-2 bg-white px-4 pt-2 pb-4">
<MessageInput
onSubmit={(text) => handleQuestionSubmission(text)}
loading={status === 'loading'}
showSourceButton={false}
showToolButton={false}
autoFocus={true}
/>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,18 @@
import { Monitor } from 'lucide-react';
export default function MobileBlocker() {
return (
<div className="bg-lotion dark:bg-raisin-black flex min-h-screen flex-col items-center justify-center px-6 text-center md:hidden">
<div className="bg-violets-are-blue/10 dark:bg-violets-are-blue/20 mb-6 flex h-20 w-20 items-center justify-center rounded-2xl">
<Monitor className="text-violets-are-blue h-10 w-10" />
</div>
<h2 className="mb-2 text-xl font-bold text-gray-900 dark:text-white">
Desktop Required
</h2>
<p className="max-w-sm text-sm leading-relaxed text-gray-500 dark:text-[#E0E0E0]">
The Workflow Builder requires a larger screen for the best experience.
Please open this page on a desktop or laptop computer.
</p>
</div>
);
}

View File

@@ -0,0 +1,395 @@
import { Braces, Plus, Search } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Edge, Node } from 'reactflow';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
interface WorkflowVariable {
name: string;
section: string;
}
function getUpstreamNodeIds(nodeId: string, edges: Edge[]): Set<string> {
const upstream = new Set<string>();
const queue = [nodeId];
while (queue.length > 0) {
const current = queue.shift()!;
for (const edge of edges) {
if (edge.target === current && !upstream.has(edge.source)) {
upstream.add(edge.source);
queue.push(edge.source);
}
}
}
return upstream;
}
function extractUpstreamVariables(
nodes: Node[],
edges: Edge[],
selectedNodeId: string,
): WorkflowVariable[] {
const variables: WorkflowVariable[] = [
{ name: 'query', section: 'Workflow input' },
{ name: 'chat_history', section: 'Workflow input' },
];
const seen = new Set(['query', 'chat_history']);
const upstreamIds = getUpstreamNodeIds(selectedNodeId, edges);
for (const node of nodes) {
if (!upstreamIds.has(node.id)) continue;
if (node.type === 'agent' && node.data?.config?.output_variable) {
const name = node.data.config.output_variable;
if (!seen.has(name)) {
seen.add(name);
variables.push({
name,
section: node.data.title || node.data.label || 'Agent',
});
}
}
if (node.type === 'state' && node.data?.variable) {
const name = node.data.variable;
if (!seen.has(name)) {
seen.add(name);
variables.push({
name,
section: 'Set State',
});
}
}
}
return variables;
}
function groupBySection(
vars: WorkflowVariable[],
): Map<string, WorkflowVariable[]> {
const groups = new Map<string, WorkflowVariable[]>();
for (const v of vars) {
const list = groups.get(v.section) ?? [];
list.push(v);
groups.set(v.section, list);
}
return groups;
}
function HighlightedOverlay({ text }: { text: string }) {
const parts = text.split(/(\{\{[^}]*\}\})/g);
return (
<>
{parts.map((part, i) =>
/^\{\{[^}]*\}\}$/.test(part) ? (
<span key={i} className="text-violets-are-blue font-medium">
{part}
</span>
) : (
<span key={i} className="text-gray-900 dark:text-white">
{part}
</span>
),
)}
</>
);
}
function VariableListWithSearch({
variables,
onSelect,
}: {
variables: WorkflowVariable[];
onSelect: (name: string) => void;
}) {
const [search, setSearch] = useState('');
const filtered = useMemo(
() =>
variables.filter((v) =>
v.name.toLowerCase().includes(search.toLowerCase()),
),
[variables, search],
);
const grouped = useMemo(() => groupBySection(filtered), [filtered]);
return (
<div className="flex w-full flex-col overflow-hidden">
<div className="flex items-center gap-2 border-b border-[#E5E5E5] px-3 py-2 dark:border-[#3A3A3A]">
<Search className="text-muted-foreground h-3.5 w-3.5 shrink-0" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search variables..."
className="placeholder:text-muted-foreground w-full bg-transparent text-sm text-gray-800 outline-none dark:text-gray-200"
/>
</div>
<div className="max-h-48 overflow-y-auto">
{filtered.length === 0 ? (
<div className="text-muted-foreground px-3 py-4 text-center text-xs">
No variables found
</div>
) : (
Array.from(grouped.entries()).map(([section, vars]) => (
<div key={section}>
<div className="text-muted-foreground truncate px-3 pt-2.5 pb-1 text-[10px] font-semibold tracking-wider uppercase">
{section}
</div>
{vars.map((v) => (
<button
key={v.name}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
onSelect(v.name);
}}
className="flex w-full cursor-pointer items-center gap-2 px-3 py-1.5 text-left text-sm transition-colors hover:bg-gray-50 dark:hover:bg-[#383838]"
>
<Braces className="text-violets-are-blue h-3.5 w-3.5 shrink-0" />
<span className="truncate font-medium text-gray-800 dark:text-gray-200">
{v.name}
</span>
</button>
))}
</div>
))
)}
</div>
</div>
);
}
interface PromptTextAreaProps {
value: string;
onChange: (value: string) => void;
nodes: Node[];
edges: Edge[];
selectedNodeId: string;
placeholder?: string;
rows?: number;
label?: string;
}
export default function PromptTextArea({
value,
onChange,
nodes,
edges,
selectedNodeId,
placeholder,
rows = 4,
label,
}: PromptTextAreaProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const overlayRef = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
const [showDropdown, setShowDropdown] = useState(false);
const [dropdownPos, setDropdownPos] = useState({ top: 0, left: 0 });
const [filterText, setFilterText] = useState('');
const [cursorInsertPos, setCursorInsertPos] = useState<number | null>(null);
const [contextOpen, setContextOpen] = useState(false);
const variables = useMemo(
() => extractUpstreamVariables(nodes, edges, selectedNodeId),
[nodes, edges, selectedNodeId],
);
const filtered = useMemo(
() =>
variables.filter((v) =>
v.name.toLowerCase().includes(filterText.toLowerCase()),
),
[variables, filterText],
);
const checkForTrigger = useCallback(() => {
const textarea = textareaRef.current;
if (!textarea) return;
const cursorPos = textarea.selectionStart;
const textBeforeCursor = value.slice(0, cursorPos);
const triggerMatch = textBeforeCursor.match(/\{\{(\w*)$/);
if (triggerMatch) {
setFilterText(triggerMatch[1]);
setCursorInsertPos(cursorPos);
const wrapper = wrapperRef.current;
if (!wrapper) return;
setDropdownPos({
top: wrapper.offsetHeight + 4,
left: 0,
});
setShowDropdown(true);
} else {
setShowDropdown(false);
}
}, [value]);
const insertVariable = useCallback(
(varName: string) => {
if (cursorInsertPos === null) return;
const textBeforeCursor = value.slice(0, cursorInsertPos);
const triggerMatch = textBeforeCursor.match(/\{\{(\w*)$/);
if (!triggerMatch) return;
const startPos = cursorInsertPos - triggerMatch[0].length;
const insertion = `{{${varName}}}`;
const newValue =
value.slice(0, startPos) + insertion + value.slice(cursorInsertPos);
onChange(newValue);
setShowDropdown(false);
requestAnimationFrame(() => {
const newCursorPos = startPos + insertion.length;
textareaRef.current?.setSelectionRange(newCursorPos, newCursorPos);
textareaRef.current?.focus();
});
},
[value, cursorInsertPos, onChange],
);
const insertVariableFromButton = useCallback(
(varName: string) => {
const textarea = textareaRef.current;
const cursorPos = textarea?.selectionStart ?? value.length;
const insertion = `{{${varName}}}`;
const newValue =
value.slice(0, cursorPos) + insertion + value.slice(cursorPos);
onChange(newValue);
setContextOpen(false);
requestAnimationFrame(() => {
const newCursorPos = cursorPos + insertion.length;
textareaRef.current?.setSelectionRange(newCursorPos, newCursorPos);
textareaRef.current?.focus();
});
},
[value, onChange],
);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(e.target as HTMLElement)
) {
setShowDropdown(false);
}
};
if (showDropdown) {
document.addEventListener('mousedown', handleClickOutside);
return () =>
document.removeEventListener('mousedown', handleClickOutside);
}
}, [showDropdown]);
return (
<div>
{label && (
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{label}
</label>
)}
<div
ref={wrapperRef}
className="border-light-silver focus-within:ring-purple-30 relative rounded-xl border bg-white transition-all focus-within:ring-2 dark:border-[#3A3A3A] dark:bg-[#2C2C2C]"
>
<div
ref={overlayRef}
aria-hidden
className="pointer-events-none absolute inset-0 overflow-hidden rounded-xl border border-transparent px-3 py-2 text-sm wrap-break-word whitespace-pre-wrap"
>
{value ? (
<HighlightedOverlay text={value} />
) : (
<span className="text-gray-400 dark:text-gray-500">
{placeholder}
</span>
)}
</div>
<textarea
ref={textareaRef}
value={value}
onChange={(e) => {
onChange(e.target.value);
setTimeout(checkForTrigger, 0);
}}
onKeyUp={checkForTrigger}
onKeyDown={(e) => {
if (showDropdown && e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
setShowDropdown(false);
}
}}
onScroll={() => {
if (overlayRef.current && textareaRef.current) {
overlayRef.current.scrollTop = textareaRef.current.scrollTop;
}
}}
className="relative w-full rounded-xl bg-transparent px-3 pt-2 pb-8 text-sm caret-black outline-none dark:caret-white"
style={{
color: 'transparent',
WebkitTextFillColor: 'transparent',
}}
rows={rows}
placeholder={placeholder}
spellCheck={false}
/>
<div className="absolute right-4 bottom-1.5 z-10">
<Popover open={contextOpen} onOpenChange={setContextOpen}>
<PopoverTrigger asChild>
<button
type="button"
className="text-violets-are-blue hover:bg-violets-are-blue/10 flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium transition-colors"
>
<Plus className="h-3 w-3" />
Add context
</button>
</PopoverTrigger>
<PopoverContent
align="end"
side="top"
className="w-60 rounded-xl border border-[#E5E5E5] bg-white p-0 shadow-lg dark:border-[#3A3A3A] dark:bg-[#2C2C2C]"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<VariableListWithSearch
variables={variables}
onSelect={insertVariableFromButton}
/>
</PopoverContent>
</Popover>
</div>
{showDropdown && filtered.length > 0 && (
<div
ref={dropdownRef}
className="absolute z-50 w-64 rounded-xl border border-[#E5E5E5] bg-white shadow-lg dark:border-[#3A3A3A] dark:bg-[#2C2C2C]"
style={{ top: dropdownPos.top, left: dropdownPos.left }}
>
<VariableListWithSearch
variables={filtered}
onSelect={insertVariable}
/>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,91 @@
import React, { ReactNode } from 'react';
import { Handle, Position } from 'reactflow';
interface BaseNodeProps {
title: string;
children?: ReactNode;
selected?: boolean;
type?: 'start' | 'end' | 'default' | 'state' | 'agent';
icon?: ReactNode;
handles?: {
source?: boolean;
target?: boolean;
};
}
export const BaseNode: React.FC<BaseNodeProps> = ({
title,
children,
selected,
type = 'default',
icon,
handles = { source: true, target: true },
}) => {
let bgColor = 'bg-white dark:bg-[#2C2C2C]';
let borderColor = 'border-gray-200 dark:border-[#3A3A3A]';
let iconBg = 'bg-gray-100 dark:bg-gray-800';
let iconColor = 'text-gray-600 dark:text-gray-400';
if (selected) {
borderColor =
'border-violets-are-blue ring-2 ring-purple-300 dark:ring-violets-are-blue';
}
if (type === 'start') {
iconBg = 'bg-green-100 dark:bg-green-900/30';
iconColor = 'text-green-600 dark:text-green-400';
} else if (type === 'end') {
iconBg = 'bg-red-100 dark:bg-red-900/30';
iconColor = 'text-red-600 dark:text-red-400';
} else if (type === 'state') {
iconBg = 'bg-gray-100 dark:bg-gray-800';
iconColor = 'text-gray-600 dark:text-gray-400';
}
return (
<div
className={`rounded-full border ${bgColor} ${borderColor} shadow-md transition-all hover:shadow-lg ${
selected ? 'scale-105' : ''
} max-w-[250px] min-w-[180px]`}
>
{handles.target && (
<Handle
type="target"
position={Position.Left}
isConnectable={true}
className="hover:bg-violets-are-blue! -left-1! h-3! w-3! rounded-full! border-2! border-white! bg-gray-400! transition-colors dark:border-[#2C2C2C]!"
/>
)}
<div className="flex items-center gap-3 px-4 py-3">
<div
className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-full ${iconBg} ${iconColor}`}
>
{icon}
</div>
<div className="min-w-0 flex-1 pr-3">
<div
className="truncate text-sm font-semibold text-gray-900 dark:text-white"
title={title}
>
{title}
</div>
{children && (
<div className="mt-1 truncate text-xs text-gray-600 dark:text-gray-400">
{children}
</div>
)}
</div>
</div>
{handles.source && (
<Handle
type="source"
position={Position.Right}
isConnectable={true}
className="hover:bg-violets-are-blue! -right-1! h-3! w-3! rounded-full! border-2! border-white! bg-gray-400! transition-colors dark:border-[#2C2C2C]!"
/>
)}
</div>
);
};

View File

@@ -0,0 +1,46 @@
import { Database } from 'lucide-react';
import { memo } from 'react';
import { NodeProps } from 'reactflow';
import { BaseNode } from './BaseNode';
type SetStateNodeData = {
label?: string;
title?: string;
variable?: string;
value?: string;
};
const SetStateNode = ({ data, selected }: NodeProps<SetStateNodeData>) => {
const title = data.title || data.label || 'Set State';
return (
<BaseNode
title={title}
type="state"
selected={selected}
icon={<Database size={16} />}
handles={{ source: true, target: true }}
>
<div className="flex flex-col gap-1">
{data.variable && (
<div
className="truncate text-[10px] text-gray-500 uppercase"
title={`Variable: ${data.variable}`}
>
{data.variable}
</div>
)}
{data.value && (
<div
className="truncate text-xs text-blue-600 dark:text-blue-400"
title={`Value: ${data.value}`}
>
{data.value}
</div>
)}
</div>
</BaseNode>
);
};
export default memo(SetStateNode);

View File

@@ -0,0 +1,144 @@
import React, { memo } from 'react';
import { BaseNode } from './BaseNode';
import SetStateNode from './SetStateNode';
import { Play, Bot, StickyNote, Flag } from 'lucide-react';
export const StartNode = memo(function StartNode({
selected,
}: {
selected: boolean;
}) {
return (
<BaseNode
title="Start"
type="start"
selected={selected}
handles={{ target: false, source: true }}
icon={<Play size={16} />}
>
<div className="text-xs text-gray-500">Entry point of the workflow</div>
</BaseNode>
);
});
export const EndNode = memo(function EndNode({
selected,
}: {
selected: boolean;
}) {
return (
<BaseNode
title="End"
type="end"
selected={selected}
handles={{ target: true, source: false }}
icon={<Flag size={16} />}
>
<div className="text-xs text-gray-500">Workflow completion</div>
</BaseNode>
);
});
export const AgentNode = memo(function AgentNode({
data,
selected,
}: {
data: {
title?: string;
label?: string;
config?: {
agent_type?: string;
model_id?: string;
prompt_template?: string;
output_variable?: string;
};
};
selected: boolean;
}) {
const title = data.title || data.label || 'Agent';
const config = data.config || {};
return (
<BaseNode
title={title}
type="agent"
selected={selected}
icon={<Bot size={16} />}
>
<div className="flex flex-col gap-1">
{config.agent_type && (
<div
className="truncate text-[10px] text-gray-500 uppercase"
title={config.agent_type}
>
{config.agent_type}
</div>
)}
{config.model_id && (
<div
className="text-purple-30 dark:text-violets-are-blue truncate text-xs"
title={config.model_id}
>
{config.model_id}
</div>
)}
{config.output_variable && (
<div
className="truncate text-xs text-green-600 dark:text-green-400"
title={`Output ➔ ${config.output_variable}`}
>
Output {config.output_variable}
</div>
)}
</div>
</BaseNode>
);
});
export const NoteNode = memo(function NoteNode({
data,
selected,
}: {
data: { title?: string; label?: string; content?: string };
selected: boolean;
}) {
const title = data.title || data.label || 'Note';
const maxContentLength = 120;
const displayContent =
data.content && data.content.length > maxContentLength
? `${data.content.substring(0, maxContentLength)}...`
: data.content;
return (
<div
className={`max-w-[250px] rounded-3xl border border-yellow-200 bg-yellow-50 px-5 py-3 shadow-md transition-all dark:border-yellow-800 dark:bg-yellow-900/20 ${
selected
? 'scale-105 ring-2 ring-yellow-300 dark:ring-yellow-700'
: 'hover:shadow-lg'
}`}
>
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-yellow-100 text-yellow-700 dark:bg-yellow-800/30 dark:text-yellow-500">
<StickyNote size={18} />
</div>
<div className="min-w-0 flex-1">
<div
className="truncate text-sm font-semibold text-yellow-800 dark:text-yellow-300"
title={title}
>
{title}
</div>
{displayContent && (
<div
className="mt-1 text-xs wrap-break-word text-yellow-700 italic dark:text-yellow-400"
title={data.content}
>
{displayContent}
</div>
)}
</div>
</div>
</div>
);
});
export { SetStateNode };

View File

@@ -0,0 +1,441 @@
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
import conversationService from '../../api/services/conversationService';
import { Query, Status } from '../../conversation/conversationModels';
import { WorkflowEdge, WorkflowNode } from '../types/workflow';
export interface WorkflowExecutionStep {
nodeId: string;
nodeType: string;
nodeTitle: string;
status: 'pending' | 'running' | 'completed' | 'failed';
reasoning?: string;
startedAt?: number;
completedAt?: number;
stateSnapshot?: Record<string, unknown>;
output?: string;
error?: string;
}
interface WorkflowData {
name: string;
description?: string;
nodes: WorkflowNode[];
edges: WorkflowEdge[];
}
export interface WorkflowQuery extends Query {
executionSteps?: WorkflowExecutionStep[];
}
export interface WorkflowPreviewState {
queries: WorkflowQuery[];
status: Status;
executionSteps: WorkflowExecutionStep[];
activeNodeId: string | null;
}
const initialState: WorkflowPreviewState = {
queries: [],
status: 'idle',
executionSteps: [],
activeNodeId: null,
};
let abortController: AbortController | null = null;
export function handleWorkflowPreviewAbort() {
if (abortController) {
abortController.abort();
abortController = null;
}
}
interface ThunkState {
preference: {
token: string | null;
};
workflowPreview: WorkflowPreviewState;
}
export const fetchWorkflowPreviewAnswer = createAsyncThunk<
void,
{
question: string;
workflowData: WorkflowData;
indx?: number;
},
{ state: ThunkState }
>(
'workflowPreview/fetchAnswer',
async ({ question, workflowData, indx }, { dispatch, getState }) => {
if (abortController) abortController.abort();
abortController = new AbortController();
const { signal } = abortController;
const state = getState();
if (state.preference) {
const payload = {
question,
workflow: workflowData,
save_conversation: false,
};
await new Promise<void>((resolve, reject) => {
conversationService
.answerStream(payload, state.preference.token, signal)
.then((response) => {
if (!response.body) throw Error('No response body');
let buffer = '';
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
const processStream = ({
done,
value,
}: ReadableStreamReadResult<Uint8Array>): Promise<void> | void => {
if (done) {
resolve();
return;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
const currentState = getState();
for (const line of lines) {
if (line.startsWith('data:')) {
try {
const data = JSON.parse(line.slice(5));
const targetIndex =
indx ?? currentState.workflowPreview.queries.length - 1;
if (data.type === 'end') {
dispatch(workflowPreviewSlice.actions.setStatus('idle'));
} else if (data.type === 'thought') {
dispatch(
updateThought({
index: targetIndex,
query: { thought: data.thought },
}),
);
} else if (data.type === 'workflow_step') {
dispatch(
updateExecutionStep({
index: targetIndex,
step: {
nodeId: data.node_id,
nodeType: data.node_type,
nodeTitle: data.node_title,
status: data.status,
reasoning: data.reasoning,
stateSnapshot: data.state_snapshot,
output: data.output,
error: data.error,
},
}),
);
if (data.status === 'running') {
dispatch(setActiveNodeId(data.node_id));
}
} else if (data.type === 'source') {
dispatch(
updateStreamingSource({
index: targetIndex,
query: { sources: data.source ?? [] },
}),
);
} else if (data.type === 'tool_call') {
dispatch(
updateToolCall({
index: targetIndex,
tool_call: data.data,
}),
);
} else if (data.type === 'error') {
dispatch(
workflowPreviewSlice.actions.setStatus('failed'),
);
dispatch(
workflowPreviewSlice.actions.raiseError({
index: targetIndex,
message: data.error,
}),
);
} else if (data.type === 'structured_answer') {
dispatch(
updateStreamingQuery({
index: targetIndex,
query: {
response: data.answer,
structured: data.structured,
schema: data.schema,
},
}),
);
} else if (data.answer !== undefined) {
dispatch(
updateStreamingQuery({
index: targetIndex,
query: { response: data.answer },
}),
);
}
} catch {
/* empty */
}
}
}
return reader.read().then(processStream);
};
reader.read().then(processStream).catch(reject);
})
.catch(reject);
});
}
},
);
export const workflowPreviewSlice = createSlice({
name: 'workflowPreview',
initialState,
reducers: {
addQuery(state, action: PayloadAction<Query>) {
state.queries.push(action.payload);
},
resendQuery(
state,
action: PayloadAction<{ index: number; prompt: string; query?: Query }>,
) {
state.queries = [
...state.queries.slice(0, action.payload.index),
{ prompt: action.payload.prompt },
];
state.executionSteps = [];
state.activeNodeId = null;
},
updateStreamingQuery(
state,
action: PayloadAction<{
index: number;
query: Partial<Query>;
}>,
) {
const { index, query } = action.payload;
if (state.status === 'idle') return;
if (query.response !== undefined) {
state.queries[index].response =
(state.queries[index].response || '') + query.response;
}
if (query.structured !== undefined) {
state.queries[index].structured = query.structured;
}
if (query.schema !== undefined) {
state.queries[index].schema = query.schema;
}
},
updateThought(
state,
action: PayloadAction<{
index: number;
query: Partial<Query>;
}>,
) {
const { index, query } = action.payload;
if (query.thought !== undefined) {
state.queries[index].thought =
(state.queries[index].thought || '') + query.thought;
}
},
updateStreamingSource(
state,
action: PayloadAction<{
index: number;
query: Partial<Query>;
}>,
) {
const { index, query } = action.payload;
if (!state.queries[index].sources) {
state.queries[index].sources = query?.sources;
} else if (query.sources) {
state.queries[index].sources!.push(...query.sources);
}
},
updateToolCall(state, action) {
const { index, tool_call } = action.payload;
if (!state.queries[index].tool_calls) {
state.queries[index].tool_calls = [];
}
const existingIndex = state.queries[index].tool_calls.findIndex(
(call: { call_id: string }) => call.call_id === tool_call.call_id,
);
if (existingIndex !== -1) {
const existingCall = state.queries[index].tool_calls[existingIndex];
state.queries[index].tool_calls[existingIndex] = {
...existingCall,
...tool_call,
};
} else {
state.queries[index].tool_calls.push(tool_call);
}
},
updateQuery(
state,
action: PayloadAction<{ index: number; query: Partial<Query> }>,
) {
const { index, query } = action.payload;
state.queries[index] = {
...state.queries[index],
...query,
};
},
updateExecutionStep(
state,
action: PayloadAction<{
index: number;
step: Partial<WorkflowExecutionStep> & {
nodeId: string;
nodeType: string;
nodeTitle: string;
status: WorkflowExecutionStep['status'];
};
}>,
) {
const { index, step } = action.payload;
if (!state.queries[index]) return;
if (!state.queries[index].executionSteps) {
state.queries[index].executionSteps = [];
}
const querySteps = state.queries[index].executionSteps!;
const existingIndex = querySteps.findIndex((s) => s.nodeId === step.nodeId);
const updatedStep: WorkflowExecutionStep = {
nodeId: step.nodeId,
nodeType: step.nodeType,
nodeTitle: step.nodeTitle,
status: step.status,
reasoning: step.reasoning,
stateSnapshot: step.stateSnapshot,
output: step.output,
error: step.error,
startedAt: existingIndex !== -1 ? querySteps[existingIndex].startedAt : Date.now(),
completedAt:
step.status === 'completed' || step.status === 'failed'
? Date.now()
: existingIndex !== -1
? querySteps[existingIndex].completedAt
: undefined,
};
if (existingIndex !== -1) {
updatedStep.stateSnapshot = step.stateSnapshot ?? querySteps[existingIndex].stateSnapshot;
updatedStep.output = step.output ?? querySteps[existingIndex].output;
updatedStep.error = step.error ?? querySteps[existingIndex].error;
querySteps[existingIndex] = updatedStep;
} else {
querySteps.push(updatedStep);
}
const globalIndex = state.executionSteps.findIndex((s) => s.nodeId === step.nodeId);
if (globalIndex !== -1) {
state.executionSteps[globalIndex] = updatedStep;
} else {
state.executionSteps.push(updatedStep);
}
},
setActiveNodeId(state, action: PayloadAction<string | null>) {
state.activeNodeId = action.payload;
},
setStatus(state, action: PayloadAction<Status>) {
state.status = action.payload;
},
raiseError(
state,
action: PayloadAction<{
index: number;
message: string;
}>,
) {
const { index, message } = action.payload;
state.queries[index].error = message;
},
resetWorkflowPreview: (state) => {
state.queries = initialState.queries;
state.status = initialState.status;
state.executionSteps = initialState.executionSteps;
state.activeNodeId = initialState.activeNodeId;
handleWorkflowPreviewAbort();
},
clearExecutionSteps: (state) => {
state.executionSteps = [];
state.activeNodeId = null;
},
},
extraReducers(builder) {
builder
.addCase(fetchWorkflowPreviewAnswer.pending, (state) => {
state.status = 'loading';
state.executionSteps = [];
state.activeNodeId = null;
})
.addCase(fetchWorkflowPreviewAnswer.rejected, (state, action) => {
if (action.meta.aborted) {
state.status = 'idle';
return;
}
state.status = 'failed';
if (state.queries.length > 0) {
state.queries[state.queries.length - 1].error =
'Something went wrong';
}
});
},
});
interface RootStateWithWorkflowPreview {
workflowPreview: WorkflowPreviewState;
}
export const selectWorkflowPreviewQueries = (
state: RootStateWithWorkflowPreview,
) => state.workflowPreview.queries;
export const selectWorkflowPreviewStatus = (
state: RootStateWithWorkflowPreview,
) => state.workflowPreview.status;
export const selectWorkflowExecutionSteps = (
state: RootStateWithWorkflowPreview,
) => state.workflowPreview.executionSteps;
export const selectActiveNodeId = (state: RootStateWithWorkflowPreview) =>
state.workflowPreview.activeNodeId;
export const {
addQuery,
updateQuery,
resendQuery,
updateStreamingQuery,
updateThought,
updateStreamingSource,
updateToolCall,
updateExecutionStep,
setActiveNodeId,
setStatus,
raiseError,
resetWorkflowPreview,
clearExecutionSteps,
} = workflowPreviewSlice.actions;
export default workflowPreviewSlice.reducer;