mirror of
https://github.com/arc53/DocsGPT.git
synced 2026-02-22 12:21:39 +00:00
feat: agent workflow builder (#2264)
* feat: implement WorkflowAgent and GraphExecutor for workflow management and execution * refactor: workflow schemas and introduce WorkflowEngine - Updated schemas in `schemas.py` to include new agent types and configurations. - Created `WorkflowEngine` class in `workflow_engine.py` to manage workflow execution. - Enhanced `StreamProcessor` to handle workflow-related data. - Added new routes and utilities for managing workflows in the user API. - Implemented validation and serialization functions for workflows. - Established MongoDB collections and indexes for workflows and related entities. * refactor: improve WorkflowAgent documentation and update type hints in WorkflowEngine * feat: workflow builder and managing in frontend - Added new endpoints for workflows in `endpoints.ts`. - Implemented `getWorkflow`, `createWorkflow`, and `updateWorkflow` methods in `userService.ts`. - Introduced new UI components for alerts, buttons, commands, dialogs, multi-select, popovers, and selects. - Enhanced styling in `index.css` with new theme variables and animations. - Refactored modal components for better layout and styling. - Configured TypeScript paths and Vite aliases for cleaner imports. * feat: add workflow preview component and related state management - Implemented WorkflowPreview component for displaying workflow execution. - Created WorkflowPreviewSlice for managing workflow preview state, including queries and execution steps. - Added WorkflowMiniMap for visual representation of workflow nodes and their statuses. - Integrated conversation handling with the ability to fetch answers and manage query states. - Introduced reusable Sheet component for UI overlays. - Updated Redux store to include workflowPreview reducer. * feat: enhance workflow execution details and state management in WorkflowEngine and WorkflowPreview * feat: enhance workflow components with improved UI and functionality - Updated WorkflowPreview to allow text truncation for better display of long names. - Enhanced BaseNode with connectable handles and improved styling for better visibility. - Added MobileBlocker component to inform users about desktop requirements for the Workflow Builder. - Introduced PromptTextArea component for improved variable insertion and search functionality, including upstream variable extraction and context addition. * feat(workflow): add owner validation and graph version support * fix: ruff lint --------- Co-authored-by: Alex <a@tushynski.me>
This commit is contained in:
22
frontend/components.json
Normal file
22
frontend/components.json
Normal 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": {}
|
||||
}
|
||||
1080
frontend/package-lock.json
generated
1080
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
@@ -83,7 +83,11 @@ export default function AgentCard({
|
||||
label: 'Edit',
|
||||
onClick: (e: SyntheticEvent) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/agents/edit/${agent.id}`);
|
||||
if (agent.agent_type === 'workflow') {
|
||||
navigate(`/agents/workflow/edit/${agent.id}`);
|
||||
} else {
|
||||
navigate(`/agents/edit/${agent.id}`);
|
||||
}
|
||||
},
|
||||
variant: 'primary',
|
||||
iconWidth: 14,
|
||||
|
||||
@@ -5,6 +5,8 @@ import { useDispatch, useSelector } from 'react-redux';
|
||||
import MessageInput from '../components/MessageInput';
|
||||
import ConversationMessages from '../conversation/ConversationMessages';
|
||||
import { Query } from '../conversation/conversationModels';
|
||||
import { selectSelectedAgent } from '../preferences/preferenceSlice';
|
||||
import { AppDispatch } from '../store';
|
||||
import {
|
||||
addQuery,
|
||||
fetchPreviewAnswer,
|
||||
@@ -14,8 +16,6 @@ import {
|
||||
selectPreviewQueries,
|
||||
selectPreviewStatus,
|
||||
} from './agentPreviewSlice';
|
||||
import { selectSelectedAgent } from '../preferences/preferenceSlice';
|
||||
import { AppDispatch } from '../store';
|
||||
|
||||
export default function AgentPreview() {
|
||||
const { t } = useTranslation();
|
||||
@@ -112,7 +112,7 @@ export default function AgentPreview() {
|
||||
}, [queries]);
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<div className="scrollbar-thin absolute inset-0 bottom-[180px] overflow-hidden px-4 pt-4 [&>div>div]:!w-full [&>div>div]:!max-w-none">
|
||||
<div className="scrollbar-thin absolute inset-0 bottom-[180px] overflow-hidden px-4 pt-4 [&>div>div]:w-full! [&>div>div]:max-w-none!">
|
||||
<ConversationMessages
|
||||
handleQuestion={handleQuestion}
|
||||
handleQuestionSubmission={handleQuestionSubmission}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from '../preferences/preferenceSlice';
|
||||
import AgentCard from './AgentCard';
|
||||
import { AgentSectionId, agentSectionsConfig } from './agents.config';
|
||||
import AgentTypeModal from './components/AgentTypeModal';
|
||||
import FolderCard from './FolderCard';
|
||||
import { AgentFilterTab, useAgentSearch } from './hooks/useAgentSearch';
|
||||
import { useAgentsFetch } from './hooks/useAgentsFetch';
|
||||
@@ -43,14 +44,19 @@ export default function AgentsList() {
|
||||
const folderIdFromUrl = searchParams.get('folder');
|
||||
return folderIdFromUrl ? [folderIdFromUrl] : [];
|
||||
});
|
||||
const [showAgentTypeModal, setShowAgentTypeModal] = useState(false);
|
||||
const [modalFolderId, setModalFolderId] = useState<string | null>(null);
|
||||
|
||||
// Sync folder path with URL
|
||||
useEffect(() => {
|
||||
const currentFolderInUrl = searchParams.get('folder');
|
||||
const currentFolderId = folderPath.length > 0 ? folderPath[folderPath.length - 1] : null;
|
||||
const currentFolderId =
|
||||
folderPath.length > 0 ? folderPath[folderPath.length - 1] : null;
|
||||
|
||||
if (currentFolderId !== currentFolderInUrl) {
|
||||
const newUrl = currentFolderId ? `/agents?folder=${currentFolderId}` : '/agents';
|
||||
const newUrl = currentFolderId
|
||||
? `/agents?folder=${currentFolderId}`
|
||||
: '/agents';
|
||||
navigate(newUrl, { replace: true });
|
||||
}
|
||||
}, [folderPath, searchParams, navigate]);
|
||||
@@ -212,6 +218,8 @@ export default function AgentsList() {
|
||||
onCreateFolder={handleSubmitNewFolder}
|
||||
onDeleteFolder={handleDeleteFolder}
|
||||
onRenameFolder={handleRenameFolder}
|
||||
setModalFolderId={setModalFolderId}
|
||||
setShowAgentTypeModal={setShowAgentTypeModal}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -221,6 +229,12 @@ export default function AgentsList() {
|
||||
<p className="text-sm">{t('agents.tryDifferentSearch')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AgentTypeModal
|
||||
isOpen={showAgentTypeModal}
|
||||
onClose={() => setShowAgentTypeModal(false)}
|
||||
folderId={modalFolderId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -238,6 +252,8 @@ interface AgentSectionProps {
|
||||
onCreateFolder: (name: string, parentId?: string) => void;
|
||||
onDeleteFolder: (id: string) => Promise<boolean>;
|
||||
onRenameFolder: (id: string, name: string) => void;
|
||||
setModalFolderId: (folderId: string | null) => void;
|
||||
setShowAgentTypeModal: (show: boolean) => void;
|
||||
}
|
||||
|
||||
function AgentSection({
|
||||
@@ -253,6 +269,8 @@ function AgentSection({
|
||||
onCreateFolder,
|
||||
onDeleteFolder,
|
||||
onRenameFolder,
|
||||
setModalFolderId,
|
||||
setShowAgentTypeModal,
|
||||
}: AgentSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -280,13 +298,17 @@ function AgentSection({
|
||||
const updateAgents = (updatedAgents: Agent[]) => {
|
||||
dispatch(config.updateAction(updatedAgents));
|
||||
};
|
||||
|
||||
|
||||
const currentFolderDescendantIds = useMemo(() => {
|
||||
if (config.id !== 'user' || !folders || currentFolderId === null) return null;
|
||||
if (config.id !== 'user' || !folders || currentFolderId === null)
|
||||
return null;
|
||||
|
||||
const getDescendants = (folderId: string): string[] => {
|
||||
const children = folders.filter((f) => f.parent_id === folderId);
|
||||
return children.flatMap((child) => [child.id, ...getDescendants(child.id)]);
|
||||
return children.flatMap((child) => [
|
||||
child.id,
|
||||
...getDescendants(child.id),
|
||||
]);
|
||||
};
|
||||
|
||||
return new Set([currentFolderId, ...getDescendants(currentFolderId)]);
|
||||
@@ -294,7 +316,9 @@ function AgentSection({
|
||||
|
||||
const folderHasMatchingAgents = useCallback(
|
||||
(folderId: string): boolean => {
|
||||
const directMatches = filteredAgents.some((a) => a.folder_id === folderId);
|
||||
const directMatches = filteredAgents.some(
|
||||
(a) => a.folder_id === folderId,
|
||||
);
|
||||
if (directMatches) return true;
|
||||
const childFolders = (folders || []).filter(
|
||||
(f) => f.parent_id === folderId,
|
||||
@@ -314,7 +338,13 @@ function AgentSection({
|
||||
return foldersAtLevel.filter((f) => folderHasMatchingAgents(f.id));
|
||||
}
|
||||
return foldersAtLevel;
|
||||
}, [folders, currentFolderId, config.id, searchQuery, folderHasMatchingAgents]);
|
||||
}, [
|
||||
folders,
|
||||
currentFolderId,
|
||||
config.id,
|
||||
searchQuery,
|
||||
folderHasMatchingAgents,
|
||||
]);
|
||||
|
||||
const unfolderedAgents = useMemo(() => {
|
||||
if (config.id !== 'user' || !folders) return filteredAgents;
|
||||
@@ -334,7 +364,14 @@ function AgentSection({
|
||||
return filteredAgents.filter(
|
||||
(a) => (a.folder_id || null) === currentFolderId,
|
||||
);
|
||||
}, [filteredAgents, folders, config.id, currentFolderId, searchQuery, currentFolderDescendantIds]);
|
||||
}, [
|
||||
filteredAgents,
|
||||
folders,
|
||||
config.id,
|
||||
currentFolderId,
|
||||
searchQuery,
|
||||
currentFolderDescendantIds,
|
||||
]);
|
||||
|
||||
const getAgentsForFolder = (folderId: string) => {
|
||||
return filteredAgents.filter((a) => a.folder_id === folderId);
|
||||
@@ -376,7 +413,10 @@ function AgentSection({
|
||||
{config.showNewAgentButton && (
|
||||
<button
|
||||
className="bg-purple-30 hover:bg-violets-are-blue rounded-full px-4 py-2 text-sm text-white"
|
||||
onClick={() => navigate('/agents/new')}
|
||||
onClick={() => {
|
||||
setModalFolderId(null);
|
||||
setShowAgentTypeModal(true);
|
||||
}}
|
||||
>
|
||||
{t('agents.newAgent')}
|
||||
</button>
|
||||
@@ -478,7 +518,7 @@ function AgentSection({
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
className="shrink-0 whitespace-nowrap rounded-full border border-[#E5E5E5] bg-white px-4 py-2 text-sm text-[#18181B] hover:bg-[#F5F5F5] dark:border-[#3A3A3A] dark:bg-[#2C2C2C] dark:text-white dark:hover:bg-[#383838]"
|
||||
className="shrink-0 rounded-full border border-[#E5E5E5] bg-white px-4 py-2 text-sm whitespace-nowrap text-[#18181B] hover:bg-[#F5F5F5] dark:border-[#3A3A3A] dark:bg-[#2C2C2C] dark:text-white dark:hover:bg-[#383838]"
|
||||
onClick={() => {
|
||||
setIsCreatingFolder(true);
|
||||
setTimeout(() => newFolderInputRef.current?.focus(), 0);
|
||||
@@ -489,14 +529,11 @@ function AgentSection({
|
||||
))}
|
||||
{config.showNewAgentButton && (
|
||||
<button
|
||||
className="bg-purple-30 hover:bg-violets-are-blue shrink-0 whitespace-nowrap rounded-full px-4 py-2 text-sm text-white"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
currentFolderId
|
||||
? `/agents/new?folder_id=${currentFolderId}`
|
||||
: '/agents/new',
|
||||
)
|
||||
}
|
||||
className="bg-purple-30 hover:bg-violets-are-blue shrink-0 rounded-full px-4 py-2 text-sm whitespace-nowrap text-white"
|
||||
onClick={() => {
|
||||
setModalFolderId(currentFolderId);
|
||||
setShowAgentTypeModal(true);
|
||||
}}
|
||||
>
|
||||
{t('agents.newAgent')}
|
||||
</button>
|
||||
@@ -551,13 +588,10 @@ function AgentSection({
|
||||
{config.showNewAgentButton && !currentFolderId && (
|
||||
<button
|
||||
className="bg-purple-30 hover:bg-violets-are-blue ml-2 rounded-full px-4 py-2 text-sm text-white"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
currentFolderId
|
||||
? `/agents/new?folder_id=${currentFolderId}`
|
||||
: '/agents/new',
|
||||
)
|
||||
}
|
||||
onClick={() => {
|
||||
setModalFolderId(currentFolderId);
|
||||
setShowAgentTypeModal(true);
|
||||
}}
|
||||
>
|
||||
{t('agents.newAgent')}
|
||||
</button>
|
||||
|
||||
@@ -125,4 +125,3 @@ export default function FolderCard({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import Prompts from '../settings/Prompts';
|
||||
import { UserToolType } from '../settings/types';
|
||||
import AgentPreview from './AgentPreview';
|
||||
import { Agent, ToolSummary } from './types';
|
||||
import WorkflowBuilder from './workflow/WorkflowBuilder';
|
||||
|
||||
import type { Model } from '../models/types';
|
||||
|
||||
@@ -48,7 +49,9 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
const prompts = useSelector(selectPrompts);
|
||||
const agentFolders = useSelector(selectAgentFolders);
|
||||
|
||||
const [validatedFolderId, setValidatedFolderId] = useState<string | null>(null);
|
||||
const [validatedFolderId, setValidatedFolderId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const [effectiveMode, setEffectiveMode] = useState(mode);
|
||||
const [agent, setAgent] = useState<Agent>({
|
||||
@@ -252,6 +255,9 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
if (agent.default_model_id) {
|
||||
formData.append('default_model_id', agent.default_model_id);
|
||||
}
|
||||
if (agent.agent_type === 'workflow' && agent.workflow) {
|
||||
formData.append('workflow', JSON.stringify(agent.workflow));
|
||||
}
|
||||
|
||||
if (effectiveMode === 'new' && validatedFolderId) {
|
||||
formData.append('folder_id', validatedFolderId);
|
||||
@@ -359,6 +365,9 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
if (agent.default_model_id) {
|
||||
formData.append('default_model_id', agent.default_model_id);
|
||||
}
|
||||
if (agent.agent_type === 'workflow' && agent.workflow) {
|
||||
formData.append('workflow', JSON.stringify(agent.workflow));
|
||||
}
|
||||
|
||||
if (effectiveMode === 'new' && validatedFolderId) {
|
||||
formData.append('folder_id', validatedFolderId);
|
||||
@@ -664,6 +673,11 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
<h1 className="text-eerie-black m-0 text-[32px] font-bold lg:text-[40px] dark:text-white">
|
||||
{modeConfig[effectiveMode].heading}
|
||||
</h1>
|
||||
{agent.agent_type === 'workflow' && (
|
||||
<div className="mt-4 w-full">
|
||||
<WorkflowBuilder />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<button
|
||||
className="text-purple-30 dark:text-light-gray mr-4 rounded-3xl py-2 text-sm font-medium dark:bg-transparent"
|
||||
|
||||
1153
frontend/src/agents/WorkflowBuilder.tsx
Normal file
1153
frontend/src/agents/WorkflowBuilder.tsx
Normal file
File diff suppressed because it is too large
Load Diff
93
frontend/src/agents/components/AgentTypeModal.tsx
Normal file
93
frontend/src/agents/components/AgentTypeModal.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { Bot, Workflow, X } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface AgentTypeModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
folderId?: string | null;
|
||||
}
|
||||
|
||||
export default function AgentTypeModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
folderId,
|
||||
}: AgentTypeModalProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSelect = (type: 'normal' | 'workflow') => {
|
||||
if (type === 'workflow') {
|
||||
navigate(
|
||||
`/agents/workflow/new${folderId ? `?folder_id=${folderId}` : ''}`,
|
||||
);
|
||||
} else {
|
||||
navigate(`/agents/new${folderId ? `?folder_id=${folderId}` : ''}`);
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="relative w-full max-w-lg rounded-xl bg-white p-8 shadow-2xl dark:bg-[#1e1e1e]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-5 right-5 text-gray-400 transition-colors hover:text-gray-600 dark:hover:text-gray-200"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
|
||||
<h2 className="text-jet dark:text-bright-gray mb-3 text-2xl font-bold">
|
||||
Create New Agent
|
||||
</h2>
|
||||
<p className="mb-8 text-sm text-gray-500 dark:text-gray-400">
|
||||
Choose the type of agent you want to create
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<button
|
||||
onClick={() => handleSelect('normal')}
|
||||
className="hover:border-purple-30 hover:bg-purple-30/5 dark:hover:border-purple-30 dark:hover:bg-purple-30/10 group flex items-start gap-5 rounded-xl border-2 border-gray-200 p-5 text-left transition-all dark:border-[#2E2F34]"
|
||||
>
|
||||
<div className="dark:bg-purple-30/20 bg-purple-30/10 text-purple-30 group-hover:bg-purple-30 flex h-14 w-14 shrink-0 items-center justify-center rounded-xl transition-colors group-hover:text-white dark:text-purple-300">
|
||||
<Bot size={28} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-jet dark:text-bright-gray mb-2 text-lg font-semibold">
|
||||
Classic Agent
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed text-gray-600 dark:text-gray-400">
|
||||
Create a standard AI agent with a single model, tools, and
|
||||
knowledge sources
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleSelect('workflow')}
|
||||
className="hover:border-violets-are-blue hover:bg-violets-are-blue/5 dark:hover:border-violets-are-blue dark:hover:bg-violets-are-blue/10 group flex items-start gap-5 rounded-xl border-2 border-gray-200 p-5 text-left transition-all dark:border-[#2E2F34]"
|
||||
>
|
||||
<div className="dark:bg-violets-are-blue/20 bg-violets-are-blue/10 text-violets-are-blue group-hover:bg-violets-are-blue flex h-14 w-14 shrink-0 items-center justify-center rounded-xl transition-colors group-hover:text-white dark:text-purple-300">
|
||||
<Workflow size={28} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-jet dark:text-bright-gray mb-2 text-lg font-semibold">
|
||||
Workflow Agent
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed text-gray-600 dark:text-gray-400">
|
||||
Design complex multi-step workflows with different models,
|
||||
conditional logic, and state management
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import AgentLogs from './AgentLogs';
|
||||
import AgentsList from './AgentsList';
|
||||
import NewAgent from './NewAgent';
|
||||
import SharedAgent from './SharedAgent';
|
||||
import WorkflowBuilder from './workflow/WorkflowBuilder';
|
||||
|
||||
export default function Agents() {
|
||||
return (
|
||||
@@ -13,6 +14,8 @@ export default function Agents() {
|
||||
<Route path="/edit/:agentId" element={<NewAgent mode="edit" />} />
|
||||
<Route path="/logs/:agentId" element={<AgentLogs />} />
|
||||
<Route path="/shared/:agentId" element={<SharedAgent />} />
|
||||
<Route path="/workflow/new" element={<WorkflowBuilder />} />
|
||||
<Route path="/workflow/edit/:agentId" element={<WorkflowBuilder />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ export type Agent = {
|
||||
models?: string[];
|
||||
default_model_id?: string;
|
||||
folder_id?: string;
|
||||
workflow?: string;
|
||||
};
|
||||
|
||||
export type AgentFolder = {
|
||||
@@ -44,3 +45,5 @@ export type AgentFolder = {
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
export * from './workflow';
|
||||
|
||||
49
frontend/src/agents/types/workflow.ts
Normal file
49
frontend/src/agents/types/workflow.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
export type NodeType = 'start' | 'end' | 'agent' | 'note' | 'state';
|
||||
|
||||
export interface WorkflowEdge {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
sourceHandle?: string;
|
||||
targetHandle?: string;
|
||||
}
|
||||
|
||||
export interface WorkflowNode {
|
||||
id: string;
|
||||
type: NodeType;
|
||||
title: string;
|
||||
description?: string;
|
||||
position: { x: number; y: number };
|
||||
data: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface WorkflowDefinition {
|
||||
id?: string;
|
||||
name: string;
|
||||
nodes: WorkflowNode[];
|
||||
edges: WorkflowEdge[];
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export type ExecutionStatus = 'pending' | 'running' | 'completed' | 'failed';
|
||||
|
||||
export interface NodeExecutionLog {
|
||||
node_id: string;
|
||||
status: ExecutionStatus;
|
||||
input_state: Record<string, any>;
|
||||
output_state: Record<string, any>;
|
||||
error?: string;
|
||||
started_at: number;
|
||||
completed_at?: number;
|
||||
logs: string[];
|
||||
}
|
||||
|
||||
export interface WorkflowRun {
|
||||
workflow_id: string;
|
||||
status: ExecutionStatus;
|
||||
state: Record<string, any>;
|
||||
node_logs: NodeExecutionLog[];
|
||||
created_at: number;
|
||||
completed_at?: number;
|
||||
}
|
||||
1216
frontend/src/agents/workflow/WorkflowBuilder.tsx
Normal file
1216
frontend/src/agents/workflow/WorkflowBuilder.tsx
Normal file
File diff suppressed because it is too large
Load Diff
611
frontend/src/agents/workflow/WorkflowPreview.tsx
Normal file
611
frontend/src/agents/workflow/WorkflowPreview.tsx
Normal file
@@ -0,0 +1,611 @@
|
||||
import {
|
||||
Bot,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Database,
|
||||
Flag,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Play,
|
||||
StickyNote,
|
||||
Workflow,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import ChevronDownIcon from '../../assets/chevron-down.svg';
|
||||
import MessageInput from '../../components/MessageInput';
|
||||
import ConversationBubble from '../../conversation/ConversationBubble';
|
||||
import { Query } from '../../conversation/conversationModels';
|
||||
import { AppDispatch } from '../../store';
|
||||
import { WorkflowEdge, WorkflowNode } from '../types/workflow';
|
||||
import {
|
||||
addQuery,
|
||||
fetchWorkflowPreviewAnswer,
|
||||
handleWorkflowPreviewAbort,
|
||||
resendQuery,
|
||||
resetWorkflowPreview,
|
||||
selectActiveNodeId,
|
||||
selectWorkflowExecutionSteps,
|
||||
selectWorkflowPreviewQueries,
|
||||
selectWorkflowPreviewStatus,
|
||||
WorkflowExecutionStep,
|
||||
WorkflowQuery,
|
||||
} from './workflowPreviewSlice';
|
||||
|
||||
interface WorkflowData {
|
||||
name: string;
|
||||
description?: string;
|
||||
nodes: WorkflowNode[];
|
||||
edges: WorkflowEdge[];
|
||||
}
|
||||
|
||||
interface WorkflowPreviewProps {
|
||||
workflowData: WorkflowData;
|
||||
}
|
||||
|
||||
const NODE_ICONS: Record<string, React.ReactNode> = {
|
||||
start: <Play className="h-3 w-3" />,
|
||||
agent: <Bot className="h-3 w-3" />,
|
||||
end: <Flag className="h-3 w-3" />,
|
||||
note: <StickyNote className="h-3 w-3" />,
|
||||
state: <Database className="h-3 w-3" />,
|
||||
};
|
||||
|
||||
const NODE_COLORS: Record<string, string> = {
|
||||
start: 'text-green-600 dark:text-green-400',
|
||||
agent: 'text-purple-600 dark:text-purple-400',
|
||||
end: 'text-gray-600 dark:text-gray-400',
|
||||
note: 'text-yellow-600 dark:text-yellow-400',
|
||||
state: 'text-blue-600 dark:text-blue-400',
|
||||
};
|
||||
|
||||
function ExecutionDetails({
|
||||
steps,
|
||||
nodes,
|
||||
isOpen,
|
||||
onToggle,
|
||||
stepRefs,
|
||||
}: {
|
||||
steps: WorkflowExecutionStep[];
|
||||
nodes: WorkflowNode[];
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
stepRefs?: React.RefObject<Map<string, HTMLDivElement>>;
|
||||
}) {
|
||||
const completedSteps = steps.filter(
|
||||
(s) => s.status === 'completed' || s.status === 'failed',
|
||||
);
|
||||
|
||||
if (completedSteps.length === 0) return null;
|
||||
|
||||
const formatValue = (value: unknown): string => {
|
||||
if (typeof value === 'string') return value;
|
||||
return JSON.stringify(value, null, 2);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-4 flex w-full flex-col flex-wrap items-start self-start lg:flex-nowrap">
|
||||
<div className="my-2 flex flex-row items-center justify-center gap-3">
|
||||
<div className="flex h-[26px] w-[30px] items-center justify-center">
|
||||
<Workflow className="h-5 w-5 text-gray-600 dark:text-gray-400" />
|
||||
</div>
|
||||
<button className="flex flex-row items-center gap-2" onClick={onToggle}>
|
||||
<p className="text-base font-semibold">
|
||||
Execution Details
|
||||
<span className="ml-1.5 text-sm font-normal text-gray-500 dark:text-gray-400">
|
||||
({completedSteps.length}{' '}
|
||||
{completedSteps.length === 1 ? 'step' : 'steps'})
|
||||
</span>
|
||||
</p>
|
||||
<img
|
||||
src={ChevronDownIcon}
|
||||
alt="ChevronDown"
|
||||
className={cn(
|
||||
'h-4 w-4 transform transition-transform duration-200 dark:invert',
|
||||
isOpen ? 'rotate-180' : '',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'ml-3 grid w-full transition-all duration-300 ease-in-out',
|
||||
isOpen ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0',
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="space-y-2 pr-2">
|
||||
{completedSteps.map((step, stepIndex) => {
|
||||
const node = nodes.find((n) => n.id === step.nodeId);
|
||||
const displayName =
|
||||
node?.title || node?.data?.title || step.nodeTitle;
|
||||
const stateVars = step.stateSnapshot
|
||||
? Object.entries(step.stateSnapshot).filter(
|
||||
([key]) => !['query', 'chat_history'].includes(key),
|
||||
)
|
||||
: [];
|
||||
|
||||
const truncateText = (text: string, maxLength: number) => {
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.slice(0, maxLength) + '...';
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={step.nodeId}
|
||||
ref={(el) => {
|
||||
if (el && stepRefs) stepRefs.current.set(step.nodeId, el);
|
||||
}}
|
||||
className="rounded-xl bg-[#F5F5F5] p-3 dark:bg-[#383838]"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{stepIndex + 1}.
|
||||
</span>
|
||||
<div
|
||||
className={cn(
|
||||
'shrink-0',
|
||||
NODE_COLORS[step.nodeType] || NODE_COLORS.state,
|
||||
)}
|
||||
>
|
||||
{NODE_ICONS[step.nodeType] || (
|
||||
<Circle className="h-3 w-3" />
|
||||
)}
|
||||
</div>
|
||||
<span className="min-w-0 truncate font-medium text-gray-900 dark:text-white">
|
||||
{displayName}
|
||||
</span>
|
||||
<div className="ml-auto shrink-0">
|
||||
{step.status === 'completed' && (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600 dark:text-green-400" />
|
||||
)}
|
||||
{step.status === 'failed' && (
|
||||
<XCircle className="h-4 w-4 text-red-600 dark:text-red-400" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{(step.output || step.error || stateVars.length > 0) && (
|
||||
<div className="mt-3 space-y-2 text-sm">
|
||||
{step.output && (
|
||||
<div className="rounded-lg bg-white p-2 dark:bg-[#2A2A2A]">
|
||||
<span className="font-medium text-gray-600 dark:text-gray-400">
|
||||
Output:{' '}
|
||||
</span>
|
||||
<span className="wrap-break-word whitespace-pre-wrap text-gray-900 dark:text-gray-100">
|
||||
{truncateText(step.output, 300)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{step.error && (
|
||||
<div className="rounded-lg bg-red-50 p-2 dark:bg-red-900/30">
|
||||
<span className="font-medium text-red-700 dark:text-red-300">
|
||||
Error:{' '}
|
||||
</span>
|
||||
<span className="wrap-break-word whitespace-pre-wrap text-red-800 dark:text-red-200">
|
||||
{step.error}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{stateVars.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{stateVars.map(([key, value]) => (
|
||||
<span
|
||||
key={key}
|
||||
className="inline-flex items-center rounded-lg bg-white px-2 py-1 text-xs dark:bg-[#2A2A2A]"
|
||||
>
|
||||
<span className="max-w-[100px] truncate font-medium text-gray-600 dark:text-gray-400">
|
||||
{key}:
|
||||
</span>
|
||||
<span
|
||||
className="ml-1 max-w-[200px] truncate text-gray-900 dark:text-gray-100"
|
||||
title={formatValue(value)}
|
||||
>
|
||||
{truncateText(formatValue(value), 50)}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkflowMiniMap({
|
||||
nodes,
|
||||
activeNodeId,
|
||||
executionSteps,
|
||||
onNodeClick,
|
||||
}: {
|
||||
nodes: WorkflowNode[];
|
||||
activeNodeId: string | null;
|
||||
executionSteps: WorkflowExecutionStep[];
|
||||
onNodeClick?: (nodeId: string) => void;
|
||||
}) {
|
||||
const getNodeDisplayName = (node: WorkflowNode) => {
|
||||
if (node.type === 'start') return 'Start';
|
||||
if (node.type === 'end') return 'End';
|
||||
return node.title || node.data?.title || node.type;
|
||||
};
|
||||
|
||||
const getNodeSubtitle = (node: WorkflowNode) => {
|
||||
if (node.type === 'agent' && node.data?.model_id) {
|
||||
return node.data.model_id;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getNodeStatus = (nodeId: string) => {
|
||||
const step = executionSteps.find((s) => s.nodeId === nodeId);
|
||||
return step?.status || 'pending';
|
||||
};
|
||||
|
||||
const getStatusColor = (nodeId: string, nodeType: string) => {
|
||||
const status = getNodeStatus(nodeId);
|
||||
const isActive = nodeId === activeNodeId;
|
||||
|
||||
if (isActive) {
|
||||
return 'ring-2 ring-purple-500 bg-purple-100 dark:bg-purple-900/50';
|
||||
}
|
||||
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'bg-green-100 dark:bg-green-900/30 border-green-300 dark:border-green-700';
|
||||
case 'running':
|
||||
return 'bg-purple-100 dark:bg-purple-900/30 border-purple-300 dark:border-purple-700 animate-pulse';
|
||||
case 'failed':
|
||||
return 'bg-red-100 dark:bg-red-900/30 border-red-300 dark:border-red-700';
|
||||
default:
|
||||
if (nodeType === 'start') {
|
||||
return 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800';
|
||||
}
|
||||
if (nodeType === 'agent') {
|
||||
return 'bg-purple-50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-800';
|
||||
}
|
||||
if (nodeType === 'end') {
|
||||
return 'bg-gray-50 dark:bg-gray-800 border-gray-200 dark:border-gray-700';
|
||||
}
|
||||
return 'bg-gray-50 dark:bg-gray-800 border-gray-200 dark:border-gray-700';
|
||||
}
|
||||
};
|
||||
|
||||
const sortedNodes = [...nodes].sort((a, b) => {
|
||||
if (a.type === 'start') return -1;
|
||||
if (b.type === 'start') return 1;
|
||||
if (a.type === 'end') return 1;
|
||||
if (b.type === 'end') return -1;
|
||||
return (a.position?.y || 0) - (b.position?.y || 0);
|
||||
});
|
||||
|
||||
const hasStepData = (nodeId: string) => {
|
||||
const step = executionSteps.find((s) => s.nodeId === nodeId);
|
||||
return step && (step.status === 'completed' || step.status === 'failed');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{sortedNodes.map((node, index) => (
|
||||
<div key={node.id} className="relative">
|
||||
{index < sortedNodes.length - 1 && (
|
||||
<div className="absolute top-12 left-4 h-3 w-0.5 bg-gray-200 dark:bg-gray-700" />
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => hasStepData(node.id) && onNodeClick?.(node.id)}
|
||||
disabled={!hasStepData(node.id)}
|
||||
className={cn(
|
||||
'flex h-12 w-full items-center gap-2 rounded-lg border px-3 text-xs transition-all',
|
||||
getStatusColor(node.id, node.type),
|
||||
hasStepData(node.id) && 'cursor-pointer hover:opacity-80',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-5 w-5 shrink-0 items-center justify-center rounded-full',
|
||||
NODE_COLORS[node.type] || NODE_COLORS.state,
|
||||
)}
|
||||
>
|
||||
{NODE_ICONS[node.type] || <Circle className="h-3 w-3" />}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<div className="truncate font-medium text-gray-700 dark:text-gray-200">
|
||||
{getNodeDisplayName(node)}
|
||||
</div>
|
||||
{getNodeSubtitle(node) && (
|
||||
<div className="truncate text-[10px] text-gray-500 dark:text-gray-400">
|
||||
{getNodeSubtitle(node)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
{getNodeStatus(node.id) === 'running' && (
|
||||
<Loader2 className="h-3 w-3 animate-spin text-purple-500" />
|
||||
)}
|
||||
{getNodeStatus(node.id) === 'completed' && (
|
||||
<CheckCircle2 className="h-3 w-3 text-green-500" />
|
||||
)}
|
||||
{getNodeStatus(node.id) === 'failed' && (
|
||||
<XCircle className="h-3 w-3 text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WorkflowPreview({
|
||||
workflowData,
|
||||
}: WorkflowPreviewProps) {
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
|
||||
const queries = useSelector(selectWorkflowPreviewQueries) as WorkflowQuery[];
|
||||
const status = useSelector(selectWorkflowPreviewStatus);
|
||||
const executionSteps = useSelector(selectWorkflowExecutionSteps);
|
||||
const activeNodeId = useSelector(selectActiveNodeId);
|
||||
|
||||
const [lastQueryReturnedErr, setLastQueryReturnedErr] = useState(false);
|
||||
const [openDetailsIndex, setOpenDetailsIndex] = useState<number | null>(null);
|
||||
|
||||
const fetchStream = useRef<{ abort: () => void } | null>(null);
|
||||
const stepRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
const chatContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrollToStep = useCallback(
|
||||
(nodeId: string) => {
|
||||
const lastQueryIndex = queries.length - 1;
|
||||
if (lastQueryIndex >= 0) {
|
||||
setOpenDetailsIndex(lastQueryIndex);
|
||||
setTimeout(() => {
|
||||
const stepEl = stepRefs.current.get(nodeId);
|
||||
if (stepEl) {
|
||||
stepEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
},
|
||||
[queries.length],
|
||||
);
|
||||
|
||||
const handleFetchAnswer = useCallback(
|
||||
({ question, index }: { question: string; index?: number }) => {
|
||||
const promise = dispatch(
|
||||
fetchWorkflowPreviewAnswer({
|
||||
question,
|
||||
workflowData,
|
||||
indx: index,
|
||||
}),
|
||||
);
|
||||
fetchStream.current = promise;
|
||||
},
|
||||
[dispatch, workflowData],
|
||||
);
|
||||
|
||||
const handleQuestion = useCallback(
|
||||
({
|
||||
question,
|
||||
isRetry = false,
|
||||
index = undefined,
|
||||
}: {
|
||||
question: string;
|
||||
isRetry?: boolean;
|
||||
index?: number;
|
||||
}) => {
|
||||
const trimmedQuestion = question.trim();
|
||||
if (trimmedQuestion === '') return;
|
||||
|
||||
if (index !== undefined) {
|
||||
if (!isRetry) dispatch(resendQuery({ index, prompt: trimmedQuestion }));
|
||||
handleFetchAnswer({ question: trimmedQuestion, index });
|
||||
} else {
|
||||
if (!isRetry) {
|
||||
const newQuery: Query = { prompt: trimmedQuestion };
|
||||
dispatch(addQuery(newQuery));
|
||||
}
|
||||
handleFetchAnswer({ question: trimmedQuestion, index: undefined });
|
||||
}
|
||||
},
|
||||
[dispatch, handleFetchAnswer],
|
||||
);
|
||||
|
||||
const handleQuestionSubmission = (
|
||||
question?: string,
|
||||
updated?: boolean,
|
||||
indx?: number,
|
||||
) => {
|
||||
if (updated === true && question !== undefined && indx !== undefined) {
|
||||
handleQuestion({
|
||||
question,
|
||||
index: indx,
|
||||
isRetry: false,
|
||||
});
|
||||
} else if (question && status !== 'loading') {
|
||||
const currentInput = question.trim();
|
||||
if (lastQueryReturnedErr && queries.length > 0) {
|
||||
const lastQueryIndex = queries.length - 1;
|
||||
handleQuestion({
|
||||
question: currentInput,
|
||||
isRetry: true,
|
||||
index: lastQueryIndex,
|
||||
});
|
||||
} else {
|
||||
handleQuestion({
|
||||
question: currentInput,
|
||||
isRetry: false,
|
||||
index: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(resetWorkflowPreview());
|
||||
return () => {
|
||||
if (fetchStream.current) fetchStream.current.abort();
|
||||
handleWorkflowPreviewAbort();
|
||||
dispatch(resetWorkflowPreview());
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (queries.length > 0) {
|
||||
const lastQuery = queries[queries.length - 1];
|
||||
setLastQueryReturnedErr(!!lastQuery.error);
|
||||
} else setLastQueryReturnedErr(false);
|
||||
}, [queries]);
|
||||
|
||||
const lastQuerySteps =
|
||||
queries.length > 0 ? queries[queries.length - 1].executionSteps || [] : [];
|
||||
|
||||
return (
|
||||
<div className="dark:bg-raisin-black flex h-full flex-col bg-white">
|
||||
<div className="border-light-silver dark:bg-raisin-black flex h-[77px] items-center justify-between border-b bg-white px-6 dark:border-[#3A3A3A]">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center rounded-full bg-gray-100 p-3 text-gray-600 dark:bg-[#2C2C2C] dark:text-gray-300">
|
||||
<Play className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
Preview
|
||||
</h2>
|
||||
<p className="max-w-md truncate text-xs text-gray-500 dark:text-gray-400">
|
||||
{workflowData.name}
|
||||
{workflowData.description && ` - ${workflowData.description}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{status === 'loading' && (
|
||||
<span className="text-purple-30 dark:text-violets-are-blue flex items-center gap-1 text-xs">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Running
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<div className="flex w-64 shrink-0 flex-col border-r border-gray-200 dark:border-[#3A3A3A]">
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<h3 className="text-xs font-semibold tracking-wider text-gray-500 uppercase dark:text-gray-400">
|
||||
Workflow
|
||||
</h3>
|
||||
</div>
|
||||
<div className="scrollbar-thin flex-1 overflow-y-auto p-3">
|
||||
<WorkflowMiniMap
|
||||
nodes={workflowData.nodes}
|
||||
activeNodeId={activeNodeId}
|
||||
executionSteps={
|
||||
lastQuerySteps.length > 0 ? lastQuerySteps : executionSteps
|
||||
}
|
||||
onNodeClick={scrollToStep}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative flex min-w-0 flex-1 flex-col">
|
||||
<div
|
||||
ref={chatContainerRef}
|
||||
className="scrollbar-thin absolute inset-0 bottom-[100px] overflow-y-auto px-4 pt-4"
|
||||
>
|
||||
{queries.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center">
|
||||
<div className="mb-2 flex size-14 shrink-0 items-center justify-center rounded-xl bg-gray-100 dark:bg-[#2C2C2C]">
|
||||
<MessageSquare className="size-6 text-gray-600 dark:text-gray-300" />
|
||||
</div>
|
||||
<p className="text-xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Test the workflow
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full">
|
||||
{queries.map((query, index) => {
|
||||
const querySteps = query.executionSteps || [];
|
||||
const hasResponse = !!(query.response || query.error);
|
||||
const isLastQuery = index === queries.length - 1;
|
||||
const isOpen =
|
||||
openDetailsIndex === index ||
|
||||
(!hasResponse && isLastQuery && querySteps.length > 0);
|
||||
|
||||
return (
|
||||
<div key={index}>
|
||||
{/* Query bubble */}
|
||||
<ConversationBubble
|
||||
className={index === 0 ? 'mt-5' : ''}
|
||||
message={query.prompt}
|
||||
type="QUESTION"
|
||||
handleUpdatedQuestionSubmission={
|
||||
handleQuestionSubmission
|
||||
}
|
||||
questionNumber={index}
|
||||
/>
|
||||
|
||||
{/* Execution Details */}
|
||||
{querySteps.length > 0 && (
|
||||
<ExecutionDetails
|
||||
steps={querySteps}
|
||||
nodes={workflowData.nodes}
|
||||
isOpen={isOpen}
|
||||
onToggle={() =>
|
||||
setOpenDetailsIndex(
|
||||
openDetailsIndex === index ? null : index,
|
||||
)
|
||||
}
|
||||
stepRefs={isLastQuery ? stepRefs : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Response bubble */}
|
||||
{(query.response ||
|
||||
query.thought ||
|
||||
query.tool_calls) && (
|
||||
<ConversationBubble
|
||||
className={isLastQuery ? 'mb-32' : 'mb-7'}
|
||||
message={query.response}
|
||||
type="ANSWER"
|
||||
thought={query.thought}
|
||||
sources={query.sources}
|
||||
toolCalls={query.tool_calls}
|
||||
feedback={query.feedback}
|
||||
isStreaming={status === 'loading' && isLastQuery}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Error bubble */}
|
||||
{query.error && (
|
||||
<ConversationBubble
|
||||
className={isLastQuery ? 'mb-32' : 'mb-7'}
|
||||
message={query.error}
|
||||
type="ERROR"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="dark:bg-raisin-black absolute right-0 bottom-0 left-0 flex w-full flex-col gap-2 bg-white px-4 pt-2 pb-4">
|
||||
<MessageInput
|
||||
onSubmit={(text) => handleQuestionSubmission(text)}
|
||||
loading={status === 'loading'}
|
||||
showSourceButton={false}
|
||||
showToolButton={false}
|
||||
autoFocus={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
frontend/src/agents/workflow/components/MobileBlocker.tsx
Normal file
18
frontend/src/agents/workflow/components/MobileBlocker.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Monitor } from 'lucide-react';
|
||||
|
||||
export default function MobileBlocker() {
|
||||
return (
|
||||
<div className="bg-lotion dark:bg-raisin-black flex min-h-screen flex-col items-center justify-center px-6 text-center md:hidden">
|
||||
<div className="bg-violets-are-blue/10 dark:bg-violets-are-blue/20 mb-6 flex h-20 w-20 items-center justify-center rounded-2xl">
|
||||
<Monitor className="text-violets-are-blue h-10 w-10" />
|
||||
</div>
|
||||
<h2 className="mb-2 text-xl font-bold text-gray-900 dark:text-white">
|
||||
Desktop Required
|
||||
</h2>
|
||||
<p className="max-w-sm text-sm leading-relaxed text-gray-500 dark:text-[#E0E0E0]">
|
||||
The Workflow Builder requires a larger screen for the best experience.
|
||||
Please open this page on a desktop or laptop computer.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
395
frontend/src/agents/workflow/components/PromptTextArea.tsx
Normal file
395
frontend/src/agents/workflow/components/PromptTextArea.tsx
Normal file
@@ -0,0 +1,395 @@
|
||||
import { Braces, Plus, Search } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Edge, Node } from 'reactflow';
|
||||
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
|
||||
interface WorkflowVariable {
|
||||
name: string;
|
||||
section: string;
|
||||
}
|
||||
|
||||
function getUpstreamNodeIds(nodeId: string, edges: Edge[]): Set<string> {
|
||||
const upstream = new Set<string>();
|
||||
const queue = [nodeId];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!;
|
||||
for (const edge of edges) {
|
||||
if (edge.target === current && !upstream.has(edge.source)) {
|
||||
upstream.add(edge.source);
|
||||
queue.push(edge.source);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return upstream;
|
||||
}
|
||||
|
||||
function extractUpstreamVariables(
|
||||
nodes: Node[],
|
||||
edges: Edge[],
|
||||
selectedNodeId: string,
|
||||
): WorkflowVariable[] {
|
||||
const variables: WorkflowVariable[] = [
|
||||
{ name: 'query', section: 'Workflow input' },
|
||||
{ name: 'chat_history', section: 'Workflow input' },
|
||||
];
|
||||
const seen = new Set(['query', 'chat_history']);
|
||||
const upstreamIds = getUpstreamNodeIds(selectedNodeId, edges);
|
||||
|
||||
for (const node of nodes) {
|
||||
if (!upstreamIds.has(node.id)) continue;
|
||||
|
||||
if (node.type === 'agent' && node.data?.config?.output_variable) {
|
||||
const name = node.data.config.output_variable;
|
||||
if (!seen.has(name)) {
|
||||
seen.add(name);
|
||||
variables.push({
|
||||
name,
|
||||
section: node.data.title || node.data.label || 'Agent',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (node.type === 'state' && node.data?.variable) {
|
||||
const name = node.data.variable;
|
||||
if (!seen.has(name)) {
|
||||
seen.add(name);
|
||||
variables.push({
|
||||
name,
|
||||
section: 'Set State',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return variables;
|
||||
}
|
||||
|
||||
function groupBySection(
|
||||
vars: WorkflowVariable[],
|
||||
): Map<string, WorkflowVariable[]> {
|
||||
const groups = new Map<string, WorkflowVariable[]>();
|
||||
for (const v of vars) {
|
||||
const list = groups.get(v.section) ?? [];
|
||||
list.push(v);
|
||||
groups.set(v.section, list);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
function HighlightedOverlay({ text }: { text: string }) {
|
||||
const parts = text.split(/(\{\{[^}]*\}\})/g);
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, i) =>
|
||||
/^\{\{[^}]*\}\}$/.test(part) ? (
|
||||
<span key={i} className="text-violets-are-blue font-medium">
|
||||
{part}
|
||||
</span>
|
||||
) : (
|
||||
<span key={i} className="text-gray-900 dark:text-white">
|
||||
{part}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function VariableListWithSearch({
|
||||
variables,
|
||||
onSelect,
|
||||
}: {
|
||||
variables: WorkflowVariable[];
|
||||
onSelect: (name: string) => void;
|
||||
}) {
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const filtered = useMemo(
|
||||
() =>
|
||||
variables.filter((v) =>
|
||||
v.name.toLowerCase().includes(search.toLowerCase()),
|
||||
),
|
||||
[variables, search],
|
||||
);
|
||||
|
||||
const grouped = useMemo(() => groupBySection(filtered), [filtered]);
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-2 border-b border-[#E5E5E5] px-3 py-2 dark:border-[#3A3A3A]">
|
||||
<Search className="text-muted-foreground h-3.5 w-3.5 shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search variables..."
|
||||
className="placeholder:text-muted-foreground w-full bg-transparent text-sm text-gray-800 outline-none dark:text-gray-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="text-muted-foreground px-3 py-4 text-center text-xs">
|
||||
No variables found
|
||||
</div>
|
||||
) : (
|
||||
Array.from(grouped.entries()).map(([section, vars]) => (
|
||||
<div key={section}>
|
||||
<div className="text-muted-foreground truncate px-3 pt-2.5 pb-1 text-[10px] font-semibold tracking-wider uppercase">
|
||||
{section}
|
||||
</div>
|
||||
{vars.map((v) => (
|
||||
<button
|
||||
key={v.name}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onSelect(v.name);
|
||||
}}
|
||||
className="flex w-full cursor-pointer items-center gap-2 px-3 py-1.5 text-left text-sm transition-colors hover:bg-gray-50 dark:hover:bg-[#383838]"
|
||||
>
|
||||
<Braces className="text-violets-are-blue h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate font-medium text-gray-800 dark:text-gray-200">
|
||||
{v.name}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PromptTextAreaProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
nodes: Node[];
|
||||
edges: Edge[];
|
||||
selectedNodeId: string;
|
||||
placeholder?: string;
|
||||
rows?: number;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export default function PromptTextArea({
|
||||
value,
|
||||
onChange,
|
||||
nodes,
|
||||
edges,
|
||||
selectedNodeId,
|
||||
placeholder,
|
||||
rows = 4,
|
||||
label,
|
||||
}: PromptTextAreaProps) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const [dropdownPos, setDropdownPos] = useState({ top: 0, left: 0 });
|
||||
const [filterText, setFilterText] = useState('');
|
||||
const [cursorInsertPos, setCursorInsertPos] = useState<number | null>(null);
|
||||
const [contextOpen, setContextOpen] = useState(false);
|
||||
|
||||
const variables = useMemo(
|
||||
() => extractUpstreamVariables(nodes, edges, selectedNodeId),
|
||||
[nodes, edges, selectedNodeId],
|
||||
);
|
||||
const filtered = useMemo(
|
||||
() =>
|
||||
variables.filter((v) =>
|
||||
v.name.toLowerCase().includes(filterText.toLowerCase()),
|
||||
),
|
||||
[variables, filterText],
|
||||
);
|
||||
|
||||
const checkForTrigger = useCallback(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
|
||||
const cursorPos = textarea.selectionStart;
|
||||
const textBeforeCursor = value.slice(0, cursorPos);
|
||||
const triggerMatch = textBeforeCursor.match(/\{\{(\w*)$/);
|
||||
|
||||
if (triggerMatch) {
|
||||
setFilterText(triggerMatch[1]);
|
||||
setCursorInsertPos(cursorPos);
|
||||
|
||||
const wrapper = wrapperRef.current;
|
||||
if (!wrapper) return;
|
||||
|
||||
setDropdownPos({
|
||||
top: wrapper.offsetHeight + 4,
|
||||
left: 0,
|
||||
});
|
||||
setShowDropdown(true);
|
||||
} else {
|
||||
setShowDropdown(false);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const insertVariable = useCallback(
|
||||
(varName: string) => {
|
||||
if (cursorInsertPos === null) return;
|
||||
|
||||
const textBeforeCursor = value.slice(0, cursorInsertPos);
|
||||
const triggerMatch = textBeforeCursor.match(/\{\{(\w*)$/);
|
||||
if (!triggerMatch) return;
|
||||
|
||||
const startPos = cursorInsertPos - triggerMatch[0].length;
|
||||
const insertion = `{{${varName}}}`;
|
||||
const newValue =
|
||||
value.slice(0, startPos) + insertion + value.slice(cursorInsertPos);
|
||||
|
||||
onChange(newValue);
|
||||
setShowDropdown(false);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const newCursorPos = startPos + insertion.length;
|
||||
textareaRef.current?.setSelectionRange(newCursorPos, newCursorPos);
|
||||
textareaRef.current?.focus();
|
||||
});
|
||||
},
|
||||
[value, cursorInsertPos, onChange],
|
||||
);
|
||||
|
||||
const insertVariableFromButton = useCallback(
|
||||
(varName: string) => {
|
||||
const textarea = textareaRef.current;
|
||||
const cursorPos = textarea?.selectionStart ?? value.length;
|
||||
const insertion = `{{${varName}}}`;
|
||||
const newValue =
|
||||
value.slice(0, cursorPos) + insertion + value.slice(cursorPos);
|
||||
|
||||
onChange(newValue);
|
||||
setContextOpen(false);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const newCursorPos = cursorPos + insertion.length;
|
||||
textareaRef.current?.setSelectionRange(newCursorPos, newCursorPos);
|
||||
textareaRef.current?.focus();
|
||||
});
|
||||
},
|
||||
[value, onChange],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(e.target as HTMLElement)
|
||||
) {
|
||||
setShowDropdown(false);
|
||||
}
|
||||
};
|
||||
if (showDropdown) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () =>
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
}, [showDropdown]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{label && (
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className="border-light-silver focus-within:ring-purple-30 relative rounded-xl border bg-white transition-all focus-within:ring-2 dark:border-[#3A3A3A] dark:bg-[#2C2C2C]"
|
||||
>
|
||||
<div
|
||||
ref={overlayRef}
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 overflow-hidden rounded-xl border border-transparent px-3 py-2 text-sm wrap-break-word whitespace-pre-wrap"
|
||||
>
|
||||
{value ? (
|
||||
<HighlightedOverlay text={value} />
|
||||
) : (
|
||||
<span className="text-gray-400 dark:text-gray-500">
|
||||
{placeholder}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
setTimeout(checkForTrigger, 0);
|
||||
}}
|
||||
onKeyUp={checkForTrigger}
|
||||
onKeyDown={(e) => {
|
||||
if (showDropdown && e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setShowDropdown(false);
|
||||
}
|
||||
}}
|
||||
onScroll={() => {
|
||||
if (overlayRef.current && textareaRef.current) {
|
||||
overlayRef.current.scrollTop = textareaRef.current.scrollTop;
|
||||
}
|
||||
}}
|
||||
className="relative w-full rounded-xl bg-transparent px-3 pt-2 pb-8 text-sm caret-black outline-none dark:caret-white"
|
||||
style={{
|
||||
color: 'transparent',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
}}
|
||||
rows={rows}
|
||||
placeholder={placeholder}
|
||||
spellCheck={false}
|
||||
/>
|
||||
|
||||
<div className="absolute right-4 bottom-1.5 z-10">
|
||||
<Popover open={contextOpen} onOpenChange={setContextOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="text-violets-are-blue hover:bg-violets-are-blue/10 flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium transition-colors"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Add context
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
side="top"
|
||||
className="w-60 rounded-xl border border-[#E5E5E5] bg-white p-0 shadow-lg dark:border-[#3A3A3A] dark:bg-[#2C2C2C]"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<VariableListWithSearch
|
||||
variables={variables}
|
||||
onSelect={insertVariableFromButton}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{showDropdown && filtered.length > 0 && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="absolute z-50 w-64 rounded-xl border border-[#E5E5E5] bg-white shadow-lg dark:border-[#3A3A3A] dark:bg-[#2C2C2C]"
|
||||
style={{ top: dropdownPos.top, left: dropdownPos.left }}
|
||||
>
|
||||
<VariableListWithSearch
|
||||
variables={filtered}
|
||||
onSelect={insertVariable}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
91
frontend/src/agents/workflow/nodes/BaseNode.tsx
Normal file
91
frontend/src/agents/workflow/nodes/BaseNode.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Handle, Position } from 'reactflow';
|
||||
|
||||
interface BaseNodeProps {
|
||||
title: string;
|
||||
children?: ReactNode;
|
||||
selected?: boolean;
|
||||
type?: 'start' | 'end' | 'default' | 'state' | 'agent';
|
||||
icon?: ReactNode;
|
||||
handles?: {
|
||||
source?: boolean;
|
||||
target?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export const BaseNode: React.FC<BaseNodeProps> = ({
|
||||
title,
|
||||
children,
|
||||
selected,
|
||||
type = 'default',
|
||||
icon,
|
||||
handles = { source: true, target: true },
|
||||
}) => {
|
||||
let bgColor = 'bg-white dark:bg-[#2C2C2C]';
|
||||
let borderColor = 'border-gray-200 dark:border-[#3A3A3A]';
|
||||
let iconBg = 'bg-gray-100 dark:bg-gray-800';
|
||||
let iconColor = 'text-gray-600 dark:text-gray-400';
|
||||
|
||||
if (selected) {
|
||||
borderColor =
|
||||
'border-violets-are-blue ring-2 ring-purple-300 dark:ring-violets-are-blue';
|
||||
}
|
||||
|
||||
if (type === 'start') {
|
||||
iconBg = 'bg-green-100 dark:bg-green-900/30';
|
||||
iconColor = 'text-green-600 dark:text-green-400';
|
||||
} else if (type === 'end') {
|
||||
iconBg = 'bg-red-100 dark:bg-red-900/30';
|
||||
iconColor = 'text-red-600 dark:text-red-400';
|
||||
} else if (type === 'state') {
|
||||
iconBg = 'bg-gray-100 dark:bg-gray-800';
|
||||
iconColor = 'text-gray-600 dark:text-gray-400';
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-full border ${bgColor} ${borderColor} shadow-md transition-all hover:shadow-lg ${
|
||||
selected ? 'scale-105' : ''
|
||||
} max-w-[250px] min-w-[180px]`}
|
||||
>
|
||||
{handles.target && (
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
isConnectable={true}
|
||||
className="hover:bg-violets-are-blue! -left-1! h-3! w-3! rounded-full! border-2! border-white! bg-gray-400! transition-colors dark:border-[#2C2C2C]!"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<div
|
||||
className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-full ${iconBg} ${iconColor}`}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 pr-3">
|
||||
<div
|
||||
className="truncate text-sm font-semibold text-gray-900 dark:text-white"
|
||||
title={title}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
{children && (
|
||||
<div className="mt-1 truncate text-xs text-gray-600 dark:text-gray-400">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{handles.source && (
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
isConnectable={true}
|
||||
className="hover:bg-violets-are-blue! -right-1! h-3! w-3! rounded-full! border-2! border-white! bg-gray-400! transition-colors dark:border-[#2C2C2C]!"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
46
frontend/src/agents/workflow/nodes/SetStateNode.tsx
Normal file
46
frontend/src/agents/workflow/nodes/SetStateNode.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Database } from 'lucide-react';
|
||||
import { memo } from 'react';
|
||||
import { NodeProps } from 'reactflow';
|
||||
|
||||
import { BaseNode } from './BaseNode';
|
||||
|
||||
type SetStateNodeData = {
|
||||
label?: string;
|
||||
title?: string;
|
||||
variable?: string;
|
||||
value?: string;
|
||||
};
|
||||
|
||||
const SetStateNode = ({ data, selected }: NodeProps<SetStateNodeData>) => {
|
||||
const title = data.title || data.label || 'Set State';
|
||||
return (
|
||||
<BaseNode
|
||||
title={title}
|
||||
type="state"
|
||||
selected={selected}
|
||||
icon={<Database size={16} />}
|
||||
handles={{ source: true, target: true }}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
{data.variable && (
|
||||
<div
|
||||
className="truncate text-[10px] text-gray-500 uppercase"
|
||||
title={`Variable: ${data.variable}`}
|
||||
>
|
||||
{data.variable}
|
||||
</div>
|
||||
)}
|
||||
{data.value && (
|
||||
<div
|
||||
className="truncate text-xs text-blue-600 dark:text-blue-400"
|
||||
title={`Value: ${data.value}`}
|
||||
>
|
||||
{data.value}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</BaseNode>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(SetStateNode);
|
||||
144
frontend/src/agents/workflow/nodes/index.tsx
Normal file
144
frontend/src/agents/workflow/nodes/index.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import React, { memo } from 'react';
|
||||
import { BaseNode } from './BaseNode';
|
||||
import SetStateNode from './SetStateNode';
|
||||
import { Play, Bot, StickyNote, Flag } from 'lucide-react';
|
||||
|
||||
export const StartNode = memo(function StartNode({
|
||||
selected,
|
||||
}: {
|
||||
selected: boolean;
|
||||
}) {
|
||||
return (
|
||||
<BaseNode
|
||||
title="Start"
|
||||
type="start"
|
||||
selected={selected}
|
||||
handles={{ target: false, source: true }}
|
||||
icon={<Play size={16} />}
|
||||
>
|
||||
<div className="text-xs text-gray-500">Entry point of the workflow</div>
|
||||
</BaseNode>
|
||||
);
|
||||
});
|
||||
|
||||
export const EndNode = memo(function EndNode({
|
||||
selected,
|
||||
}: {
|
||||
selected: boolean;
|
||||
}) {
|
||||
return (
|
||||
<BaseNode
|
||||
title="End"
|
||||
type="end"
|
||||
selected={selected}
|
||||
handles={{ target: true, source: false }}
|
||||
icon={<Flag size={16} />}
|
||||
>
|
||||
<div className="text-xs text-gray-500">Workflow completion</div>
|
||||
</BaseNode>
|
||||
);
|
||||
});
|
||||
|
||||
export const AgentNode = memo(function AgentNode({
|
||||
data,
|
||||
selected,
|
||||
}: {
|
||||
data: {
|
||||
title?: string;
|
||||
label?: string;
|
||||
config?: {
|
||||
agent_type?: string;
|
||||
model_id?: string;
|
||||
prompt_template?: string;
|
||||
output_variable?: string;
|
||||
};
|
||||
};
|
||||
selected: boolean;
|
||||
}) {
|
||||
const title = data.title || data.label || 'Agent';
|
||||
const config = data.config || {};
|
||||
return (
|
||||
<BaseNode
|
||||
title={title}
|
||||
type="agent"
|
||||
selected={selected}
|
||||
icon={<Bot size={16} />}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
{config.agent_type && (
|
||||
<div
|
||||
className="truncate text-[10px] text-gray-500 uppercase"
|
||||
title={config.agent_type}
|
||||
>
|
||||
{config.agent_type}
|
||||
</div>
|
||||
)}
|
||||
{config.model_id && (
|
||||
<div
|
||||
className="text-purple-30 dark:text-violets-are-blue truncate text-xs"
|
||||
title={config.model_id}
|
||||
>
|
||||
{config.model_id}
|
||||
</div>
|
||||
)}
|
||||
{config.output_variable && (
|
||||
<div
|
||||
className="truncate text-xs text-green-600 dark:text-green-400"
|
||||
title={`Output ➔ ${config.output_variable}`}
|
||||
>
|
||||
Output ➔ {config.output_variable}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</BaseNode>
|
||||
);
|
||||
});
|
||||
|
||||
export const NoteNode = memo(function NoteNode({
|
||||
data,
|
||||
selected,
|
||||
}: {
|
||||
data: { title?: string; label?: string; content?: string };
|
||||
selected: boolean;
|
||||
}) {
|
||||
const title = data.title || data.label || 'Note';
|
||||
const maxContentLength = 120;
|
||||
const displayContent =
|
||||
data.content && data.content.length > maxContentLength
|
||||
? `${data.content.substring(0, maxContentLength)}...`
|
||||
: data.content;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`max-w-[250px] rounded-3xl border border-yellow-200 bg-yellow-50 px-5 py-3 shadow-md transition-all dark:border-yellow-800 dark:bg-yellow-900/20 ${
|
||||
selected
|
||||
? 'scale-105 ring-2 ring-yellow-300 dark:ring-yellow-700'
|
||||
: 'hover:shadow-lg'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-yellow-100 text-yellow-700 dark:bg-yellow-800/30 dark:text-yellow-500">
|
||||
<StickyNote size={18} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className="truncate text-sm font-semibold text-yellow-800 dark:text-yellow-300"
|
||||
title={title}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
{displayContent && (
|
||||
<div
|
||||
className="mt-1 text-xs wrap-break-word text-yellow-700 italic dark:text-yellow-400"
|
||||
title={data.content}
|
||||
>
|
||||
{displayContent}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export { SetStateNode };
|
||||
441
frontend/src/agents/workflow/workflowPreviewSlice.ts
Normal file
441
frontend/src/agents/workflow/workflowPreviewSlice.ts
Normal file
@@ -0,0 +1,441 @@
|
||||
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
import conversationService from '../../api/services/conversationService';
|
||||
import { Query, Status } from '../../conversation/conversationModels';
|
||||
import { WorkflowEdge, WorkflowNode } from '../types/workflow';
|
||||
|
||||
export interface WorkflowExecutionStep {
|
||||
nodeId: string;
|
||||
nodeType: string;
|
||||
nodeTitle: string;
|
||||
status: 'pending' | 'running' | 'completed' | 'failed';
|
||||
reasoning?: string;
|
||||
startedAt?: number;
|
||||
completedAt?: number;
|
||||
stateSnapshot?: Record<string, unknown>;
|
||||
output?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface WorkflowData {
|
||||
name: string;
|
||||
description?: string;
|
||||
nodes: WorkflowNode[];
|
||||
edges: WorkflowEdge[];
|
||||
}
|
||||
|
||||
export interface WorkflowQuery extends Query {
|
||||
executionSteps?: WorkflowExecutionStep[];
|
||||
}
|
||||
|
||||
export interface WorkflowPreviewState {
|
||||
queries: WorkflowQuery[];
|
||||
status: Status;
|
||||
executionSteps: WorkflowExecutionStep[];
|
||||
activeNodeId: string | null;
|
||||
}
|
||||
|
||||
const initialState: WorkflowPreviewState = {
|
||||
queries: [],
|
||||
status: 'idle',
|
||||
executionSteps: [],
|
||||
activeNodeId: null,
|
||||
};
|
||||
|
||||
let abortController: AbortController | null = null;
|
||||
|
||||
export function handleWorkflowPreviewAbort() {
|
||||
if (abortController) {
|
||||
abortController.abort();
|
||||
abortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
interface ThunkState {
|
||||
preference: {
|
||||
token: string | null;
|
||||
};
|
||||
workflowPreview: WorkflowPreviewState;
|
||||
}
|
||||
|
||||
export const fetchWorkflowPreviewAnswer = createAsyncThunk<
|
||||
void,
|
||||
{
|
||||
question: string;
|
||||
workflowData: WorkflowData;
|
||||
indx?: number;
|
||||
},
|
||||
{ state: ThunkState }
|
||||
>(
|
||||
'workflowPreview/fetchAnswer',
|
||||
async ({ question, workflowData, indx }, { dispatch, getState }) => {
|
||||
if (abortController) abortController.abort();
|
||||
abortController = new AbortController();
|
||||
const { signal } = abortController;
|
||||
|
||||
const state = getState();
|
||||
|
||||
if (state.preference) {
|
||||
const payload = {
|
||||
question,
|
||||
workflow: workflowData,
|
||||
save_conversation: false,
|
||||
};
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
conversationService
|
||||
.answerStream(payload, state.preference.token, signal)
|
||||
.then((response) => {
|
||||
if (!response.body) throw Error('No response body');
|
||||
|
||||
let buffer = '';
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
|
||||
const processStream = ({
|
||||
done,
|
||||
value,
|
||||
}: ReadableStreamReadResult<Uint8Array>): Promise<void> | void => {
|
||||
if (done) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
const currentState = getState();
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data:')) {
|
||||
try {
|
||||
const data = JSON.parse(line.slice(5));
|
||||
const targetIndex =
|
||||
indx ?? currentState.workflowPreview.queries.length - 1;
|
||||
|
||||
if (data.type === 'end') {
|
||||
dispatch(workflowPreviewSlice.actions.setStatus('idle'));
|
||||
} else if (data.type === 'thought') {
|
||||
dispatch(
|
||||
updateThought({
|
||||
index: targetIndex,
|
||||
query: { thought: data.thought },
|
||||
}),
|
||||
);
|
||||
} else if (data.type === 'workflow_step') {
|
||||
dispatch(
|
||||
updateExecutionStep({
|
||||
index: targetIndex,
|
||||
step: {
|
||||
nodeId: data.node_id,
|
||||
nodeType: data.node_type,
|
||||
nodeTitle: data.node_title,
|
||||
status: data.status,
|
||||
reasoning: data.reasoning,
|
||||
stateSnapshot: data.state_snapshot,
|
||||
output: data.output,
|
||||
error: data.error,
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (data.status === 'running') {
|
||||
dispatch(setActiveNodeId(data.node_id));
|
||||
}
|
||||
} else if (data.type === 'source') {
|
||||
dispatch(
|
||||
updateStreamingSource({
|
||||
index: targetIndex,
|
||||
query: { sources: data.source ?? [] },
|
||||
}),
|
||||
);
|
||||
} else if (data.type === 'tool_call') {
|
||||
dispatch(
|
||||
updateToolCall({
|
||||
index: targetIndex,
|
||||
tool_call: data.data,
|
||||
}),
|
||||
);
|
||||
} else if (data.type === 'error') {
|
||||
dispatch(
|
||||
workflowPreviewSlice.actions.setStatus('failed'),
|
||||
);
|
||||
dispatch(
|
||||
workflowPreviewSlice.actions.raiseError({
|
||||
index: targetIndex,
|
||||
message: data.error,
|
||||
}),
|
||||
);
|
||||
} else if (data.type === 'structured_answer') {
|
||||
dispatch(
|
||||
updateStreamingQuery({
|
||||
index: targetIndex,
|
||||
query: {
|
||||
response: data.answer,
|
||||
structured: data.structured,
|
||||
schema: data.schema,
|
||||
},
|
||||
}),
|
||||
);
|
||||
} else if (data.answer !== undefined) {
|
||||
dispatch(
|
||||
updateStreamingQuery({
|
||||
index: targetIndex,
|
||||
query: { response: data.answer },
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return reader.read().then(processStream);
|
||||
};
|
||||
|
||||
reader.read().then(processStream).catch(reject);
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const workflowPreviewSlice = createSlice({
|
||||
name: 'workflowPreview',
|
||||
initialState,
|
||||
reducers: {
|
||||
addQuery(state, action: PayloadAction<Query>) {
|
||||
state.queries.push(action.payload);
|
||||
},
|
||||
resendQuery(
|
||||
state,
|
||||
action: PayloadAction<{ index: number; prompt: string; query?: Query }>,
|
||||
) {
|
||||
state.queries = [
|
||||
...state.queries.slice(0, action.payload.index),
|
||||
{ prompt: action.payload.prompt },
|
||||
];
|
||||
state.executionSteps = [];
|
||||
state.activeNodeId = null;
|
||||
},
|
||||
updateStreamingQuery(
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
index: number;
|
||||
query: Partial<Query>;
|
||||
}>,
|
||||
) {
|
||||
const { index, query } = action.payload;
|
||||
if (state.status === 'idle') return;
|
||||
|
||||
if (query.response !== undefined) {
|
||||
state.queries[index].response =
|
||||
(state.queries[index].response || '') + query.response;
|
||||
}
|
||||
|
||||
if (query.structured !== undefined) {
|
||||
state.queries[index].structured = query.structured;
|
||||
}
|
||||
|
||||
if (query.schema !== undefined) {
|
||||
state.queries[index].schema = query.schema;
|
||||
}
|
||||
},
|
||||
updateThought(
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
index: number;
|
||||
query: Partial<Query>;
|
||||
}>,
|
||||
) {
|
||||
const { index, query } = action.payload;
|
||||
if (query.thought !== undefined) {
|
||||
state.queries[index].thought =
|
||||
(state.queries[index].thought || '') + query.thought;
|
||||
}
|
||||
},
|
||||
updateStreamingSource(
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
index: number;
|
||||
query: Partial<Query>;
|
||||
}>,
|
||||
) {
|
||||
const { index, query } = action.payload;
|
||||
if (!state.queries[index].sources) {
|
||||
state.queries[index].sources = query?.sources;
|
||||
} else if (query.sources) {
|
||||
state.queries[index].sources!.push(...query.sources);
|
||||
}
|
||||
},
|
||||
updateToolCall(state, action) {
|
||||
const { index, tool_call } = action.payload;
|
||||
|
||||
if (!state.queries[index].tool_calls) {
|
||||
state.queries[index].tool_calls = [];
|
||||
}
|
||||
|
||||
const existingIndex = state.queries[index].tool_calls.findIndex(
|
||||
(call: { call_id: string }) => call.call_id === tool_call.call_id,
|
||||
);
|
||||
|
||||
if (existingIndex !== -1) {
|
||||
const existingCall = state.queries[index].tool_calls[existingIndex];
|
||||
state.queries[index].tool_calls[existingIndex] = {
|
||||
...existingCall,
|
||||
...tool_call,
|
||||
};
|
||||
} else {
|
||||
state.queries[index].tool_calls.push(tool_call);
|
||||
}
|
||||
},
|
||||
updateQuery(
|
||||
state,
|
||||
action: PayloadAction<{ index: number; query: Partial<Query> }>,
|
||||
) {
|
||||
const { index, query } = action.payload;
|
||||
state.queries[index] = {
|
||||
...state.queries[index],
|
||||
...query,
|
||||
};
|
||||
},
|
||||
updateExecutionStep(
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
index: number;
|
||||
step: Partial<WorkflowExecutionStep> & {
|
||||
nodeId: string;
|
||||
nodeType: string;
|
||||
nodeTitle: string;
|
||||
status: WorkflowExecutionStep['status'];
|
||||
};
|
||||
}>,
|
||||
) {
|
||||
const { index, step } = action.payload;
|
||||
|
||||
if (!state.queries[index]) return;
|
||||
if (!state.queries[index].executionSteps) {
|
||||
state.queries[index].executionSteps = [];
|
||||
}
|
||||
|
||||
const querySteps = state.queries[index].executionSteps!;
|
||||
const existingIndex = querySteps.findIndex((s) => s.nodeId === step.nodeId);
|
||||
|
||||
const updatedStep: WorkflowExecutionStep = {
|
||||
nodeId: step.nodeId,
|
||||
nodeType: step.nodeType,
|
||||
nodeTitle: step.nodeTitle,
|
||||
status: step.status,
|
||||
reasoning: step.reasoning,
|
||||
stateSnapshot: step.stateSnapshot,
|
||||
output: step.output,
|
||||
error: step.error,
|
||||
startedAt: existingIndex !== -1 ? querySteps[existingIndex].startedAt : Date.now(),
|
||||
completedAt:
|
||||
step.status === 'completed' || step.status === 'failed'
|
||||
? Date.now()
|
||||
: existingIndex !== -1
|
||||
? querySteps[existingIndex].completedAt
|
||||
: undefined,
|
||||
};
|
||||
|
||||
if (existingIndex !== -1) {
|
||||
updatedStep.stateSnapshot = step.stateSnapshot ?? querySteps[existingIndex].stateSnapshot;
|
||||
updatedStep.output = step.output ?? querySteps[existingIndex].output;
|
||||
updatedStep.error = step.error ?? querySteps[existingIndex].error;
|
||||
querySteps[existingIndex] = updatedStep;
|
||||
} else {
|
||||
querySteps.push(updatedStep);
|
||||
}
|
||||
|
||||
const globalIndex = state.executionSteps.findIndex((s) => s.nodeId === step.nodeId);
|
||||
if (globalIndex !== -1) {
|
||||
state.executionSteps[globalIndex] = updatedStep;
|
||||
} else {
|
||||
state.executionSteps.push(updatedStep);
|
||||
}
|
||||
},
|
||||
setActiveNodeId(state, action: PayloadAction<string | null>) {
|
||||
state.activeNodeId = action.payload;
|
||||
},
|
||||
setStatus(state, action: PayloadAction<Status>) {
|
||||
state.status = action.payload;
|
||||
},
|
||||
raiseError(
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
index: number;
|
||||
message: string;
|
||||
}>,
|
||||
) {
|
||||
const { index, message } = action.payload;
|
||||
state.queries[index].error = message;
|
||||
},
|
||||
resetWorkflowPreview: (state) => {
|
||||
state.queries = initialState.queries;
|
||||
state.status = initialState.status;
|
||||
state.executionSteps = initialState.executionSteps;
|
||||
state.activeNodeId = initialState.activeNodeId;
|
||||
handleWorkflowPreviewAbort();
|
||||
},
|
||||
clearExecutionSteps: (state) => {
|
||||
state.executionSteps = [];
|
||||
state.activeNodeId = null;
|
||||
},
|
||||
},
|
||||
extraReducers(builder) {
|
||||
builder
|
||||
.addCase(fetchWorkflowPreviewAnswer.pending, (state) => {
|
||||
state.status = 'loading';
|
||||
state.executionSteps = [];
|
||||
state.activeNodeId = null;
|
||||
})
|
||||
.addCase(fetchWorkflowPreviewAnswer.rejected, (state, action) => {
|
||||
if (action.meta.aborted) {
|
||||
state.status = 'idle';
|
||||
return;
|
||||
}
|
||||
state.status = 'failed';
|
||||
if (state.queries.length > 0) {
|
||||
state.queries[state.queries.length - 1].error =
|
||||
'Something went wrong';
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
interface RootStateWithWorkflowPreview {
|
||||
workflowPreview: WorkflowPreviewState;
|
||||
}
|
||||
|
||||
export const selectWorkflowPreviewQueries = (
|
||||
state: RootStateWithWorkflowPreview,
|
||||
) => state.workflowPreview.queries;
|
||||
export const selectWorkflowPreviewStatus = (
|
||||
state: RootStateWithWorkflowPreview,
|
||||
) => state.workflowPreview.status;
|
||||
export const selectWorkflowExecutionSteps = (
|
||||
state: RootStateWithWorkflowPreview,
|
||||
) => state.workflowPreview.executionSteps;
|
||||
export const selectActiveNodeId = (state: RootStateWithWorkflowPreview) =>
|
||||
state.workflowPreview.activeNodeId;
|
||||
|
||||
export const {
|
||||
addQuery,
|
||||
updateQuery,
|
||||
resendQuery,
|
||||
updateStreamingQuery,
|
||||
updateThought,
|
||||
updateStreamingSource,
|
||||
updateToolCall,
|
||||
updateExecutionStep,
|
||||
setActiveNodeId,
|
||||
setStatus,
|
||||
raiseError,
|
||||
resetWorkflowPreview,
|
||||
clearExecutionSteps,
|
||||
} = workflowPreviewSlice.actions;
|
||||
|
||||
export default workflowPreviewSlice.reducer;
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -563,8 +563,7 @@ export default function MessageInput({
|
||||
e.preventDefault();
|
||||
uploadFiles(files);
|
||||
}
|
||||
};
|
||||
|
||||
};
|
||||
|
||||
const handlePostDocumentSelect = (doc: any) => {
|
||||
console.log('Selected document:', doc);
|
||||
|
||||
59
frontend/src/components/ui/alert.tsx
Normal file
59
frontend/src/components/ui/alert.tsx
Normal 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 };
|
||||
62
frontend/src/components/ui/button.tsx
Normal file
62
frontend/src/components/ui/button.tsx
Normal 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 };
|
||||
182
frontend/src/components/ui/command.tsx
Normal file
182
frontend/src/components/ui/command.tsx
Normal 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,
|
||||
};
|
||||
143
frontend/src/components/ui/dialog.tsx
Normal file
143
frontend/src/components/ui/dialog.tsx
Normal 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,
|
||||
};
|
||||
166
frontend/src/components/ui/multi-select.tsx
Normal file
166
frontend/src/components/ui/multi-select.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
frontend/src/components/ui/popover.tsx
Normal file
46
frontend/src/components/ui/popover.tsx
Normal 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 };
|
||||
188
frontend/src/components/ui/select.tsx
Normal file
188
frontend/src/components/ui/select.tsx
Normal 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,
|
||||
};
|
||||
141
frontend/src/components/ui/sheet.tsx
Normal file
141
frontend/src/components/ui/sheet.tsx
Normal 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,
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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')} "{agentName}" to
|
||||
</h2>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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" }]
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user