mirror of
https://github.com/arc53/DocsGPT.git
synced 2026-02-22 12:21:39 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -125,4 +125,3 @@ export default function FolderCard({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
1153
frontend/src/agents/WorkflowBuilder.tsx
Normal file
1153
frontend/src/agents/WorkflowBuilder.tsx
Normal file
File diff suppressed because it is too large
Load Diff
93
frontend/src/agents/components/AgentTypeModal.tsx
Normal file
93
frontend/src/agents/components/AgentTypeModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
49
frontend/src/agents/types/workflow.ts
Normal file
49
frontend/src/agents/types/workflow.ts
Normal 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;
|
||||
}
|
||||
1216
frontend/src/agents/workflow/WorkflowBuilder.tsx
Normal file
1216
frontend/src/agents/workflow/WorkflowBuilder.tsx
Normal file
File diff suppressed because it is too large
Load Diff
611
frontend/src/agents/workflow/WorkflowPreview.tsx
Normal file
611
frontend/src/agents/workflow/WorkflowPreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
frontend/src/agents/workflow/components/MobileBlocker.tsx
Normal file
18
frontend/src/agents/workflow/components/MobileBlocker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
395
frontend/src/agents/workflow/components/PromptTextArea.tsx
Normal file
395
frontend/src/agents/workflow/components/PromptTextArea.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
91
frontend/src/agents/workflow/nodes/BaseNode.tsx
Normal file
91
frontend/src/agents/workflow/nodes/BaseNode.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
46
frontend/src/agents/workflow/nodes/SetStateNode.tsx
Normal file
46
frontend/src/agents/workflow/nodes/SetStateNode.tsx
Normal 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);
|
||||
144
frontend/src/agents/workflow/nodes/index.tsx
Normal file
144
frontend/src/agents/workflow/nodes/index.tsx
Normal 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 };
|
||||
441
frontend/src/agents/workflow/workflowPreviewSlice.ts
Normal file
441
frontend/src/agents/workflow/workflowPreviewSlice.ts
Normal 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;
|
||||
Reference in New Issue
Block a user