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

22
frontend/components.json Normal file
View File

@@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

File diff suppressed because it is too large Load Diff

View File

@@ -19,13 +19,20 @@
]
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@reduxjs/toolkit": "^2.10.1",
"chart.js": "^4.4.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"copy-to-clipboard": "^3.3.3",
"i18next": "^25.5.3",
"i18next-browser-languagedetector": "^8.2.0",
"lodash": "^4.17.21",
"lucide-react": "^0.562.0",
"mermaid": "^11.12.1",
"prop-types": "^15.8.1",
"react": "^19.1.0",
@@ -38,10 +45,11 @@
"react-redux": "^9.2.0",
"react-router-dom": "^7.6.1",
"react-syntax-highlighter": "^15.6.1",
"reactflow": "^11.11.4",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.0",
"remark-math": "^6.0.0",
"tailwind-merge": "^3.3.1"
"tailwind-merge": "^3.4.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.10",
@@ -66,6 +74,7 @@
"prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.7.1",
"tailwindcss": "^4.1.11",
"tw-animate-css": "^1.4.0",
"typescript": "^5.8.3",
"vite": "^7.2.0",
"vite-plugin-svgr": "^4.3.0"

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;

View File

@@ -68,6 +68,8 @@ const endpoints = {
AGENT_FOLDERS: '/api/agents/folders/',
AGENT_FOLDER: (id: string) => `/api/agents/folders/${id}`,
MOVE_AGENT_TO_FOLDER: '/api/agents/folders/move_agent',
WORKFLOWS: '/api/workflows',
WORKFLOW: (id: string) => `/api/workflows/${id}`,
},
CONVERSATION: {
ANSWER: '/api/answer',

View File

@@ -144,15 +144,15 @@ const userService = {
createAgentFolder: (
data: { name: string; parent_id?: string },
token: string | null,
): Promise<any> =>
apiClient.post(endpoints.USER.AGENT_FOLDERS, data, token),
): Promise<any> => apiClient.post(endpoints.USER.AGENT_FOLDERS, data, token),
getAgentFolder: (id: string, token: string | null): Promise<any> =>
apiClient.get(endpoints.USER.AGENT_FOLDER(id), token),
updateAgentFolder: (
id: string,
data: { name?: string; parent_id?: string },
token: string | null,
): Promise<any> => apiClient.put(endpoints.USER.AGENT_FOLDER(id), data, token),
): Promise<any> =>
apiClient.put(endpoints.USER.AGENT_FOLDER(id), data, token),
deleteAgentFolder: (id: string, token: string | null): Promise<any> =>
apiClient.delete(endpoints.USER.AGENT_FOLDER(id), token),
moveAgentToFolder: (
@@ -160,6 +160,14 @@ const userService = {
token: string | null,
): Promise<any> =>
apiClient.post(endpoints.USER.MOVE_AGENT_TO_FOLDER, data, token),
getWorkflow: (id: string, token: string | null): Promise<any> =>
apiClient.get(endpoints.USER.WORKFLOW(id), token),
createWorkflow: (data: any, token: string | null): Promise<any> =>
apiClient.post(endpoints.USER.WORKFLOWS, data, token),
updateWorkflow: (id: string, data: any, token: string | null): Promise<any> =>
apiClient.put(endpoints.USER.WORKFLOW(id), data, token),
deleteWorkflow: (id: string, token: string | null): Promise<any> =>
apiClient.delete(endpoints.USER.WORKFLOW(id), token),
};
export default userService;

View File

@@ -563,8 +563,7 @@ export default function MessageInput({
e.preventDefault();
uploadFiles(files);
}
};
};
const handlePostDocumentSelect = (doc: any) => {
console.log('Selected document:', doc);

View File

@@ -0,0 +1,59 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const alertVariants = cva(
'relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7',
{
variants: {
variant: {
default: 'bg-background text-foreground',
destructive:
'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
},
},
defaultVariants: {
variant: 'default',
},
},
);
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
));
Alert.displayName = 'Alert';
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn('mb-1 leading-none font-medium tracking-tight', className)}
{...props}
/>
));
AlertTitle.displayName = 'AlertTitle';
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('text-sm [&_p]:leading-relaxed', className)}
{...props}
/>
));
AlertDescription.displayName = 'AlertDescription';
export { Alert, AlertTitle, AlertDescription };

View File

@@ -0,0 +1,62 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost:
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
'icon-sm': 'size-8',
'icon-lg': 'size-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
function Button({
className,
variant = 'default',
size = 'default',
asChild = false,
...props
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : 'button';
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };

View File

@@ -0,0 +1,182 @@
import { Command as CommandPrimitive } from 'cmdk';
import { SearchIcon } from 'lucide-react';
import * as React from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { cn } from '@/lib/utils';
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
'bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md',
className,
)}
{...props}
/>
);
}
function CommandDialog({
title = 'Command Palette',
description = 'Search for a command to run...',
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string;
description?: string;
className?: string;
showCloseButton?: boolean;
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn('overflow-hidden p-0', className)}
showCloseButton={showCloseButton}
>
<Command className="**:[[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5 **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group]]:px-2 **:[[cmdk-input]]:h-12 **:[[cmdk-item]]:px-2 **:[[cmdk-item]]:py-3">
{children}
</Command>
</DialogContent>
</Dialog>
);
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
'placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
</div>
);
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
'max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto',
className,
)}
{...props}
/>
);
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
);
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
'text-foreground **:[[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium',
className,
)}
{...props}
/>
);
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn('bg-border -mx-1 h-px', className)}
{...props}
/>
);
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot="command-shortcut"
className={cn(
'text-muted-foreground ml-auto text-xs tracking-widest',
className,
)}
{...props}
/>
);
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};

View File

@@ -0,0 +1,143 @@
'use client';
import { XIcon } from 'lucide-react';
import * as React from 'react';
import { cn } from '@/lib/utils';
import * as DialogPrimitive from '@radix-ui/react-dialog';
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg',
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="dialog-header"
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
{...props}
/>
);
}
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="dialog-footer"
className={cn(
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
className,
)}
{...props}
/>
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn('text-lg leading-none font-semibold', className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};

View File

@@ -0,0 +1,166 @@
'use client';
import { Check, ChevronsUpDown, X } from 'lucide-react';
import * as React from 'react';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { cn } from '@/lib/utils';
export interface MultiSelectOption {
value: string;
label: string;
}
interface MultiSelectProps {
options: MultiSelectOption[];
selected: string[];
onChange: (selected: string[]) => void;
placeholder?: string;
emptyText?: string;
searchPlaceholder?: string;
className?: string;
}
export function MultiSelect({
options,
selected,
onChange,
placeholder = 'Select items...',
emptyText = 'No results found.',
searchPlaceholder = 'Search...',
className,
}: MultiSelectProps) {
const [open, setOpen] = React.useState(false);
const handleSelect = (value: string) => {
const newSelected = selected.includes(value)
? selected.filter((item) => item !== value)
: [...selected, value];
onChange(newSelected);
};
const handleRemove = (value: string, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onChange(selected.filter((item) => item !== value));
};
const selectedLabels = options
.filter((option) => selected.includes(option.value))
.map((option) => option.label);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={cn(
'w-full justify-between border-[#E5E5E5] bg-white hover:bg-gray-50 dark:border-[#3A3A3A] dark:bg-[#2C2C2C] dark:hover:bg-[#383838]',
!selected.length && 'text-gray-500 dark:text-gray-400',
className,
)}
>
<div className="flex flex-wrap gap-1">
{selected.length === 0 ? (
placeholder
) : (
<>
{selectedLabels.slice(0, 2).map((label) => {
const option = options.find((o) => o.label === label);
return (
<span
key={option?.value || label}
className="dark:bg-purple-30/30 bg-violets-are-blue/20 inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium text-purple-700 dark:text-purple-300"
>
{label}
<span
role="button"
tabIndex={0}
className="flex h-3 w-3 cursor-pointer items-center justify-center hover:text-purple-900 dark:hover:text-purple-200"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={(e) => handleRemove(option?.value || '', e)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleRemove(
option?.value || '',
e as unknown as React.MouseEvent,
);
}
}}
>
<X className="h-3 w-3" />
</span>
</span>
);
})}
{selected.length > 2 && (
<span className="text-xs text-gray-600 dark:text-gray-400">
+{selected.length - 2} more
</span>
)}
</>
)}
</div>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-(--radix-popover-trigger-width) border-[#E5E5E5] bg-white p-0 dark:border-[#3A3A3A] dark:bg-[#2C2C2C]"
align="start"
>
<Command className="bg-transparent">
<CommandInput placeholder={searchPlaceholder} className="h-9" />
<CommandList>
<CommandEmpty className="py-2 text-center text-sm">
{emptyText}
</CommandEmpty>
<CommandGroup className="p-1">
{options.map((option) => {
const isSelected = selected.includes(option.value);
return (
<CommandItem
key={option.value}
value={option.label}
onSelect={() => handleSelect(option.value)}
className="cursor-pointer dark:hover:bg-[#383838]"
>
<div
className={cn(
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border-2',
isSelected
? 'border-purple-30 bg-purple-30 text-white'
: 'border-gray-400 dark:border-gray-500',
)}
>
{isSelected && <Check className="h-3 w-3 stroke-white" />}
</div>
{option.label}
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,46 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
import * as PopoverPrimitive from '@radix-ui/react-popover';
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}
function PopoverContent({
className,
align = 'center',
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
);
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };

View File

@@ -0,0 +1,188 @@
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
import * as React from 'react';
import { cn } from '@/lib/utils';
import * as SelectPrimitive from '@radix-ui/react-select';
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = 'default',
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: 'sm' | 'default';
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-light-silver aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive focus-visible:ring-purple-30/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-white px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none hover:bg-gray-50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-placeholder:text-gray-500 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:border-[#3A3A3A] dark:bg-[#2C2C2C] dark:hover:bg-[#383838] dark:data-placeholder:text-gray-400 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-gray-600 dark:[&_svg:not([class*='text-'])]:text-gray-400",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = 'item-aligned',
align = 'center',
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
'border-light-silver bg-lotion data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-32 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border text-gray-900 shadow-md dark:border-[#3A3A3A] dark:bg-[#2C2C2C] dark:text-white',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className,
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width) scroll-my-1',
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"[&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none hover:bg-gray-100 data-disabled:pointer-events-none data-disabled:opacity-50 dark:hover:bg-[#383838] [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
'flex cursor-default items-center justify-center py-1',
className,
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
'flex cursor-default items-center justify-center py-1',
className,
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};

View File

@@ -0,0 +1,141 @@
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -150,7 +150,7 @@ const ConversationBubble = forwardRef<
{!isEditClicked && (
<>
<div className="relative mr-2 flex w-full flex-col">
<div className="from-medium-purple to-slate-blue mr-2 ml-2 flex max-w-full items-start gap-2 rounded-[28px] bg-linear-to-b px-5 py-4 text-sm leading-normal break-words whitespace-pre-wrap text-white sm:text-base">
<div className="from-medium-purple to-slate-blue mr-2 ml-2 flex max-w-full items-start gap-2 rounded-[28px] bg-linear-to-b px-5 py-4 text-sm leading-normal wrap-break-word whitespace-pre-wrap text-white sm:text-base">
<div
ref={messageRef}
className={`${isQuestionCollapsed ? 'line-clamp-4' : ''} w-full`}
@@ -305,15 +305,15 @@ const ConversationBubble = forwardRef<
{sources?.slice(0, 3)?.map((source, index) => (
<div key={index} className="relative">
<div
className="bg-gray-1000 dark:bg-gun-metal h-28 cursor-pointer rounded-[20px] p-4 hover:bg-[#F1F1F1] dark:hover:bg-[#2C2E3C]"
className="bg-gray-1000 dark:bg-gun-metal h-28 cursor-pointer rounded-4xl p-4 hover:bg-[#F1F1F1] dark:hover:bg-[#2C2E3C]"
onMouseOver={() => setActiveTooltip(index)}
onMouseOut={() => setActiveTooltip(null)}
>
<p className="ellipsis-text h-12 text-xs break-words">
<p className="ellipsis-text h-12 text-xs wrap-break-word">
{source.text}
</p>
<div
className={`mt-[14px] flex flex-row items-center gap-[6px] underline-offset-2 ${
className={`mt-3.5 flex flex-row items-center gap-1.5 underline-offset-2 ${
source.link && source.link !== 'local'
? 'hover:text-[#007DFF] hover:underline dark:hover:text-[#48A0FF]'
: ''
@@ -334,7 +334,7 @@ const ConversationBubble = forwardRef<
className="h-[17px] w-[17px] object-fill"
/>
<p
className="mt-[2px] truncate text-xs"
className="mt-0.5 truncate text-xs"
title={
source.link && source.link !== 'local'
? source.link
@@ -353,7 +353,7 @@ const ConversationBubble = forwardRef<
onMouseOver={() => setActiveTooltip(index)}
onMouseOut={() => setActiveTooltip(null)}
>
<p className="line-clamp-6 max-h-[164px] overflow-hidden rounded-md text-sm break-words text-ellipsis">
<p className="line-clamp-6 max-h-[164px] overflow-hidden rounded-md text-sm wrap-break-word text-ellipsis">
{source.text}
</p>
</div>
@@ -362,7 +362,7 @@ const ConversationBubble = forwardRef<
))}
{(sources?.length ?? 0) > 3 && (
<div
className="bg-gray-1000 text-purple-30 dark:bg-gun-metal flex h-28 cursor-pointer flex-col-reverse rounded-[20px] p-4 hover:bg-[#F1F1F1] hover:text-[#6D3ECC] dark:hover:bg-[#2C2E3C] dark:hover:text-[#8C67D7]"
className="bg-gray-1000 text-purple-30 dark:bg-gun-metal flex h-28 cursor-pointer flex-col-reverse rounded-4xl p-4 hover:bg-[#F1F1F1] hover:text-[#6D3ECC] dark:hover:bg-[#2C2E3C] dark:hover:text-[#8C67D7]"
onClick={() => setIsSidebarOpen(true)}
>
<p className="ellipsis-text h-22 text-xs">
@@ -414,7 +414,7 @@ const ConversationBubble = forwardRef<
<Fragment key={index}>
{segment.type === 'text' ? (
<ReactMarkdown
className="fade-in flex flex-col gap-3 leading-normal break-words whitespace-pre-wrap"
className="fade-in flex flex-col gap-3 leading-normal wrap-break-word whitespace-pre-wrap"
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeKatex]}
components={{
@@ -462,7 +462,7 @@ const ConversationBubble = forwardRef<
</SyntaxHighlighter>
</div>
) : (
<code className="dark:bg-independence dark:text-bright-gray rounded-[6px] bg-gray-200 px-[8px] py-[4px] text-xs font-normal whitespace-pre-line">
<code className="dark:bg-independence dark:text-bright-gray rounded-[6px] bg-gray-200 px-2 py-1 text-xs font-normal whitespace-pre-line">
{children}
</code>
);
@@ -651,7 +651,7 @@ function AllSources(sources: AllSourcesProps) {
return (
<div
key={index}
className={`group/card bg-gray-1000 relative w-full rounded-[20px] p-4 transition-colors hover:bg-[#F1F1F1] dark:bg-[#28292E] dark:hover:bg-[#2C2E3C] ${
className={`group/card bg-gray-1000 relative w-full rounded-4xl p-4 transition-colors hover:bg-[#F1F1F1] dark:bg-[#28292E] dark:hover:bg-[#2C2E3C] ${
isExternalSource ? 'cursor-pointer' : ''
}`}
onClick={() =>
@@ -660,7 +660,7 @@ function AllSources(sources: AllSourcesProps) {
>
<p
title={source.title}
className={`ellipsis-text text-left text-sm font-semibold break-words ${
className={`ellipsis-text text-left text-sm font-semibold wrap-break-word ${
isExternalSource
? 'group-hover/card:text-purple-30 dark:group-hover/card:text-[#8C67D7]'
: ''
@@ -679,7 +679,7 @@ function AllSources(sources: AllSourcesProps) {
/>
)}
</p>
<p className="dark:text-chinese-silver mt-3 line-clamp-4 rounded-md text-left text-xs break-words text-black">
<p className="dark:text-chinese-silver mt-3 line-clamp-4 rounded-md text-left text-xs wrap-break-word text-black">
{source.text}
</p>
</div>
@@ -725,12 +725,12 @@ function ToolCalls({ toolCalls }: { toolCalls: ToolCallsType[] }) {
<Accordion
key={`tool-call-${index}`}
title={`${toolCall.tool_name} - ${toolCall.action_name.substring(0, toolCall.action_name.lastIndexOf('_'))}`}
className="bg-gray-1000 dark:bg-gun-metal w-full rounded-[20px] hover:bg-[#F1F1F1] dark:hover:bg-[#2C2E3C]"
className="bg-gray-1000 dark:bg-gun-metal w-full rounded-4xl hover:bg-[#F1F1F1] dark:hover:bg-[#2C2E3C]"
titleClassName="px-6 py-2 text-sm font-semibold"
>
<div className="flex flex-col gap-1">
<div className="border-silver dark:border-silver/20 flex flex-col rounded-2xl border">
<p className="dark:bg-eerie-black-2 flex flex-row items-center justify-between rounded-t-2xl bg-black/10 px-2 py-1 text-sm font-semibold break-words">
<p className="dark:bg-eerie-black-2 flex flex-row items-center justify-between rounded-t-2xl bg-black/10 px-2 py-1 text-sm font-semibold wrap-break-word">
<span style={{ fontFamily: 'IBMPlexMono-Medium' }}>
Arguments
</span>{' '}
@@ -738,7 +738,7 @@ function ToolCalls({ toolCalls }: { toolCalls: ToolCallsType[] }) {
textToCopy={JSON.stringify(toolCall.arguments, null, 2)}
/>
</p>
<p className="dark:tex dark:bg-raisin-black rounded-b-2xl p-2 font-mono text-sm break-words">
<p className="dark:tex dark:bg-raisin-black rounded-b-2xl p-2 font-mono text-sm wrap-break-word">
<span
className="leading-[23px] text-black dark:text-gray-400"
style={{ fontFamily: 'IBMPlexMono-Medium' }}
@@ -748,7 +748,7 @@ function ToolCalls({ toolCalls }: { toolCalls: ToolCallsType[] }) {
</p>
</div>
<div className="border-silver dark:border-silver/20 flex flex-col rounded-2xl border">
<p className="dark:bg-eerie-black-2 flex flex-row items-center justify-between rounded-t-2xl bg-black/10 px-2 py-1 text-sm font-semibold break-words">
<p className="dark:bg-eerie-black-2 flex flex-row items-center justify-between rounded-t-2xl bg-black/10 px-2 py-1 text-sm font-semibold wrap-break-word">
<span style={{ fontFamily: 'IBMPlexMono-Medium' }}>
Response
</span>{' '}
@@ -766,7 +766,7 @@ function ToolCalls({ toolCalls }: { toolCalls: ToolCallsType[] }) {
</span>
)}
{toolCall.status === 'completed' && (
<p className="dark:bg-raisin-black rounded-b-2xl p-2 font-mono text-sm break-words">
<p className="dark:bg-raisin-black rounded-b-2xl p-2 font-mono text-sm wrap-break-word">
<span
className="leading-[23px] text-black dark:text-gray-400"
style={{ fontFamily: 'IBMPlexMono-Medium' }}
@@ -776,7 +776,7 @@ function ToolCalls({ toolCalls }: { toolCalls: ToolCallsType[] }) {
</p>
)}
{toolCall.status === 'error' && (
<p className="dark:bg-raisin-black rounded-b-2xl p-2 font-mono text-sm break-words">
<p className="dark:bg-raisin-black rounded-b-2xl p-2 font-mono text-sm wrap-break-word">
<span
className="leading-[23px] text-red-500 dark:text-red-400"
style={{ fontFamily: 'IBMPlexMono-Medium' }}
@@ -838,7 +838,7 @@ function Thought({
<div className="fade-in mr-5 ml-2 max-w-[90vw] md:max-w-[70vw] lg:max-w-[50vw]">
<div className="bg-gray-1000 dark:bg-gun-metal rounded-[28px] px-7 py-[18px]">
<ReactMarkdown
className="fade-in leading-normal break-words whitespace-pre-wrap"
className="fade-in leading-normal wrap-break-word whitespace-pre-wrap"
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeKatex]}
components={{
@@ -873,7 +873,7 @@ function Thought({
</SyntaxHighlighter>
</div>
) : (
<code className="dark:bg-independence dark:text-bright-gray rounded-[6px] bg-gray-200 px-[8px] py-[4px] text-xs font-normal whitespace-pre-line">
<code className="dark:bg-independence dark:text-bright-gray rounded-[6px] bg-gray-200 px-2 py-1 text-xs font-normal whitespace-pre-line">
{children}
</code>
);

View File

@@ -3,6 +3,8 @@ layer(base);
@import 'tailwindcss';
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme {
@@ -713,3 +715,138 @@ Avoid over-scrolling in mobile browsers
}
}
}
/*
---break---
*/
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
/*
---break---
*/
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
/*
---break---
*/
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
/*
---break---
*/
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -38,10 +38,7 @@ export default function AddActionModal({
if (modalState !== 'ACTIVE') return null;
return (
<WrapperModal
close={() => setModalState('INACTIVE')}
className="sm:w-[512px]"
>
<WrapperModal close={() => setModalState('INACTIVE')} className="sm:w-lg">
<div>
<h2 className="text-jet dark:text-bright-gray px-3 text-xl font-semibold">
{t('modals.addAction.title')}

View File

@@ -1,13 +1,13 @@
import { useEffect, useState, useMemo, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { AgentFolder } from '../agents/types';
import userService from '../api/services/userService';
import FolderIcon from '../assets/folder.svg';
import ChevronRight from '../assets/chevron-right.svg';
import FolderIcon from '../assets/folder.svg';
import { ActiveState } from '../models/misc';
import { selectToken, setAgentFolders } from '../preferences/preferenceSlice';
import { AgentFolder } from '../agents/types';
import WrapperModal from './WrapperModal';
type MoveToFolderModalProps = {
@@ -135,7 +135,7 @@ export default function MoveToFolderModal({
if (modalState !== 'ACTIVE') return null;
return (
<WrapperModal close={() => setModalState('INACTIVE')} className="!p-0">
<WrapperModal close={() => setModalState('INACTIVE')} className="p-0!">
<div className="w-[800px] max-w-[90vw]">
<div className="px-6 pt-4">
<h2
@@ -147,7 +147,7 @@ export default function MoveToFolderModal({
letterSpacing: '0.15px',
}}
>
{t('agents.folders.move')} "{agentName}" to
{t('agents.folders.move')} &quot;{agentName}&quot; to
</h2>
</div>
<div

View File

@@ -1,6 +1,7 @@
import { configureStore } from '@reduxjs/toolkit';
import agentPreviewReducer from './agents/agentPreviewSlice';
import workflowPreviewReducer from './agents/workflow/workflowPreviewSlice';
import { conversationSlice } from './conversation/conversationSlice';
import { sharedConversationSlice } from './conversation/sharedConversationSlice';
import { getStoredRecentDocs } from './preferences/preferenceApi';
@@ -65,6 +66,7 @@ const store = configureStore({
sharedConversation: sharedConversationSlice.reducer,
upload: uploadReducer,
agentPreview: agentPreviewReducer,
workflowPreview: workflowPreviewReducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(prefListenerMiddleware.middleware),

View File

@@ -15,7 +15,11 @@
"types": ["vite-plugin-svgr/client"],
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]

View File

@@ -1,8 +1,14 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import svgr from 'vite-plugin-svgr';
import path from 'path';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react(), svgr()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});