mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-11 00:06:04 +00:00
后端没修完版
This commit is contained in:
476
web/src/app/home/workflows/WorkflowDetailContent.tsx
Normal file
476
web/src/app/home/workflows/WorkflowDetailContent.tsx
Normal file
@@ -0,0 +1,476 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import WorkflowEditorComponent from './components/workflow-editor/WorkflowEditorComponent';
|
||||
import WorkflowFormComponent from './components/workflow-form/WorkflowFormComponent';
|
||||
import WorkflowExecutionsTab from './components/workflow-executions/WorkflowExecutionsTab';
|
||||
import WorkflowDebugDialog from './components/workflow-debug-dialog/WorkflowDebugDialog';
|
||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Settings, Play, BarChart3, GitBranch, Download, Upload, Bug } from 'lucide-react';
|
||||
import { backendClient } from '@/app/infra/http';
|
||||
import { Workflow } from '@/app/infra/entities/api';
|
||||
import { useWorkflowStore } from './store/useWorkflowStore';
|
||||
import { toast } from 'sonner';
|
||||
import EmojiPicker from '@/components/ui/emoji-picker';
|
||||
|
||||
export default function WorkflowDetailContent({ id }: { id: string }) {
|
||||
const isCreateMode = id === 'new';
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const { refreshWorkflows, workflows, setDetailEntityName } = useSidebarData();
|
||||
|
||||
const {
|
||||
currentWorkflow,
|
||||
setCurrentWorkflow,
|
||||
fromWorkflowDefinition,
|
||||
toWorkflowDefinition,
|
||||
isDirty,
|
||||
setDirty,
|
||||
isSaving,
|
||||
setSaving,
|
||||
setLoading,
|
||||
reset,
|
||||
nodeTypes,
|
||||
setNodeTypes,
|
||||
} = useWorkflowStore();
|
||||
|
||||
const [activeTab, setActiveTab] = useState('editor');
|
||||
const [workflow, setWorkflow] = useState<Workflow | null>(null);
|
||||
const [createStep, setCreateStep] = useState<'basic' | 'editor'>('basic');
|
||||
const [basicInfo, setBasicInfo] = useState<{ name: string; description: string; emoji: string }>({
|
||||
name: '',
|
||||
description: '',
|
||||
emoji: '🔄',
|
||||
});
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isWebSocketConnected, setIsWebSocketConnected] = useState(false);
|
||||
|
||||
// Set breadcrumb entity name
|
||||
useEffect(() => {
|
||||
if (isCreateMode) {
|
||||
setDetailEntityName(t('workflows.createWorkflow'));
|
||||
} else {
|
||||
const wf = workflows.find((w) => w.id === id);
|
||||
setDetailEntityName(wf?.name ?? id);
|
||||
}
|
||||
return () => setDetailEntityName(null);
|
||||
}, [id, isCreateMode, workflows, setDetailEntityName, t]);
|
||||
|
||||
// Load node types
|
||||
useEffect(() => {
|
||||
if (nodeTypes.length === 0) {
|
||||
backendClient.getWorkflowNodeTypes().then((resp) => {
|
||||
setNodeTypes(resp.node_types, resp.categories);
|
||||
}).catch((err) => {
|
||||
console.error('Failed to load node types:', err);
|
||||
});
|
||||
}
|
||||
}, [nodeTypes.length, setNodeTypes]);
|
||||
|
||||
// Load workflow data
|
||||
useEffect(() => {
|
||||
if (isCreateMode) {
|
||||
reset();
|
||||
setWorkflow(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
backendClient.getWorkflow(id).then((resp) => {
|
||||
setWorkflow(resp.workflow);
|
||||
setCurrentWorkflow(resp.workflow);
|
||||
fromWorkflowDefinition(resp.workflow.nodes || [], resp.workflow.edges || []);
|
||||
}).catch((err) => {
|
||||
console.error('Failed to load workflow:', err);
|
||||
toast.error(t('workflows.loadError'));
|
||||
}).finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
reset();
|
||||
};
|
||||
}, [id, isCreateMode]);
|
||||
|
||||
// Save handler
|
||||
const handleSave = useCallback(async () => {
|
||||
if (isSaving) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const { nodes, edges } = toWorkflowDefinition();
|
||||
|
||||
if (isCreateMode) {
|
||||
const resp = await backendClient.createWorkflow({
|
||||
name: basicInfo.name || t('workflows.newWorkflow'),
|
||||
description: basicInfo.description,
|
||||
emoji: basicInfo.emoji,
|
||||
nodes,
|
||||
edges,
|
||||
});
|
||||
refreshWorkflows();
|
||||
navigate(`/home/workflows?id=${encodeURIComponent(resp.uuid)}`);
|
||||
toast.success(t('workflows.createSuccess'));
|
||||
} else {
|
||||
await backendClient.updateWorkflow(id, {
|
||||
name: workflow?.name,
|
||||
emoji: workflow?.emoji,
|
||||
description: workflow?.description,
|
||||
nodes,
|
||||
edges,
|
||||
variables: workflow?.variables,
|
||||
settings: workflow?.settings,
|
||||
triggers: workflow?.triggers,
|
||||
});
|
||||
setDirty(false);
|
||||
refreshWorkflows();
|
||||
toast.success(t('workflows.saveSuccess'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to save workflow:', err);
|
||||
toast.error(t('workflows.saveError'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [id, isCreateMode, workflow, isSaving, toWorkflowDefinition, refreshWorkflows, navigate, t, basicInfo]);
|
||||
|
||||
// Export workflow handler
|
||||
const handleExport = useCallback(() => {
|
||||
const { nodes, edges } = toWorkflowDefinition();
|
||||
|
||||
const exportData = {
|
||||
name: workflow?.name || t('workflows.newWorkflow'),
|
||||
description: workflow?.description || '',
|
||||
emoji: workflow?.emoji || '🔄',
|
||||
nodes,
|
||||
edges,
|
||||
variables: workflow?.variables || {},
|
||||
settings: workflow?.settings || {},
|
||||
version: '1.0',
|
||||
exportedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${workflow?.name || 'workflow'}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast.success(t('workflows.exportSuccess'));
|
||||
}, [workflow, toWorkflowDefinition, t]);
|
||||
|
||||
// Import workflow handler
|
||||
const handleImport = useCallback((file: File) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const importData = JSON.parse(e.target?.result as string);
|
||||
|
||||
// Validate imported data structure
|
||||
if (!importData.nodes || !Array.isArray(importData.nodes)) {
|
||||
throw new Error('Invalid workflow file: missing nodes');
|
||||
}
|
||||
if (!importData.edges || !Array.isArray(importData.edges)) {
|
||||
throw new Error('Invalid workflow file: missing edges');
|
||||
}
|
||||
|
||||
// Validate each node has required fields
|
||||
const nodeIds = new Set<string>();
|
||||
for (const node of importData.nodes) {
|
||||
if (!node.id || !node.type) {
|
||||
throw new Error(`Invalid node: missing id or type`);
|
||||
}
|
||||
if (!node.position || typeof node.position.x !== 'number' || typeof node.position.y !== 'number') {
|
||||
throw new Error(`Invalid node "${node.id}": missing or invalid position`);
|
||||
}
|
||||
nodeIds.add(node.id);
|
||||
}
|
||||
|
||||
// Validate each edge has required fields and references existing nodes
|
||||
for (const edge of importData.edges) {
|
||||
if (!edge.id || !edge.source || !edge.target) {
|
||||
throw new Error(`Invalid edge: missing id, source, or target`);
|
||||
}
|
||||
if (!nodeIds.has(edge.source)) {
|
||||
throw new Error(`Edge "${edge.id}" references unknown source node "${edge.source}"`);
|
||||
}
|
||||
if (!nodeIds.has(edge.target)) {
|
||||
throw new Error(`Edge "${edge.id}" references unknown target node "${edge.target}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// Load nodes and edges into the store
|
||||
fromWorkflowDefinition(importData.nodes, importData.edges);
|
||||
|
||||
// Update workflow metadata if available
|
||||
if (workflow && (importData.name || importData.description || importData.emoji)) {
|
||||
setWorkflow({
|
||||
...workflow,
|
||||
name: importData.name || workflow.name,
|
||||
description: importData.description || workflow.description,
|
||||
emoji: importData.emoji || workflow.emoji,
|
||||
variables: importData.variables || workflow.variables,
|
||||
settings: importData.settings || workflow.settings,
|
||||
});
|
||||
}
|
||||
|
||||
setDirty(true);
|
||||
toast.success(t('workflows.importSuccess'));
|
||||
} catch (error) {
|
||||
console.error('Failed to import workflow:', error);
|
||||
toast.error(t('workflows.importError'));
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}, [workflow, fromWorkflowDefinition, setDirty, t]);
|
||||
|
||||
// Handle file input change
|
||||
const handleFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
handleImport(file);
|
||||
// Reset file input
|
||||
e.target.value = '';
|
||||
}
|
||||
}, [handleImport]);
|
||||
|
||||
// Publish handler
|
||||
const handlePublish = useCallback(async () => {
|
||||
if (!workflow?.uuid) return;
|
||||
|
||||
try {
|
||||
await backendClient.publishWorkflow(workflow.uuid);
|
||||
toast.success(t('workflows.publishSuccess'));
|
||||
refreshWorkflows();
|
||||
} catch (err) {
|
||||
console.error('Failed to publish workflow:', err);
|
||||
toast.error(t('workflows.publishError'));
|
||||
}
|
||||
}, [workflow, refreshWorkflows, t]);
|
||||
|
||||
// Delete handler
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!workflow?.uuid) return;
|
||||
|
||||
try {
|
||||
await backendClient.deleteWorkflow(workflow.uuid);
|
||||
refreshWorkflows();
|
||||
navigate('/home/workflows');
|
||||
toast.success(t('workflows.deleteSuccess'));
|
||||
} catch (err) {
|
||||
console.error('Failed to delete workflow:', err);
|
||||
toast.error(t('workflows.deleteError'));
|
||||
}
|
||||
}, [workflow, refreshWorkflows, navigate, t]);
|
||||
|
||||
// ==================== Create Mode ====================
|
||||
if (isCreateMode && createStep === 'basic') {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between pb-4 shrink-0">
|
||||
<h1 className="text-xl font-semibold">
|
||||
{t('workflows.createWorkflow')}
|
||||
</h1>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleFileChange}
|
||||
style={{ display: 'none' }}
|
||||
ref={fileInputRef}
|
||||
/>
|
||||
<Button variant="outline" onClick={() => fileInputRef.current?.click()}>
|
||||
<Upload className="size-4 mr-1" />
|
||||
{t('workflows.import')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setCreateStep('editor')}
|
||||
disabled={!basicInfo.name.trim()}
|
||||
>
|
||||
{t('common.next')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<div className="mx-auto max-w-2xl space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('workflows.basicInfo')}</CardTitle>
|
||||
<CardDescription>{t('workflows.basicInfoDesc')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="workflow-name">{t('workflows.name')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<EmojiPicker value={basicInfo.emoji} onChange={(emoji: string) => setBasicInfo({ ...basicInfo, emoji })} />
|
||||
<Input
|
||||
id="workflow-name"
|
||||
value={basicInfo.name}
|
||||
onChange={(e) => setBasicInfo({ ...basicInfo, name: e.target.value })}
|
||||
placeholder={t('workflows.namePlaceholder')}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="workflow-description">{t('workflows.description')}</Label>
|
||||
<Textarea
|
||||
id="workflow-description"
|
||||
value={basicInfo.description}
|
||||
onChange={(e) => setBasicInfo({ ...basicInfo, description: e.target.value })}
|
||||
placeholder={t('workflows.descriptionPlaceholder')}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isCreateMode) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between pb-4 shrink-0">
|
||||
<h1 className="text-xl font-semibold">
|
||||
{t('workflows.createWorkflow')}
|
||||
</h1>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setCreateStep('basic')}>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
{isSaving ? t('common.saving') : t('common.create')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0">
|
||||
<WorkflowEditorComponent />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Edit Mode ====================
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Hidden file input for import */}
|
||||
<input
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleFileChange}
|
||||
style={{ display: 'none' }}
|
||||
ref={fileInputRef}
|
||||
/>
|
||||
|
||||
{/* Sticky Header: title + save button */}
|
||||
<div className="flex items-center justify-between pb-4 shrink-0">
|
||||
<h1 className="text-xl font-semibold">{t('workflows.editWorkflow')}</h1>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => fileInputRef.current?.click()}>
|
||||
<Upload className="size-4 mr-1" />
|
||||
{t('workflows.import')}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleExport}>
|
||||
<Download className="size-4 mr-1" />
|
||||
{t('workflows.export')}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handlePublish}>
|
||||
<GitBranch className="size-4 mr-1" />
|
||||
{t('workflows.publish')}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!isDirty || isSaving}>
|
||||
{isSaving ? t('common.saving') : t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Horizontal Tabs */}
|
||||
<Tabs
|
||||
key={id}
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="flex flex-1 flex-col min-h-0"
|
||||
>
|
||||
<TabsList className="shrink-0">
|
||||
<TabsTrigger value="editor" className="gap-1.5">
|
||||
<Play className="size-3.5" />
|
||||
{t('workflows.editor')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="debug" className="gap-1.5">
|
||||
<Bug className="size-3.5" />
|
||||
{t('workflows.debugChat')}
|
||||
{activeTab === 'debug' && (
|
||||
<span
|
||||
className={`inline-block size-2 rounded-full ${
|
||||
isWebSocketConnected ? 'bg-green-500' : 'bg-red-500'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="config" className="gap-1.5">
|
||||
<Settings className="size-3.5" />
|
||||
{t('workflows.configuration')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="executions" className="gap-1.5">
|
||||
<BarChart3 className="size-3.5" />
|
||||
{t('workflows.executions')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Tab: Editor */}
|
||||
<TabsContent
|
||||
value="editor"
|
||||
className="flex-1 min-h-0 mt-4"
|
||||
>
|
||||
<WorkflowEditorComponent />
|
||||
</TabsContent>
|
||||
|
||||
{/* Tab: Debug Chat */}
|
||||
<TabsContent value="debug" className="flex-1 min-h-0 mt-4">
|
||||
<WorkflowDebugDialog
|
||||
open={activeTab === 'debug'}
|
||||
workflowId={id}
|
||||
isEmbedded={true}
|
||||
onConnectionStatusChange={setIsWebSocketConnected}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* Tab: Configuration */}
|
||||
<TabsContent
|
||||
value="config"
|
||||
className="flex-1 min-h-0 overflow-y-auto mt-4"
|
||||
>
|
||||
<WorkflowFormComponent
|
||||
workflow={workflow}
|
||||
onWorkflowChange={setWorkflow}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* Tab: Executions */}
|
||||
<TabsContent
|
||||
value="executions"
|
||||
className="flex-1 min-h-0 overflow-y-auto mt-4"
|
||||
>
|
||||
<WorkflowExecutionsTab workflowId={id} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface AtBadgeProps {
|
||||
targetName: string;
|
||||
readonly?: boolean;
|
||||
onRemove?: () => void;
|
||||
}
|
||||
|
||||
export default function AtBadge({
|
||||
targetName,
|
||||
readonly = false,
|
||||
onRemove,
|
||||
}: AtBadgeProps) {
|
||||
return (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="flex items-center gap-1 px-2 py-1 text-sm bg-blue-100 dark:bg-blue-900/40 text-blue-600 dark:text-blue-300 hover:bg-blue-200 dark:hover:bg-blue-900/60"
|
||||
>
|
||||
@{targetName}
|
||||
{!readonly && onRemove && (
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="ml-1 hover:text-blue-800 dark:hover:text-blue-200 focus:outline-none"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
|
||||
interface ImagePreviewDialogProps {
|
||||
open: boolean;
|
||||
imageUrl: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ImagePreviewDialog({
|
||||
open,
|
||||
imageUrl,
|
||||
onClose,
|
||||
}: ImagePreviewDialogProps) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center p-8 animate-in fade-in duration-200"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/20 " />
|
||||
|
||||
<div className="relative z-10 flex flex-col items-center gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="self-end w-9 h-9 rounded-full bg-white hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 text-gray-800 dark:text-gray-100 shadow-lg transition-all hover:scale-105 flex items-center justify-center"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="Preview"
|
||||
className="max-w-[50vw] max-h-[50vh] object-contain rounded-lg shadow-2xl bg-white"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,959 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { backendClient } from '@/app/infra/http';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Message,
|
||||
MessageChainComponent,
|
||||
Image,
|
||||
Plain,
|
||||
At,
|
||||
Quote,
|
||||
Voice,
|
||||
Source,
|
||||
} from '@/app/infra/entities/message';
|
||||
import { toast } from 'sonner';
|
||||
import AtBadge from './AtBadge';
|
||||
import { WorkflowWebSocketClient } from '@/app/infra/websocket/WorkflowWebSocketClient';
|
||||
import ImagePreviewDialog from './ImagePreviewDialog';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeHighlight from 'rehype-highlight';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
import rehypeSanitize from 'rehype-sanitize';
|
||||
import rehypeSlug from 'rehype-slug';
|
||||
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
|
||||
import '@/styles/github-markdown.css';
|
||||
import {
|
||||
User,
|
||||
Users,
|
||||
ImageIcon,
|
||||
Paperclip,
|
||||
Send,
|
||||
Reply,
|
||||
Music,
|
||||
Code,
|
||||
AlignLeft,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface WorkflowDebugDialogProps {
|
||||
open: boolean;
|
||||
workflowId: string;
|
||||
isEmbedded?: boolean;
|
||||
onConnectionStatusChange?: (isConnected: boolean) => void;
|
||||
}
|
||||
|
||||
export default function WorkflowDebugDialog({
|
||||
open,
|
||||
workflowId,
|
||||
isEmbedded = false,
|
||||
onConnectionStatusChange,
|
||||
}: WorkflowDebugDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [selectedWorkflowId, setSelectedWorkflowId] = useState(workflowId);
|
||||
const [sessionType, setSessionType] = useState<'person' | 'group'>('person');
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [showAtPopover, setShowAtPopover] = useState(false);
|
||||
const [hasAt, setHasAt] = useState(false);
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [selectedImages, setSelectedImages] = useState<
|
||||
Array<{ file: File; preview: string; fileKey?: string }>
|
||||
>([]);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [previewImageUrl, setPreviewImageUrl] = useState<string>('');
|
||||
const [showImagePreview, setShowImagePreview] = useState(false);
|
||||
const [quotedMessage, setQuotedMessage] = useState<Message | null>(null);
|
||||
const [rawModeMessages, setRawModeMessages] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [streamOutput, setStreamOutput] = useState(true);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const wsClientRef = useRef<WorkflowWebSocketClient | null>(null);
|
||||
const isInitializingRef = useRef<boolean>(false);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
setTimeout(() => {
|
||||
const scrollArea = document.querySelector('.workflow-scroll-area') as HTMLElement;
|
||||
if (scrollArea) {
|
||||
scrollArea.scrollTo({
|
||||
top: scrollArea.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, 0);
|
||||
}, []);
|
||||
|
||||
const loadMessages = useCallback(
|
||||
async (workflowId: string) => {
|
||||
try {
|
||||
const response = await backendClient.getWorkflowWebSocketHistoryMessages(
|
||||
workflowId,
|
||||
sessionType,
|
||||
);
|
||||
setMessages(response.messages);
|
||||
} catch (error) {
|
||||
console.error('Failed to load messages:', error);
|
||||
}
|
||||
},
|
||||
[sessionType],
|
||||
);
|
||||
|
||||
const initWebSocket = useCallback(
|
||||
async (workflowId: string) => {
|
||||
if (isInitializingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
isInitializingRef.current = true;
|
||||
|
||||
if (wsClientRef.current) {
|
||||
wsClientRef.current.disconnect();
|
||||
wsClientRef.current = null;
|
||||
}
|
||||
|
||||
const wsClient = new WorkflowWebSocketClient(workflowId, sessionType);
|
||||
|
||||
wsClient
|
||||
.onConnected(() => {
|
||||
setIsConnected(true);
|
||||
isInitializingRef.current = false;
|
||||
})
|
||||
.onMessage((wsMessage) => {
|
||||
const message: Message = {
|
||||
...wsMessage,
|
||||
message_chain: wsMessage.message_chain as MessageChainComponent[],
|
||||
};
|
||||
|
||||
setMessages((prevMessages) => {
|
||||
const existingIndex = prevMessages.findIndex(
|
||||
(m) => m.id === message.id,
|
||||
);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
const newMessages = [...prevMessages];
|
||||
newMessages[existingIndex] = message;
|
||||
return newMessages;
|
||||
} else {
|
||||
return [...prevMessages, message];
|
||||
}
|
||||
});
|
||||
})
|
||||
.onError((error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
setIsConnected(false);
|
||||
isInitializingRef.current = false;
|
||||
const errorMessage =
|
||||
error instanceof Error && error.message
|
||||
? error.message
|
||||
: t('workflows.debugDialog.connectionError');
|
||||
toast.error(errorMessage);
|
||||
})
|
||||
.onClose(() => {
|
||||
setIsConnected(false);
|
||||
isInitializingRef.current = false;
|
||||
})
|
||||
.onBroadcast((message) => {
|
||||
toast.info(message);
|
||||
});
|
||||
|
||||
await wsClient.connect();
|
||||
wsClientRef.current = wsClient;
|
||||
} catch (error) {
|
||||
console.error('WebSocket connection failed:', error);
|
||||
setIsConnected(false);
|
||||
isInitializingRef.current = false;
|
||||
toast.error(t('workflows.debugDialog.connectionFailed'));
|
||||
}
|
||||
},
|
||||
[sessionType, t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages, scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelectedWorkflowId(workflowId);
|
||||
} else {
|
||||
if (wsClientRef.current) {
|
||||
wsClientRef.current.disconnect();
|
||||
wsClientRef.current = null;
|
||||
setIsConnected(false);
|
||||
isInitializingRef.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (wsClientRef.current) {
|
||||
wsClientRef.current.disconnect();
|
||||
wsClientRef.current = null;
|
||||
isInitializingRef.current = false;
|
||||
}
|
||||
};
|
||||
}, [open, workflowId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setMessages([]);
|
||||
loadMessages(selectedWorkflowId);
|
||||
initWebSocket(selectedWorkflowId);
|
||||
}
|
||||
}, [sessionType, selectedWorkflowId, open, loadMessages, initWebSocket]);
|
||||
|
||||
useEffect(() => {
|
||||
onConnectionStatusChange?.(isConnected);
|
||||
}, [isConnected, onConnectionStatusChange]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
popoverRef.current &&
|
||||
!popoverRef.current.contains(event.target as Node) &&
|
||||
!inputRef.current?.contains(event.target as Node)
|
||||
) {
|
||||
setShowAtPopover(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (showAtPopover) {
|
||||
setIsHovering(true);
|
||||
}
|
||||
}, [showAtPopover]);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
if (sessionType === 'group') {
|
||||
if (value.endsWith('@')) {
|
||||
setShowAtPopover(true);
|
||||
} else if (showAtPopover && (!value.includes('@') || value.length > 1)) {
|
||||
setShowAtPopover(false);
|
||||
}
|
||||
}
|
||||
setInputValue(value);
|
||||
};
|
||||
|
||||
const handleAtSelect = () => {
|
||||
setHasAt(true);
|
||||
setShowAtPopover(false);
|
||||
setInputValue(inputValue.slice(0, -1));
|
||||
};
|
||||
|
||||
const handleAtRemove = () => {
|
||||
setHasAt(false);
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (showAtPopover) {
|
||||
handleAtSelect();
|
||||
} else {
|
||||
sendMessage();
|
||||
}
|
||||
} else if (e.key === 'Backspace' && hasAt && inputValue === '') {
|
||||
handleAtRemove();
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const newImages: Array<{ file: File; preview: string }> = [];
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
if (file.type.startsWith('image/')) {
|
||||
const preview = URL.createObjectURL(file);
|
||||
newImages.push({ file, preview });
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedImages((prev) => [...prev, ...newImages]);
|
||||
};
|
||||
|
||||
const handleRemoveImage = (index: number) => {
|
||||
setSelectedImages((prev) => {
|
||||
const newImages = [...prev];
|
||||
URL.revokeObjectURL(newImages[index].preview);
|
||||
newImages.splice(index, 1);
|
||||
return newImages;
|
||||
});
|
||||
};
|
||||
|
||||
const sendMessage = async () => {
|
||||
if (
|
||||
!inputValue.trim() &&
|
||||
!hasAt &&
|
||||
selectedImages.length === 0 &&
|
||||
!quotedMessage
|
||||
)
|
||||
return;
|
||||
if (!isConnected || !wsClientRef.current) {
|
||||
toast.error(t('workflows.debugDialog.notConnected'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsUploading(true);
|
||||
|
||||
const messageChain = [];
|
||||
|
||||
if (quotedMessage) {
|
||||
const sourceComponent = quotedMessage.message_chain.find(
|
||||
(c) => c.type === 'Source',
|
||||
) as Source | undefined;
|
||||
const messageId = sourceComponent
|
||||
? sourceComponent.id
|
||||
: quotedMessage.id;
|
||||
|
||||
messageChain.push({
|
||||
type: 'Quote',
|
||||
id: messageId,
|
||||
origin: quotedMessage.message_chain.filter(
|
||||
(c) => c.type !== 'Source',
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
let text_content = inputValue.trim();
|
||||
if (hasAt) {
|
||||
text_content = ' ' + text_content;
|
||||
}
|
||||
|
||||
if (hasAt) {
|
||||
messageChain.push({
|
||||
type: 'At',
|
||||
target: 'websocketbot',
|
||||
display: 'websocketbot',
|
||||
});
|
||||
}
|
||||
|
||||
if (text_content) {
|
||||
messageChain.push({
|
||||
type: 'Plain',
|
||||
text: text_content,
|
||||
});
|
||||
}
|
||||
|
||||
for (const image of selectedImages) {
|
||||
try {
|
||||
const result = await backendClient.uploadWorkflowWebSocketImage(
|
||||
selectedWorkflowId,
|
||||
image.file,
|
||||
);
|
||||
messageChain.push({
|
||||
type: 'Image',
|
||||
path: result.file_key,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Image upload failed:', error);
|
||||
toast.error(t('workflows.debugDialog.imageUploadFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
setInputValue('');
|
||||
setHasAt(false);
|
||||
setQuotedMessage(null);
|
||||
selectedImages.forEach((img) => URL.revokeObjectURL(img.preview));
|
||||
setSelectedImages([]);
|
||||
|
||||
wsClientRef.current.sendMessage(messageChain, streamOutput);
|
||||
} catch (error) {
|
||||
console.error('Failed to send message:', error);
|
||||
toast.error(t('workflows.debugDialog.sendFailed'));
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const renderMessageComponent = (
|
||||
component: MessageChainComponent,
|
||||
index: number,
|
||||
) => {
|
||||
switch (component.type) {
|
||||
case 'Plain':
|
||||
return <span key={index}>{(component as Plain).text}</span>;
|
||||
|
||||
case 'At': {
|
||||
const atComponent = component as At;
|
||||
const displayName =
|
||||
atComponent.display || atComponent.target?.toString() || '';
|
||||
return (
|
||||
<span key={index} className="inline-flex align-middle mx-1">
|
||||
<AtBadge targetName={displayName} readonly={true} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
case 'AtAll':
|
||||
return (
|
||||
<span key={index} className="inline-flex align-middle mx-1">
|
||||
<AtBadge
|
||||
targetName={t('workflows.debugDialog.allMembers')}
|
||||
readonly={true}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
|
||||
case 'Image': {
|
||||
const img = component as Image;
|
||||
const imageUrl = img.url || (img.base64 ? img.base64 : '');
|
||||
|
||||
if (!imageUrl) return null;
|
||||
|
||||
return (
|
||||
<div key={index} className="my-2">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="Image"
|
||||
className="max-w-full max-h-96 rounded-lg cursor-pointer hover:opacity-90 transition-opacity"
|
||||
onClick={() => {
|
||||
setPreviewImageUrl(imageUrl);
|
||||
setShowImagePreview(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'File': {
|
||||
const file = component as MessageChainComponent & { name?: string };
|
||||
return (
|
||||
<div key={index} className="my-2 flex items-center gap-2 text-sm">
|
||||
<Paperclip className="size-4" />
|
||||
<span>
|
||||
[{t('workflows.debugDialog.file')}] {file.name || 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'Voice': {
|
||||
const voice = component as Voice;
|
||||
const voiceUrl = voice.url || (voice.base64 ? voice.base64 : '');
|
||||
|
||||
if (!voiceUrl) {
|
||||
return <span key={index}>[{t('workflows.debugDialog.voice')}]</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={index} className="my-2 flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-muted rounded-lg">
|
||||
<Music className="size-5" />
|
||||
<audio
|
||||
controls
|
||||
src={voiceUrl}
|
||||
className="h-8"
|
||||
style={{ maxWidth: '200px' }}
|
||||
>
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
{voice.length && voice.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{voice.length}s
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'Quote': {
|
||||
const quote = component as Quote;
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="mb-2 pl-3 border-l-2 border-muted-foreground/50"
|
||||
>
|
||||
<div className="text-sm opacity-75">
|
||||
{quote.origin?.map((comp, idx) =>
|
||||
renderMessageComponent(comp as MessageChainComponent, idx),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'Source':
|
||||
return null;
|
||||
|
||||
default:
|
||||
return <span key={index}>[{component.type}]</span>;
|
||||
}
|
||||
};
|
||||
|
||||
const getMessageTimestamp = (message: Message): number => {
|
||||
const sourceComponent = message.message_chain.find(
|
||||
(c) => c.type === 'Source',
|
||||
) as Source | undefined;
|
||||
|
||||
if (sourceComponent && sourceComponent.timestamp) {
|
||||
return sourceComponent.timestamp;
|
||||
}
|
||||
|
||||
if (message.timestamp) {
|
||||
return Math.floor(new Date(message.timestamp).getTime() / 1000);
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
const formatTimestamp = (timestamp: number): string => {
|
||||
if (!timestamp) return '';
|
||||
|
||||
const date = new Date(timestamp * 1000);
|
||||
const now = new Date();
|
||||
|
||||
const hours = date.getHours().toString().padStart(2, '0');
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||
|
||||
const isToday = now.toDateString() === date.toDateString();
|
||||
if (isToday) {
|
||||
return `${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
const isYesterday = yesterday.toDateString() === date.toDateString();
|
||||
if (isYesterday) {
|
||||
return `${t('bots.yesterday')} ${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
const isThisYear = now.getFullYear() === date.getFullYear();
|
||||
if (isThisYear) {
|
||||
const month = date.getMonth() + 1;
|
||||
const day = date.getDate();
|
||||
return t('bots.dateFormat', { month, day });
|
||||
}
|
||||
|
||||
return t('bots.earlier');
|
||||
};
|
||||
|
||||
const getMessageKey = (message: Message): string => {
|
||||
return `${message.id}-${message.timestamp}`;
|
||||
};
|
||||
|
||||
const toggleRawMode = (message: Message) => {
|
||||
const key = getMessageKey(message);
|
||||
setRawModeMessages((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(key)) {
|
||||
newSet.delete(key);
|
||||
} else {
|
||||
newSet.add(key);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const hasPlainText = (message: Message): boolean => {
|
||||
return message.message_chain.some((c) => c.type === 'Plain');
|
||||
};
|
||||
|
||||
const getPlainText = (message: Message): string => {
|
||||
return message.message_chain
|
||||
.filter((c) => c.type === 'Plain')
|
||||
.map((c) => (c as Plain).text)
|
||||
.join('');
|
||||
};
|
||||
|
||||
const renderMessageContent = (message: Message) => {
|
||||
const key = getMessageKey(message);
|
||||
const isRawMode = rawModeMessages.has(key);
|
||||
|
||||
if (!isRawMode && hasPlainText(message)) {
|
||||
const plainText = getPlainText(message);
|
||||
const nonPlainComponents = message.message_chain.filter(
|
||||
(c) => c.type !== 'Plain' && c.type !== 'Source',
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="text-base leading-relaxed align-middle">
|
||||
{nonPlainComponents.map((component, index) =>
|
||||
renderMessageComponent(component, index),
|
||||
)}
|
||||
<div className="markdown-body">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[
|
||||
rehypeRaw,
|
||||
rehypeSanitize,
|
||||
rehypeHighlight,
|
||||
rehypeSlug,
|
||||
[
|
||||
rehypeAutolinkHeadings,
|
||||
{
|
||||
behavior: 'wrap',
|
||||
properties: {
|
||||
className: ['anchor'],
|
||||
},
|
||||
},
|
||||
],
|
||||
]}
|
||||
components={{
|
||||
ul: ({ children }) => <ul className="list-disc">{children}</ul>,
|
||||
ol: ({ children }) => (
|
||||
<ol className="list-decimal">{children}</ol>
|
||||
),
|
||||
li: ({ children }) => <li className="ml-4">{children}</li>,
|
||||
img: ({ src, alt, ...props }) => {
|
||||
const imageSrc = src || '';
|
||||
|
||||
if (typeof imageSrc !== 'string') {
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt || ''}
|
||||
className="max-w-full h-auto rounded-lg my-4"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt={alt || ''}
|
||||
className="max-w-lg h-auto my-4"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{plainText}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-base leading-relaxed align-middle whitespace-pre-wrap">
|
||||
{message.message_chain.map((component, index) =>
|
||||
renderMessageComponent(component, index),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderContent = () => (
|
||||
<div className="flex flex-1 h-full min-h-0">
|
||||
<div className="w-14 p-2 pl-0 shrink-0 flex flex-col justify-start gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
'w-10 h-10 justify-center rounded-md transition-none border-0 shadow-none',
|
||||
sessionType === 'person'
|
||||
? 'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
)}
|
||||
onClick={() => setSessionType('person')}
|
||||
>
|
||||
<User className="size-5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
'w-10 h-10 justify-center rounded-md transition-none border-0 shadow-none',
|
||||
sessionType === 'group'
|
||||
? 'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
)}
|
||||
onClick={() => setSessionType('group')}
|
||||
>
|
||||
<Users className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col w-[10rem] h-full min-h-0">
|
||||
<ScrollArea className="flex-1 p-6 overflow-y-auto min-h-0 workflow-scroll-area">
|
||||
<div className="space-y-6">
|
||||
{messages.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-12 text-lg">
|
||||
{t('workflows.debugDialog.noMessages')}
|
||||
</div>
|
||||
) : (
|
||||
messages.map((message) => (
|
||||
<div
|
||||
key={message.id + message.timestamp}
|
||||
className={cn(
|
||||
'flex',
|
||||
message.role === 'user' ? 'justify-end' : 'justify-start',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'max-w-3xl px-5 py-3 rounded-2xl',
|
||||
message.role === 'user'
|
||||
? 'user-message-bubble bg-primary/10 text-foreground rounded-br-none'
|
||||
: 'bg-muted text-foreground rounded-bl-none',
|
||||
)}
|
||||
>
|
||||
{renderMessageContent(message)}
|
||||
<div
|
||||
className={cn(
|
||||
'text-xs mt-2 flex items-center justify-between gap-2',
|
||||
'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>
|
||||
{message.role === 'user'
|
||||
? t('workflows.debugDialog.userMessage')
|
||||
: t('workflows.debugDialog.botMessage')}
|
||||
</span>
|
||||
{hasPlainText(message) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleRawMode(message)}
|
||||
className={cn(
|
||||
'px-1.5 py-0.5 rounded text-[10px] transition-colors',
|
||||
'hover:bg-accent',
|
||||
)}
|
||||
title={
|
||||
rawModeMessages.has(getMessageKey(message))
|
||||
? t('workflows.debugDialog.showMarkdown')
|
||||
: t('workflows.debugDialog.showRaw')
|
||||
}
|
||||
>
|
||||
{rawModeMessages.has(getMessageKey(message)) ? (
|
||||
<span className="flex items-center gap-0.5">
|
||||
<Code className="size-3" />
|
||||
MD
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-0.5">
|
||||
<AlignLeft className="size-3" />
|
||||
{t('workflows.debugDialog.showRaw')}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuotedMessage(message)}
|
||||
className={cn(
|
||||
'px-1.5 py-0.5 rounded text-[10px] transition-colors flex items-center gap-0.5',
|
||||
'hover:bg-accent',
|
||||
)}
|
||||
title={t('workflows.debugDialog.reply')}
|
||||
>
|
||||
<Reply className="size-3" />
|
||||
{t('workflows.debugDialog.reply')}
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-[10px]">
|
||||
{formatTimestamp(getMessageTimestamp(message))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Quoted message preview */}
|
||||
{quotedMessage && (
|
||||
<div className="px-4 py-2 bg-muted/50 border-t">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex-1 pl-3 border-l-2 border-primary">
|
||||
<div className="text-xs text-muted-foreground mb-1">
|
||||
{t('workflows.debugDialog.replyTo')}{' '}
|
||||
{quotedMessage.role === 'user'
|
||||
? t('workflows.debugDialog.userMessage')
|
||||
: t('workflows.debugDialog.botMessage')}
|
||||
</div>
|
||||
<div className="text-sm text-foreground/70 line-clamp-2">
|
||||
{quotedMessage.message_chain
|
||||
.filter((c) => c.type === 'Plain')
|
||||
.map((c) => (c as Plain).text)
|
||||
.join('')}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuotedMessage(null)}
|
||||
className="w-5 h-5 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Image preview area */}
|
||||
{selectedImages.length > 0 && (
|
||||
<div className="px-4 pb-2">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{selectedImages.map((image, index) => (
|
||||
<div key={index} className="relative group">
|
||||
<img
|
||||
src={image.preview}
|
||||
alt={`preview-${index}`}
|
||||
className="w-20 h-20 object-cover rounded-lg border"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveImage(index)}
|
||||
className="absolute -top-2 -right-2 w-5 h-5 bg-destructive text-destructive-foreground rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-4 pb-0 flex gap-2">
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('workflows.debugDialog.streamOutput')}
|
||||
</span>
|
||||
<Switch
|
||||
checked={streamOutput}
|
||||
onCheckedChange={setStreamOutput}
|
||||
disabled={!isConnected}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
onChange={handleImageSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={!isConnected || isUploading}
|
||||
className="w-10 h-10 rounded-md hover:bg-accent"
|
||||
title={t('workflows.debugDialog.uploadImage')}
|
||||
>
|
||||
<ImageIcon className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
{hasAt && (
|
||||
<AtBadge targetName="websocketbot" onRemove={handleAtRemove} />
|
||||
)}
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder={t('workflows.debugDialog.inputPlaceholder', {
|
||||
type:
|
||||
sessionType === 'person'
|
||||
? t('workflows.debugDialog.privateChat')
|
||||
: t('workflows.debugDialog.groupChat'),
|
||||
})}
|
||||
disabled={!isConnected || isUploading}
|
||||
className="flex-1 rounded-md px-3 py-2 transition-none text-base disabled:opacity-50"
|
||||
/>
|
||||
{showAtPopover && (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className="absolute bottom-full left-0 mb-2 w-auto rounded-md border bg-popover text-popover-foreground shadow-lg"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-4 py-1.5 rounded cursor-pointer',
|
||||
isHovering ? 'bg-accent' : '',
|
||||
)}
|
||||
onClick={handleAtSelect}
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => setIsHovering(false)}
|
||||
>
|
||||
<span>
|
||||
@websocketbot - {t('workflows.debugDialog.atTips')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={sendMessage}
|
||||
disabled={
|
||||
(!inputValue.trim() &&
|
||||
!hasAt &&
|
||||
selectedImages.length === 0 &&
|
||||
!quotedMessage) ||
|
||||
!isConnected ||
|
||||
isUploading
|
||||
}
|
||||
className="rounded-md w-20 px-6 py-2 text-base font-medium transition-none flex items-center gap-2 shadow-none disabled:opacity-50"
|
||||
>
|
||||
{isUploading ? (
|
||||
t('workflows.debugDialog.uploading')
|
||||
) : (
|
||||
<>
|
||||
<Send className="size-4" />
|
||||
{t('workflows.debugDialog.send')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isEmbedded) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="flex-1 min-h-0 flex flex-col">{renderContent()}</div>
|
||||
</div>
|
||||
<ImagePreviewDialog
|
||||
open={showImagePreview}
|
||||
imageUrl={previewImageUrl}
|
||||
onClose={() => setShowImagePreview(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="flex-1 min-h-0 flex flex-col">{renderContent()}</div>
|
||||
</div>
|
||||
<ImagePreviewDialog
|
||||
open={showImagePreview}
|
||||
imageUrl={previewImageUrl}
|
||||
onClose={() => setShowImagePreview(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,716 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useWorkflowStore, DebugLog, NodeExecutionResult } from '../../store/useWorkflowStore';
|
||||
import { backendClient } from '@/app/infra/http';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
StepForward,
|
||||
Square,
|
||||
Bug,
|
||||
Terminal,
|
||||
Eye,
|
||||
Circle,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Loader2,
|
||||
XCircle,
|
||||
Plus,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface WorkflowDebuggerProps {
|
||||
workflowId: string;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
const statusIcons: Record<string, React.ElementType> = {
|
||||
pending: Clock,
|
||||
running: Loader2,
|
||||
completed: CheckCircle2,
|
||||
failed: AlertCircle,
|
||||
skipped: XCircle,
|
||||
};
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
pending: 'text-yellow-500',
|
||||
running: 'text-blue-500 animate-spin',
|
||||
completed: 'text-green-500',
|
||||
failed: 'text-red-500',
|
||||
skipped: 'text-gray-400',
|
||||
};
|
||||
|
||||
const logLevelColors: Record<string, string> = {
|
||||
info: 'text-blue-400',
|
||||
warning: 'text-yellow-400',
|
||||
error: 'text-red-400',
|
||||
debug: 'text-gray-400',
|
||||
};
|
||||
|
||||
export default function WorkflowDebugger({ workflowId, onClose }: WorkflowDebuggerProps) {
|
||||
const { t } = useTranslation();
|
||||
const logsEndRef = useRef<HTMLDivElement>(null);
|
||||
const [activeTab, setActiveTab] = useState<string>('context');
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const [newVariable, setNewVariable] = useState({ key: '', value: '' });
|
||||
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
|
||||
const pollCancelledRef = useRef(false);
|
||||
|
||||
const {
|
||||
debugMode,
|
||||
debugState,
|
||||
debugExecutionId,
|
||||
currentNodeId,
|
||||
nodeExecutionResults,
|
||||
breakpoints,
|
||||
debugLogs,
|
||||
debugContext,
|
||||
watchedVariables,
|
||||
nodes,
|
||||
setDebugMode,
|
||||
setDebugState,
|
||||
setDebugExecutionId,
|
||||
setCurrentNodeId,
|
||||
updateNodeExecutionResult,
|
||||
clearNodeExecutionResults,
|
||||
toggleBreakpoint,
|
||||
clearBreakpoints,
|
||||
addDebugLog,
|
||||
clearDebugLogs,
|
||||
setDebugContext,
|
||||
resetDebugContext,
|
||||
addWatchedVariable,
|
||||
removeWatchedVariable,
|
||||
resetDebugState,
|
||||
} = useWorkflowStore();
|
||||
|
||||
// Cleanup polling on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
pollCancelledRef.current = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Auto-scroll logs
|
||||
useEffect(() => {
|
||||
if (autoScroll && logsEndRef.current) {
|
||||
logsEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, [debugLogs, autoScroll]);
|
||||
|
||||
// Start debug execution
|
||||
const handleStart = useCallback(async () => {
|
||||
try {
|
||||
setDebugState('running');
|
||||
clearNodeExecutionResults();
|
||||
clearDebugLogs();
|
||||
|
||||
addDebugLog({ level: 'info', message: t('workflows.debug.starting') });
|
||||
|
||||
const response = await backendClient.startWorkflowDebug(workflowId, {
|
||||
context: {
|
||||
message_content: debugContext.messageContent,
|
||||
sender_id: debugContext.senderId,
|
||||
sender_name: debugContext.senderName,
|
||||
platform: debugContext.platform,
|
||||
conversation_id: debugContext.conversationId,
|
||||
is_group: debugContext.isGroup,
|
||||
},
|
||||
variables: debugContext.customVariables,
|
||||
breakpoints: Object.keys(breakpoints).filter(k => breakpoints[k]),
|
||||
});
|
||||
|
||||
setDebugExecutionId(response.execution_id);
|
||||
addDebugLog({ level: 'info', message: t('workflows.debug.started', { id: response.execution_id }) });
|
||||
|
||||
// Start polling for state updates
|
||||
pollDebugState(response.execution_id);
|
||||
} catch (error) {
|
||||
setDebugState('error');
|
||||
addDebugLog({ level: 'error', message: `${t('workflows.debug.startError')}: ${error}` });
|
||||
}
|
||||
}, [workflowId, debugContext, breakpoints, t]);
|
||||
|
||||
// Poll debug state
|
||||
const pollDebugState = useCallback(async (executionId: string) => {
|
||||
pollCancelledRef.current = false;
|
||||
const poll = async () => {
|
||||
if (pollCancelledRef.current) return;
|
||||
try {
|
||||
const state = await backendClient.getWorkflowDebugState(workflowId, executionId);
|
||||
if (pollCancelledRef.current) return;
|
||||
|
||||
setDebugState(state.status as typeof debugState);
|
||||
setCurrentNodeId(state.current_node_id || null);
|
||||
|
||||
// Update node execution results
|
||||
if (state.node_states) {
|
||||
for (const [nodeId, nodeState] of Object.entries(state.node_states)) {
|
||||
updateNodeExecutionResult(nodeId, nodeState as Partial<NodeExecutionResult>);
|
||||
}
|
||||
}
|
||||
|
||||
// Add new logs
|
||||
if (state.new_logs) {
|
||||
for (const log of state.new_logs) {
|
||||
addDebugLog(log);
|
||||
}
|
||||
}
|
||||
|
||||
// Continue polling if still running or paused
|
||||
if (!pollCancelledRef.current && (state.status === 'running' || state.status === 'paused')) {
|
||||
setTimeout(poll, 500);
|
||||
} else if (state.status === 'completed') {
|
||||
addDebugLog({ level: 'info', message: t('workflows.debug.completed') });
|
||||
} else if (state.status === 'error') {
|
||||
addDebugLog({ level: 'error', message: state.error || t('workflows.debug.unknownError') });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to poll debug state:', error);
|
||||
}
|
||||
};
|
||||
|
||||
poll();
|
||||
}, [workflowId, t]);
|
||||
|
||||
// Pause execution
|
||||
const handlePause = useCallback(async () => {
|
||||
if (!debugExecutionId) return;
|
||||
|
||||
try {
|
||||
await backendClient.pauseWorkflowDebug(workflowId, debugExecutionId);
|
||||
setDebugState('paused');
|
||||
addDebugLog({ level: 'info', message: t('workflows.debug.paused') });
|
||||
} catch (error) {
|
||||
addDebugLog({ level: 'error', message: `${t('workflows.debug.pauseError')}: ${error}` });
|
||||
}
|
||||
}, [workflowId, debugExecutionId, t]);
|
||||
|
||||
// Resume execution
|
||||
const handleResume = useCallback(async () => {
|
||||
if (!debugExecutionId) return;
|
||||
|
||||
try {
|
||||
await backendClient.resumeWorkflowDebug(workflowId, debugExecutionId);
|
||||
setDebugState('running');
|
||||
addDebugLog({ level: 'info', message: t('workflows.debug.resumed') });
|
||||
pollDebugState(debugExecutionId);
|
||||
} catch (error) {
|
||||
addDebugLog({ level: 'error', message: `${t('workflows.debug.resumeError')}: ${error}` });
|
||||
}
|
||||
}, [workflowId, debugExecutionId, t, pollDebugState]);
|
||||
|
||||
// Step execution
|
||||
const handleStep = useCallback(async () => {
|
||||
if (!debugExecutionId) return;
|
||||
|
||||
try {
|
||||
const result = await backendClient.stepWorkflowDebug(workflowId, debugExecutionId);
|
||||
|
||||
if (result.node_id) {
|
||||
setCurrentNodeId(result.node_id);
|
||||
updateNodeExecutionResult(result.node_id, result.node_state as Partial<NodeExecutionResult>);
|
||||
addDebugLog({
|
||||
level: 'info',
|
||||
message: t('workflows.debug.steppedTo', { node: result.node_id }),
|
||||
nodeId: result.node_id,
|
||||
});
|
||||
}
|
||||
|
||||
if (result.completed) {
|
||||
setDebugState('completed');
|
||||
addDebugLog({ level: 'info', message: t('workflows.debug.completed') });
|
||||
}
|
||||
} catch (error) {
|
||||
addDebugLog({ level: 'error', message: `${t('workflows.debug.stepError')}: ${error}` });
|
||||
}
|
||||
}, [workflowId, debugExecutionId, t]);
|
||||
|
||||
// Stop execution
|
||||
const handleStop = useCallback(async () => {
|
||||
if (!debugExecutionId) return;
|
||||
|
||||
try {
|
||||
await backendClient.stopWorkflowDebug(workflowId, debugExecutionId);
|
||||
resetDebugState();
|
||||
addDebugLog({ level: 'info', message: t('workflows.debug.stopped') });
|
||||
} catch (error) {
|
||||
addDebugLog({ level: 'error', message: `${t('workflows.debug.stopError')}: ${error}` });
|
||||
}
|
||||
}, [workflowId, debugExecutionId, t, resetDebugState]);
|
||||
|
||||
// Toggle node expansion
|
||||
const toggleNodeExpanded = (nodeId: string) => {
|
||||
setExpandedNodes((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(nodeId)) {
|
||||
newSet.delete(nodeId);
|
||||
} else {
|
||||
newSet.add(nodeId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
// Add custom variable
|
||||
const handleAddVariable = () => {
|
||||
if (newVariable.key.trim()) {
|
||||
try {
|
||||
const value = JSON.parse(newVariable.value);
|
||||
setDebugContext({
|
||||
customVariables: {
|
||||
...debugContext.customVariables,
|
||||
[newVariable.key]: value,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
setDebugContext({
|
||||
customVariables: {
|
||||
...debugContext.customVariables,
|
||||
[newVariable.key]: newVariable.value,
|
||||
},
|
||||
});
|
||||
}
|
||||
setNewVariable({ key: '', value: '' });
|
||||
}
|
||||
};
|
||||
|
||||
// Remove custom variable
|
||||
const handleRemoveVariable = (key: string) => {
|
||||
const newVars = { ...debugContext.customVariables };
|
||||
delete newVars[key];
|
||||
setDebugContext({ customVariables: newVars });
|
||||
};
|
||||
|
||||
const isRunning = debugState === 'running';
|
||||
const isPaused = debugState === 'paused';
|
||||
const canStart = debugState === 'idle' || debugState === 'completed' || debugState === 'error';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background border-l">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bug className="size-5 text-primary" />
|
||||
<span className="font-semibold">{t('workflows.debug.title')}</span>
|
||||
{debugState !== 'idle' && (
|
||||
<Badge variant={isRunning ? 'default' : isPaused ? 'secondary' : 'outline'}>
|
||||
{t(`workflows.debug.state.${debugState}`)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{onClose && (
|
||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Control Bar */}
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b bg-muted/30">
|
||||
{canStart ? (
|
||||
<Button size="sm" onClick={handleStart} className="gap-1">
|
||||
<Play className="size-4" />
|
||||
{t('workflows.debug.start')}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
{isRunning ? (
|
||||
<Button size="sm" variant="secondary" onClick={handlePause} className="gap-1">
|
||||
<Pause className="size-4" />
|
||||
{t('workflows.debug.pause')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" onClick={handleResume} className="gap-1">
|
||||
<Play className="size-4" />
|
||||
{t('workflows.debug.resume')}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="outline" onClick={handleStep} disabled={isRunning} className="gap-1">
|
||||
<StepForward className="size-4" />
|
||||
{t('workflows.debug.step')}
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" onClick={handleStop} className="gap-1">
|
||||
<Square className="size-4" />
|
||||
{t('workflows.debug.stop')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<Button size="sm" variant="ghost" onClick={clearBreakpoints} title={t('workflows.debug.clearBreakpoints')}>
|
||||
<Circle className="size-4" />
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={clearDebugLogs} title={t('workflows.debug.clearLogs')}>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col min-h-0">
|
||||
<TabsList className="mx-4 mt-2 justify-start">
|
||||
<TabsTrigger value="context" className="gap-1">
|
||||
<Terminal className="size-4" />
|
||||
{t('workflows.debug.context')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="variables" className="gap-1">
|
||||
<Eye className="size-4" />
|
||||
{t('workflows.debug.variables')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="nodes" className="gap-1">
|
||||
<CheckCircle2 className="size-4" />
|
||||
{t('workflows.debug.nodeStates')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="logs" className="gap-1">
|
||||
<Terminal className="size-4" />
|
||||
{t('workflows.debug.logs')}
|
||||
{debugLogs.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-1 text-xs">
|
||||
{debugLogs.length}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Context Tab */}
|
||||
<TabsContent value="context" className="flex-1 p-4 overflow-auto">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('workflows.debug.messageContent')}</Label>
|
||||
<Textarea
|
||||
value={debugContext.messageContent}
|
||||
onChange={(e) => setDebugContext({ messageContent: e.target.value })}
|
||||
placeholder={t('workflows.debug.messageContentPlaceholder')}
|
||||
className="min-h-[80px]"
|
||||
disabled={!canStart}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('workflows.debug.senderId')}</Label>
|
||||
<Input
|
||||
value={debugContext.senderId}
|
||||
onChange={(e) => setDebugContext({ senderId: e.target.value })}
|
||||
placeholder={t('workflows.debug.senderIdPlaceholder')}
|
||||
disabled={!canStart}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('workflows.debug.senderName')}</Label>
|
||||
<Input
|
||||
value={debugContext.senderName}
|
||||
onChange={(e) => setDebugContext({ senderName: e.target.value })}
|
||||
placeholder={t('workflows.debug.senderNamePlaceholder')}
|
||||
disabled={!canStart}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('workflows.debug.platform')}</Label>
|
||||
<Input
|
||||
value={debugContext.platform}
|
||||
onChange={(e) => setDebugContext({ platform: e.target.value })}
|
||||
placeholder={t('workflows.debug.platformPlaceholder')}
|
||||
disabled={!canStart}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('workflows.debug.conversationId')}</Label>
|
||||
<Input
|
||||
value={debugContext.conversationId}
|
||||
onChange={(e) => setDebugContext({ conversationId: e.target.value })}
|
||||
placeholder={t('workflows.debug.conversationIdPlaceholder')}
|
||||
disabled={!canStart}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={debugContext.isGroup}
|
||||
onCheckedChange={(checked) => setDebugContext({ isGroup: checked })}
|
||||
disabled={!canStart}
|
||||
/>
|
||||
<Label>{t('workflows.debug.isGroup')}</Label>
|
||||
</div>
|
||||
|
||||
{/* Custom Variables */}
|
||||
<Card>
|
||||
<CardHeader className="py-3">
|
||||
<CardTitle className="text-sm">{t('workflows.debug.customVariables')}</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
{t('workflows.debug.customVariablesDesc')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="py-2">
|
||||
<div className="space-y-2">
|
||||
{Object.entries(debugContext.customVariables).map(([key, value]) => (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<code className="text-xs bg-muted px-2 py-1 rounded flex-1">
|
||||
{key}: {JSON.stringify(value)}
|
||||
</code>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-6"
|
||||
onClick={() => handleRemoveVariable(key)}
|
||||
disabled={!canStart}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder={t('workflows.debug.variableKey')}
|
||||
value={newVariable.key}
|
||||
onChange={(e) => setNewVariable({ ...newVariable, key: e.target.value })}
|
||||
className="text-xs"
|
||||
disabled={!canStart}
|
||||
/>
|
||||
<Input
|
||||
placeholder={t('workflows.debug.variableValue')}
|
||||
value={newVariable.value}
|
||||
onChange={(e) => setNewVariable({ ...newVariable, value: e.target.value })}
|
||||
className="text-xs"
|
||||
disabled={!canStart}
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={handleAddVariable}
|
||||
disabled={!canStart}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Button variant="outline" size="sm" onClick={resetDebugContext} disabled={!canStart}>
|
||||
<RefreshCw className="size-4 mr-2" />
|
||||
{t('workflows.debug.resetContext')}
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Variables Tab */}
|
||||
<TabsContent value="variables" className="flex-1 p-4 overflow-auto">
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader className="py-3">
|
||||
<CardTitle className="text-sm">{t('workflows.debug.watchedVariables')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="py-2">
|
||||
{watchedVariables.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('workflows.debug.noWatchedVariables')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{watchedVariables.map((variable) => {
|
||||
// Try to find value from node outputs
|
||||
let value: unknown = undefined;
|
||||
const parts = variable.split('.');
|
||||
if (parts[0] === 'nodes' && parts.length >= 3) {
|
||||
const nodeId = parts[1];
|
||||
const outputKey = parts.slice(2).join('.');
|
||||
const nodeResult = nodeExecutionResults[nodeId];
|
||||
if (nodeResult?.outputs) {
|
||||
value = nodeResult.outputs[outputKey];
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={variable} className="flex items-center gap-2">
|
||||
<code className="text-xs bg-muted px-2 py-1 rounded flex-1 font-mono">
|
||||
{variable} = {value !== undefined ? JSON.stringify(value) : 'undefined'}
|
||||
</code>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-6"
|
||||
onClick={() => removeWatchedVariable(variable)}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Node Outputs */}
|
||||
<Card>
|
||||
<CardHeader className="py-3">
|
||||
<CardTitle className="text-sm">{t('workflows.debug.nodeOutputs')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="py-2">
|
||||
{Object.keys(nodeExecutionResults).length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('workflows.debug.noNodeOutputs')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{Object.entries(nodeExecutionResults).map(([nodeId, result]) => {
|
||||
const node = nodes.find((n) => n.id === nodeId);
|
||||
return (
|
||||
<Collapsible key={nodeId}>
|
||||
<CollapsibleTrigger className="flex items-center gap-2 w-full text-left">
|
||||
<ChevronRight className="size-4 transition-transform data-[state=open]:rotate-90" />
|
||||
<span className="text-sm font-medium">
|
||||
{node?.data.label || nodeId}
|
||||
</span>
|
||||
{result.outputs && Object.keys(result.outputs).length > 0 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{Object.keys(result.outputs).length} outputs
|
||||
</Badge>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pl-6 pt-2">
|
||||
{result.outputs ? (
|
||||
<pre className="text-xs bg-muted p-2 rounded overflow-auto max-h-40">
|
||||
{JSON.stringify(result.outputs, null, 2)}
|
||||
</pre>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">No outputs</p>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Node States Tab */}
|
||||
<TabsContent value="nodes" className="flex-1 p-4 overflow-auto">
|
||||
<div className="space-y-2">
|
||||
{nodes.map((node) => {
|
||||
const result = nodeExecutionResults[node.id];
|
||||
const StatusIcon = result ? statusIcons[result.status] || Clock : Clock;
|
||||
const statusColor = result ? statusColors[result.status] || '' : 'text-gray-400';
|
||||
const isCurrentNode = currentNodeId === node.id;
|
||||
const hasBreakpoint = !!breakpoints[node.id];
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={node.id}
|
||||
className={`${isCurrentNode ? 'border-primary ring-1 ring-primary' : ''}`}
|
||||
>
|
||||
<CardHeader className="py-2 px-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => toggleBreakpoint(node.id)}
|
||||
className={`size-4 rounded-full border-2 ${
|
||||
hasBreakpoint
|
||||
? 'bg-red-500 border-red-500'
|
||||
: 'border-gray-400 hover:border-red-400'
|
||||
}`}
|
||||
title={t('workflows.debug.toggleBreakpoint')}
|
||||
/>
|
||||
<StatusIcon className={`size-4 ${statusColor}`} />
|
||||
<span className="text-sm font-medium">{node.data.label}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{node.data.type}
|
||||
</Badge>
|
||||
</div>
|
||||
{result?.duration !== undefined && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{result.duration}ms
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
{result?.error && (
|
||||
<CardContent className="py-2 px-3">
|
||||
<div className="text-xs text-red-500 bg-red-50 dark:bg-red-950 p-2 rounded">
|
||||
{result.error}
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Logs Tab */}
|
||||
<TabsContent value="logs" className="flex-1 flex flex-col min-h-0">
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={autoScroll}
|
||||
onCheckedChange={setAutoScroll}
|
||||
className="scale-75"
|
||||
/>
|
||||
<Label className="text-xs">{t('workflows.debug.autoScroll')}</Label>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{debugLogs.length} {t('workflows.debug.logEntries')}
|
||||
</span>
|
||||
</div>
|
||||
<ScrollArea className="flex-1 p-4">
|
||||
<div className="space-y-1 font-mono text-xs">
|
||||
{debugLogs.length === 0 ? (
|
||||
<p className="text-muted-foreground">{t('workflows.debug.noLogs')}</p>
|
||||
) : (
|
||||
debugLogs.map((log) => (
|
||||
<div key={log.id} className="flex gap-2">
|
||||
<span className="text-muted-foreground shrink-0">
|
||||
{new Date(log.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
<span className={`shrink-0 uppercase ${logLevelColors[log.level]}`}>
|
||||
[{log.level}]
|
||||
</span>
|
||||
{log.nodeId && (
|
||||
<span className="text-purple-400 shrink-0">[{log.nodeId}]</span>
|
||||
)}
|
||||
<span className="text-foreground">{log.message}</span>
|
||||
{log.data && (
|
||||
<span className="text-muted-foreground">
|
||||
{JSON.stringify(log.data)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={logsEndRef} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as WorkflowDebugger } from './WorkflowDebugger';
|
||||
@@ -0,0 +1,334 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useWorkflowStore } from '../../store/useWorkflowStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Search,
|
||||
Settings,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ExternalLink,
|
||||
Layers,
|
||||
Cpu,
|
||||
} from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
NODE_ICONS,
|
||||
NODE_TYPE_I18N_KEYS,
|
||||
CATEGORY_I18N_KEYS,
|
||||
PALETTE_CATEGORY_COLORS as categoryColors,
|
||||
PALETTE_CATEGORY_BG as categoryBgColors,
|
||||
PALETTE_CATEGORY_BORDER as categoryBorderColors,
|
||||
CATEGORY_ICONS as categoryIcons,
|
||||
findNodeI18nKeys,
|
||||
} from './workflow-constants';
|
||||
import { resolveI18nLabel } from './workflow-i18n';
|
||||
|
||||
// Use shared icon mapping
|
||||
const nodeIcons = NODE_ICONS;
|
||||
|
||||
// Use shared i18n key mapping
|
||||
const nodeTypeI18nKeys = NODE_TYPE_I18N_KEYS;
|
||||
|
||||
// Use shared category i18n keys
|
||||
const categoryI18nKeys = CATEGORY_I18N_KEYS;
|
||||
|
||||
// Common node type definition for UI purposes
|
||||
interface NodeTypeForUI {
|
||||
type: string;
|
||||
category: string;
|
||||
labelKey?: string;
|
||||
descriptionKey?: string;
|
||||
// Also support raw label dict from backend
|
||||
label?: Record<string, string>;
|
||||
description?: Record<string, string>;
|
||||
}
|
||||
|
||||
// Default node types generated from shared constants
|
||||
const defaultNodeTypes: NodeTypeForUI[] = Object.entries(NODE_TYPE_I18N_KEYS).map(([type, keys]) => ({
|
||||
type,
|
||||
category: type.split('.')[0],
|
||||
labelKey: keys.labelKey,
|
||||
descriptionKey: keys.descriptionKey,
|
||||
}));
|
||||
|
||||
export default function NodePalette() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { nodeTypes: backendNodeTypes, nodeCategories } = useWorkflowStore();
|
||||
|
||||
// Search state
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// Expanded categories state
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
|
||||
new Set(['trigger', 'process', 'control', 'action', 'integration'])
|
||||
);
|
||||
|
||||
// Helper: get label string for a node using i18n
|
||||
const getNodeLabel = useCallback((node: NodeTypeForUI): string => {
|
||||
if (node.labelKey) {
|
||||
return t(node.labelKey, { defaultValue: node.labelKey });
|
||||
}
|
||||
if (node.label) {
|
||||
const labelValue = resolveI18nLabel(node.label);
|
||||
return labelValue || node.type;
|
||||
}
|
||||
return node.type;
|
||||
}, [t]);
|
||||
|
||||
// Helper: get description string for a node using i18n
|
||||
const getNodeDescription = useCallback((node: NodeTypeForUI): string => {
|
||||
if (node.descriptionKey) {
|
||||
return t(node.descriptionKey, { defaultValue: '' });
|
||||
}
|
||||
if (node.description) {
|
||||
const descValue = resolveI18nLabel(node.description);
|
||||
return descValue || '';
|
||||
}
|
||||
return '';
|
||||
}, [t]);
|
||||
|
||||
// Use backend node types if available, otherwise use defaults
|
||||
const nodeTypes = useMemo((): NodeTypeForUI[] => {
|
||||
if (backendNodeTypes && backendNodeTypes.length > 0) {
|
||||
return backendNodeTypes.map((node) => {
|
||||
const i18nKeys = findNodeI18nKeys(node.type);
|
||||
|
||||
return {
|
||||
type: node.type,
|
||||
category: node.category,
|
||||
labelKey: i18nKeys?.labelKey,
|
||||
descriptionKey: i18nKeys?.descriptionKey,
|
||||
// Keep raw label dict as fallback for unknown nodes
|
||||
label: i18nKeys ? undefined : node.label,
|
||||
description: i18nKeys ? undefined : node.description,
|
||||
};
|
||||
});
|
||||
}
|
||||
return defaultNodeTypes;
|
||||
}, [backendNodeTypes]);
|
||||
|
||||
// Filter nodes based on search query
|
||||
const filteredNodes = useMemo(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
return nodeTypes;
|
||||
}
|
||||
const query = searchQuery.toLowerCase();
|
||||
return nodeTypes.filter((node) => {
|
||||
const label = getNodeLabel(node);
|
||||
const description = getNodeDescription(node);
|
||||
return (
|
||||
label.toLowerCase().includes(query) ||
|
||||
description.toLowerCase().includes(query) ||
|
||||
node.type.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
}, [nodeTypes, searchQuery, getNodeLabel, getNodeDescription]);
|
||||
|
||||
// Group filtered nodes by category
|
||||
const groupedNodes = useMemo(() => {
|
||||
const groups: Record<string, typeof nodeTypes> = {};
|
||||
const categoryOrder = ['trigger', 'process', 'control', 'action', 'integration'];
|
||||
|
||||
// Initialize groups in order
|
||||
categoryOrder.forEach((cat) => {
|
||||
groups[cat] = [];
|
||||
});
|
||||
|
||||
for (const node of filteredNodes) {
|
||||
const category = node.category || node.type.split('.')[0];
|
||||
if (!groups[category]) {
|
||||
groups[category] = [];
|
||||
}
|
||||
groups[category].push(node);
|
||||
}
|
||||
|
||||
// Remove empty categories
|
||||
Object.keys(groups).forEach((key) => {
|
||||
if (groups[key].length === 0) {
|
||||
delete groups[key];
|
||||
}
|
||||
});
|
||||
|
||||
return groups;
|
||||
}, [filteredNodes]);
|
||||
|
||||
// Toggle category expansion
|
||||
const toggleCategory = useCallback((category: string) => {
|
||||
setExpandedCategories((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(category)) {
|
||||
next.delete(category);
|
||||
} else {
|
||||
next.add(category);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Handle drag start
|
||||
const onDragStart = useCallback(
|
||||
(event: React.DragEvent<HTMLDivElement>, nodeType: string) => {
|
||||
event.dataTransfer.setData('application/reactflow', nodeType);
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
event.dataTransfer.setData('text/plain', nodeType);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Get category label using i18n
|
||||
const getCategoryLabel = useCallback(
|
||||
(categoryName: string) => {
|
||||
const i18nKey = categoryI18nKeys[categoryName];
|
||||
if (i18nKey) {
|
||||
return t(i18nKey.labelKey, { defaultValue: categoryName });
|
||||
}
|
||||
// Fallback to backend category label dict
|
||||
const category = nodeCategories?.find((c) => c.name === categoryName);
|
||||
if (category?.label) {
|
||||
const lang = i18n.language;
|
||||
if (lang.startsWith('zh')) {
|
||||
return category.label['zh-CN'] || category.label['zh-Hans'] || category.label['en'] || categoryName;
|
||||
}
|
||||
return category.label['en'] || category.label['en-US'] || categoryName;
|
||||
}
|
||||
return categoryName;
|
||||
},
|
||||
[nodeCategories, t, i18n.language]
|
||||
);
|
||||
|
||||
// Clear search
|
||||
const clearSearch = useCallback(() => {
|
||||
setSearchQuery('');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="p-3 border-b">
|
||||
<h3 className="font-semibold text-sm mb-3 flex items-center gap-2">
|
||||
<Layers className="size-4" />
|
||||
{t('workflows.nodePalette')}
|
||||
</h3>
|
||||
|
||||
{/* Search input */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t('workflows.searchNodes')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-8 pr-8 h-8 text-sm"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={clearSearch}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Node list */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<div className="p-2 space-y-1">
|
||||
{/* Show loading state if no nodes */}
|
||||
{Object.keys(groupedNodes).length === 0 && !searchQuery && (
|
||||
<div className="text-sm text-muted-foreground text-center py-8">
|
||||
<Cpu className="size-8 mx-auto mb-2 opacity-50" />
|
||||
<p>{t('workflows.loadingNodeTypes')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show no results message */}
|
||||
{Object.keys(groupedNodes).length === 0 && searchQuery && (
|
||||
<div className="text-sm text-muted-foreground text-center py-8">
|
||||
<Search className="size-8 mx-auto mb-2 opacity-50" />
|
||||
<p>{t('workflows.noNodesFound')}</p>
|
||||
<button
|
||||
onClick={clearSearch}
|
||||
className="text-primary hover:underline mt-2"
|
||||
>
|
||||
{t('workflows.clearSearch')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Category groups */}
|
||||
{Object.entries(groupedNodes).map(([category, nodes]) => {
|
||||
const isExpanded = expandedCategories.has(category);
|
||||
const CategoryIcon = categoryIcons[category] || Settings;
|
||||
const ChevronIcon = isExpanded ? ChevronDown : ChevronRight;
|
||||
|
||||
return (
|
||||
<div key={category} className="mb-1">
|
||||
{/* Category header */}
|
||||
<button
|
||||
onClick={() => toggleCategory(category)}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm font-medium transition-colors',
|
||||
categoryBgColors[category],
|
||||
categoryColors[category]
|
||||
)}
|
||||
>
|
||||
<ChevronIcon className="size-4 flex-shrink-0" />
|
||||
<CategoryIcon className="size-4 flex-shrink-0" />
|
||||
<span className="flex-1 text-left">{getCategoryLabel(category)}</span>
|
||||
<Badge variant="secondary" className="text-xs px-1.5 py-0">
|
||||
{nodes.length}
|
||||
</Badge>
|
||||
</button>
|
||||
|
||||
{/* Node list */}
|
||||
{isExpanded && (
|
||||
<div className="mt-1 space-y-0.5 ml-2">
|
||||
{nodes.map((node) => {
|
||||
const Icon = nodeIcons[node.type] || Settings;
|
||||
const label = getNodeLabel(node);
|
||||
const description = getNodeDescription(node);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={node.type}
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, node.type)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-2 py-1.5 rounded-md cursor-grab select-none',
|
||||
'hover:bg-muted/80 active:cursor-grabbing transition-colors',
|
||||
'border border-transparent hover:border-border',
|
||||
'group'
|
||||
)}
|
||||
title={description || label}
|
||||
>
|
||||
<div className={cn(
|
||||
'p-1 rounded',
|
||||
categoryBgColors[category],
|
||||
categoryBorderColors[category],
|
||||
'border'
|
||||
)}>
|
||||
<Icon className={cn('size-3.5', categoryColors[category])} />
|
||||
</div>
|
||||
<span className="text-sm truncate flex-1">{label}</span>
|
||||
<ExternalLink className="size-3 opacity-0 group-hover:opacity-50 transition-opacity" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer hint */}
|
||||
<div className="p-2 border-t text-xs text-muted-foreground text-center">
|
||||
{t('workflows.dragToAdd')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,713 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useWorkflowStore } from '../../store/useWorkflowStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Trash2,
|
||||
Settings,
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
Variable,
|
||||
Code,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
Info,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
|
||||
import { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic';
|
||||
import { getNodeConfig } from './node-configs';
|
||||
import i18n from 'i18next';
|
||||
import { I18nObject } from '@/app/infra/entities/common';
|
||||
import { normalizeWorkflowNodeTypeMeta } from './workflow-node-metadata';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { resolveI18nLabel, maybeTranslateKey } from './workflow-i18n';
|
||||
|
||||
// Delegate to shared utility
|
||||
const translateIfKey = (value: string | undefined): string | undefined => {
|
||||
if (!value) return value;
|
||||
return maybeTranslateKey(value);
|
||||
};
|
||||
|
||||
// Delegate to shared utility
|
||||
const extractI18nLabel = (obj: Record<string, string> | I18nObject | undefined): string | undefined => {
|
||||
if (!obj) return undefined;
|
||||
const result = resolveI18nLabel(obj as Record<string, string>);
|
||||
return result || undefined;
|
||||
};
|
||||
|
||||
// Get translated type label using i18n
|
||||
const getTypeLabel = (type: string | undefined): string => {
|
||||
if (!type) return '';
|
||||
const i18nKey = `workflows.type.${type.toLowerCase()}`;
|
||||
const translated = i18n.t(i18nKey);
|
||||
// If translation key doesn't exist, return the original type
|
||||
return translated === i18nKey ? type : translated;
|
||||
};
|
||||
|
||||
interface PropertyPanelProps {
|
||||
selectedNodeId: string | null;
|
||||
selectedEdgeId: string | null;
|
||||
}
|
||||
|
||||
// Variable reference component
|
||||
// Format variable reference to show only the short name
|
||||
const formatVariableName = (fullPath: string): string => {
|
||||
// nodes.node_xxx.body -> body
|
||||
// message.content -> content
|
||||
const parts = fullPath.split('.');
|
||||
return parts.length > 2 ? parts.slice(2).join('.') : parts[parts.length - 1];
|
||||
};
|
||||
|
||||
const resolvePortDisplayLabel = (
|
||||
port: { name: string; label?: string | Record<string, string> | I18nObject },
|
||||
prefix: 'workflows.nodeInputs' | 'workflows.nodeOutputs',
|
||||
): string => {
|
||||
if (port.label) {
|
||||
if (typeof port.label === 'object') {
|
||||
const resolved = extractI18nLabel(port.label as Record<string, string>);
|
||||
if (resolved) return resolved;
|
||||
} else {
|
||||
const resolved = translateIfKey(port.label);
|
||||
if (resolved && resolved !== port.label) return resolved;
|
||||
if (resolved && !resolved.startsWith('workflows.')) return resolved;
|
||||
}
|
||||
}
|
||||
|
||||
const translated = translateIfKey(`${prefix}.${port.name}`);
|
||||
return translated && translated !== `${prefix}.${port.name}`
|
||||
? translated
|
||||
: port.name;
|
||||
};
|
||||
|
||||
function VariableReference({
|
||||
variable,
|
||||
onCopy
|
||||
}: {
|
||||
variable: { name: string; label?: string; type?: string };
|
||||
onCopy: (ref: string) => void;
|
||||
}) {
|
||||
const ref = `{{${variable.name}}}`;
|
||||
const displayName = variable.label || formatVariableName(variable.name);
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center justify-between py-1 px-2 rounded bg-muted/50 text-sm group overflow-hidden w-full cursor-default">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1 overflow-hidden">
|
||||
<Variable className="size-3.5 text-muted-foreground flex-shrink-0" />
|
||||
<span className="truncate font-mono text-xs min-w-0">{displayName}</span>
|
||||
{variable.type && (
|
||||
<Badge variant="outline" className="text-[10px] px-1 py-0 flex-shrink-0">
|
||||
{getTypeLabel(variable.type)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
|
||||
onClick={() => onCopy(ref)}
|
||||
>
|
||||
<Copy className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="max-w-xs">
|
||||
<code className="text-xs break-all">{ref}</code>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// Collapsible section component
|
||||
function CollapsibleSection({
|
||||
title,
|
||||
icon: Icon,
|
||||
children,
|
||||
defaultOpen = true,
|
||||
badge,
|
||||
}: {
|
||||
title: string;
|
||||
icon: React.ElementType;
|
||||
children: React.ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
badge?: React.ReactNode;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||
|
||||
return (
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen} className="w-full overflow-hidden">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button className="w-full flex items-center gap-2 py-2 px-1 hover:bg-muted/50 rounded transition-colors min-w-0 overflow-hidden">
|
||||
{isOpen ? (
|
||||
<ChevronDown className="size-4 text-muted-foreground flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="size-4 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<Icon className="size-4 text-muted-foreground flex-shrink-0" />
|
||||
<span className="font-medium text-sm flex-1 text-left truncate min-w-0">{title}</span>
|
||||
{badge}
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pl-6 pr-1 pb-2 w-full overflow-hidden">
|
||||
<div className="w-full overflow-hidden">
|
||||
{children}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PropertyPanel({
|
||||
selectedNodeId,
|
||||
selectedEdgeId,
|
||||
}: PropertyPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
nodes,
|
||||
edges,
|
||||
nodeTypes,
|
||||
updateNodeConfig,
|
||||
updateNodeLabel,
|
||||
deleteNode,
|
||||
deleteEdge,
|
||||
updateEdgeCondition,
|
||||
pushHistory,
|
||||
} = useWorkflowStore();
|
||||
|
||||
// Use extractI18nObject for i18n handling (it automatically handles language detection)
|
||||
|
||||
// Get selected node
|
||||
const selectedNode = useMemo(() => {
|
||||
if (!selectedNodeId) return null;
|
||||
return nodes.find((n) => n.id === selectedNodeId) || null;
|
||||
}, [nodes, selectedNodeId]);
|
||||
|
||||
// Get selected edge
|
||||
const selectedEdge = useMemo(() => {
|
||||
if (!selectedEdgeId) return null;
|
||||
return edges.find((e) => e.id === selectedEdgeId) || null;
|
||||
}, [edges, selectedEdgeId]);
|
||||
|
||||
// Get node type metadata for selected node
|
||||
// Priority: API metadata first, local registry as normalized fallback
|
||||
const nodeTypeMeta = useMemo(() => {
|
||||
if (!selectedNode) return null;
|
||||
|
||||
const nodeType = selectedNode.data.type;
|
||||
return normalizeWorkflowNodeTypeMeta(
|
||||
nodeType,
|
||||
nodeTypes.find((t) => t.type === nodeType),
|
||||
);
|
||||
}, [selectedNode, nodeTypes]);
|
||||
|
||||
// Get local node config for additional metadata not carried by backend schema
|
||||
const localNodeConfig = useMemo(() => {
|
||||
if (!selectedNode) return null;
|
||||
const nodeType = selectedNode.data.type;
|
||||
return getNodeConfig(nodeType) || null;
|
||||
}, [selectedNode]);
|
||||
|
||||
// Prefer local registry config schema so workflow editor can reuse the existing
|
||||
// form item definitions, i18n labels/descriptions and option labels consistently.
|
||||
// Fall back to backend metadata for nodes that do not exist in the local registry.
|
||||
const configSchema = useMemo(() => {
|
||||
const localConfigSchema = (localNodeConfig?.configSchema as IDynamicFormItemSchema[]) || [];
|
||||
const backendConfigSchema = (nodeTypeMeta?.config_schema as IDynamicFormItemSchema[]) || [];
|
||||
const rawConfigSchema = localConfigSchema.length > 0 ? localConfigSchema : backendConfigSchema;
|
||||
|
||||
return rawConfigSchema.map((item) => {
|
||||
const backendItem = backendConfigSchema.find(
|
||||
(candidate) => candidate.name === item.name || candidate.id === item.id,
|
||||
);
|
||||
|
||||
return {
|
||||
...(backendItem || {}),
|
||||
...item,
|
||||
label: item.label || backendItem?.label || {
|
||||
en_US: item.name,
|
||||
zh_Hans: item.name,
|
||||
},
|
||||
description: item.description || backendItem?.description || {
|
||||
en_US: '',
|
||||
zh_Hans: '',
|
||||
},
|
||||
options: item.options || backendItem?.options,
|
||||
show_if: item.show_if || backendItem?.show_if,
|
||||
};
|
||||
});
|
||||
}, [localNodeConfig?.configSchema, nodeTypeMeta?.config_schema]);
|
||||
|
||||
// Get available input variables from connected upstream nodes
|
||||
const availableInputVariables = useMemo(() => {
|
||||
if (!selectedNode) return [];
|
||||
|
||||
const variables: { nodeId: string; nodeLabel: string; outputs: { name: string; label?: string; type?: string }[] }[] = [];
|
||||
|
||||
// Find all upstream nodes
|
||||
const incomingEdges = edges.filter((e) => e.target === selectedNode.id);
|
||||
const upstreamNodeIds = incomingEdges.map((e) => e.source);
|
||||
|
||||
for (const nodeId of upstreamNodeIds) {
|
||||
const node = nodes.find((n) => n.id === nodeId);
|
||||
if (node) {
|
||||
const outputs = node.data.outputs || [{ name: 'output', type: 'any' }];
|
||||
variables.push({
|
||||
nodeId: node.id,
|
||||
nodeLabel: node.data.label,
|
||||
outputs: outputs.map((o) => ({
|
||||
name: `nodes.${node.id}.${o.name}`,
|
||||
label: resolvePortDisplayLabel(o, 'workflows.nodeOutputs'),
|
||||
type: o.type,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add global variables
|
||||
variables.push({
|
||||
nodeId: '__global__',
|
||||
nodeLabel: t('workflows.globalVariables'),
|
||||
outputs: [
|
||||
{ name: 'message.content', label: t('workflows.messageContent'), type: 'string' },
|
||||
{ name: 'message.sender', label: t('workflows.messageSender'), type: 'string' },
|
||||
{ name: 'context.platform', label: t('workflows.platform'), type: 'string' },
|
||||
{ name: 'context.session_id', label: t('workflows.sessionId'), type: 'string' },
|
||||
{ name: 'context.timestamp', label: t('workflows.timestamp'), type: 'datetime' },
|
||||
],
|
||||
});
|
||||
|
||||
return variables;
|
||||
}, [selectedNode, edges, nodes, t]);
|
||||
|
||||
// Handle label change
|
||||
const handleLabelChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (selectedNodeId) {
|
||||
updateNodeLabel(selectedNodeId, e.target.value);
|
||||
}
|
||||
},
|
||||
[selectedNodeId, updateNodeLabel]
|
||||
);
|
||||
|
||||
// Handle config change from dynamic form
|
||||
const handleConfigChange = useCallback(
|
||||
(values: object): unknown => {
|
||||
if (selectedNodeId) {
|
||||
updateNodeConfig(selectedNodeId, values as Record<string, unknown>);
|
||||
pushHistory();
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
[selectedNodeId, updateNodeConfig, pushHistory]
|
||||
);
|
||||
|
||||
// Handle node delete
|
||||
const handleDeleteNode = useCallback(() => {
|
||||
if (selectedNodeId) {
|
||||
deleteNode(selectedNodeId);
|
||||
}
|
||||
}, [selectedNodeId, deleteNode]);
|
||||
|
||||
// Handle edge delete
|
||||
const handleDeleteEdge = useCallback(() => {
|
||||
if (selectedEdgeId) {
|
||||
deleteEdge(selectedEdgeId);
|
||||
}
|
||||
}, [selectedEdgeId, deleteEdge]);
|
||||
|
||||
// Handle edge condition change
|
||||
const handleConditionChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
if (selectedEdgeId) {
|
||||
updateEdgeCondition(selectedEdgeId, e.target.value);
|
||||
}
|
||||
},
|
||||
[selectedEdgeId, updateEdgeCondition]
|
||||
);
|
||||
|
||||
// Copy variable reference
|
||||
const handleCopyVariable = useCallback((ref: string) => {
|
||||
navigator.clipboard.writeText(ref);
|
||||
toast.success(t('common.copySuccess'));
|
||||
}, [t]);
|
||||
|
||||
// No selection
|
||||
if (!selectedNodeId && !selectedEdgeId) {
|
||||
return (
|
||||
<div className="h-full w-full flex flex-col overflow-hidden">
|
||||
<div className="p-4 border-b w-full overflow-hidden">
|
||||
<h3 className="font-semibold text-sm flex items-center gap-2">
|
||||
<Settings className="size-4" />
|
||||
{t('workflows.properties')}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center w-full overflow-hidden">
|
||||
<div className="text-center p-8 w-full max-w-full overflow-hidden">
|
||||
<Settings className="size-12 mx-auto mb-3 opacity-20" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('workflows.selectNodeOrEdge')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{t('workflows.selectNodeOrEdgeHint')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Edge selected
|
||||
if (selectedEdge) {
|
||||
const sourceNode = nodes.find((n) => n.id === selectedEdge.source);
|
||||
const targetNode = nodes.find((n) => n.id === selectedEdge.target);
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="h-full w-full flex flex-col overflow-hidden">
|
||||
<div className="p-4 border-b w-full overflow-hidden">
|
||||
<h3 className="font-semibold text-sm flex items-center gap-2">
|
||||
<ArrowRight className="size-4" />
|
||||
{t('workflows.edgeProperties')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 w-full min-h-0">
|
||||
<div className="p-4 space-y-4 w-full overflow-hidden box-border">
|
||||
{/* Connection info */}
|
||||
<div className="bg-muted/50 p-3 rounded-lg w-full overflow-hidden">
|
||||
<div className="flex items-center gap-2 text-sm min-w-0 w-full overflow-hidden">
|
||||
<Badge variant="outline" className="font-mono text-xs truncate max-w-[35%] flex-shrink min-w-0">
|
||||
{sourceNode?.data.label || selectedEdge.source}
|
||||
</Badge>
|
||||
<ArrowRight className="size-4 text-muted-foreground flex-shrink-0" />
|
||||
<Badge variant="outline" className="font-mono text-xs truncate max-w-[35%] flex-shrink min-w-0">
|
||||
{targetNode?.data.label || selectedEdge.target}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Condition */}
|
||||
<CollapsibleSection
|
||||
title={t('workflows.condition')}
|
||||
icon={Code}
|
||||
badge={selectedEdge.data?.condition ? (
|
||||
<Badge variant="secondary" className="text-xs flex-shrink-0">
|
||||
{t('workflows.hasCondition')}
|
||||
</Badge>
|
||||
) : null}
|
||||
>
|
||||
<div className="space-y-2 w-full overflow-hidden">
|
||||
<Textarea
|
||||
value={selectedEdge.data?.condition || ''}
|
||||
onChange={handleConditionChange}
|
||||
placeholder={t('workflows.conditionPlaceholder')}
|
||||
rows={4}
|
||||
className="font-mono text-sm w-full"
|
||||
/>
|
||||
<div className="flex items-start gap-2 text-xs text-muted-foreground">
|
||||
<Info className="size-3.5 mt-0.5 flex-shrink-0" />
|
||||
<p>{t('workflows.conditionHelp')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Delete edge */}
|
||||
<div className="w-full overflow-hidden">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" size="sm" className="w-full">
|
||||
<Trash2 className="size-4 mr-2 flex-shrink-0" />
|
||||
<span className="truncate">{t('workflows.deleteEdge')}</span>
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('workflows.deleteEdgeConfirm')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('workflows.deleteEdgeConfirmDesc')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className="flex-wrap gap-2">
|
||||
<AlertDialogCancel className="flex-1 min-w-[80px]">{t('common.cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDeleteEdge} className="flex-1 min-w-[80px]">
|
||||
{t('common.delete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// Node selected
|
||||
if (selectedNode) {
|
||||
const nodeInputs = selectedNode.data.inputs || nodeTypeMeta?.inputs || [{ name: 'input', type: 'any' }];
|
||||
const nodeOutputs = selectedNode.data.outputs || nodeTypeMeta?.outputs || [{ name: 'output', type: 'any' }];
|
||||
|
||||
// Extract i18n labels using extractI18nLabel
|
||||
const nodeLabel = nodeTypeMeta?.label
|
||||
? extractI18nLabel(nodeTypeMeta.label)
|
||||
: selectedNode.data.type;
|
||||
const nodeDescription = nodeTypeMeta?.description
|
||||
? extractI18nLabel(nodeTypeMeta.description)
|
||||
: undefined;
|
||||
|
||||
// Get node category color from local config
|
||||
const nodeColor = localNodeConfig?.color || nodeTypeMeta?.color;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="h-full w-full flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b w-full overflow-hidden">
|
||||
<h3 className="font-semibold text-sm flex items-center gap-2">
|
||||
<Sparkles className="size-4" />
|
||||
{t('workflows.nodeProperties')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 w-full min-h-0">
|
||||
<div className="p-4 space-y-4 w-full box-border">
|
||||
{/* Node type info */}
|
||||
<div className="bg-muted/50 p-3 rounded-lg space-y-2 w-full overflow-hidden">
|
||||
<div className="flex items-center gap-2 min-w-0 overflow-hidden">
|
||||
<Badge className="font-mono text-xs truncate max-w-full">
|
||||
{selectedNode.data.type}
|
||||
</Badge>
|
||||
</div>
|
||||
{nodeDescription && (
|
||||
<p className="text-xs text-muted-foreground">{nodeDescription}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Basic Info */}
|
||||
<CollapsibleSection
|
||||
title={t('workflows.basicInfo')}
|
||||
icon={Settings}
|
||||
>
|
||||
<div className="space-y-3 w-full overflow-hidden">
|
||||
<div className="space-y-1.5 w-full overflow-hidden">
|
||||
<Label htmlFor="node-label" className="text-xs">
|
||||
{t('workflows.nodeLabel')}
|
||||
</Label>
|
||||
<Input
|
||||
id="node-label"
|
||||
value={selectedNode.data.label}
|
||||
onChange={handleLabelChange}
|
||||
placeholder={t('workflows.nodeLabelPlaceholder')}
|
||||
className="h-8 w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 w-full overflow-hidden">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
{t('workflows.nodeId')}
|
||||
</Label>
|
||||
<div className="flex items-center gap-2 min-w-0 w-full overflow-hidden">
|
||||
<code className="text-xs bg-muted px-2 py-1 rounded flex-1 truncate min-w-0 overflow-hidden">
|
||||
{selectedNode.id}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 flex-shrink-0"
|
||||
onClick={() => handleCopyVariable(selectedNode.id)}
|
||||
>
|
||||
<Copy className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Input/Output Variables */}
|
||||
<CollapsibleSection
|
||||
title={t('workflows.inputOutputVariables')}
|
||||
icon={Variable}
|
||||
badge={
|
||||
<Badge variant="secondary" className="text-xs flex-shrink-0">
|
||||
{nodeInputs.length} / {nodeOutputs.length}
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3 w-full overflow-hidden">
|
||||
{/* Inputs */}
|
||||
<div className="space-y-1.5 w-full overflow-hidden">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
<ArrowLeft className="size-3" />
|
||||
{t('workflows.inputs')}
|
||||
</div>
|
||||
<div className="space-y-1 w-full overflow-hidden">
|
||||
{nodeInputs.map((input) => (
|
||||
<div
|
||||
key={input.name}
|
||||
className="flex items-center gap-2 py-1 px-2 rounded bg-blue-50 dark:bg-blue-950/30 text-sm overflow-hidden w-full min-w-0"
|
||||
>
|
||||
<Variable className="size-3.5 text-blue-600 dark:text-blue-400 flex-shrink-0" />
|
||||
<span className="font-mono text-xs truncate min-w-0 flex-1">
|
||||
{resolvePortDisplayLabel(input, 'workflows.nodeInputs')}
|
||||
</span>
|
||||
{input.type && (
|
||||
<Badge variant="outline" className="text-[10px] px-1 py-0 flex-shrink-0">
|
||||
{getTypeLabel(input.type)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Outputs */}
|
||||
<div className="space-y-1.5 w-full overflow-hidden">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
<ArrowRight className="size-3" />
|
||||
{t('workflows.outputs')}
|
||||
</div>
|
||||
<div className="space-y-1 w-full overflow-hidden">
|
||||
{nodeOutputs.map((output) => (
|
||||
<VariableReference
|
||||
key={output.name}
|
||||
variable={{
|
||||
name: `nodes.${selectedNode.id}.${output.name}`,
|
||||
label: resolvePortDisplayLabel(output, 'workflows.nodeOutputs'),
|
||||
type: output.type,
|
||||
}}
|
||||
onCopy={handleCopyVariable}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Available Variables from upstream nodes */}
|
||||
{availableInputVariables.length > 0 && (
|
||||
<CollapsibleSection
|
||||
title={t('workflows.availableVariables')}
|
||||
icon={Code}
|
||||
defaultOpen={false}
|
||||
>
|
||||
<div className="space-y-3 w-full overflow-hidden">
|
||||
{availableInputVariables.map((group) => (
|
||||
<div key={group.nodeId} className="space-y-1.5 w-full overflow-hidden">
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
{group.nodeLabel}
|
||||
</div>
|
||||
<div className="space-y-1 w-full overflow-hidden">
|
||||
{group.outputs.map((variable) => (
|
||||
<VariableReference
|
||||
key={variable.name}
|
||||
variable={variable}
|
||||
onCopy={handleCopyVariable}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Node configuration */}
|
||||
{configSchema.length > 0 && (
|
||||
<CollapsibleSection
|
||||
title={t('workflows.nodeConfig')}
|
||||
icon={Settings}
|
||||
badge={
|
||||
<Badge variant="secondary" className="text-xs flex-shrink-0">
|
||||
{configSchema.length}
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
<div className="space-y-2 w-full overflow-hidden box-border">
|
||||
<DynamicFormComponent
|
||||
itemConfigList={configSchema}
|
||||
initialValues={selectedNode.data.config as Record<string, object>}
|
||||
onSubmit={handleConfigChange}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Show message if no config schema */}
|
||||
{configSchema.length === 0 && (
|
||||
<div className="text-sm text-muted-foreground text-center py-4 bg-muted/30 rounded-lg">
|
||||
<Settings className="size-6 mx-auto mb-2 opacity-50" />
|
||||
<p>{t('workflows.noConfigOptions')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Delete node */}
|
||||
<div className="w-full overflow-hidden">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" size="sm" className="w-full">
|
||||
<Trash2 className="size-4 mr-2 flex-shrink-0" />
|
||||
<span className="truncate">{t('workflows.deleteNode')}</span>
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('workflows.deleteNodeConfirm')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('workflows.deleteNodeConfirmDesc')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className="flex-wrap gap-2">
|
||||
<AlertDialogCancel className="flex-1 min-w-[80px]">{t('common.cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDeleteNode} className="flex-1 min-w-[80px]">
|
||||
{t('common.delete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,691 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
Panel,
|
||||
ReactFlowProvider,
|
||||
useReactFlow,
|
||||
BackgroundVariant,
|
||||
SelectionMode,
|
||||
} from '@xyflow/react';
|
||||
import type { Node, NodeTypes, OnSelectionChangeParams } from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import {
|
||||
useWorkflowStore,
|
||||
WorkflowNode,
|
||||
WorkflowEdge,
|
||||
} from '../../store/useWorkflowStore';
|
||||
import WorkflowNodeComponent from './WorkflowNodeComponent';
|
||||
import NodePalette from './NodePalette';
|
||||
import PropertyPanel from './PropertyPanel';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Undo2,
|
||||
Redo2,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
Maximize2,
|
||||
Copy,
|
||||
ClipboardPaste,
|
||||
Trash2,
|
||||
Keyboard,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
|
||||
// Custom node types - use type assertion to satisfy NodeTypes
|
||||
const nodeTypes: NodeTypes = {
|
||||
workflowNode: WorkflowNodeComponent,
|
||||
};
|
||||
|
||||
// Clipboard storage for copy/paste
|
||||
interface ClipboardData {
|
||||
nodes: WorkflowNode[];
|
||||
edges: WorkflowEdge[];
|
||||
}
|
||||
|
||||
// Generate unique ID for pasted nodes
|
||||
const generatePastedNodeId = () =>
|
||||
`node_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
const generatePastedEdgeId = () =>
|
||||
`edge_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
function WorkflowEditorInner() {
|
||||
const { t } = useTranslation();
|
||||
const reactFlowWrapper = useRef<HTMLDivElement>(null);
|
||||
const { screenToFlowPosition, fitView, zoomIn, zoomOut } = useReactFlow();
|
||||
|
||||
// Clipboard state
|
||||
const [clipboard, setClipboard] = useState<ClipboardData | null>(null);
|
||||
// Multi-selection state
|
||||
const [selectedNodes, setSelectedNodes] = useState<string[]>([]);
|
||||
const [selectedEdges, setSelectedEdges] = useState<string[]>([]);
|
||||
// Property panel visibility state
|
||||
const [showPropertyPanel, setShowPropertyPanel] = useState(false);
|
||||
|
||||
const {
|
||||
nodes,
|
||||
edges,
|
||||
onNodesChange,
|
||||
onEdgesChange,
|
||||
onConnect,
|
||||
addNode,
|
||||
selectNode,
|
||||
selectEdge,
|
||||
clearSelection,
|
||||
selectedNodeId,
|
||||
selectedEdgeId,
|
||||
deleteNode,
|
||||
deleteEdge,
|
||||
undo,
|
||||
redo,
|
||||
history,
|
||||
historyIndex,
|
||||
isDirty,
|
||||
setNodes,
|
||||
setEdges,
|
||||
pushHistory,
|
||||
nodeExecutionResults,
|
||||
} = useWorkflowStore();
|
||||
|
||||
// Handle node click
|
||||
const handleNodeClick = useCallback(
|
||||
(_: React.MouseEvent, node: WorkflowNode) => {
|
||||
selectNode(node.id);
|
||||
setShowPropertyPanel(true);
|
||||
},
|
||||
[selectNode],
|
||||
);
|
||||
|
||||
// Handle edge click
|
||||
const handleEdgeClick = useCallback(
|
||||
(_: React.MouseEvent, edge: WorkflowEdge) => {
|
||||
selectEdge(edge.id);
|
||||
},
|
||||
[selectEdge],
|
||||
);
|
||||
|
||||
// Handle pane click (deselect)
|
||||
const handlePaneClick = useCallback(() => {
|
||||
clearSelection();
|
||||
setSelectedNodes([]);
|
||||
setSelectedEdges([]);
|
||||
setShowPropertyPanel(false);
|
||||
}, [clearSelection]);
|
||||
|
||||
// Handle selection change for multi-select
|
||||
const handleSelectionChange = useCallback(
|
||||
({
|
||||
nodes: selectedNodesArr,
|
||||
edges: selectedEdgesArr,
|
||||
}: OnSelectionChangeParams) => {
|
||||
const nodeIds = selectedNodesArr.map((n) => n.id);
|
||||
const edgeIds = selectedEdgesArr.map((e) => e.id);
|
||||
setSelectedNodes(nodeIds);
|
||||
setSelectedEdges(edgeIds);
|
||||
|
||||
// Update single selection for property panel
|
||||
if (nodeIds.length === 1) {
|
||||
selectNode(nodeIds[0]);
|
||||
} else if (edgeIds.length === 1 && nodeIds.length === 0) {
|
||||
selectEdge(edgeIds[0]);
|
||||
} else if (nodeIds.length === 0 && edgeIds.length === 0) {
|
||||
clearSelection();
|
||||
}
|
||||
},
|
||||
[selectNode, selectEdge, clearSelection],
|
||||
);
|
||||
|
||||
// Handle drop from palette
|
||||
const onDrop = useCallback(
|
||||
(event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
const type = event.dataTransfer.getData('application/reactflow');
|
||||
if (!type || !reactFlowWrapper.current) return;
|
||||
|
||||
const position = screenToFlowPosition({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
|
||||
addNode(type, position);
|
||||
},
|
||||
[screenToFlowPosition, addNode],
|
||||
);
|
||||
|
||||
const onDragOver = useCallback((event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
}, []);
|
||||
|
||||
// Copy selected nodes
|
||||
const handleCopy = useCallback(() => {
|
||||
const nodesToCopy = nodes.filter(
|
||||
(n) => selectedNodes.includes(n.id) || n.id === selectedNodeId,
|
||||
);
|
||||
|
||||
if (nodesToCopy.length === 0) {
|
||||
toast.error(t('workflows.nothingToCopy'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Get edges between selected nodes
|
||||
const nodeIds = new Set(nodesToCopy.map((n) => n.id));
|
||||
const edgesToCopy = edges.filter(
|
||||
(e) => nodeIds.has(e.source) && nodeIds.has(e.target),
|
||||
);
|
||||
|
||||
setClipboard({
|
||||
nodes: nodesToCopy,
|
||||
edges: edgesToCopy,
|
||||
});
|
||||
|
||||
toast.success(t('workflows.copied', { count: nodesToCopy.length }));
|
||||
}, [nodes, edges, selectedNodes, selectedNodeId, t]);
|
||||
|
||||
// Paste nodes from clipboard
|
||||
const handlePaste = useCallback(() => {
|
||||
if (!clipboard || clipboard.nodes.length === 0) {
|
||||
toast.error(t('workflows.nothingToPaste'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Create ID mapping for pasted nodes
|
||||
const idMapping: Record<string, string> = {};
|
||||
clipboard.nodes.forEach((node) => {
|
||||
idMapping[node.id] = generatePastedNodeId();
|
||||
});
|
||||
|
||||
// Offset position for pasted nodes
|
||||
const offset = { x: 50, y: 50 };
|
||||
|
||||
// Create new nodes with new IDs and offset positions
|
||||
const newNodes: WorkflowNode[] = clipboard.nodes.map((node) => ({
|
||||
...node,
|
||||
id: idMapping[node.id],
|
||||
position: {
|
||||
x: node.position.x + offset.x,
|
||||
y: node.position.y + offset.y,
|
||||
},
|
||||
selected: true,
|
||||
data: {
|
||||
...node.data,
|
||||
label: `${node.data.label} (copy)`,
|
||||
},
|
||||
}));
|
||||
|
||||
// Create new edges with updated source/target IDs
|
||||
const newEdges: WorkflowEdge[] = clipboard.edges.map((edge) => ({
|
||||
...edge,
|
||||
id: generatePastedEdgeId(),
|
||||
source: idMapping[edge.source],
|
||||
target: idMapping[edge.target],
|
||||
}));
|
||||
|
||||
// Add new nodes and edges
|
||||
setNodes([...nodes.map((n) => ({ ...n, selected: false })), ...newNodes]);
|
||||
setEdges([...edges, ...newEdges]);
|
||||
pushHistory();
|
||||
|
||||
// Select pasted nodes
|
||||
setSelectedNodes(newNodes.map((n) => n.id));
|
||||
|
||||
toast.success(t('workflows.pasted', { count: newNodes.length }));
|
||||
}, [clipboard, nodes, edges, setNodes, setEdges, pushHistory, t]);
|
||||
|
||||
// Delete selected nodes/edges
|
||||
const handleDelete = useCallback(() => {
|
||||
const nodesToDelete =
|
||||
selectedNodes.length > 0
|
||||
? selectedNodes
|
||||
: selectedNodeId
|
||||
? [selectedNodeId]
|
||||
: [];
|
||||
const edgesToDelete =
|
||||
selectedEdges.length > 0
|
||||
? selectedEdges
|
||||
: selectedEdgeId
|
||||
? [selectedEdgeId]
|
||||
: [];
|
||||
|
||||
if (nodesToDelete.length === 0 && edgesToDelete.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete nodes (this will also delete connected edges)
|
||||
nodesToDelete.forEach((nodeId) => {
|
||||
deleteNode(nodeId);
|
||||
});
|
||||
|
||||
// Delete edges
|
||||
edgesToDelete.forEach((edgeId) => {
|
||||
deleteEdge(edgeId);
|
||||
});
|
||||
|
||||
setSelectedNodes([]);
|
||||
setSelectedEdges([]);
|
||||
clearSelection();
|
||||
|
||||
toast.success(t('workflows.deleted'));
|
||||
}, [
|
||||
selectedNodes,
|
||||
selectedEdges,
|
||||
selectedNodeId,
|
||||
selectedEdgeId,
|
||||
deleteNode,
|
||||
deleteEdge,
|
||||
clearSelection,
|
||||
t,
|
||||
]);
|
||||
|
||||
// Select all nodes
|
||||
const handleSelectAll = useCallback(() => {
|
||||
const allNodeIds = nodes.map((n) => n.id);
|
||||
setSelectedNodes(allNodeIds);
|
||||
setNodes(nodes.map((n) => ({ ...n, selected: true })));
|
||||
}, [nodes, setNodes]);
|
||||
|
||||
// Keyboard shortcuts
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent) => {
|
||||
// Prevent shortcuts when typing in input fields
|
||||
if (
|
||||
event.target instanceof HTMLInputElement ||
|
||||
event.target instanceof HTMLTextAreaElement
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isCtrlOrCmd = event.ctrlKey || event.metaKey;
|
||||
|
||||
// Ctrl/Cmd + Z: Undo
|
||||
if (isCtrlOrCmd && event.key === 'z' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
undo();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl/Cmd + Shift + Z or Ctrl/Cmd + Y: Redo
|
||||
if (
|
||||
(isCtrlOrCmd && event.shiftKey && event.key === 'z') ||
|
||||
(isCtrlOrCmd && event.key === 'y')
|
||||
) {
|
||||
event.preventDefault();
|
||||
redo();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl/Cmd + C: Copy
|
||||
if (isCtrlOrCmd && event.key === 'c') {
|
||||
event.preventDefault();
|
||||
handleCopy();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl/Cmd + V: Paste
|
||||
if (isCtrlOrCmd && event.key === 'v') {
|
||||
event.preventDefault();
|
||||
handlePaste();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl/Cmd + A: Select All
|
||||
if (isCtrlOrCmd && event.key === 'a') {
|
||||
event.preventDefault();
|
||||
handleSelectAll();
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete or Backspace: Delete selected
|
||||
if (event.key === 'Delete' || event.key === 'Backspace') {
|
||||
event.preventDefault();
|
||||
handleDelete();
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape: Clear selection
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
clearSelection();
|
||||
setSelectedNodes([]);
|
||||
setSelectedEdges([]);
|
||||
return;
|
||||
}
|
||||
},
|
||||
[
|
||||
undo,
|
||||
redo,
|
||||
handleCopy,
|
||||
handlePaste,
|
||||
handleSelectAll,
|
||||
handleDelete,
|
||||
clearSelection,
|
||||
],
|
||||
);
|
||||
|
||||
// Memoize mini map node color
|
||||
const minimapNodeColor = useCallback((node: Node) => {
|
||||
const workflowNode = node as WorkflowNode;
|
||||
const categoryColors: Record<string, string> = {
|
||||
trigger: '#22c55e',
|
||||
process: '#3b82f6',
|
||||
control: '#f59e0b',
|
||||
action: '#8b5cf6',
|
||||
integration: '#ec4899',
|
||||
};
|
||||
// Extract category from node type (e.g., 'trigger.message' -> 'trigger')
|
||||
const category = workflowNode.data?.type?.split('.')[0] || 'process';
|
||||
return categoryColors[category] || '#6b7280';
|
||||
}, []);
|
||||
|
||||
const displayNodes = nodes.map((node) => {
|
||||
const executionResult = nodeExecutionResults[node.id];
|
||||
|
||||
if (!executionResult) {
|
||||
return node;
|
||||
}
|
||||
|
||||
return {
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
executionStatus: executionResult.status,
|
||||
executionError: executionResult.error,
|
||||
executionDuration: executionResult.duration,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const canUndo = historyIndex > 0;
|
||||
const canRedo = historyIndex < history.length - 1;
|
||||
const hasSelection = selectedNodes.length > 0 || selectedNodeId !== null;
|
||||
const hasClipboard = clipboard !== null && clipboard.nodes.length > 0;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div
|
||||
ref={reactFlowWrapper}
|
||||
className="h-full w-full flex"
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={0}
|
||||
>
|
||||
{/* Left: Node Palette */}
|
||||
<div className="w-64 border-r bg-muted/30 overflow-y-auto flex-shrink-0">
|
||||
<NodePalette />
|
||||
</div>
|
||||
|
||||
{/* Center: Flow Canvas */}
|
||||
<div className="flex-1 relative">
|
||||
<ReactFlow
|
||||
nodes={displayNodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
onNodeClick={handleNodeClick}
|
||||
onEdgeClick={handleEdgeClick}
|
||||
onPaneClick={handlePaneClick}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onDrop={onDrop}
|
||||
onDragOver={onDragOver}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
snapToGrid
|
||||
snapGrid={[15, 15]}
|
||||
selectionMode={SelectionMode.Partial}
|
||||
selectionOnDrag
|
||||
panOnDrag={[1, 2]} // Middle click and right click to pan
|
||||
selectNodesOnDrag={false}
|
||||
defaultEdgeOptions={{
|
||||
type: 'smoothstep',
|
||||
animated: true,
|
||||
}}
|
||||
deleteKeyCode={null} // We handle delete manually
|
||||
>
|
||||
<Background
|
||||
gap={15}
|
||||
size={1}
|
||||
variant={BackgroundVariant.Dots}
|
||||
color="hsl(var(--muted-foreground) / 0.3)"
|
||||
/>
|
||||
<Controls
|
||||
showInteractive={false}
|
||||
className="!bg-background !border-border !shadow-md [&_button]:!bg-background [&_button]:!border-border [&_button]:!fill-foreground [&_button:hover]:!bg-muted"
|
||||
/>
|
||||
<MiniMap
|
||||
nodeColor={minimapNodeColor}
|
||||
maskColor="rgba(0, 0, 0, 0.1)"
|
||||
className="!bg-background/80"
|
||||
pannable
|
||||
zoomable
|
||||
/>
|
||||
|
||||
{/* Main Toolbar Panel */}
|
||||
<Panel
|
||||
position="top-right"
|
||||
className="flex gap-1 bg-background/80 backdrop-blur-sm rounded-lg p-1 shadow-md border"
|
||||
>
|
||||
{/* Undo/Redo */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => undo()}
|
||||
disabled={!canUndo}
|
||||
className="size-8"
|
||||
>
|
||||
<Undo2 className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{t('common.undo')} (Ctrl+Z)</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => redo()}
|
||||
disabled={!canRedo}
|
||||
className="size-8"
|
||||
>
|
||||
<Redo2 className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{t('common.redo')} (Ctrl+Shift+Z)</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div className="w-px bg-border mx-0.5" />
|
||||
|
||||
{/* Copy/Paste */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleCopy}
|
||||
disabled={!hasSelection}
|
||||
className="size-8"
|
||||
>
|
||||
<Copy className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{t('common.copy')} (Ctrl+C)</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handlePaste}
|
||||
disabled={!hasClipboard}
|
||||
className="size-8"
|
||||
>
|
||||
<ClipboardPaste className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{t('workflows.paste')} (Ctrl+V)</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleDelete}
|
||||
disabled={!hasSelection && selectedEdgeId === null}
|
||||
className="size-8 text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{t('common.delete')} (Delete)</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div className="w-px bg-border mx-0.5" />
|
||||
|
||||
{/* Zoom controls */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => zoomIn()}
|
||||
className="size-8 text-muted-foreground hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground"
|
||||
>
|
||||
<ZoomIn className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{t('workflows.zoomIn')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => zoomOut()}
|
||||
className="size-8 text-muted-foreground hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground"
|
||||
>
|
||||
<ZoomOut className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{t('workflows.zoomOut')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => fitView()}
|
||||
className="size-8 text-muted-foreground hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground"
|
||||
>
|
||||
<Maximize2 className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{t('workflows.fitView')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</Panel>
|
||||
|
||||
{/* Keyboard shortcuts hint */}
|
||||
<Panel
|
||||
position="bottom-center"
|
||||
className="text-xs text-muted-foreground bg-background/60 backdrop-blur-sm rounded px-2 py-1"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Keyboard className="size-3" />
|
||||
<span>
|
||||
Ctrl+Z/Y: {t('common.undo')}/{t('common.redo')} | Ctrl+C/V:{' '}
|
||||
{t('common.copy')}/{t('workflows.paste')} | Del:{' '}
|
||||
{t('common.delete')}
|
||||
</span>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{/* Dirty indicator */}
|
||||
{isDirty && (
|
||||
<Panel position="top-left" className="ml-2">
|
||||
<div className="text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/50 border border-amber-200 dark:border-amber-800 px-2 py-1 rounded flex items-center gap-1">
|
||||
<div className="size-1.5 rounded-full bg-amber-500 animate-pulse" />
|
||||
{t('workflows.unsavedChanges')}
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{/* Selection info */}
|
||||
{(selectedNodes.length > 1 || selectedEdges.length > 0) && (
|
||||
<Panel
|
||||
position="bottom-right"
|
||||
className="text-xs text-muted-foreground bg-background/80 backdrop-blur-sm rounded px-2 py-1 mr-2 mb-2"
|
||||
>
|
||||
{selectedNodes.length > 0 && (
|
||||
<span>
|
||||
{t('workflows.nodesSelected', {
|
||||
count: selectedNodes.length,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{selectedNodes.length > 0 && selectedEdges.length > 0 && (
|
||||
<span> | </span>
|
||||
)}
|
||||
{selectedEdges.length > 0 && (
|
||||
<span>
|
||||
{t('workflows.edgesSelected', {
|
||||
count: selectedEdges.length,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</Panel>
|
||||
)}
|
||||
</ReactFlow>
|
||||
</div>
|
||||
|
||||
{/* Right: Property Panel (conditionally rendered) */}
|
||||
{showPropertyPanel && (
|
||||
<div className="w-80 border-l bg-muted/30 overflow-hidden flex-shrink-0 h-full">
|
||||
<PropertyPanel
|
||||
selectedNodeId={selectedNodeId}
|
||||
selectedEdgeId={selectedEdgeId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WorkflowEditorComponent() {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<WorkflowEditorInner />
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
import { memo, useMemo } from 'react';
|
||||
import { Handle, Position } from '@xyflow/react';
|
||||
import type { NodeProps } from '@xyflow/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
PauseCircle,
|
||||
Settings,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Clock,
|
||||
Play,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { NODE_ICONS, NODE_TYPE_I18N_KEYS, getNodeTypeLabel } from './workflow-constants';
|
||||
import { resolveI18nLabel, maybeTranslateKey } from './workflow-i18n';
|
||||
import type { I18nObject } from '@/app/infra/entities/common';
|
||||
|
||||
// Use shared icon mapping
|
||||
const nodeIcons = NODE_ICONS;
|
||||
|
||||
// Use shared i18n key mapping
|
||||
const nodeTypeI18nKeys: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(NODE_TYPE_I18N_KEYS).map(([k, v]) => [k, v.labelKey]),
|
||||
);
|
||||
|
||||
// Category colors with improved design
|
||||
const categoryColors: Record<string, {
|
||||
bg: string;
|
||||
border: string;
|
||||
text: string;
|
||||
icon: string;
|
||||
gradient: string;
|
||||
handleBg: string;
|
||||
}> = {
|
||||
trigger: {
|
||||
bg: 'bg-amber-50 dark:bg-amber-950/40',
|
||||
border: 'border-amber-400 dark:border-amber-600',
|
||||
text: 'text-amber-900 dark:text-amber-100',
|
||||
icon: 'text-amber-600 dark:text-amber-400',
|
||||
gradient: 'from-amber-500/10 to-transparent',
|
||||
handleBg: '#f59e0b',
|
||||
},
|
||||
process: {
|
||||
bg: 'bg-blue-50 dark:bg-blue-950/40',
|
||||
border: 'border-blue-400 dark:border-blue-600',
|
||||
text: 'text-blue-900 dark:text-blue-100',
|
||||
icon: 'text-blue-600 dark:text-blue-400',
|
||||
gradient: 'from-blue-500/10 to-transparent',
|
||||
handleBg: '#3b82f6',
|
||||
},
|
||||
control: {
|
||||
bg: 'bg-purple-50 dark:bg-purple-950/40',
|
||||
border: 'border-purple-400 dark:border-purple-600',
|
||||
text: 'text-purple-900 dark:text-purple-100',
|
||||
icon: 'text-purple-600 dark:text-purple-400',
|
||||
gradient: 'from-purple-500/10 to-transparent',
|
||||
handleBg: '#8b5cf6',
|
||||
},
|
||||
action: {
|
||||
bg: 'bg-green-50 dark:bg-green-950/40',
|
||||
border: 'border-green-400 dark:border-green-600',
|
||||
text: 'text-green-900 dark:text-green-100',
|
||||
icon: 'text-green-600 dark:text-green-400',
|
||||
gradient: 'from-green-500/10 to-transparent',
|
||||
handleBg: '#22c55e',
|
||||
},
|
||||
integration: {
|
||||
bg: 'bg-pink-50 dark:bg-pink-950/40',
|
||||
border: 'border-pink-400 dark:border-pink-600',
|
||||
text: 'text-pink-900 dark:text-pink-100',
|
||||
icon: 'text-pink-600 dark:text-pink-400',
|
||||
gradient: 'from-pink-500/10 to-transparent',
|
||||
handleBg: '#ec4899',
|
||||
},
|
||||
};
|
||||
|
||||
// Node execution status
|
||||
export type NodeExecutionStatus = 'idle' | 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
|
||||
|
||||
// Status colors and icons
|
||||
const statusConfig: Record<NodeExecutionStatus, {
|
||||
icon: React.ElementType;
|
||||
color: string;
|
||||
bgColor: string;
|
||||
animate?: boolean;
|
||||
}> = {
|
||||
idle: {
|
||||
icon: Play,
|
||||
color: 'text-muted-foreground',
|
||||
bgColor: 'bg-muted',
|
||||
},
|
||||
pending: {
|
||||
icon: Clock,
|
||||
color: 'text-amber-600 dark:text-amber-400',
|
||||
bgColor: 'bg-amber-100 dark:bg-amber-900/50',
|
||||
},
|
||||
running: {
|
||||
icon: Loader2,
|
||||
color: 'text-blue-600 dark:text-blue-400',
|
||||
bgColor: 'bg-blue-100 dark:bg-blue-900/50',
|
||||
animate: true,
|
||||
},
|
||||
completed: {
|
||||
icon: CheckCircle2,
|
||||
color: 'text-green-600 dark:text-green-400',
|
||||
bgColor: 'bg-green-100 dark:bg-green-900/50',
|
||||
},
|
||||
failed: {
|
||||
icon: XCircle,
|
||||
color: 'text-red-600 dark:text-red-400',
|
||||
bgColor: 'bg-red-100 dark:bg-red-900/50',
|
||||
},
|
||||
skipped: {
|
||||
icon: PauseCircle,
|
||||
color: 'text-gray-500 dark:text-gray-400',
|
||||
bgColor: 'bg-gray-100 dark:bg-gray-800',
|
||||
},
|
||||
};
|
||||
|
||||
export interface WorkflowNodeData extends Record<string, unknown> {
|
||||
label: string;
|
||||
type: string;
|
||||
config: Record<string, unknown>;
|
||||
inputs?: { name: string; label?: string | Record<string, string> | I18nObject; type?: string }[];
|
||||
outputs?: { name: string; label?: string | Record<string, string> | I18nObject; type?: string }[];
|
||||
// Execution state
|
||||
executionStatus?: NodeExecutionStatus;
|
||||
executionError?: string;
|
||||
executionDuration?: number; // in milliseconds
|
||||
// Node type metadata from backend
|
||||
nodeTypeLabel?: Record<string, string>; // i18n label dict from backend
|
||||
nodeTypeDescription?: Record<string, string>; // i18n description from backend
|
||||
}
|
||||
|
||||
// Helper function to get port label with i18n support
|
||||
function getPortLabel(
|
||||
label: string | Record<string, string> | I18nObject | undefined,
|
||||
fallbackName: string,
|
||||
prefix: 'workflows.nodeOutputs' | 'workflows.nodeInputs',
|
||||
t: (key: string, options?: { defaultValue: string }) => string,
|
||||
): string {
|
||||
if (label && typeof label === 'object') {
|
||||
const resolved = resolveI18nLabel(label);
|
||||
if (resolved) return resolved;
|
||||
}
|
||||
|
||||
if (typeof label === 'string' && label) {
|
||||
if (label.startsWith('workflows.nodeOutputs.') || label.startsWith('workflows.nodeInputs.')) {
|
||||
return t(label, { defaultValue: fallbackName });
|
||||
}
|
||||
return label;
|
||||
}
|
||||
|
||||
const key = `${prefix}.${fallbackName}`;
|
||||
const translated = t(key, { defaultValue: fallbackName });
|
||||
return translated === key ? fallbackName : translated;
|
||||
}
|
||||
|
||||
// Helper function to extract i18n value from I18nObject (delegates to shared utility)
|
||||
function extractI18nValue(i18nObj: Record<string, string> | undefined, _t: (key: string) => string): string {
|
||||
return resolveI18nLabel(i18nObj);
|
||||
}
|
||||
|
||||
// Helper function to get node type description: show the raw type name after the dot
|
||||
function getNodeTypeDescription(
|
||||
nodeType: string,
|
||||
t: (key: string, options?: { defaultValue: string }) => string,
|
||||
nodeTypeLabel?: Record<string, string>
|
||||
): string {
|
||||
return nodeType.includes('.') ? nodeType.split('.').slice(1).join('.') : nodeType;
|
||||
}
|
||||
|
||||
function WorkflowNodeComponent({ data, selected }: NodeProps) {
|
||||
const { t } = useTranslation();
|
||||
const nodeData = data as WorkflowNodeData;
|
||||
const category = nodeData.type.split('.')[0];
|
||||
const colors = categoryColors[category] || categoryColors.process;
|
||||
const Icon = nodeIcons[nodeData.type] || Settings;
|
||||
|
||||
// Get execution status
|
||||
const status = nodeData.executionStatus || 'idle';
|
||||
const statusInfo = statusConfig[status];
|
||||
const StatusIcon = statusInfo.icon;
|
||||
|
||||
// Get inputs and outputs with defaults (use i18n keys for default labels)
|
||||
const inputs = useMemo(() => {
|
||||
return nodeData.inputs || [{ name: 'input', label: 'workflows.nodeInputs.input', type: 'any' }];
|
||||
}, [nodeData.inputs]);
|
||||
|
||||
const outputs = useMemo(() => {
|
||||
return nodeData.outputs || [{ name: 'output', label: 'workflows.nodeOutputs.output', type: 'any' }];
|
||||
}, [nodeData.outputs]);
|
||||
|
||||
// Determine if this is a trigger node (no inputs)
|
||||
const isTrigger = category === 'trigger';
|
||||
|
||||
// Format execution duration
|
||||
const formattedDuration = useMemo(() => {
|
||||
if (!nodeData.executionDuration) return null;
|
||||
if (nodeData.executionDuration < 1000) {
|
||||
return `${nodeData.executionDuration}ms`;
|
||||
}
|
||||
return `${(nodeData.executionDuration / 1000).toFixed(2)}s`;
|
||||
}, [nodeData.executionDuration]);
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-[200px] max-w-[280px] rounded-xl border-2 shadow-lg transition-all duration-200',
|
||||
colors.bg,
|
||||
colors.border,
|
||||
selected && 'ring-2 ring-primary ring-offset-2 ring-offset-background shadow-xl scale-[1.02]',
|
||||
status === 'running' && 'shadow-blue-200 dark:shadow-blue-900/50',
|
||||
status === 'failed' && 'shadow-red-200 dark:shadow-red-900/50 border-red-500',
|
||||
)}
|
||||
>
|
||||
{/* Input handles - only show if not trigger */}
|
||||
{!isTrigger && inputs.map((input, index) => (
|
||||
<Tooltip key={`input-${input.name}`}>
|
||||
<TooltipTrigger asChild>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
id={input.name}
|
||||
style={{
|
||||
top: inputs.length === 1 ? '50%' : `${((index + 1) / (inputs.length + 1)) * 100}%`,
|
||||
background: colors.handleBg,
|
||||
width: 12,
|
||||
height: 12,
|
||||
border: '2px solid white',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
}}
|
||||
className="!transition-transform hover:!scale-125"
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p className="font-medium">{getPortLabel(input.label, input.name, 'workflows.nodeInputs', t)}</p>
|
||||
{input.type && <p className="text-xs text-muted-foreground">{input.type}</p>}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
|
||||
{/* Node content */}
|
||||
<div className={cn('p-3 bg-gradient-to-b', colors.gradient)}>
|
||||
{/* Header row with icon and status */}
|
||||
<div className="flex items-start gap-2.5">
|
||||
{/* Node icon */}
|
||||
<div className={cn(
|
||||
'p-2 rounded-lg shrink-0',
|
||||
colors.icon,
|
||||
'bg-white/60 dark:bg-black/20',
|
||||
'shadow-sm'
|
||||
)}>
|
||||
<Icon className="size-5" />
|
||||
</div>
|
||||
|
||||
{/* Title and type */}
|
||||
<div className="flex-1 min-w-0 pt-0.5">
|
||||
<div className={cn('font-semibold text-sm truncate', colors.text)}>
|
||||
{nodeData.label}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate mt-0.5">
|
||||
{getNodeTypeDescription(nodeData.type, t, nodeData.nodeTypeLabel)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status indicator */}
|
||||
{status !== 'idle' && (
|
||||
<div className={cn(
|
||||
'p-1 rounded-full shrink-0',
|
||||
statusInfo.bgColor
|
||||
)}>
|
||||
<StatusIcon
|
||||
className={cn(
|
||||
'size-4',
|
||||
statusInfo.color,
|
||||
statusInfo.animate && 'animate-spin'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Execution info */}
|
||||
{(status === 'completed' || status === 'failed') && (
|
||||
<div className={cn(
|
||||
'mt-2 pt-2 border-t flex items-center justify-between text-xs',
|
||||
'border-black/5 dark:border-white/5'
|
||||
)}>
|
||||
<div className={cn('flex items-center gap-1', statusInfo.color)}>
|
||||
<StatusIcon className="size-3" />
|
||||
<span className="capitalize">{status}</span>
|
||||
</div>
|
||||
{formattedDuration && (
|
||||
<span className="text-muted-foreground">{formattedDuration}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error message */}
|
||||
{status === 'failed' && nodeData.executionError && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="mt-2 p-2 rounded-md bg-red-100 dark:bg-red-950/50 text-xs text-red-700 dark:text-red-300 truncate cursor-help">
|
||||
{nodeData.executionError}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-[300px]">
|
||||
<p className="text-sm">{nodeData.executionError}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Output handles */}
|
||||
{outputs.map((output, index) => (
|
||||
<Tooltip key={`output-${output.name}`}>
|
||||
<TooltipTrigger asChild>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id={output.name}
|
||||
style={{
|
||||
top: outputs.length === 1 ? '50%' : `${((index + 1) / (outputs.length + 1)) * 100}%`,
|
||||
background: colors.handleBg,
|
||||
width: 12,
|
||||
height: 12,
|
||||
border: '2px solid white',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
}}
|
||||
className="!transition-transform hover:!scale-125"
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p className="font-medium">{getPortLabel(output.label, output.name, 'workflows.nodeOutputs', t)}</p>
|
||||
{output.type && <p className="text-xs text-muted-foreground">{output.type}</p>}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(WorkflowNodeComponent);
|
||||
@@ -0,0 +1,7 @@
|
||||
export { default as WorkflowEditorComponent } from './WorkflowEditorComponent';
|
||||
export { default as WorkflowNodeComponent, type WorkflowNodeData } from './WorkflowNodeComponent';
|
||||
export { default as NodePalette } from './NodePalette';
|
||||
export { default as PropertyPanel } from './PropertyPanel';
|
||||
|
||||
// Export node configurations
|
||||
export * from './node-configs';
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,749 @@
|
||||
/**
|
||||
* AI Node Configurations
|
||||
*
|
||||
* Defines configurations for all AI-related node types:
|
||||
* - llm_call: Call a large language model
|
||||
* - question_classifier: Classify user questions into categories
|
||||
* - parameter_extractor: Extract structured parameters from text
|
||||
* - knowledge_retrieval: Retrieve information from knowledge bases
|
||||
* - text_embedding: Generate text embeddings
|
||||
* - intent_recognition: Recognize user intent
|
||||
*/
|
||||
|
||||
import { DynamicFormItemType } from '@/app/infra/entities/form/dynamic';
|
||||
import { NodeConfigMeta, createInput, createOutput } from './types';
|
||||
|
||||
/**
|
||||
* LLM Call Node
|
||||
* Makes a call to a large language model
|
||||
*/
|
||||
export const llmCallConfig: NodeConfigMeta = {
|
||||
nodeType: 'llm_call',
|
||||
label: {
|
||||
en_US: 'LLM Call',
|
||||
zh_Hans: 'LLM 调用',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Call a large language model to generate responses',
|
||||
zh_Hans: '调用大语言模型生成响应',
|
||||
},
|
||||
icon: 'Brain',
|
||||
category: 'process',
|
||||
color: '#8b5cf6',
|
||||
inputs: [
|
||||
createInput('input', 'string', {
|
||||
description: 'Input text to send to the model',
|
||||
label: { en_US: 'Input', zh_Hans: '输入' },
|
||||
}),
|
||||
createInput('context', 'object', {
|
||||
description: 'Additional context data',
|
||||
label: { en_US: 'Context', zh_Hans: '上下文' },
|
||||
required: false,
|
||||
}),
|
||||
],
|
||||
outputs: [
|
||||
createOutput('response', 'string', {
|
||||
description: 'Model response text',
|
||||
label: { en_US: 'Response', zh_Hans: '响应' },
|
||||
}),
|
||||
createOutput('usage', 'object', {
|
||||
description: 'Token usage information',
|
||||
label: { en_US: 'Usage', zh_Hans: '使用量' },
|
||||
}),
|
||||
createOutput('parsed', 'object', {
|
||||
description: 'Parsed output (if output format is JSON)',
|
||||
label: { en_US: 'Parsed', zh_Hans: '解析结果' },
|
||||
}),
|
||||
],
|
||||
configSchema: [
|
||||
{
|
||||
id: 'model',
|
||||
name: 'model',
|
||||
type: DynamicFormItemType.LLM_MODEL_SELECTOR,
|
||||
label: {
|
||||
en_US: 'Model',
|
||||
zh_Hans: '模型',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Select the LLM model to use',
|
||||
zh_Hans: '选择要使用的 LLM 模型',
|
||||
},
|
||||
required: true,
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
id: 'system_prompt',
|
||||
name: 'system_prompt',
|
||||
type: DynamicFormItemType.TEXT,
|
||||
label: {
|
||||
en_US: 'System Prompt',
|
||||
zh_Hans: '系统提示词',
|
||||
},
|
||||
description: {
|
||||
en_US: 'System prompt to set the model behavior (supports variable interpolation with {{variable}})',
|
||||
zh_Hans: '设置模型行为的系统提示词(支持使用 {{variable}} 进行变量插值)',
|
||||
},
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
id: 'user_prompt_template',
|
||||
name: 'user_prompt_template',
|
||||
type: DynamicFormItemType.TEXT,
|
||||
label: {
|
||||
en_US: 'User Prompt Template',
|
||||
zh_Hans: '用户提示词模板',
|
||||
},
|
||||
description: {
|
||||
en_US: 'User prompt template with variable placeholders (e.g., {{input}}, {{context.key}})',
|
||||
zh_Hans: '带有变量占位符的用户提示词模板(例如 {{input}}、{{context.key}})',
|
||||
},
|
||||
required: true,
|
||||
default: '{{input}}',
|
||||
},
|
||||
{
|
||||
id: 'temperature',
|
||||
name: 'temperature',
|
||||
type: DynamicFormItemType.FLOAT,
|
||||
label: {
|
||||
en_US: 'Temperature',
|
||||
zh_Hans: '温度',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Controls randomness in responses (0.0 = deterministic, 2.0 = very random)',
|
||||
zh_Hans: '控制响应的随机性(0.0 = 确定性,2.0 = 非常随机)',
|
||||
},
|
||||
required: false,
|
||||
default: 0.7,
|
||||
},
|
||||
{
|
||||
id: 'max_tokens',
|
||||
name: 'max_tokens',
|
||||
type: DynamicFormItemType.INT,
|
||||
label: {
|
||||
en_US: 'Max Tokens',
|
||||
zh_Hans: '最大令牌数',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Maximum number of tokens to generate (leave 0 for model default)',
|
||||
zh_Hans: '生成的最大令牌数(设为 0 使用模型默认值)',
|
||||
},
|
||||
required: false,
|
||||
default: 0,
|
||||
},
|
||||
{
|
||||
id: 'output_format',
|
||||
name: 'output_format',
|
||||
type: DynamicFormItemType.SELECT,
|
||||
label: {
|
||||
en_US: 'Output Format',
|
||||
zh_Hans: '输出格式',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Expected format of the model output',
|
||||
zh_Hans: '模型输出的预期格式',
|
||||
},
|
||||
required: false,
|
||||
default: 'text',
|
||||
options: [
|
||||
{ name: 'text', label: { en_US: 'Plain Text', zh_Hans: '纯文本' } },
|
||||
{ name: 'json', label: { en_US: 'JSON', zh_Hans: 'JSON' } },
|
||||
{ name: 'markdown', label: { en_US: 'Markdown', zh_Hans: 'Markdown 文本' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'json_schema',
|
||||
name: 'json_schema',
|
||||
type: DynamicFormItemType.TEXT,
|
||||
label: {
|
||||
en_US: 'JSON Schema',
|
||||
zh_Hans: 'JSON Schema',
|
||||
},
|
||||
description: {
|
||||
en_US: 'JSON schema for structured output validation (optional)',
|
||||
zh_Hans: '用于结构化输出验证的 JSON Schema(可选)',
|
||||
},
|
||||
required: false,
|
||||
default: '',
|
||||
show_if: {
|
||||
field: 'output_format',
|
||||
operator: 'eq',
|
||||
value: 'json',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'enable_tools',
|
||||
name: 'enable_tools',
|
||||
type: DynamicFormItemType.BOOLEAN,
|
||||
label: {
|
||||
en_US: 'Enable Tools',
|
||||
zh_Hans: '启用工具',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Allow the model to use function calling tools',
|
||||
zh_Hans: '允许模型使用函数调用工具',
|
||||
},
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
id: 'tools',
|
||||
name: 'tools',
|
||||
type: DynamicFormItemType.TOOLS_SELECTOR,
|
||||
label: {
|
||||
en_US: 'Tools',
|
||||
zh_Hans: '工具',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Select tools that the model can use',
|
||||
zh_Hans: '选择模型可以使用的工具',
|
||||
},
|
||||
required: false,
|
||||
default: [],
|
||||
show_if: {
|
||||
field: 'enable_tools',
|
||||
operator: 'eq',
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
defaultConfig: {
|
||||
model: '',
|
||||
system_prompt: '',
|
||||
user_prompt_template: '{{input}}',
|
||||
temperature: 0.7,
|
||||
max_tokens: 0,
|
||||
output_format: 'text',
|
||||
json_schema: '',
|
||||
enable_tools: false,
|
||||
tools: [],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Question Classifier Node
|
||||
* Classifies user questions into predefined categories
|
||||
*/
|
||||
export const questionClassifierConfig: NodeConfigMeta = {
|
||||
nodeType: 'question_classifier',
|
||||
label: {
|
||||
en_US: 'Question Classifier',
|
||||
zh_Hans: '问题分类器',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Classify user questions into predefined categories using AI',
|
||||
zh_Hans: '使用 AI 将用户问题分类到预定义的类别中',
|
||||
},
|
||||
icon: 'Tags',
|
||||
category: 'process',
|
||||
color: '#8b5cf6',
|
||||
inputs: [
|
||||
createInput('question', 'string', {
|
||||
description: 'The question to classify',
|
||||
label: { en_US: 'Question', zh_Hans: '问题' },
|
||||
}),
|
||||
],
|
||||
outputs: [
|
||||
createOutput('category', 'string', {
|
||||
description: 'The classified category',
|
||||
label: { en_US: 'Category', zh_Hans: '分类' },
|
||||
}),
|
||||
createOutput('confidence', 'number', {
|
||||
description: 'Classification confidence score (0-1)',
|
||||
label: { en_US: 'Confidence', zh_Hans: '置信度' },
|
||||
}),
|
||||
createOutput('all_scores', 'object', {
|
||||
description: 'Scores for all categories',
|
||||
label: { en_US: 'All Scores', zh_Hans: '所有分数' },
|
||||
}),
|
||||
],
|
||||
configSchema: [
|
||||
{
|
||||
id: 'model',
|
||||
name: 'model',
|
||||
type: DynamicFormItemType.LLM_MODEL_SELECTOR,
|
||||
label: {
|
||||
en_US: 'Classification Model',
|
||||
zh_Hans: '分类模型',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Select the model to use for classification',
|
||||
zh_Hans: '选择用于分类的模型',
|
||||
},
|
||||
required: true,
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
id: 'categories',
|
||||
name: 'categories',
|
||||
type: DynamicFormItemType.TEXT,
|
||||
label: {
|
||||
en_US: 'Categories Definition',
|
||||
zh_Hans: '分类定义',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Define categories in JSON format: [{"name": "category1", "description": "...", "examples": ["..."]}]',
|
||||
zh_Hans: '使用 JSON 格式定义分类: [{"name": "分类1", "description": "...", "examples": ["..."]}]',
|
||||
},
|
||||
required: true,
|
||||
default: '[]',
|
||||
},
|
||||
{
|
||||
id: 'confidence_threshold',
|
||||
name: 'confidence_threshold',
|
||||
type: DynamicFormItemType.FLOAT,
|
||||
label: {
|
||||
en_US: 'Confidence Threshold',
|
||||
zh_Hans: '置信度阈值',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Minimum confidence score required (0.0-1.0)',
|
||||
zh_Hans: '所需的最小置信度分数(0.0-1.0)',
|
||||
},
|
||||
required: false,
|
||||
default: 0.7,
|
||||
},
|
||||
{
|
||||
id: 'fallback_category',
|
||||
name: 'fallback_category',
|
||||
type: DynamicFormItemType.STRING,
|
||||
label: {
|
||||
en_US: 'Fallback Category',
|
||||
zh_Hans: '默认分类',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Category to use when confidence is below threshold',
|
||||
zh_Hans: '当置信度低于阈值时使用的分类',
|
||||
},
|
||||
required: false,
|
||||
default: 'other',
|
||||
},
|
||||
],
|
||||
defaultConfig: {
|
||||
model: '',
|
||||
categories: '[]',
|
||||
confidence_threshold: 0.7,
|
||||
fallback_category: 'other',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Parameter Extractor Node
|
||||
* Extracts structured parameters from natural language
|
||||
*/
|
||||
export const parameterExtractorConfig: NodeConfigMeta = {
|
||||
nodeType: 'parameter_extractor',
|
||||
label: {
|
||||
en_US: 'Parameter Extractor',
|
||||
zh_Hans: '参数提取器',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Extract structured parameters from natural language text using AI',
|
||||
zh_Hans: '使用 AI 从自然语言文本中提取结构化参数',
|
||||
},
|
||||
icon: 'FileSearch',
|
||||
category: 'process',
|
||||
color: '#8b5cf6',
|
||||
inputs: [
|
||||
createInput('text', 'string', {
|
||||
description: 'Text to extract parameters from',
|
||||
label: { en_US: 'Text', zh_Hans: '文本' },
|
||||
}),
|
||||
],
|
||||
outputs: [
|
||||
createOutput('parameters', 'object', {
|
||||
description: 'Extracted parameters as key-value pairs',
|
||||
label: { en_US: 'Parameters', zh_Hans: '参数' },
|
||||
}),
|
||||
createOutput('missing', 'array', {
|
||||
description: 'List of required parameters that could not be extracted',
|
||||
label: { en_US: 'Missing', zh_Hans: '缺失项' },
|
||||
}),
|
||||
createOutput('success', 'boolean', {
|
||||
description: 'Whether all required parameters were extracted',
|
||||
label: { en_US: 'Success', zh_Hans: '成功' },
|
||||
}),
|
||||
],
|
||||
configSchema: [
|
||||
{
|
||||
id: 'model',
|
||||
name: 'model',
|
||||
type: DynamicFormItemType.LLM_MODEL_SELECTOR,
|
||||
label: {
|
||||
en_US: 'Extraction Model',
|
||||
zh_Hans: '提取模型',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Select the model to use for parameter extraction',
|
||||
zh_Hans: '选择用于参数提取的模型',
|
||||
},
|
||||
required: true,
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
id: 'parameters',
|
||||
name: 'parameters',
|
||||
type: DynamicFormItemType.TEXT,
|
||||
label: {
|
||||
en_US: 'Parameters Schema',
|
||||
zh_Hans: '参数架构',
|
||||
},
|
||||
description: {
|
||||
en_US: 'JSON array defining expected parameters: [{"name": "date", "type": "string", "description": "Meeting date", "required": true}]',
|
||||
zh_Hans: '定义期望参数的 JSON 数组: [{"name": "日期", "type": "string", "description": "会议日期", "required": true}]',
|
||||
},
|
||||
required: true,
|
||||
default: '[]',
|
||||
},
|
||||
{
|
||||
id: 'extraction_prompt',
|
||||
name: 'extraction_prompt',
|
||||
type: DynamicFormItemType.TEXT,
|
||||
label: {
|
||||
en_US: 'Extraction Prompt',
|
||||
zh_Hans: '提取提示',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Additional instructions for the extraction model',
|
||||
zh_Hans: '提取模型的额外指令',
|
||||
},
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
id: 'strict_mode',
|
||||
name: 'strict_mode',
|
||||
type: DynamicFormItemType.BOOLEAN,
|
||||
label: {
|
||||
en_US: 'Strict Mode',
|
||||
zh_Hans: '严格模式',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Fail if any required parameter cannot be extracted',
|
||||
zh_Hans: '如果任何必需参数无法提取则失败',
|
||||
},
|
||||
required: false,
|
||||
default: true,
|
||||
},
|
||||
],
|
||||
defaultConfig: {
|
||||
model: '',
|
||||
parameters_definition: '[]',
|
||||
extraction_prompt: '',
|
||||
strict_mode: true,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Knowledge Retrieval Node
|
||||
* Retrieves relevant information from knowledge bases
|
||||
*/
|
||||
export const knowledgeRetrievalConfig: NodeConfigMeta = {
|
||||
nodeType: 'knowledge_retrieval',
|
||||
label: {
|
||||
en_US: 'Knowledge Retrieval',
|
||||
zh_Hans: '知识检索',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Retrieve relevant information from knowledge bases using semantic search',
|
||||
zh_Hans: '使用语义搜索从知识库中检索相关信息',
|
||||
},
|
||||
icon: 'BookOpen',
|
||||
category: 'process',
|
||||
color: '#8b5cf6',
|
||||
inputs: [
|
||||
createInput('query', 'string', {
|
||||
description: 'Query text to search for',
|
||||
label: { en_US: 'Query', zh_Hans: '查询' },
|
||||
}),
|
||||
],
|
||||
outputs: [
|
||||
createOutput('results', 'array', {
|
||||
description: 'Retrieved documents/chunks',
|
||||
label: { en_US: 'Results', zh_Hans: '结果' },
|
||||
}),
|
||||
createOutput('context', 'string', {
|
||||
description: 'Concatenated text from all results',
|
||||
label: { en_US: 'Context', zh_Hans: '上下文' },
|
||||
}),
|
||||
createOutput('scores', 'array', {
|
||||
description: 'Similarity scores for each result',
|
||||
label: { en_US: 'Scores', zh_Hans: '分数' },
|
||||
}),
|
||||
],
|
||||
configSchema: [
|
||||
{
|
||||
id: 'knowledge_bases',
|
||||
name: 'knowledge_bases',
|
||||
type: DynamicFormItemType.KNOWLEDGE_BASE_MULTI_SELECTOR,
|
||||
label: {
|
||||
en_US: 'Knowledge Bases',
|
||||
zh_Hans: '知识库',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Select knowledge bases to search',
|
||||
zh_Hans: '选择要搜索的知识库',
|
||||
},
|
||||
required: true,
|
||||
default: [],
|
||||
},
|
||||
{
|
||||
id: 'top_k',
|
||||
name: 'top_k',
|
||||
type: DynamicFormItemType.INT,
|
||||
label: {
|
||||
en_US: 'Top K Results',
|
||||
zh_Hans: '返回数量 (Top K)',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Number of top results to retrieve',
|
||||
zh_Hans: '返回的最相关结果数量',
|
||||
},
|
||||
required: false,
|
||||
default: 5,
|
||||
},
|
||||
{
|
||||
id: 'similarity_threshold',
|
||||
name: 'similarity_threshold',
|
||||
type: DynamicFormItemType.FLOAT,
|
||||
label: {
|
||||
en_US: 'Similarity Threshold',
|
||||
zh_Hans: '相似度阈值',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Minimum similarity score (0.0-1.0) for results to be included',
|
||||
zh_Hans: '结果被包含的最小相似度分数(0.0-1.0)',
|
||||
},
|
||||
required: false,
|
||||
default: 0.5,
|
||||
},
|
||||
{
|
||||
id: 'retrieval_mode',
|
||||
name: 'retrieval_mode',
|
||||
type: DynamicFormItemType.SELECT,
|
||||
label: {
|
||||
en_US: 'Retrieval Mode',
|
||||
zh_Hans: '检索模式',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Method used for retrieving documents',
|
||||
zh_Hans: '用于检索文档的方法',
|
||||
},
|
||||
required: false,
|
||||
default: 'vector',
|
||||
options: [
|
||||
{ name: 'vector', label: { en_US: 'Vector Search', zh_Hans: '向量检索' } },
|
||||
{ name: 'hybrid', label: { en_US: 'Hybrid Search', zh_Hans: '混合检索' } },
|
||||
{ name: 'keyword', label: { en_US: 'Keyword Search', zh_Hans: '关键词检索' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'rerank_enabled',
|
||||
name: 'rerank_enabled',
|
||||
type: DynamicFormItemType.BOOLEAN,
|
||||
label: {
|
||||
en_US: 'Enable Reranking',
|
||||
zh_Hans: '启用重排序',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Use a reranking model to improve result relevance',
|
||||
zh_Hans: '使用重排序模型提高结果相关性',
|
||||
},
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
id: 'rerank_model',
|
||||
name: 'rerank_model',
|
||||
type: DynamicFormItemType.RERANK_MODEL_SELECTOR,
|
||||
label: {
|
||||
en_US: 'Rerank Model',
|
||||
zh_Hans: '重排序模型',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Model to use for reranking results',
|
||||
zh_Hans: '用于结果重排序的模型',
|
||||
},
|
||||
required: false,
|
||||
default: '',
|
||||
show_if: {
|
||||
field: 'rerank_enabled',
|
||||
operator: 'eq',
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
defaultConfig: {
|
||||
knowledge_bases: [],
|
||||
top_k: 5,
|
||||
similarity_threshold: 0.5,
|
||||
retrieval_mode: 'vector',
|
||||
rerank_enabled: false,
|
||||
rerank_model: '',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Text Embedding Node
|
||||
* Generates vector embeddings for text
|
||||
*/
|
||||
export const textEmbeddingConfig: NodeConfigMeta = {
|
||||
nodeType: 'text_embedding',
|
||||
label: {
|
||||
en_US: 'Text Embedding',
|
||||
zh_Hans: '文本嵌入',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Generate vector embeddings for text using an embedding model',
|
||||
zh_Hans: '使用嵌入模型为文本生成向量嵌入',
|
||||
},
|
||||
icon: 'Binary',
|
||||
category: 'process',
|
||||
color: '#8b5cf6',
|
||||
inputs: [
|
||||
createInput('text', 'string', {
|
||||
description: 'Text to embed',
|
||||
label: { en_US: 'Text', zh_Hans: '文本' },
|
||||
}),
|
||||
],
|
||||
outputs: [
|
||||
createOutput('embedding', 'array', {
|
||||
description: 'Vector embedding array',
|
||||
label: { en_US: 'Embedding', zh_Hans: '嵌入向量' },
|
||||
}),
|
||||
createOutput('dimensions', 'number', {
|
||||
description: 'Number of dimensions in the embedding',
|
||||
label: { en_US: 'Dimensions', zh_Hans: '维度数' },
|
||||
}),
|
||||
],
|
||||
configSchema: [
|
||||
{
|
||||
id: 'model',
|
||||
name: 'model',
|
||||
type: DynamicFormItemType.EMBEDDING_MODEL_SELECTOR,
|
||||
label: {
|
||||
en_US: 'Embedding Model',
|
||||
zh_Hans: '嵌入模型',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Select the embedding model to use',
|
||||
zh_Hans: '选择要使用的嵌入模型',
|
||||
},
|
||||
required: true,
|
||||
default: '',
|
||||
},
|
||||
],
|
||||
defaultConfig: {
|
||||
model: '',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Intent Recognition Node
|
||||
* Recognizes user intent from natural language
|
||||
*/
|
||||
export const intentRecognitionConfig: NodeConfigMeta = {
|
||||
nodeType: 'intent_recognition',
|
||||
label: {
|
||||
en_US: 'Intent Recognition',
|
||||
zh_Hans: '意图识别',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Recognize user intent from natural language using AI',
|
||||
zh_Hans: '使用 AI 从自然语言中识别用户意图',
|
||||
},
|
||||
icon: 'Target',
|
||||
category: 'process',
|
||||
color: '#8b5cf6',
|
||||
inputs: [
|
||||
createInput('text', 'string', {
|
||||
description: 'Text to analyze',
|
||||
label: { en_US: 'Text', zh_Hans: '文本' },
|
||||
}),
|
||||
],
|
||||
outputs: [
|
||||
createOutput('intent', 'string', {
|
||||
description: 'Recognized intent',
|
||||
label: { en_US: 'Intent', zh_Hans: '意图' },
|
||||
}),
|
||||
createOutput('confidence', 'number', {
|
||||
description: 'Recognition confidence score',
|
||||
label: { en_US: 'Confidence', zh_Hans: '置信度' },
|
||||
}),
|
||||
createOutput('entities', 'object', {
|
||||
description: 'Extracted entities from the text',
|
||||
label: { en_US: 'Entities', zh_Hans: '实体' },
|
||||
}),
|
||||
],
|
||||
configSchema: [
|
||||
{
|
||||
id: 'model',
|
||||
name: 'model',
|
||||
type: DynamicFormItemType.LLM_MODEL_SELECTOR,
|
||||
label: {
|
||||
en_US: 'Recognition Model',
|
||||
zh_Hans: '识别模型',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Select the model for intent recognition',
|
||||
zh_Hans: '选择用于意图识别的模型',
|
||||
},
|
||||
required: true,
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
id: 'intents_definition',
|
||||
name: 'intents_definition',
|
||||
type: DynamicFormItemType.TEXT,
|
||||
label: {
|
||||
en_US: 'Intents Definition',
|
||||
zh_Hans: '意图定义',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Define intents in JSON format: [{"name": "intent1", "description": "...", "examples": ["..."]}]',
|
||||
zh_Hans: '使用 JSON 格式定义意图: [{"name": "意图1", "description": "...", "examples": ["..."]}]',
|
||||
},
|
||||
required: true,
|
||||
default: '[]',
|
||||
},
|
||||
{
|
||||
id: 'extract_entities',
|
||||
name: 'extract_entities',
|
||||
type: DynamicFormItemType.BOOLEAN,
|
||||
label: {
|
||||
en_US: 'Extract Entities',
|
||||
zh_Hans: '提取实体',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Also extract named entities from the text',
|
||||
zh_Hans: '同时从文本中提取命名实体',
|
||||
},
|
||||
required: false,
|
||||
default: true,
|
||||
},
|
||||
],
|
||||
defaultConfig: {
|
||||
model: '',
|
||||
intents_definition: '[]',
|
||||
extract_entities: true,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* All AI node configurations
|
||||
*/
|
||||
export const aiConfigs: NodeConfigMeta[] = [
|
||||
llmCallConfig,
|
||||
questionClassifierConfig,
|
||||
parameterExtractorConfig,
|
||||
knowledgeRetrievalConfig,
|
||||
textEmbeddingConfig,
|
||||
intentRecognitionConfig,
|
||||
];
|
||||
|
||||
/**
|
||||
* Get AI config by type
|
||||
*/
|
||||
export function getAIConfig(nodeType: string): NodeConfigMeta | undefined {
|
||||
return aiConfigs.find((config) => config.nodeType === nodeType);
|
||||
}
|
||||
@@ -0,0 +1,926 @@
|
||||
/**
|
||||
* Control Node Configurations
|
||||
*
|
||||
* Defines configurations for flow control node types:
|
||||
* - condition: Conditional branching
|
||||
* - switch_case: Multi-way branching
|
||||
* - loop: Loop/iteration
|
||||
* - parallel: Parallel execution
|
||||
* - wait: Wait/delay
|
||||
* - end: End workflow
|
||||
*/
|
||||
|
||||
import { DynamicFormItemType } from '@/app/infra/entities/form/dynamic';
|
||||
import { NodeConfigMeta, createInput, createOutput } from './types';
|
||||
|
||||
/**
|
||||
* Condition Node
|
||||
* Conditional branching based on expression
|
||||
*/
|
||||
export const conditionConfig: NodeConfigMeta = {
|
||||
nodeType: 'condition',
|
||||
label: {
|
||||
en_US: 'Condition',
|
||||
zh_Hans: '条件分支',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Branch workflow based on a condition',
|
||||
zh_Hans: '根据条件分支工作流',
|
||||
},
|
||||
icon: 'GitBranch',
|
||||
category: 'control',
|
||||
color: '#8b5cf6',
|
||||
inputs: [
|
||||
createInput('input', 'any', {
|
||||
description: 'Input data for condition evaluation',
|
||||
label: { en_US: 'Input', zh_Hans: '输入' },
|
||||
}),
|
||||
],
|
||||
outputs: [
|
||||
createOutput('true', 'any', {
|
||||
description: 'Output when condition is true',
|
||||
label: { en_US: 'True', zh_Hans: '真' },
|
||||
}),
|
||||
createOutput('false', 'any', {
|
||||
description: 'Output when condition is false',
|
||||
label: { en_US: 'False', zh_Hans: '假' },
|
||||
}),
|
||||
],
|
||||
configSchema: [
|
||||
{
|
||||
id: 'condition_type',
|
||||
name: 'condition_type',
|
||||
type: DynamicFormItemType.SELECT,
|
||||
label: {
|
||||
en_US: 'Condition Type',
|
||||
zh_Hans: '条件类型',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Type of condition to evaluate',
|
||||
zh_Hans: '要评估的条件类型',
|
||||
},
|
||||
required: true,
|
||||
default: 'expression',
|
||||
options: [
|
||||
{ name: 'expression', label: { en_US: 'Expression', zh_Hans: '表达式' } },
|
||||
{ name: 'comparison', label: { en_US: 'Comparison', zh_Hans: '比较' } },
|
||||
{ name: 'exists', label: { en_US: 'Value Exists', zh_Hans: '值存在' } },
|
||||
{ name: 'type_check', label: { en_US: 'Type Check', zh_Hans: '类型检查' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'expression',
|
||||
name: 'expression',
|
||||
type: DynamicFormItemType.STRING,
|
||||
label: {
|
||||
en_US: 'Expression',
|
||||
zh_Hans: '表达式',
|
||||
},
|
||||
description: {
|
||||
en_US: 'JavaScript expression that evaluates to true/false',
|
||||
zh_Hans: '评估为 true/false 的 JavaScript 表达式',
|
||||
},
|
||||
required: true,
|
||||
default: '',
|
||||
show_if: {
|
||||
field: 'condition_type',
|
||||
operator: 'eq',
|
||||
value: 'expression',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'left_value',
|
||||
name: 'left_value',
|
||||
type: DynamicFormItemType.STRING,
|
||||
label: {
|
||||
en_US: 'Left Value',
|
||||
zh_Hans: '左值',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Left side of comparison (supports variable references)',
|
||||
zh_Hans: '比较的左侧(支持变量引用)',
|
||||
},
|
||||
required: true,
|
||||
default: '{{input}}',
|
||||
show_if: {
|
||||
field: 'condition_type',
|
||||
operator: 'in',
|
||||
value: ['comparison', 'exists', 'type_check'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'operator',
|
||||
name: 'operator',
|
||||
type: DynamicFormItemType.SELECT,
|
||||
label: {
|
||||
en_US: 'Operator',
|
||||
zh_Hans: '运算符',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Comparison operator',
|
||||
zh_Hans: '比较运算符',
|
||||
},
|
||||
required: true,
|
||||
default: 'eq',
|
||||
options: [
|
||||
{ name: 'eq', label: { en_US: 'Equals (==)', zh_Hans: '等于 (==)' } },
|
||||
{ name: 'neq', label: { en_US: 'Not Equals (!=)', zh_Hans: '不等于 (!=)' } },
|
||||
{ name: 'gt', label: { en_US: 'Greater Than (>)', zh_Hans: '大于 (>)' } },
|
||||
{ name: 'gte', label: { en_US: 'Greater or Equal (>=)', zh_Hans: '大于等于 (>=)' } },
|
||||
{ name: 'lt', label: { en_US: 'Less Than (<)', zh_Hans: '小于 (<)' } },
|
||||
{ name: 'lte', label: { en_US: 'Less or Equal (<=)', zh_Hans: '小于等于 (<=)' } },
|
||||
{ name: 'contains', label: { en_US: 'Contains', zh_Hans: '包含' } },
|
||||
{ name: 'starts_with', label: { en_US: 'Starts With', zh_Hans: '以...开头' } },
|
||||
{ name: 'ends_with', label: { en_US: 'Ends With', zh_Hans: '以...结尾' } },
|
||||
{ name: 'matches', label: { en_US: 'Matches Regex', zh_Hans: '匹配正则' } },
|
||||
],
|
||||
show_if: {
|
||||
field: 'condition_type',
|
||||
operator: 'eq',
|
||||
value: 'comparison',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'right_value',
|
||||
name: 'right_value',
|
||||
type: DynamicFormItemType.STRING,
|
||||
label: {
|
||||
en_US: 'Right Value',
|
||||
zh_Hans: '右值',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Right side of comparison',
|
||||
zh_Hans: '比较的右侧',
|
||||
},
|
||||
required: true,
|
||||
default: '',
|
||||
show_if: {
|
||||
field: 'condition_type',
|
||||
operator: 'eq',
|
||||
value: 'comparison',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'expected_type',
|
||||
name: 'expected_type',
|
||||
type: DynamicFormItemType.SELECT,
|
||||
label: {
|
||||
en_US: 'Expected Type',
|
||||
zh_Hans: '期望类型',
|
||||
},
|
||||
description: {
|
||||
en_US: 'The type to check for',
|
||||
zh_Hans: '要检查的类型',
|
||||
},
|
||||
required: true,
|
||||
default: 'string',
|
||||
options: [
|
||||
{ name: 'string', label: { en_US: 'String', zh_Hans: '字符串' } },
|
||||
{ name: 'number', label: { en_US: 'Number', zh_Hans: '数字' } },
|
||||
{ name: 'boolean', label: { en_US: 'Boolean', zh_Hans: '布尔' } },
|
||||
{ name: 'object', label: { en_US: 'Object', zh_Hans: '对象' } },
|
||||
{ name: 'array', label: { en_US: 'Array', zh_Hans: '数组' } },
|
||||
{ name: 'null', label: { en_US: 'Null', zh_Hans: '空' } },
|
||||
],
|
||||
show_if: {
|
||||
field: 'condition_type',
|
||||
operator: 'eq',
|
||||
value: 'type_check',
|
||||
},
|
||||
},
|
||||
],
|
||||
defaultConfig: {
|
||||
condition_type: 'expression',
|
||||
expression: '',
|
||||
left_value: '{{input}}',
|
||||
operator: 'eq',
|
||||
right_value: '',
|
||||
expected_type: 'string',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Switch Case Node
|
||||
* Multi-way branching based on value
|
||||
*/
|
||||
export const switchCaseConfig: NodeConfigMeta = {
|
||||
nodeType: 'switch_case',
|
||||
label: {
|
||||
en_US: 'Switch',
|
||||
zh_Hans: '多路分支',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Branch workflow based on multiple cases',
|
||||
zh_Hans: '根据多个条件分支工作流',
|
||||
},
|
||||
icon: 'GitFork',
|
||||
category: 'control',
|
||||
color: '#8b5cf6',
|
||||
inputs: [
|
||||
createInput('input', 'any', {
|
||||
description: 'Value to switch on',
|
||||
label: { en_US: 'Input', zh_Hans: '输入' },
|
||||
}),
|
||||
],
|
||||
outputs: [
|
||||
createOutput('case_1', 'any', {
|
||||
description: 'Branch 1 output',
|
||||
label: { en_US: 'Branch 1', zh_Hans: '分支 1' },
|
||||
}),
|
||||
createOutput('case_2', 'any', {
|
||||
description: 'Branch 2 output',
|
||||
label: { en_US: 'Branch 2', zh_Hans: '分支 2' },
|
||||
}),
|
||||
createOutput('default', 'any', {
|
||||
description: 'Default branch output',
|
||||
label: { en_US: 'Default Branch', zh_Hans: '默认分支' },
|
||||
}),
|
||||
],
|
||||
configSchema: [
|
||||
{
|
||||
id: 'switch_expression',
|
||||
name: 'switch_expression',
|
||||
type: DynamicFormItemType.STRING,
|
||||
label: {
|
||||
en_US: 'Switch Expression',
|
||||
zh_Hans: '开关表达式',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Expression to evaluate for switching (e.g., {{input.type}})',
|
||||
zh_Hans: '用于切换的表达式(例如 {{input.type}})',
|
||||
},
|
||||
required: true,
|
||||
default: '{{input}}',
|
||||
},
|
||||
{
|
||||
id: 'cases',
|
||||
name: 'cases',
|
||||
type: DynamicFormItemType.TEXT,
|
||||
label: {
|
||||
en_US: 'Cases',
|
||||
zh_Hans: '情况',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Define cases as JSON array: [{"name": "case_1", "value": "value1"}, {"name": "case_2", "values": ["v1", "v2"]}]',
|
||||
zh_Hans: '使用 JSON 数组定义情况: [{"name": "case_1", "value": "value1"}, {"name": "case_2", "values": ["v1", "v2"]}]',
|
||||
},
|
||||
required: true,
|
||||
default: '[{"name": "case_1", "value": ""}, {"name": "case_2", "value": ""}]',
|
||||
},
|
||||
{
|
||||
id: 'case_sensitive',
|
||||
name: 'case_sensitive',
|
||||
type: DynamicFormItemType.BOOLEAN,
|
||||
label: {
|
||||
en_US: 'Case Sensitive',
|
||||
zh_Hans: '区分大小写',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Whether string comparisons are case-sensitive',
|
||||
zh_Hans: '字符串比较是否区分大小写',
|
||||
},
|
||||
required: false,
|
||||
default: true,
|
||||
},
|
||||
],
|
||||
defaultConfig: {
|
||||
switch_expression: '{{input}}',
|
||||
cases: '[{"name": "case_1", "value": ""}, {"name": "case_2", "value": ""}]',
|
||||
case_sensitive: true,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Loop Node
|
||||
* Iterates over items or until condition
|
||||
*/
|
||||
export const loopConfig: NodeConfigMeta = {
|
||||
nodeType: 'loop',
|
||||
label: {
|
||||
en_US: 'Loop',
|
||||
zh_Hans: '循环',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Iterate over items or repeat until condition',
|
||||
zh_Hans: '遍历项目或重复直到满足条件',
|
||||
},
|
||||
icon: 'Repeat',
|
||||
category: 'control',
|
||||
color: '#8b5cf6',
|
||||
inputs: [
|
||||
createInput('items', 'array', {
|
||||
description: 'Items to iterate over (for each loop)',
|
||||
label: { en_US: 'Items', zh_Hans: '项目' },
|
||||
required: false,
|
||||
}),
|
||||
],
|
||||
outputs: [
|
||||
createOutput('item', 'any', {
|
||||
description: 'Current item in iteration',
|
||||
label: { en_US: 'Item', zh_Hans: '当前项' },
|
||||
}),
|
||||
createOutput('index', 'number', {
|
||||
description: 'Current iteration index',
|
||||
label: { en_US: 'Index', zh_Hans: '索引' },
|
||||
}),
|
||||
createOutput('completed', 'any', {
|
||||
description: 'Output after loop completes',
|
||||
label: { en_US: 'Completed', zh_Hans: '完成' },
|
||||
}),
|
||||
],
|
||||
configSchema: [
|
||||
{
|
||||
id: 'loop_type',
|
||||
name: 'loop_type',
|
||||
type: DynamicFormItemType.SELECT,
|
||||
label: {
|
||||
en_US: 'Loop Type',
|
||||
zh_Hans: '循环类型',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Type of loop to execute',
|
||||
zh_Hans: '要执行的循环类型',
|
||||
},
|
||||
required: true,
|
||||
default: 'foreach',
|
||||
options: [
|
||||
{ name: 'foreach', label: { en_US: 'For Each', zh_Hans: '逐项遍历' } },
|
||||
{ name: 'while', label: { en_US: 'While', zh_Hans: '条件循环' } },
|
||||
{ name: 'count', label: { en_US: 'Count', zh_Hans: '计数' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'max_iterations',
|
||||
name: 'max_iterations',
|
||||
type: DynamicFormItemType.INT,
|
||||
label: {
|
||||
en_US: 'Max Iterations',
|
||||
zh_Hans: '最大迭代次数',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Maximum number of iterations (safety limit)',
|
||||
zh_Hans: '最大迭代次数(安全限制)',
|
||||
},
|
||||
required: false,
|
||||
default: 100,
|
||||
},
|
||||
{
|
||||
id: 'count',
|
||||
name: 'count',
|
||||
type: DynamicFormItemType.INT,
|
||||
label: {
|
||||
en_US: 'Count',
|
||||
zh_Hans: '计数',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Number of times to iterate',
|
||||
zh_Hans: '迭代次数',
|
||||
},
|
||||
required: true,
|
||||
default: 10,
|
||||
show_if: {
|
||||
field: 'loop_type',
|
||||
operator: 'eq',
|
||||
value: 'count',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'while_condition',
|
||||
name: 'while_condition',
|
||||
type: DynamicFormItemType.STRING,
|
||||
label: {
|
||||
en_US: 'While Condition',
|
||||
zh_Hans: 'While 条件',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Condition expression to continue looping',
|
||||
zh_Hans: '继续循环的条件表达式',
|
||||
},
|
||||
required: true,
|
||||
default: '',
|
||||
show_if: {
|
||||
field: 'loop_type',
|
||||
operator: 'eq',
|
||||
value: 'while',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'parallel',
|
||||
name: 'parallel',
|
||||
type: DynamicFormItemType.BOOLEAN,
|
||||
label: {
|
||||
en_US: 'Parallel Execution',
|
||||
zh_Hans: '并行执行',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Execute iterations in parallel',
|
||||
zh_Hans: '并行执行迭代',
|
||||
},
|
||||
required: false,
|
||||
default: false,
|
||||
show_if: {
|
||||
field: 'loop_type',
|
||||
operator: 'eq',
|
||||
value: 'foreach',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'parallel_limit',
|
||||
name: 'parallel_limit',
|
||||
type: DynamicFormItemType.INT,
|
||||
label: {
|
||||
en_US: 'Parallel Limit',
|
||||
zh_Hans: '并行限制',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Maximum number of parallel executions',
|
||||
zh_Hans: '最大并行执行数',
|
||||
},
|
||||
required: false,
|
||||
default: 5,
|
||||
show_if: {
|
||||
field: 'parallel',
|
||||
operator: 'eq',
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
defaultConfig: {
|
||||
loop_type: 'foreach',
|
||||
max_iterations: 100,
|
||||
count: 10,
|
||||
while_condition: '',
|
||||
parallel: false,
|
||||
parallel_limit: 5,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Parallel Node
|
||||
* Execute multiple branches in parallel
|
||||
*/
|
||||
export const parallelConfig: NodeConfigMeta = {
|
||||
nodeType: 'parallel',
|
||||
label: {
|
||||
en_US: 'Parallel',
|
||||
zh_Hans: '并行执行',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Execute multiple branches in parallel',
|
||||
zh_Hans: '并行执行多个分支',
|
||||
},
|
||||
icon: 'GitMerge',
|
||||
category: 'control',
|
||||
color: '#8b5cf6',
|
||||
inputs: [
|
||||
createInput('input', 'any', {
|
||||
description: 'Input data for all branches',
|
||||
label: { en_US: 'Input', zh_Hans: '输入' },
|
||||
}),
|
||||
],
|
||||
outputs: [
|
||||
createOutput('branch_1', 'any', {
|
||||
description: 'Branch 1 output',
|
||||
label: { en_US: 'Branch 1', zh_Hans: '分支 1' },
|
||||
}),
|
||||
createOutput('branch_2', 'any', {
|
||||
description: 'Branch 2 output',
|
||||
label: { en_US: 'Branch 2', zh_Hans: '分支 2' },
|
||||
}),
|
||||
createOutput('results', 'object', {
|
||||
description: 'Combined results from all branches',
|
||||
label: { en_US: 'Results', zh_Hans: '结果' },
|
||||
}),
|
||||
],
|
||||
configSchema: [
|
||||
{
|
||||
id: 'branches',
|
||||
name: 'branches',
|
||||
type: DynamicFormItemType.TEXT,
|
||||
label: {
|
||||
en_US: 'Branches',
|
||||
zh_Hans: '分支',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Define branches as JSON array: [{"name": "branch_1"}, {"name": "branch_2"}]',
|
||||
zh_Hans: '使用 JSON 数组定义分支: [{"name": "branch_1"}, {"name": "branch_2"}]',
|
||||
},
|
||||
required: true,
|
||||
default: '[{"name": "branch_1"}, {"name": "branch_2"}]',
|
||||
},
|
||||
{
|
||||
id: 'wait_for_all',
|
||||
name: 'wait_for_all',
|
||||
type: DynamicFormItemType.BOOLEAN,
|
||||
label: {
|
||||
en_US: 'Wait for All',
|
||||
zh_Hans: '等待全部完成',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Wait for all branches to complete before continuing',
|
||||
zh_Hans: '等待所有分支完成后再继续',
|
||||
},
|
||||
required: false,
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
id: 'fail_fast',
|
||||
name: 'fail_fast',
|
||||
type: DynamicFormItemType.BOOLEAN,
|
||||
label: {
|
||||
en_US: 'Fail Fast',
|
||||
zh_Hans: '快速失败',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Stop all branches if any one fails',
|
||||
zh_Hans: '如果任何一个分支失败则停止所有分支',
|
||||
},
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
],
|
||||
defaultConfig: {
|
||||
branches: '[{"name": "branch_1"}, {"name": "branch_2"}]',
|
||||
wait_for_all: true,
|
||||
fail_fast: false,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Wait Node
|
||||
* Pause workflow execution
|
||||
*/
|
||||
export const waitConfig: NodeConfigMeta = {
|
||||
nodeType: 'wait',
|
||||
label: {
|
||||
en_US: 'Wait',
|
||||
zh_Hans: '等待',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Pause workflow execution for a specified duration or condition',
|
||||
zh_Hans: '暂停工作流执行指定的时间或等待条件满足',
|
||||
},
|
||||
icon: 'Clock',
|
||||
category: 'control',
|
||||
color: '#8b5cf6',
|
||||
inputs: [
|
||||
createInput('input', 'any', {
|
||||
description: 'Input to pass through',
|
||||
label: { en_US: 'Input', zh_Hans: '输入' },
|
||||
required: false,
|
||||
}),
|
||||
],
|
||||
outputs: [
|
||||
createOutput('output', 'any', {
|
||||
description: 'Passed through input',
|
||||
label: { en_US: 'Output', zh_Hans: '输出' },
|
||||
}),
|
||||
],
|
||||
configSchema: [
|
||||
{
|
||||
id: 'wait_type',
|
||||
name: 'wait_type',
|
||||
type: DynamicFormItemType.SELECT,
|
||||
label: {
|
||||
en_US: 'Wait Type',
|
||||
zh_Hans: '等待类型',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Type of wait operation',
|
||||
zh_Hans: '等待操作的类型',
|
||||
},
|
||||
required: true,
|
||||
default: 'duration',
|
||||
options: [
|
||||
{ name: 'duration', label: { en_US: 'Duration', zh_Hans: '时长' } },
|
||||
{ name: 'until', label: { en_US: 'Until Time', zh_Hans: '直到时间' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'duration',
|
||||
name: 'duration',
|
||||
type: DynamicFormItemType.INT,
|
||||
label: {
|
||||
en_US: 'Duration (seconds)',
|
||||
zh_Hans: '时长(秒)',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Number of seconds to wait',
|
||||
zh_Hans: '等待的秒数',
|
||||
},
|
||||
required: true,
|
||||
default: 5,
|
||||
show_if: {
|
||||
field: 'wait_type',
|
||||
operator: 'eq',
|
||||
value: 'duration',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'until_time',
|
||||
name: 'until_time',
|
||||
type: DynamicFormItemType.STRING,
|
||||
label: {
|
||||
en_US: 'Until Time',
|
||||
zh_Hans: '直到时间',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Wait until this time (ISO 8601 format or expression)',
|
||||
zh_Hans: '等待直到此时间(ISO 8601 格式或表达式)',
|
||||
},
|
||||
required: true,
|
||||
default: '',
|
||||
show_if: {
|
||||
field: 'wait_type',
|
||||
operator: 'eq',
|
||||
value: 'until',
|
||||
},
|
||||
},
|
||||
],
|
||||
defaultConfig: {
|
||||
wait_type: 'duration',
|
||||
duration: 5,
|
||||
until_time: '',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* End Node
|
||||
* Terminates workflow execution
|
||||
*/
|
||||
export const endConfig: NodeConfigMeta = {
|
||||
nodeType: 'end',
|
||||
label: {
|
||||
en_US: 'End',
|
||||
zh_Hans: '结束',
|
||||
},
|
||||
description: {
|
||||
en_US: 'End the workflow execution',
|
||||
zh_Hans: '结束工作流执行',
|
||||
},
|
||||
icon: 'CircleStop',
|
||||
category: 'control',
|
||||
color: '#8b5cf6',
|
||||
inputs: [
|
||||
createInput('input', 'any', {
|
||||
description: 'Final output data',
|
||||
label: { en_US: 'Input', zh_Hans: '输入' },
|
||||
required: false,
|
||||
}),
|
||||
],
|
||||
outputs: [],
|
||||
configSchema: [
|
||||
{
|
||||
id: 'status',
|
||||
name: 'status',
|
||||
type: DynamicFormItemType.SELECT,
|
||||
label: {
|
||||
en_US: 'End Status',
|
||||
zh_Hans: '结束状态',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Status to report when workflow ends',
|
||||
zh_Hans: '工作流结束时报告的状态',
|
||||
},
|
||||
required: true,
|
||||
default: 'success',
|
||||
options: [
|
||||
{ name: 'success', label: { en_US: 'Success', zh_Hans: '成功' } },
|
||||
{ name: 'failed', label: { en_US: 'Failed', zh_Hans: '失败' } },
|
||||
{ name: 'cancelled', label: { en_US: 'Cancelled', zh_Hans: '取消' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'message',
|
||||
name: 'message',
|
||||
type: DynamicFormItemType.STRING,
|
||||
label: {
|
||||
en_US: 'Message',
|
||||
zh_Hans: '消息',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Optional message to include with the end status',
|
||||
zh_Hans: '与结束状态一起包含的可选消息',
|
||||
},
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
],
|
||||
defaultConfig: {
|
||||
status: 'success',
|
||||
message: '',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Iterator Node
|
||||
* Iterates over array items one by one
|
||||
*/
|
||||
export const iteratorConfig: NodeConfigMeta = {
|
||||
nodeType: 'iterator',
|
||||
label: {
|
||||
en_US: 'Iterator',
|
||||
zh_Hans: '迭代器',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Iterate over array elements one by one',
|
||||
zh_Hans: '逐个遍历数组元素',
|
||||
},
|
||||
icon: 'Repeat',
|
||||
category: 'control',
|
||||
color: '#8b5cf6',
|
||||
inputs: [
|
||||
createInput('items', 'array', {
|
||||
description: 'Array to iterate over',
|
||||
label: { en_US: 'Items', zh_Hans: '项目' },
|
||||
}),
|
||||
],
|
||||
outputs: [
|
||||
createOutput('item', 'any', {
|
||||
description: 'Current item',
|
||||
label: { en_US: 'Item', zh_Hans: '当前项' },
|
||||
}),
|
||||
createOutput('index', 'number', {
|
||||
description: 'Current index',
|
||||
label: { en_US: 'Index', zh_Hans: '索引' },
|
||||
}),
|
||||
createOutput('is_first', 'boolean', {
|
||||
description: 'Whether this is the first item',
|
||||
label: { en_US: 'Is First', zh_Hans: '是否第一个' },
|
||||
}),
|
||||
createOutput('is_last', 'boolean', {
|
||||
description: 'Whether this is the last item',
|
||||
label: { en_US: 'Is Last', zh_Hans: '是否最后一个' },
|
||||
}),
|
||||
createOutput('completed', 'any', {
|
||||
description: 'Output after iteration completes',
|
||||
label: { en_US: 'Completed', zh_Hans: '完成' },
|
||||
}),
|
||||
],
|
||||
configSchema: [
|
||||
{
|
||||
id: 'parallel',
|
||||
name: 'parallel',
|
||||
type: DynamicFormItemType.BOOLEAN,
|
||||
label: { en_US: 'Parallel Processing', zh_Hans: '并行处理' },
|
||||
description: { en_US: 'Process items in parallel', zh_Hans: '并行处理项目' },
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
id: 'max_concurrency',
|
||||
name: 'max_concurrency',
|
||||
type: DynamicFormItemType.INT,
|
||||
label: { en_US: 'Max Concurrency', zh_Hans: '最大并发数' },
|
||||
description: { en_US: 'Maximum number of concurrent iterations', zh_Hans: '最大并发迭代数' },
|
||||
required: false,
|
||||
default: 5,
|
||||
show_if: { field: 'parallel', operator: 'eq', value: true },
|
||||
},
|
||||
{
|
||||
id: 'max_iterations',
|
||||
name: 'max_iterations',
|
||||
type: DynamicFormItemType.INT,
|
||||
label: { en_US: 'Max Iterations', zh_Hans: '最大迭代次数' },
|
||||
description: { en_US: 'Safety limit on iterations', zh_Hans: '迭代次数安全限制' },
|
||||
required: false,
|
||||
default: 1000,
|
||||
},
|
||||
],
|
||||
defaultConfig: { parallel: false, max_concurrency: 5, max_iterations: 1000 },
|
||||
};
|
||||
|
||||
/**
|
||||
* Merge Node
|
||||
* Merges multiple branches back together
|
||||
*/
|
||||
export const mergeConfig: NodeConfigMeta = {
|
||||
nodeType: 'merge',
|
||||
label: {
|
||||
en_US: 'Merge',
|
||||
zh_Hans: '合并',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Merge multiple branches back together',
|
||||
zh_Hans: '将多个分支合并在一起',
|
||||
},
|
||||
icon: 'GitMerge',
|
||||
category: 'control',
|
||||
color: '#8b5cf6',
|
||||
inputs: [
|
||||
createInput('branch_1', 'any', {
|
||||
description: 'Input from branch 1',
|
||||
label: { en_US: 'Branch 1', zh_Hans: '分支 1' },
|
||||
required: false,
|
||||
}),
|
||||
createInput('branch_2', 'any', {
|
||||
description: 'Input from branch 2',
|
||||
label: { en_US: 'Branch 2', zh_Hans: '分支 2' },
|
||||
required: false,
|
||||
}),
|
||||
],
|
||||
outputs: [
|
||||
createOutput('output', 'any', {
|
||||
description: 'Merged output',
|
||||
label: { en_US: 'Output', zh_Hans: '输出' },
|
||||
}),
|
||||
],
|
||||
configSchema: [
|
||||
{
|
||||
id: 'merge_strategy',
|
||||
name: 'merge_strategy',
|
||||
type: DynamicFormItemType.SELECT,
|
||||
label: { en_US: 'Merge Strategy', zh_Hans: '合并策略' },
|
||||
description: { en_US: 'How to merge inputs from branches', zh_Hans: '如何合并分支输入' },
|
||||
required: true,
|
||||
default: 'wait_all',
|
||||
options: [
|
||||
{ name: 'wait_all', label: { en_US: 'Wait for All', zh_Hans: '等待全部' } },
|
||||
{ name: 'first_completed', label: { en_US: 'First Completed', zh_Hans: '第一个完成' } },
|
||||
{ name: 'combine', label: { en_US: 'Combine to Object', zh_Hans: '合并为对象' } },
|
||||
{ name: 'array', label: { en_US: 'Collect to Array', zh_Hans: '收集为数组' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
defaultConfig: { merge_strategy: 'wait_all' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Variable Aggregator Node
|
||||
* Aggregates variable outputs from multiple branches
|
||||
*/
|
||||
export const variableAggregatorConfig: NodeConfigMeta = {
|
||||
nodeType: 'variable_aggregator',
|
||||
label: {
|
||||
en_US: 'Variable Aggregator',
|
||||
zh_Hans: '变量聚合器',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Aggregate variable outputs from multiple branches',
|
||||
zh_Hans: '聚合多个分支的变量输出',
|
||||
},
|
||||
icon: 'GitMerge',
|
||||
category: 'control',
|
||||
color: '#8b5cf6',
|
||||
inputs: [
|
||||
createInput('input', 'any', {
|
||||
description: 'Input data',
|
||||
label: { en_US: 'Input', zh_Hans: '输入' },
|
||||
required: false,
|
||||
}),
|
||||
],
|
||||
outputs: [
|
||||
createOutput('output', 'any', {
|
||||
description: 'Aggregated output',
|
||||
label: { en_US: 'Output', zh_Hans: '输出' },
|
||||
}),
|
||||
],
|
||||
configSchema: [
|
||||
{
|
||||
id: 'variable_mappings',
|
||||
name: 'variable_mappings',
|
||||
type: DynamicFormItemType.TEXT,
|
||||
label: { en_US: 'Variable Mappings', zh_Hans: '变量映射' },
|
||||
description: { en_US: 'JSON mapping of output variables: {"out_key": "{{nodes.xxx.value}}"}', zh_Hans: 'JSON 格式的输出变量映射: {"out_key": "{{nodes.xxx.value}}"}' },
|
||||
required: true,
|
||||
default: '{}',
|
||||
},
|
||||
{
|
||||
id: 'aggregation_mode',
|
||||
name: 'aggregation_mode',
|
||||
type: DynamicFormItemType.SELECT,
|
||||
label: { en_US: 'Aggregation Mode', zh_Hans: '聚合模式' },
|
||||
description: { en_US: 'How to aggregate the variables', zh_Hans: '如何聚合变量' },
|
||||
required: true,
|
||||
default: 'merge',
|
||||
options: [
|
||||
{ name: 'merge', label: { en_US: 'Merge Objects', zh_Hans: '合并对象' } },
|
||||
{ name: 'array', label: { en_US: 'Collect to Array', zh_Hans: '收集为数组' } },
|
||||
{ name: 'first', label: { en_US: 'First Non-null', zh_Hans: '第一个非空' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
defaultConfig: { variable_mappings: '{}', aggregation_mode: 'merge' },
|
||||
};
|
||||
|
||||
/**
|
||||
* All control node configurations
|
||||
*/
|
||||
export const controlConfigs: NodeConfigMeta[] = [
|
||||
conditionConfig,
|
||||
switchCaseConfig,
|
||||
loopConfig,
|
||||
iteratorConfig,
|
||||
parallelConfig,
|
||||
waitConfig,
|
||||
mergeConfig,
|
||||
variableAggregatorConfig,
|
||||
endConfig,
|
||||
];
|
||||
|
||||
/**
|
||||
* Get control config by type
|
||||
*/
|
||||
export function getControlConfig(nodeType: string): NodeConfigMeta | undefined {
|
||||
return controlConfigs.find((config) => config.nodeType === nodeType);
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* Node Configurations Index
|
||||
*
|
||||
* This module exports all node configuration metadata and provides
|
||||
* utility functions for accessing node configurations.
|
||||
*/
|
||||
|
||||
// Types
|
||||
export * from './types';
|
||||
|
||||
// Trigger Nodes
|
||||
export {
|
||||
triggerConfigs,
|
||||
getTriggerConfig,
|
||||
messageTriggerConfig,
|
||||
cronTriggerConfig,
|
||||
webhookTriggerConfig,
|
||||
eventTriggerConfig,
|
||||
} from './trigger-configs';
|
||||
|
||||
// AI Nodes
|
||||
export {
|
||||
aiConfigs,
|
||||
getAIConfig,
|
||||
llmCallConfig,
|
||||
questionClassifierConfig,
|
||||
parameterExtractorConfig,
|
||||
knowledgeRetrievalConfig,
|
||||
textEmbeddingConfig,
|
||||
intentRecognitionConfig,
|
||||
} from './ai-configs';
|
||||
|
||||
// Process Nodes
|
||||
export {
|
||||
processConfigs,
|
||||
getProcessConfig,
|
||||
textTemplateConfig,
|
||||
jsonTransformConfig,
|
||||
codeExecutorConfig,
|
||||
dataAggregatorConfig,
|
||||
textSplitterConfig,
|
||||
variableAssignmentConfig,
|
||||
dataTransformConfig,
|
||||
} from './process-configs';
|
||||
|
||||
// Control Nodes
|
||||
export {
|
||||
controlConfigs,
|
||||
getControlConfig,
|
||||
conditionConfig,
|
||||
switchCaseConfig,
|
||||
loopConfig,
|
||||
iteratorConfig,
|
||||
parallelConfig,
|
||||
waitConfig,
|
||||
mergeConfig,
|
||||
variableAggregatorConfig,
|
||||
endConfig,
|
||||
} from './control-configs';
|
||||
|
||||
// Action Nodes
|
||||
export {
|
||||
actionConfigs,
|
||||
getActionConfig,
|
||||
sendMessageConfig,
|
||||
replyMessageConfig,
|
||||
httpRequestConfig,
|
||||
storeDataConfig,
|
||||
callPipelineConfig,
|
||||
setVariableConfig,
|
||||
openingStatementConfig,
|
||||
botInvokeConfig,
|
||||
workflowInvokeConfig,
|
||||
notificationConfig,
|
||||
} from './action-configs';
|
||||
|
||||
// Integration Nodes
|
||||
export {
|
||||
integrationConfigs,
|
||||
getIntegrationConfig,
|
||||
difyWorkflowConfig,
|
||||
difyKnowledgeQueryConfig,
|
||||
n8nWorkflowConfig,
|
||||
langflowFlowConfig,
|
||||
cozeBotConfig,
|
||||
databaseQueryConfig,
|
||||
redisOperationConfig,
|
||||
mcpToolConfig,
|
||||
memoryStoreConfig,
|
||||
} from './integration-configs';
|
||||
|
||||
import { NodeConfigMeta, NodeConfigRegistry } from './types';
|
||||
import { triggerConfigs } from './trigger-configs';
|
||||
import { aiConfigs } from './ai-configs';
|
||||
import { processConfigs } from './process-configs';
|
||||
import { controlConfigs } from './control-configs';
|
||||
import { actionConfigs } from './action-configs';
|
||||
import { integrationConfigs } from './integration-configs';
|
||||
import { NodeCategory } from '@/app/infra/entities/workflow';
|
||||
|
||||
/**
|
||||
* All node configurations combined
|
||||
*/
|
||||
export const allNodeConfigs: NodeConfigMeta[] = [
|
||||
...triggerConfigs,
|
||||
...aiConfigs,
|
||||
...processConfigs,
|
||||
...controlConfigs,
|
||||
...actionConfigs,
|
||||
...integrationConfigs,
|
||||
];
|
||||
|
||||
/**
|
||||
* Node configuration registry by type
|
||||
* Registers each config under both its short name (e.g. "message_trigger")
|
||||
* and its full category-prefixed name (e.g. "trigger.message_trigger")
|
||||
* so lookups from PropertyPanel / useWorkflowStore always succeed.
|
||||
*/
|
||||
export const nodeConfigRegistry: NodeConfigRegistry = (() => {
|
||||
const registry: NodeConfigRegistry = {};
|
||||
for (const config of allNodeConfigs) {
|
||||
// Short name
|
||||
registry[config.nodeType] = config;
|
||||
// Full category.name
|
||||
registry[`${config.category}.${config.nodeType}`] = config;
|
||||
}
|
||||
// Aliases for nodes whose palette type differs from config nodeType
|
||||
// control.switch -> switch_case config
|
||||
if (registry['switch_case']) {
|
||||
registry['switch'] = registry['switch_case'];
|
||||
registry['control.switch'] = registry['switch_case'];
|
||||
}
|
||||
// action.end also points to the end config in control
|
||||
if (registry['end']) {
|
||||
registry['action.end'] = registry['end'];
|
||||
}
|
||||
return registry;
|
||||
})();
|
||||
|
||||
/**
|
||||
* Get node configuration by type
|
||||
*/
|
||||
export function getNodeConfig(nodeType: string): NodeConfigMeta | undefined {
|
||||
return nodeConfigRegistry[nodeType];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all node configurations for a category
|
||||
*/
|
||||
export function getNodeConfigsByCategory(category: NodeCategory): NodeConfigMeta[] {
|
||||
return allNodeConfigs.filter((config) => config.category === category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all entry point node configurations (trigger nodes)
|
||||
*/
|
||||
export function getEntryPointConfigs(): NodeConfigMeta[] {
|
||||
return allNodeConfigs.filter((config) => config.isEntryPoint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a node type exists
|
||||
*/
|
||||
export function isValidNodeType(nodeType: string): boolean {
|
||||
return nodeType in nodeConfigRegistry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default configuration for a node type
|
||||
*/
|
||||
export function getDefaultConfig(nodeType: string): Record<string, unknown> {
|
||||
const config = getNodeConfig(nodeType);
|
||||
if (!config) return {};
|
||||
|
||||
// Build default config from schema defaults
|
||||
const defaults: Record<string, unknown> = {};
|
||||
for (const field of config.configSchema) {
|
||||
defaults[field.name] = field.default;
|
||||
}
|
||||
|
||||
// Override with explicit defaultConfig if provided
|
||||
if (config.defaultConfig) {
|
||||
Object.assign(defaults, config.defaultConfig);
|
||||
}
|
||||
|
||||
return defaults;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate node configuration against schema
|
||||
*/
|
||||
export function validateNodeConfig(
|
||||
nodeType: string,
|
||||
config: Record<string, unknown>
|
||||
): { valid: boolean; errors: string[] } {
|
||||
const nodeConfig = getNodeConfig(nodeType);
|
||||
if (!nodeConfig) {
|
||||
return { valid: false, errors: [`Unknown node type: ${nodeType}`] };
|
||||
}
|
||||
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const field of nodeConfig.configSchema) {
|
||||
const value = config[field.name];
|
||||
|
||||
// Check required fields
|
||||
if (field.required && (value === undefined || value === null || value === '')) {
|
||||
errors.push(`Field "${field.name}" is required`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip validation for optional empty fields
|
||||
if (!field.required && (value === undefined || value === null)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Type-specific validation could be added here
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert node config metadata to NodeTypeMetadata format
|
||||
* (for compatibility with existing workflow store)
|
||||
*/
|
||||
export function toNodeTypeMetadata(config: NodeConfigMeta) {
|
||||
return {
|
||||
type: config.nodeType,
|
||||
name: config.label,
|
||||
description: config.description,
|
||||
category: config.category,
|
||||
icon: config.icon,
|
||||
color: config.color,
|
||||
inputs: config.inputs.map((input) => ({
|
||||
name: input.name,
|
||||
type: input.type,
|
||||
description: input.description,
|
||||
required: input.required,
|
||||
})),
|
||||
outputs: config.outputs.map((output) => ({
|
||||
name: output.name,
|
||||
type: output.type,
|
||||
description: output.description,
|
||||
required: output.required,
|
||||
})),
|
||||
config_schema: config.configSchema,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert all node configs to NodeTypeMetadata format
|
||||
*/
|
||||
export function getAllNodeTypeMetadata() {
|
||||
return allNodeConfigs.map(toNodeTypeMetadata);
|
||||
}
|
||||
@@ -0,0 +1,685 @@
|
||||
/**
|
||||
* Integration Node Configurations
|
||||
*
|
||||
* Defines configurations for integration node types:
|
||||
* - database_query: Query databases
|
||||
* - redis_operation: Redis operations
|
||||
* - mcp_tool: MCP tool invocation
|
||||
*/
|
||||
|
||||
import { DynamicFormItemType } from '@/app/infra/entities/form/dynamic';
|
||||
import { NodeConfigMeta, createInput, createOutput } from './types';
|
||||
|
||||
/**
|
||||
* Database Query Node
|
||||
* Executes database queries
|
||||
*/
|
||||
export const databaseQueryConfig: NodeConfigMeta = {
|
||||
nodeType: 'database_query',
|
||||
label: {
|
||||
en_US: 'Database Query',
|
||||
zh_Hans: '数据库查询',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Execute database queries',
|
||||
zh_Hans: '执行数据库查询',
|
||||
},
|
||||
icon: 'Database',
|
||||
category: 'integration',
|
||||
color: '#ec4899',
|
||||
inputs: [
|
||||
createInput('parameters', 'object', {
|
||||
description: 'Query parameters',
|
||||
label: { en_US: 'Parameters', zh_Hans: '参数' },
|
||||
required: false,
|
||||
}),
|
||||
],
|
||||
outputs: [
|
||||
createOutput('results', 'array', {
|
||||
description: 'Query results',
|
||||
label: { en_US: 'Results', zh_Hans: '结果' },
|
||||
}),
|
||||
createOutput('row_count', 'number', {
|
||||
description: 'Number of rows affected/returned',
|
||||
label: { en_US: 'Row Count', zh_Hans: '行数' },
|
||||
}),
|
||||
createOutput('success', 'boolean', {
|
||||
description: 'Whether query was successful',
|
||||
label: { en_US: 'Success', zh_Hans: '成功' },
|
||||
}),
|
||||
],
|
||||
configSchema: [
|
||||
{
|
||||
id: 'connection_type',
|
||||
name: 'connection_type',
|
||||
type: DynamicFormItemType.SELECT,
|
||||
label: {
|
||||
en_US: 'Database Type',
|
||||
zh_Hans: '数据库类型',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Type of database to connect to',
|
||||
zh_Hans: '要连接的数据库类型',
|
||||
},
|
||||
required: true,
|
||||
default: 'postgresql',
|
||||
options: [
|
||||
{ name: 'postgresql', label: { en_US: 'PostgreSQL', zh_Hans: 'PostgreSQL' } },
|
||||
{ name: 'mysql', label: { en_US: 'MySQL', zh_Hans: 'MySQL' } },
|
||||
{ name: 'sqlite', label: { en_US: 'SQLite', zh_Hans: 'SQLite' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'connection_string',
|
||||
name: 'connection_string',
|
||||
type: DynamicFormItemType.SECRET,
|
||||
label: {
|
||||
en_US: 'Connection String',
|
||||
zh_Hans: '连接字符串',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Database connection string',
|
||||
zh_Hans: '数据库连接字符串',
|
||||
},
|
||||
required: true,
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
id: 'query',
|
||||
name: 'query',
|
||||
type: DynamicFormItemType.TEXT,
|
||||
label: {
|
||||
en_US: 'SQL Query',
|
||||
zh_Hans: 'SQL 查询',
|
||||
},
|
||||
description: {
|
||||
en_US: 'SQL query to execute (use $1, $2, etc. for parameters)',
|
||||
zh_Hans: '要执行的 SQL 查询(使用 $1、$2 等作为参数占位符)',
|
||||
},
|
||||
required: true,
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
id: 'query_type',
|
||||
name: 'query_type',
|
||||
type: DynamicFormItemType.SELECT,
|
||||
label: {
|
||||
en_US: 'Query Type',
|
||||
zh_Hans: '查询类型',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Type of query operation',
|
||||
zh_Hans: '查询操作的类型',
|
||||
},
|
||||
required: true,
|
||||
default: 'select',
|
||||
options: [
|
||||
{ name: 'select', label: { en_US: 'SELECT', zh_Hans: 'SELECT' } },
|
||||
{ name: 'insert', label: { en_US: 'INSERT', zh_Hans: 'INSERT' } },
|
||||
{ name: 'update', label: { en_US: 'UPDATE', zh_Hans: 'UPDATE' } },
|
||||
{ name: 'delete', label: { en_US: 'DELETE', zh_Hans: 'DELETE' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'timeout',
|
||||
name: 'timeout',
|
||||
type: DynamicFormItemType.INT,
|
||||
label: {
|
||||
en_US: 'Timeout (seconds)',
|
||||
zh_Hans: '超时时间(秒)',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Query timeout',
|
||||
zh_Hans: '查询超时时间',
|
||||
},
|
||||
required: false,
|
||||
default: 30,
|
||||
},
|
||||
],
|
||||
defaultConfig: {
|
||||
connection_type: 'postgresql',
|
||||
connection_string: '',
|
||||
query: '',
|
||||
query_type: 'select',
|
||||
timeout: 30,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Redis Operation Node
|
||||
* Performs Redis operations
|
||||
*/
|
||||
export const redisOperationConfig: NodeConfigMeta = {
|
||||
nodeType: 'redis_operation',
|
||||
label: {
|
||||
en_US: 'Redis Operation',
|
||||
zh_Hans: 'Redis 操作',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Perform Redis cache operations',
|
||||
zh_Hans: '执行 Redis 缓存操作',
|
||||
},
|
||||
icon: 'Server',
|
||||
category: 'integration',
|
||||
color: '#ec4899',
|
||||
inputs: [
|
||||
createInput('key', 'string', {
|
||||
description: 'Redis key',
|
||||
label: { en_US: 'Key', zh_Hans: '键' },
|
||||
required: false,
|
||||
}),
|
||||
createInput('value', 'any', {
|
||||
description: 'Value to store',
|
||||
label: { en_US: 'Value', zh_Hans: '值' },
|
||||
required: false,
|
||||
}),
|
||||
],
|
||||
outputs: [
|
||||
createOutput('result', 'any', {
|
||||
description: 'Operation result',
|
||||
label: { en_US: 'Result', zh_Hans: '结果' },
|
||||
}),
|
||||
createOutput('success', 'boolean', {
|
||||
description: 'Whether operation was successful',
|
||||
label: { en_US: 'Success', zh_Hans: '成功' },
|
||||
}),
|
||||
],
|
||||
configSchema: [
|
||||
{
|
||||
id: 'connection_url',
|
||||
name: 'connection_url',
|
||||
type: DynamicFormItemType.SECRET,
|
||||
label: {
|
||||
en_US: 'Redis URL',
|
||||
zh_Hans: 'Redis URL',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Redis connection URL (e.g., redis://localhost:6379)',
|
||||
zh_Hans: 'Redis 连接 URL(例如 redis://localhost:6379)',
|
||||
},
|
||||
required: true,
|
||||
default: 'redis://localhost:6379',
|
||||
},
|
||||
{
|
||||
id: 'operation',
|
||||
name: 'operation',
|
||||
type: DynamicFormItemType.SELECT,
|
||||
label: {
|
||||
en_US: 'Operation',
|
||||
zh_Hans: '操作',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Redis operation to perform',
|
||||
zh_Hans: '要执行的 Redis 操作',
|
||||
},
|
||||
required: true,
|
||||
default: 'get',
|
||||
options: [
|
||||
{ name: 'get', label: { en_US: 'GET', zh_Hans: 'GET' } },
|
||||
{ name: 'set', label: { en_US: 'SET', zh_Hans: 'SET' } },
|
||||
{ name: 'delete', label: { en_US: 'DELETE', zh_Hans: 'DELETE' } },
|
||||
{ name: 'exists', label: { en_US: 'EXISTS', zh_Hans: 'EXISTS' } },
|
||||
{ name: 'incr', label: { en_US: 'INCR', zh_Hans: 'INCR' } },
|
||||
{ name: 'decr', label: { en_US: 'DECR', zh_Hans: 'DECR' } },
|
||||
{ name: 'hget', label: { en_US: 'HGET', zh_Hans: 'HGET' } },
|
||||
{ name: 'hset', label: { en_US: 'HSET', zh_Hans: 'HSET' } },
|
||||
{ name: 'lpush', label: { en_US: 'LPUSH', zh_Hans: 'LPUSH' } },
|
||||
{ name: 'rpush', label: { en_US: 'RPUSH', zh_Hans: 'RPUSH' } },
|
||||
{ name: 'lpop', label: { en_US: 'LPOP', zh_Hans: 'LPOP' } },
|
||||
{ name: 'rpop', label: { en_US: 'RPOP', zh_Hans: 'RPOP' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'key_template',
|
||||
name: 'key_template',
|
||||
type: DynamicFormItemType.STRING,
|
||||
label: {
|
||||
en_US: 'Key Template',
|
||||
zh_Hans: '键模板',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Redis key (supports variable interpolation)',
|
||||
zh_Hans: 'Redis 键(支持变量插值)',
|
||||
},
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
id: 'hash_field',
|
||||
name: 'hash_field',
|
||||
type: DynamicFormItemType.STRING,
|
||||
label: {
|
||||
en_US: 'Hash Field',
|
||||
zh_Hans: '哈希字段',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Field name for hash operations',
|
||||
zh_Hans: '哈希操作的字段名',
|
||||
},
|
||||
required: false,
|
||||
default: '',
|
||||
show_if: {
|
||||
field: 'operation',
|
||||
operator: 'in',
|
||||
value: ['hget', 'hset'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'ttl',
|
||||
name: 'ttl',
|
||||
type: DynamicFormItemType.INT,
|
||||
label: {
|
||||
en_US: 'TTL (seconds)',
|
||||
zh_Hans: 'TTL(秒)',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Time to live for SET operations (0 = no expiry)',
|
||||
zh_Hans: 'SET 操作的过期时间(0 = 不过期)',
|
||||
},
|
||||
required: false,
|
||||
default: 0,
|
||||
show_if: {
|
||||
field: 'operation',
|
||||
operator: 'eq',
|
||||
value: 'set',
|
||||
},
|
||||
},
|
||||
],
|
||||
defaultConfig: {
|
||||
connection_url: 'redis://localhost:6379',
|
||||
operation: 'get',
|
||||
key_template: '',
|
||||
hash_field: '',
|
||||
ttl: 0,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* MCP Tool Node
|
||||
* Invokes MCP (Model Context Protocol) tools
|
||||
*/
|
||||
export const mcpToolConfig: NodeConfigMeta = {
|
||||
nodeType: 'mcp_tool',
|
||||
label: {
|
||||
en_US: 'MCP Tool',
|
||||
zh_Hans: 'MCP 工具',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Invoke an MCP (Model Context Protocol) tool',
|
||||
zh_Hans: '调用 MCP(模型上下文协议)工具',
|
||||
},
|
||||
icon: 'Wrench',
|
||||
category: 'integration',
|
||||
color: '#ec4899',
|
||||
inputs: [
|
||||
createInput('arguments', 'object', {
|
||||
description: 'Tool arguments',
|
||||
label: { en_US: 'Arguments', zh_Hans: '参数' },
|
||||
required: false,
|
||||
}),
|
||||
],
|
||||
outputs: [
|
||||
createOutput('result', 'any', {
|
||||
description: 'Tool execution result',
|
||||
label: { en_US: 'Result', zh_Hans: '结果' },
|
||||
}),
|
||||
createOutput('success', 'boolean', {
|
||||
description: 'Whether tool call was successful',
|
||||
label: { en_US: 'Success', zh_Hans: '成功' },
|
||||
}),
|
||||
createOutput('error', 'string', {
|
||||
description: 'Error message if failed',
|
||||
label: { en_US: 'Error', zh_Hans: '错误' },
|
||||
}),
|
||||
],
|
||||
configSchema: [
|
||||
{
|
||||
id: 'server_name',
|
||||
name: 'server_name',
|
||||
type: DynamicFormItemType.STRING,
|
||||
label: {
|
||||
en_US: 'MCP Server',
|
||||
zh_Hans: 'MCP 服务器',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Name of the MCP server',
|
||||
zh_Hans: 'MCP 服务器名称',
|
||||
},
|
||||
required: true,
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
id: 'tool_name',
|
||||
name: 'tool_name',
|
||||
type: DynamicFormItemType.STRING,
|
||||
label: {
|
||||
en_US: 'Tool Name',
|
||||
zh_Hans: '工具名称',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Name of the MCP tool to invoke',
|
||||
zh_Hans: '要调用的 MCP 工具名称',
|
||||
},
|
||||
required: true,
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
id: 'arguments_template',
|
||||
name: 'arguments_template',
|
||||
type: DynamicFormItemType.TEXT,
|
||||
label: {
|
||||
en_US: 'Arguments Template',
|
||||
zh_Hans: '参数模板',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Tool arguments as JSON (supports variable interpolation). Leave empty to use input.',
|
||||
zh_Hans: '工具参数(JSON 格式,支持变量插值)。留空则使用输入。',
|
||||
},
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
id: 'timeout',
|
||||
name: 'timeout',
|
||||
type: DynamicFormItemType.INT,
|
||||
label: {
|
||||
en_US: 'Timeout (seconds)',
|
||||
zh_Hans: '超时时间(秒)',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Maximum execution time',
|
||||
zh_Hans: '最大执行时间',
|
||||
},
|
||||
required: false,
|
||||
default: 30,
|
||||
},
|
||||
],
|
||||
defaultConfig: {
|
||||
server_name: '',
|
||||
tool_name: '',
|
||||
arguments_template: '',
|
||||
timeout: 30,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Memory Store Node
|
||||
* Store and retrieve from workflow memory
|
||||
*/
|
||||
export const memoryStoreConfig: NodeConfigMeta = {
|
||||
nodeType: 'memory_store',
|
||||
label: {
|
||||
en_US: 'Memory Store',
|
||||
zh_Hans: '记忆存储',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Store and retrieve data from workflow memory',
|
||||
zh_Hans: '从工作流记忆中存储和检索数据',
|
||||
},
|
||||
icon: 'HardDrive',
|
||||
category: 'integration',
|
||||
color: '#ec4899',
|
||||
inputs: [
|
||||
createInput('value', 'any', {
|
||||
description: 'Value to store',
|
||||
label: { en_US: 'Value', zh_Hans: '值' },
|
||||
required: false,
|
||||
}),
|
||||
],
|
||||
outputs: [
|
||||
createOutput('result', 'any', {
|
||||
description: 'Retrieved or stored value',
|
||||
label: { en_US: 'Result', zh_Hans: '结果' },
|
||||
}),
|
||||
createOutput('success', 'boolean', {
|
||||
description: 'Whether operation was successful',
|
||||
label: { en_US: 'Success', zh_Hans: '成功' },
|
||||
}),
|
||||
],
|
||||
configSchema: [
|
||||
{
|
||||
id: 'operation',
|
||||
name: 'operation',
|
||||
type: DynamicFormItemType.SELECT,
|
||||
label: {
|
||||
en_US: 'Operation',
|
||||
zh_Hans: '操作',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Memory operation to perform',
|
||||
zh_Hans: '要执行的记忆操作',
|
||||
},
|
||||
required: true,
|
||||
default: 'get',
|
||||
options: [
|
||||
{ name: 'get', label: { en_US: 'Get', zh_Hans: '获取' } },
|
||||
{ name: 'set', label: { en_US: 'Set', zh_Hans: '设置' } },
|
||||
{ name: 'delete', label: { en_US: 'Delete', zh_Hans: '删除' } },
|
||||
{ name: 'append', label: { en_US: 'Append', zh_Hans: '追加' } },
|
||||
{ name: 'list', label: { en_US: 'List All', zh_Hans: '列出全部' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'key',
|
||||
name: 'key',
|
||||
type: DynamicFormItemType.STRING,
|
||||
label: {
|
||||
en_US: 'Key',
|
||||
zh_Hans: '键',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Memory key (supports variable interpolation)',
|
||||
zh_Hans: '记忆键(支持变量插值)',
|
||||
},
|
||||
required: true,
|
||||
default: '',
|
||||
show_if: {
|
||||
field: 'operation',
|
||||
operator: 'neq',
|
||||
value: 'list',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'scope',
|
||||
name: 'scope',
|
||||
type: DynamicFormItemType.SELECT,
|
||||
label: {
|
||||
en_US: 'Scope',
|
||||
zh_Hans: '作用域',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Scope of the memory storage',
|
||||
zh_Hans: '记忆存储的作用域',
|
||||
},
|
||||
required: true,
|
||||
default: 'execution',
|
||||
options: [
|
||||
{ name: 'execution', label: { en_US: 'Execution', zh_Hans: '执行' } },
|
||||
{ name: 'workflow', label: { en_US: 'Workflow', zh_Hans: '工作流' } },
|
||||
{ name: 'session', label: { en_US: 'Session', zh_Hans: '会话' } },
|
||||
{ name: 'user', label: { en_US: 'User', zh_Hans: '用户' } },
|
||||
{ name: 'global', label: { en_US: 'Global', zh_Hans: '全局' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ttl',
|
||||
name: 'ttl',
|
||||
type: DynamicFormItemType.INT,
|
||||
label: {
|
||||
en_US: 'TTL (seconds)',
|
||||
zh_Hans: 'TTL(秒)',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Time to live (0 = no expiry)',
|
||||
zh_Hans: '过期时间(0 = 不过期)',
|
||||
},
|
||||
required: false,
|
||||
default: 0,
|
||||
show_if: {
|
||||
field: 'operation',
|
||||
operator: 'eq',
|
||||
value: 'set',
|
||||
},
|
||||
},
|
||||
],
|
||||
defaultConfig: {
|
||||
operation: 'get',
|
||||
key: '',
|
||||
scope: 'execution',
|
||||
ttl: 0,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Dify Workflow Node
|
||||
* Calls Dify platform workflow
|
||||
*/
|
||||
export const difyWorkflowConfig: NodeConfigMeta = {
|
||||
nodeType: 'dify_workflow',
|
||||
label: { en_US: 'Dify Workflow', zh_Hans: 'Dify 工作流' },
|
||||
description: { en_US: 'Call a Dify platform workflow', zh_Hans: '调用 Dify 平台工作流' },
|
||||
icon: 'Bot',
|
||||
category: 'integration',
|
||||
color: '#ec4899',
|
||||
inputs: [
|
||||
createInput('input', 'any', { description: 'Input data', label: { en_US: 'Input', zh_Hans: '输入' }, required: false }),
|
||||
],
|
||||
outputs: [
|
||||
createOutput('result', 'any', { description: 'Workflow result', label: { en_US: 'Result', zh_Hans: '结果' } }),
|
||||
createOutput('success', 'boolean', { description: 'Whether call was successful', label: { en_US: 'Success', zh_Hans: '成功' } }),
|
||||
],
|
||||
configSchema: [
|
||||
{ id: 'base-url', name: 'base-url', type: DynamicFormItemType.STRING, label: { en_US: 'Base URL', zh_Hans: 'Base URL' }, description: { en_US: 'Dify API base URL', zh_Hans: 'Dify API 基础 URL' }, required: true, default: '' },
|
||||
{ id: 'api-key', name: 'api-key', type: DynamicFormItemType.STRING, label: { en_US: 'API Key', zh_Hans: 'API Key' }, description: { en_US: 'Dify API key', zh_Hans: 'Dify API 密钥' }, required: true, default: '' },
|
||||
{ id: 'app-type', name: 'app-type', type: DynamicFormItemType.SELECT, label: { en_US: 'App Type', zh_Hans: '应用类型' }, description: { en_US: 'Dify application type', zh_Hans: 'Dify 应用类型' }, required: true, default: 'workflow', options: [
|
||||
{ name: 'workflow', label: { en_US: 'Workflow', zh_Hans: '工作流' } },
|
||||
{ name: 'chatbot', label: { en_US: 'Chatbot', zh_Hans: '聊天机器人' } },
|
||||
] },
|
||||
{ id: 'timeout', name: 'timeout', type: DynamicFormItemType.INT, label: { en_US: 'Timeout (seconds)', zh_Hans: '超时时间(秒)' }, description: { en_US: 'Request timeout', zh_Hans: '请求超时时间' }, required: false, default: 60 },
|
||||
],
|
||||
defaultConfig: { 'base-url': '', 'api-key': '', 'app-type': 'workflow', timeout: 60 },
|
||||
};
|
||||
|
||||
/**
|
||||
* Dify Knowledge Query Node
|
||||
*/
|
||||
export const difyKnowledgeQueryConfig: NodeConfigMeta = {
|
||||
nodeType: 'dify_knowledge_query',
|
||||
label: { en_US: 'Dify Knowledge Query', zh_Hans: 'Dify 知识库查询' },
|
||||
description: { en_US: 'Query Dify knowledge base', zh_Hans: '查询 Dify 知识库' },
|
||||
icon: 'Search',
|
||||
category: 'integration',
|
||||
color: '#ec4899',
|
||||
inputs: [
|
||||
createInput('query', 'string', { description: 'Search query', label: { en_US: 'Query', zh_Hans: '查询' } }),
|
||||
],
|
||||
outputs: [
|
||||
createOutput('results', 'array', { description: 'Search results', label: { en_US: 'Results', zh_Hans: '结果' } }),
|
||||
createOutput('success', 'boolean', { description: 'Whether query was successful', label: { en_US: 'Success', zh_Hans: '成功' } }),
|
||||
],
|
||||
configSchema: [
|
||||
{ id: 'base-url', name: 'base-url', type: DynamicFormItemType.STRING, label: { en_US: 'Base URL', zh_Hans: 'Base URL' }, description: { en_US: 'Dify API base URL', zh_Hans: 'Dify API 基础 URL' }, required: true, default: '' },
|
||||
{ id: 'api-key', name: 'api-key', type: DynamicFormItemType.STRING, label: { en_US: 'API Key', zh_Hans: 'API Key' }, description: { en_US: 'Dify API key', zh_Hans: 'Dify API 密钥' }, required: true, default: '' },
|
||||
{ id: 'dataset_id', name: 'dataset_id', type: DynamicFormItemType.STRING, label: { en_US: 'Dataset ID', zh_Hans: '数据集 ID' }, description: { en_US: 'Dify dataset ID', zh_Hans: 'Dify 数据集 ID' }, required: true, default: '' },
|
||||
{ id: 'top_k', name: 'top_k', type: DynamicFormItemType.INT, label: { en_US: 'Top K', zh_Hans: 'Top K' }, description: { en_US: 'Number of results to return', zh_Hans: '返回结果数量' }, required: false, default: 5 },
|
||||
],
|
||||
defaultConfig: { 'base-url': '', 'api-key': '', dataset_id: '', top_k: 5 },
|
||||
};
|
||||
|
||||
/**
|
||||
* N8n Workflow Node
|
||||
*/
|
||||
export const n8nWorkflowConfig: NodeConfigMeta = {
|
||||
nodeType: 'n8n_workflow',
|
||||
label: { en_US: 'N8n Workflow', zh_Hans: 'n8n 工作流' },
|
||||
description: { en_US: 'Call an n8n workflow via webhook', zh_Hans: '通过 webhook 调用 n8n 工作流' },
|
||||
icon: 'Settings',
|
||||
category: 'integration',
|
||||
color: '#ec4899',
|
||||
inputs: [
|
||||
createInput('input', 'any', { description: 'Input data', label: { en_US: 'Input', zh_Hans: '输入' }, required: false }),
|
||||
],
|
||||
outputs: [
|
||||
createOutput('result', 'any', { description: 'Workflow result', label: { en_US: 'Result', zh_Hans: '结果' } }),
|
||||
createOutput('success', 'boolean', { description: 'Whether call was successful', label: { en_US: 'Success', zh_Hans: '成功' } }),
|
||||
],
|
||||
configSchema: [
|
||||
{ id: 'webhook-url', name: 'webhook-url', type: DynamicFormItemType.STRING, label: { en_US: 'Webhook URL', zh_Hans: 'Webhook URL' }, description: { en_US: 'N8n webhook URL', zh_Hans: 'n8n Webhook URL' }, required: true, default: '' },
|
||||
{ id: 'timeout', name: 'timeout', type: DynamicFormItemType.INT, label: { en_US: 'Timeout (seconds)', zh_Hans: '超时时间(秒)' }, description: { en_US: 'Request timeout', zh_Hans: '请求超时时间' }, required: false, default: 60 },
|
||||
],
|
||||
defaultConfig: { 'webhook-url': '', timeout: 60 },
|
||||
};
|
||||
|
||||
/**
|
||||
* Langflow Flow Node
|
||||
*/
|
||||
export const langflowFlowConfig: NodeConfigMeta = {
|
||||
nodeType: 'langflow_flow',
|
||||
label: { en_US: 'Langflow Flow', zh_Hans: 'Langflow 流程' },
|
||||
description: { en_US: 'Call a Langflow flow', zh_Hans: '调用 Langflow 流程' },
|
||||
icon: 'Workflow',
|
||||
category: 'integration',
|
||||
color: '#ec4899',
|
||||
inputs: [
|
||||
createInput('input', 'any', { description: 'Input data', label: { en_US: 'Input', zh_Hans: '输入' }, required: false }),
|
||||
],
|
||||
outputs: [
|
||||
createOutput('result', 'any', { description: 'Flow result', label: { en_US: 'Result', zh_Hans: '结果' } }),
|
||||
createOutput('success', 'boolean', { description: 'Whether call was successful', label: { en_US: 'Success', zh_Hans: '成功' } }),
|
||||
],
|
||||
configSchema: [
|
||||
{ id: 'base-url', name: 'base-url', type: DynamicFormItemType.STRING, label: { en_US: 'Base URL', zh_Hans: 'Base URL' }, description: { en_US: 'Langflow API base URL', zh_Hans: 'Langflow API 基础 URL' }, required: true, default: '' },
|
||||
{ id: 'flow-id', name: 'flow-id', type: DynamicFormItemType.STRING, label: { en_US: 'Flow ID', zh_Hans: '流程 ID' }, description: { en_US: 'Langflow flow ID', zh_Hans: 'Langflow 流程 ID' }, required: true, default: '' },
|
||||
{ id: 'api-key', name: 'api-key', type: DynamicFormItemType.STRING, label: { en_US: 'API Key', zh_Hans: 'API Key' }, description: { en_US: 'Langflow API key (optional)', zh_Hans: 'Langflow API 密钥(可选)' }, required: false, default: '' },
|
||||
{ id: 'timeout', name: 'timeout', type: DynamicFormItemType.INT, label: { en_US: 'Timeout (seconds)', zh_Hans: '超时时间(秒)' }, description: { en_US: 'Request timeout', zh_Hans: '请求超时时间' }, required: false, default: 60 },
|
||||
],
|
||||
defaultConfig: { 'base-url': '', 'flow-id': '', 'api-key': '', timeout: 60 },
|
||||
};
|
||||
|
||||
/**
|
||||
* Coze Bot Node
|
||||
*/
|
||||
export const cozeBotConfig: NodeConfigMeta = {
|
||||
nodeType: 'coze_bot',
|
||||
label: { en_US: 'Coze Bot', zh_Hans: 'Coze Bot' },
|
||||
description: { en_US: 'Call a Coze Bot', zh_Hans: '调用扣子 Bot' },
|
||||
icon: 'Bot',
|
||||
category: 'integration',
|
||||
color: '#ec4899',
|
||||
inputs: [
|
||||
createInput('message', 'string', { description: 'Message to send', label: { en_US: 'Message', zh_Hans: '消息' } }),
|
||||
],
|
||||
outputs: [
|
||||
createOutput('result', 'any', { description: 'Bot response', label: { en_US: 'Result', zh_Hans: '结果' } }),
|
||||
createOutput('success', 'boolean', { description: 'Whether call was successful', label: { en_US: 'Success', zh_Hans: '成功' } }),
|
||||
],
|
||||
configSchema: [
|
||||
{ id: 'api-base', name: 'api-base', type: DynamicFormItemType.STRING, label: { en_US: 'API Base URL', zh_Hans: 'API 基础 URL' }, description: { en_US: 'Coze API base URL', zh_Hans: 'Coze API 基础 URL' }, required: true, default: 'https://api.coze.com' },
|
||||
{ id: 'bot-id', name: 'bot-id', type: DynamicFormItemType.STRING, label: { en_US: 'Bot ID', zh_Hans: 'Bot ID' }, description: { en_US: 'Coze Bot ID', zh_Hans: 'Coze Bot ID' }, required: true, default: '' },
|
||||
{ id: 'api-key', name: 'api-key', type: DynamicFormItemType.STRING, label: { en_US: 'API Key', zh_Hans: 'API Key' }, description: { en_US: 'Coze API key', zh_Hans: 'Coze API 密钥' }, required: true, default: '' },
|
||||
{ id: 'timeout', name: 'timeout', type: DynamicFormItemType.INT, label: { en_US: 'Timeout (seconds)', zh_Hans: '超时时间(秒)' }, description: { en_US: 'Request timeout', zh_Hans: '请求超时时间' }, required: false, default: 60 },
|
||||
],
|
||||
defaultConfig: { 'api-base': 'https://api.coze.com', 'bot-id': '', 'api-key': '', timeout: 60 },
|
||||
};
|
||||
|
||||
/**
|
||||
* All integration node configurations
|
||||
*/
|
||||
export const integrationConfigs: NodeConfigMeta[] = [
|
||||
difyWorkflowConfig,
|
||||
difyKnowledgeQueryConfig,
|
||||
n8nWorkflowConfig,
|
||||
langflowFlowConfig,
|
||||
cozeBotConfig,
|
||||
databaseQueryConfig,
|
||||
redisOperationConfig,
|
||||
mcpToolConfig,
|
||||
memoryStoreConfig,
|
||||
];
|
||||
|
||||
/**
|
||||
* Get integration config by type
|
||||
*/
|
||||
export function getIntegrationConfig(nodeType: string): NodeConfigMeta | undefined {
|
||||
return integrationConfigs.find((config) => config.nodeType === nodeType);
|
||||
}
|
||||
@@ -0,0 +1,786 @@
|
||||
/**
|
||||
* Process Node Configurations
|
||||
*
|
||||
* Defines configurations for general processing node types:
|
||||
* - text_template: Generate text using templates
|
||||
* - json_transform: Transform JSON data
|
||||
* - code_executor: Execute custom code
|
||||
* - data_aggregator: Aggregate data from multiple sources
|
||||
* - text_splitter: Split text into chunks
|
||||
*/
|
||||
|
||||
import { DynamicFormItemType } from '@/app/infra/entities/form/dynamic';
|
||||
import { NodeConfigMeta, createInput, createOutput } from './types';
|
||||
|
||||
/**
|
||||
* Text Template Node
|
||||
* Generates text using variable interpolation
|
||||
*/
|
||||
export const textTemplateConfig: NodeConfigMeta = {
|
||||
nodeType: 'text_template',
|
||||
label: {
|
||||
en_US: 'Text Template',
|
||||
zh_Hans: '文本模板',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Generate text using templates with variable interpolation',
|
||||
zh_Hans: '使用带有变量插值的模板生成文本',
|
||||
},
|
||||
icon: 'FileText',
|
||||
category: 'process',
|
||||
color: '#3b82f6',
|
||||
inputs: [
|
||||
createInput('variables', 'object', {
|
||||
description: 'Variables to use in the template',
|
||||
label: { en_US: 'Variables', zh_Hans: '变量' },
|
||||
required: false,
|
||||
}),
|
||||
],
|
||||
outputs: [
|
||||
createOutput('text', 'string', {
|
||||
description: 'Generated text',
|
||||
label: { en_US: 'Text', zh_Hans: '文本' },
|
||||
}),
|
||||
],
|
||||
configSchema: [
|
||||
{
|
||||
id: 'template',
|
||||
name: 'template',
|
||||
type: DynamicFormItemType.TEXT,
|
||||
label: {
|
||||
en_US: 'Template',
|
||||
zh_Hans: '模板',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Text template with variable placeholders (e.g., {{variable_name}})',
|
||||
zh_Hans: '带有变量占位符的文本模板(例如 {{variable_name}})',
|
||||
},
|
||||
required: true,
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
id: 'escape_html',
|
||||
name: 'escape_html',
|
||||
type: DynamicFormItemType.BOOLEAN,
|
||||
label: {
|
||||
en_US: 'Escape HTML',
|
||||
zh_Hans: '转义 HTML',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Escape HTML characters in variable values',
|
||||
zh_Hans: '转义变量值中的 HTML 字符',
|
||||
},
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
id: 'trim_whitespace',
|
||||
name: 'trim_whitespace',
|
||||
type: DynamicFormItemType.BOOLEAN,
|
||||
label: {
|
||||
en_US: 'Trim Whitespace',
|
||||
zh_Hans: '去除空白',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Remove leading and trailing whitespace from output',
|
||||
zh_Hans: '去除输出的前后空白',
|
||||
},
|
||||
required: false,
|
||||
default: true,
|
||||
},
|
||||
],
|
||||
defaultConfig: {
|
||||
template: '',
|
||||
escape_html: false,
|
||||
trim_whitespace: true,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* JSON Transform Node
|
||||
* Transforms JSON data using JSONPath or JMESPath expressions
|
||||
*/
|
||||
export const jsonTransformConfig: NodeConfigMeta = {
|
||||
nodeType: 'json_transform',
|
||||
label: {
|
||||
en_US: 'JSON Transform',
|
||||
zh_Hans: 'JSON 转换',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Transform JSON data using expressions or mappings',
|
||||
zh_Hans: '使用表达式或映射转换 JSON 数据',
|
||||
},
|
||||
icon: 'Braces',
|
||||
category: 'process',
|
||||
color: '#3b82f6',
|
||||
inputs: [
|
||||
createInput('input', 'object', {
|
||||
description: 'JSON data to transform',
|
||||
label: { en_US: 'Input', zh_Hans: '输入' },
|
||||
}),
|
||||
],
|
||||
outputs: [
|
||||
createOutput('output', 'any', {
|
||||
description: 'Transformed data',
|
||||
label: { en_US: 'Output', zh_Hans: '输出' },
|
||||
}),
|
||||
],
|
||||
configSchema: [
|
||||
{
|
||||
id: 'transform_type',
|
||||
name: 'transform_type',
|
||||
type: DynamicFormItemType.SELECT,
|
||||
label: {
|
||||
en_US: 'Transform Type',
|
||||
zh_Hans: '转换类型',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Method of transformation',
|
||||
zh_Hans: '转换方法',
|
||||
},
|
||||
required: true,
|
||||
default: 'jmespath',
|
||||
options: [
|
||||
{ name: 'jmespath', label: { en_US: 'JMESPath Expression', zh_Hans: 'JMESPath 表达式' } },
|
||||
{ name: 'jsonpath', label: { en_US: 'JSONPath Expression', zh_Hans: 'JSONPath 表达式' } },
|
||||
{ name: 'mapping', label: { en_US: 'Field Mapping', zh_Hans: '字段映射' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'expression',
|
||||
name: 'expression',
|
||||
type: DynamicFormItemType.STRING,
|
||||
label: {
|
||||
en_US: 'Expression',
|
||||
zh_Hans: '表达式',
|
||||
},
|
||||
description: {
|
||||
en_US: 'JMESPath or JSONPath expression',
|
||||
zh_Hans: 'JMESPath 或 JSONPath 表达式',
|
||||
},
|
||||
required: true,
|
||||
default: '',
|
||||
show_if: {
|
||||
field: 'transform_type',
|
||||
operator: 'in',
|
||||
value: ['jmespath', 'jsonpath'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'mapping',
|
||||
name: 'mapping',
|
||||
type: DynamicFormItemType.TEXT,
|
||||
label: {
|
||||
en_US: 'Field Mapping',
|
||||
zh_Hans: '字段映射',
|
||||
},
|
||||
description: {
|
||||
en_US: 'JSON object defining field mappings: {"output_field": "input.path.to.field"}',
|
||||
zh_Hans: '定义字段映射的 JSON 对象: {"output_field": "input.path.to.field"}',
|
||||
},
|
||||
required: true,
|
||||
default: '{}',
|
||||
show_if: {
|
||||
field: 'transform_type',
|
||||
operator: 'eq',
|
||||
value: 'mapping',
|
||||
},
|
||||
},
|
||||
],
|
||||
defaultConfig: {
|
||||
transform_type: 'jmespath',
|
||||
expression: '',
|
||||
mapping: '{}',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Code Executor Node
|
||||
* Executes custom code (JavaScript/Python)
|
||||
*/
|
||||
export const codeExecutorConfig: NodeConfigMeta = {
|
||||
nodeType: 'code_executor',
|
||||
label: {
|
||||
en_US: 'Code Executor',
|
||||
zh_Hans: '代码执行',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Execute custom code to process data',
|
||||
zh_Hans: '执行自定义代码处理数据',
|
||||
},
|
||||
icon: 'Code',
|
||||
category: 'process',
|
||||
color: '#3b82f6',
|
||||
inputs: [
|
||||
createInput('input', 'any', {
|
||||
description: 'Input data for the code',
|
||||
label: { en_US: 'Input', zh_Hans: '输入' },
|
||||
}),
|
||||
],
|
||||
outputs: [
|
||||
createOutput('output', 'any', {
|
||||
description: 'Code execution result',
|
||||
label: { en_US: 'Output', zh_Hans: '输出' },
|
||||
}),
|
||||
createOutput('logs', 'array', {
|
||||
description: 'Console logs from code execution',
|
||||
label: { en_US: 'Logs', zh_Hans: '日志' },
|
||||
}),
|
||||
],
|
||||
configSchema: [
|
||||
{
|
||||
id: 'language',
|
||||
name: 'language',
|
||||
type: DynamicFormItemType.SELECT,
|
||||
label: {
|
||||
en_US: 'Language',
|
||||
zh_Hans: '语言',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Programming language to use',
|
||||
zh_Hans: '要使用的编程语言',
|
||||
},
|
||||
required: true,
|
||||
default: 'javascript',
|
||||
options: [
|
||||
{ name: 'javascript', label: { en_US: 'JavaScript', zh_Hans: 'JavaScript' } },
|
||||
{ name: 'python', label: { en_US: 'Python', zh_Hans: 'Python' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'code',
|
||||
name: 'code',
|
||||
type: DynamicFormItemType.TEXT,
|
||||
label: {
|
||||
en_US: 'Code',
|
||||
zh_Hans: '代码',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Code to execute. Use `input` to access input data and return the result.',
|
||||
zh_Hans: '要执行的代码。使用 `input` 访问输入数据,并返回结果。',
|
||||
},
|
||||
required: true,
|
||||
default: '// Access input with: input\n// Return result with: return result;\n\nreturn input;',
|
||||
},
|
||||
{
|
||||
id: 'timeout',
|
||||
name: 'timeout',
|
||||
type: DynamicFormItemType.INT,
|
||||
label: {
|
||||
en_US: 'Timeout (ms)',
|
||||
zh_Hans: '超时时间 (毫秒)',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Maximum execution time in milliseconds',
|
||||
zh_Hans: '最大执行时间(毫秒)',
|
||||
},
|
||||
required: false,
|
||||
default: 5000,
|
||||
},
|
||||
],
|
||||
defaultConfig: {
|
||||
language: 'javascript',
|
||||
code: '// Access input with: input\n// Return result with: return result;\n\nreturn input;',
|
||||
timeout: 5000,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Data Aggregator Node
|
||||
* Aggregates data from multiple inputs
|
||||
*/
|
||||
export const dataAggregatorConfig: NodeConfigMeta = {
|
||||
nodeType: 'data_aggregator',
|
||||
label: {
|
||||
en_US: 'Data Aggregator',
|
||||
zh_Hans: '数据聚合',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Aggregate and combine data from multiple sources',
|
||||
zh_Hans: '聚合和组合来自多个来源的数据',
|
||||
},
|
||||
icon: 'Layers',
|
||||
category: 'process',
|
||||
color: '#3b82f6',
|
||||
inputs: [
|
||||
createInput('items', 'array', {
|
||||
description: 'Array of items to aggregate',
|
||||
label: { en_US: 'Items', zh_Hans: '项目' },
|
||||
}),
|
||||
],
|
||||
outputs: [
|
||||
createOutput('result', 'any', {
|
||||
description: 'Aggregated result',
|
||||
label: { en_US: 'Result', zh_Hans: '结果' },
|
||||
}),
|
||||
createOutput('count', 'number', {
|
||||
description: 'Number of items aggregated',
|
||||
label: { en_US: 'Count', zh_Hans: '数量' },
|
||||
}),
|
||||
],
|
||||
configSchema: [
|
||||
{
|
||||
id: 'aggregation_type',
|
||||
name: 'aggregation_type',
|
||||
type: DynamicFormItemType.SELECT,
|
||||
label: {
|
||||
en_US: 'Aggregation Type',
|
||||
zh_Hans: '聚合类型',
|
||||
},
|
||||
description: {
|
||||
en_US: 'How to aggregate the data',
|
||||
zh_Hans: '如何聚合数据',
|
||||
},
|
||||
required: true,
|
||||
default: 'array',
|
||||
options: [
|
||||
{ name: 'array', label: { en_US: 'Collect to Array', zh_Hans: '收集为数组' } },
|
||||
{ name: 'concat', label: { en_US: 'Concatenate Strings', zh_Hans: '连接字符串' } },
|
||||
{ name: 'sum', label: { en_US: 'Sum Numbers', zh_Hans: '求和' } },
|
||||
{ name: 'average', label: { en_US: 'Average Numbers', zh_Hans: '求平均' } },
|
||||
{ name: 'min', label: { en_US: 'Minimum', zh_Hans: '最小值' } },
|
||||
{ name: 'max', label: { en_US: 'Maximum', zh_Hans: '最大值' } },
|
||||
{ name: 'merge', label: { en_US: 'Merge Objects', zh_Hans: '合并对象' } },
|
||||
{ name: 'first', label: { en_US: 'First Item', zh_Hans: '第一项' } },
|
||||
{ name: 'last', label: { en_US: 'Last Item', zh_Hans: '最后一项' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'separator',
|
||||
name: 'separator',
|
||||
type: DynamicFormItemType.STRING,
|
||||
label: {
|
||||
en_US: 'Separator',
|
||||
zh_Hans: '分隔符',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Separator for string concatenation',
|
||||
zh_Hans: '字符串连接的分隔符',
|
||||
},
|
||||
required: false,
|
||||
default: '\n',
|
||||
show_if: {
|
||||
field: 'aggregation_type',
|
||||
operator: 'eq',
|
||||
value: 'concat',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'field_path',
|
||||
name: 'field_path',
|
||||
type: DynamicFormItemType.STRING,
|
||||
label: {
|
||||
en_US: 'Field Path',
|
||||
zh_Hans: '字段路径',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Path to the field to aggregate (for objects)',
|
||||
zh_Hans: '要聚合的字段路径(用于对象)',
|
||||
},
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
],
|
||||
defaultConfig: {
|
||||
aggregation_type: 'array',
|
||||
separator: '\n',
|
||||
field_path: '',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Text Splitter Node
|
||||
* Splits text into chunks
|
||||
*/
|
||||
export const textSplitterConfig: NodeConfigMeta = {
|
||||
nodeType: 'text_splitter',
|
||||
label: {
|
||||
en_US: 'Text Splitter',
|
||||
zh_Hans: '文本分割',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Split text into smaller chunks',
|
||||
zh_Hans: '将文本分割成较小的块',
|
||||
},
|
||||
icon: 'Scissors',
|
||||
category: 'process',
|
||||
color: '#3b82f6',
|
||||
inputs: [
|
||||
createInput('text', 'string', {
|
||||
description: 'Text to split',
|
||||
label: { en_US: 'Text', zh_Hans: '文本' },
|
||||
}),
|
||||
],
|
||||
outputs: [
|
||||
createOutput('chunks', 'array', {
|
||||
description: 'Array of text chunks',
|
||||
label: { en_US: 'Chunks', zh_Hans: '块' },
|
||||
}),
|
||||
createOutput('count', 'number', {
|
||||
description: 'Number of chunks',
|
||||
label: { en_US: 'Count', zh_Hans: '数量' },
|
||||
}),
|
||||
],
|
||||
configSchema: [
|
||||
{
|
||||
id: 'split_type',
|
||||
name: 'split_type',
|
||||
type: DynamicFormItemType.SELECT,
|
||||
label: {
|
||||
en_US: 'Split Type',
|
||||
zh_Hans: '分割类型',
|
||||
},
|
||||
description: {
|
||||
en_US: 'How to split the text',
|
||||
zh_Hans: '如何分割文本',
|
||||
},
|
||||
required: true,
|
||||
default: 'separator',
|
||||
options: [
|
||||
{ name: 'separator', label: { en_US: 'By Separator', zh_Hans: '按分隔符' } },
|
||||
{ name: 'length', label: { en_US: 'By Length', zh_Hans: '按长度' } },
|
||||
{ name: 'sentences', label: { en_US: 'By Sentences', zh_Hans: '按句子' } },
|
||||
{ name: 'paragraphs', label: { en_US: 'By Paragraphs', zh_Hans: '按段落' } },
|
||||
{ name: 'regex', label: { en_US: 'By Regex', zh_Hans: '按正则表达式' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'separator',
|
||||
name: 'separator',
|
||||
type: DynamicFormItemType.STRING,
|
||||
label: {
|
||||
en_US: 'Separator',
|
||||
zh_Hans: '分隔符',
|
||||
},
|
||||
description: {
|
||||
en_US: 'String to split on',
|
||||
zh_Hans: '用于分割的字符串',
|
||||
},
|
||||
required: false,
|
||||
default: '\n',
|
||||
show_if: {
|
||||
field: 'split_type',
|
||||
operator: 'eq',
|
||||
value: 'separator',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'chunk_size',
|
||||
name: 'chunk_size',
|
||||
type: DynamicFormItemType.INT,
|
||||
label: {
|
||||
en_US: 'Chunk Size',
|
||||
zh_Hans: '块大小',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Maximum characters per chunk',
|
||||
zh_Hans: '每块的最大字符数',
|
||||
},
|
||||
required: false,
|
||||
default: 1000,
|
||||
show_if: {
|
||||
field: 'split_type',
|
||||
operator: 'eq',
|
||||
value: 'length',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'chunk_overlap',
|
||||
name: 'chunk_overlap',
|
||||
type: DynamicFormItemType.INT,
|
||||
label: {
|
||||
en_US: 'Chunk Overlap',
|
||||
zh_Hans: '块重叠',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Number of characters to overlap between chunks',
|
||||
zh_Hans: '块之间重叠的字符数',
|
||||
},
|
||||
required: false,
|
||||
default: 100,
|
||||
show_if: {
|
||||
field: 'split_type',
|
||||
operator: 'eq',
|
||||
value: 'length',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'regex_pattern',
|
||||
name: 'regex_pattern',
|
||||
type: DynamicFormItemType.STRING,
|
||||
label: {
|
||||
en_US: 'Regex Pattern',
|
||||
zh_Hans: '正则表达式模式',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Regular expression pattern to split on',
|
||||
zh_Hans: '用于分割的正则表达式模式',
|
||||
},
|
||||
required: false,
|
||||
default: '',
|
||||
show_if: {
|
||||
field: 'split_type',
|
||||
operator: 'eq',
|
||||
value: 'regex',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'remove_empty',
|
||||
name: 'remove_empty',
|
||||
type: DynamicFormItemType.BOOLEAN,
|
||||
label: {
|
||||
en_US: 'Remove Empty',
|
||||
zh_Hans: '移除空块',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Remove empty chunks from result',
|
||||
zh_Hans: '从结果中移除空块',
|
||||
},
|
||||
required: false,
|
||||
default: true,
|
||||
},
|
||||
],
|
||||
defaultConfig: {
|
||||
split_type: 'separator',
|
||||
separator: '\n',
|
||||
chunk_size: 1000,
|
||||
chunk_overlap: 100,
|
||||
regex_pattern: '',
|
||||
remove_empty: true,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Variable Assignment Node
|
||||
* Assigns values to workflow variables
|
||||
*/
|
||||
export const variableAssignmentConfig: NodeConfigMeta = {
|
||||
nodeType: 'variable_assignment',
|
||||
label: {
|
||||
en_US: 'Variable Assignment',
|
||||
zh_Hans: '变量赋值',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Assign values to workflow variables',
|
||||
zh_Hans: '为工作流变量赋值',
|
||||
},
|
||||
icon: 'Variable',
|
||||
category: 'process',
|
||||
color: '#3b82f6',
|
||||
inputs: [
|
||||
createInput('value', 'any', {
|
||||
description: 'Value to assign',
|
||||
label: { en_US: 'Value', zh_Hans: '值' },
|
||||
required: false,
|
||||
}),
|
||||
],
|
||||
outputs: [
|
||||
createOutput('output', 'any', {
|
||||
description: 'The assigned value',
|
||||
label: { en_US: 'Output', zh_Hans: '输出' },
|
||||
}),
|
||||
],
|
||||
configSchema: [
|
||||
{
|
||||
id: 'variable_name',
|
||||
name: 'variable_name',
|
||||
type: DynamicFormItemType.STRING,
|
||||
label: {
|
||||
en_US: 'Variable Name',
|
||||
zh_Hans: '变量名',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Name of the variable to assign',
|
||||
zh_Hans: '要赋值的变量名',
|
||||
},
|
||||
required: true,
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
id: 'value_type',
|
||||
name: 'value_type',
|
||||
type: DynamicFormItemType.SELECT,
|
||||
label: {
|
||||
en_US: 'Value Type',
|
||||
zh_Hans: '值类型',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Type of value to assign',
|
||||
zh_Hans: '要赋的值类型',
|
||||
},
|
||||
required: true,
|
||||
default: 'input',
|
||||
options: [
|
||||
{ name: 'input', label: { en_US: 'From Input', zh_Hans: '来自输入' } },
|
||||
{ name: 'static', label: { en_US: 'Static Value', zh_Hans: '静态值' } },
|
||||
{ name: 'expression', label: { en_US: 'Expression', zh_Hans: '表达式' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'static_value',
|
||||
name: 'static_value',
|
||||
type: DynamicFormItemType.TEXT,
|
||||
label: {
|
||||
en_US: 'Static Value',
|
||||
zh_Hans: '静态值',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Value to assign (as JSON)',
|
||||
zh_Hans: '要赋的值(JSON 格式)',
|
||||
},
|
||||
required: false,
|
||||
default: '',
|
||||
show_if: {
|
||||
field: 'value_type',
|
||||
operator: 'eq',
|
||||
value: 'static',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'expression',
|
||||
name: 'expression',
|
||||
type: DynamicFormItemType.STRING,
|
||||
label: {
|
||||
en_US: 'Expression',
|
||||
zh_Hans: '表达式',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Expression to evaluate (e.g., {{input}} + 1)',
|
||||
zh_Hans: '要计算的表达式(例如 {{input}} + 1)',
|
||||
},
|
||||
required: false,
|
||||
default: '',
|
||||
show_if: {
|
||||
field: 'value_type',
|
||||
operator: 'eq',
|
||||
value: 'expression',
|
||||
},
|
||||
},
|
||||
],
|
||||
defaultConfig: {
|
||||
variable_name: '',
|
||||
value_type: 'input',
|
||||
static_value: '',
|
||||
expression: '',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Data Transform Node
|
||||
* Transform and extract data using templates or JSONPath
|
||||
*/
|
||||
export const dataTransformConfig: NodeConfigMeta = {
|
||||
nodeType: 'data_transform',
|
||||
label: {
|
||||
en_US: 'Data Transform',
|
||||
zh_Hans: '数据转换',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Transform and extract data using templates or JSONPath',
|
||||
zh_Hans: '使用模板或 JSONPath 转换和提取数据',
|
||||
},
|
||||
icon: 'RefreshCw',
|
||||
category: 'process',
|
||||
color: '#3b82f6',
|
||||
inputs: [
|
||||
createInput('data', 'any', {
|
||||
description: 'Input data',
|
||||
label: { en_US: 'Data', zh_Hans: '数据' },
|
||||
required: true,
|
||||
}),
|
||||
],
|
||||
outputs: [
|
||||
createOutput('result', 'any', {
|
||||
description: 'Transform result',
|
||||
label: { en_US: 'Result', zh_Hans: '结果' },
|
||||
}),
|
||||
],
|
||||
configSchema: [
|
||||
{
|
||||
id: 'transform_type',
|
||||
name: 'transform_type',
|
||||
type: DynamicFormItemType.SELECT,
|
||||
label: {
|
||||
en_US: 'Transform Type',
|
||||
zh_Hans: '转换类型',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Type of transformation to perform',
|
||||
zh_Hans: '要执行的转换类型',
|
||||
},
|
||||
required: true,
|
||||
default: 'template',
|
||||
options: [
|
||||
{ name: 'template', label: { en_US: 'Template', zh_Hans: '模板' } },
|
||||
{ name: 'jsonpath', label: { en_US: 'JSONPath', zh_Hans: 'JSONPath' } },
|
||||
{ name: 'jmespath', label: { en_US: 'JMESPath', zh_Hans: 'JMESPath' } },
|
||||
{ name: 'expression', label: { en_US: 'Expression', zh_Hans: '表达式' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'template',
|
||||
name: 'template',
|
||||
type: DynamicFormItemType.TEXT,
|
||||
label: {
|
||||
en_US: 'Template',
|
||||
zh_Hans: '模板',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Template with {{variable}} syntax',
|
||||
zh_Hans: '支持 {{variable}} 语法的模板',
|
||||
},
|
||||
required: false,
|
||||
default: '',
|
||||
show_if: {
|
||||
field: 'transform_type',
|
||||
operator: 'eq',
|
||||
value: 'template',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'expression',
|
||||
name: 'expression',
|
||||
type: DynamicFormItemType.STRING,
|
||||
label: {
|
||||
en_US: 'Expression',
|
||||
zh_Hans: '表达式',
|
||||
},
|
||||
description: {
|
||||
en_US: 'JSONPath/JMESPath expression',
|
||||
zh_Hans: 'JSONPath/JMESPath 表达式',
|
||||
},
|
||||
required: false,
|
||||
default: '',
|
||||
show_if: {
|
||||
field: 'transform_type',
|
||||
operator: 'in',
|
||||
value: ['jsonpath', 'jmespath', 'expression'],
|
||||
},
|
||||
},
|
||||
],
|
||||
defaultConfig: {
|
||||
transform_type: 'template',
|
||||
template: '',
|
||||
expression: '',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* All process node configurations
|
||||
*/
|
||||
export const processConfigs: NodeConfigMeta[] = [
|
||||
textTemplateConfig,
|
||||
jsonTransformConfig,
|
||||
codeExecutorConfig,
|
||||
dataAggregatorConfig,
|
||||
textSplitterConfig,
|
||||
variableAssignmentConfig,
|
||||
dataTransformConfig,
|
||||
];
|
||||
|
||||
/**
|
||||
* Get process config by type
|
||||
*/
|
||||
export function getProcessConfig(nodeType: string): NodeConfigMeta | undefined {
|
||||
return processConfigs.find((config) => config.nodeType === nodeType);
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
/**
|
||||
* Trigger Node Configurations
|
||||
*
|
||||
* Defines configurations for all trigger node types:
|
||||
* - message_trigger: Triggered by incoming messages
|
||||
* - cron_trigger: Triggered by scheduled time
|
||||
* - webhook_trigger: Triggered by HTTP webhook calls
|
||||
* - event_trigger: Triggered by system events
|
||||
*/
|
||||
|
||||
import { DynamicFormItemType } from '@/app/infra/entities/form/dynamic';
|
||||
import { NodeConfigMeta, createOutput } from './types';
|
||||
|
||||
/**
|
||||
* Message Trigger Node
|
||||
* Triggers workflow when a message matches specified conditions
|
||||
*/
|
||||
export const messageTriggerConfig: NodeConfigMeta = {
|
||||
nodeType: 'message_trigger',
|
||||
label: {
|
||||
en_US: 'Message Trigger',
|
||||
zh_Hans: '消息触发',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Trigger workflow when a message matches the specified conditions',
|
||||
zh_Hans: '当收到匹配指定条件的消息时触发工作流',
|
||||
},
|
||||
icon: 'MessageSquare',
|
||||
category: 'trigger',
|
||||
color: '#f59e0b',
|
||||
isEntryPoint: true,
|
||||
maxInstances: 1,
|
||||
inputs: [],
|
||||
outputs: [
|
||||
createOutput('message', 'object', {
|
||||
description: 'The received message object',
|
||||
label: { en_US: 'Message', zh_Hans: '消息' },
|
||||
}),
|
||||
createOutput('sender', 'object', {
|
||||
description: 'Message sender information',
|
||||
label: { en_US: 'Sender', zh_Hans: '发送者' },
|
||||
}),
|
||||
createOutput('context', 'object', {
|
||||
description: 'Message context information',
|
||||
label: { en_US: 'Context', zh_Hans: '上下文' },
|
||||
}),
|
||||
],
|
||||
configSchema: [
|
||||
{
|
||||
id: 'match_type',
|
||||
name: 'match_type',
|
||||
type: DynamicFormItemType.SELECT,
|
||||
label: {
|
||||
en_US: 'Match Type',
|
||||
zh_Hans: '匹配类型',
|
||||
},
|
||||
description: {
|
||||
en_US: 'How to match the incoming message',
|
||||
zh_Hans: '如何匹配收到的消息',
|
||||
},
|
||||
required: true,
|
||||
default: 'all',
|
||||
options: [
|
||||
{ name: 'all', label: { en_US: 'All Messages', zh_Hans: '所有消息' } },
|
||||
{ name: 'prefix', label: { en_US: 'Prefix Match', zh_Hans: '前缀匹配' } },
|
||||
{ name: 'regex', label: { en_US: 'Regex Match', zh_Hans: '正则匹配' } },
|
||||
{ name: 'contains', label: { en_US: 'Contains Keyword', zh_Hans: '包含关键词' } },
|
||||
{ name: 'exact', label: { en_US: 'Exact Match', zh_Hans: '精确匹配' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'match_pattern',
|
||||
name: 'match_pattern',
|
||||
type: DynamicFormItemType.STRING,
|
||||
label: {
|
||||
en_US: 'Match Pattern',
|
||||
zh_Hans: '匹配模式',
|
||||
},
|
||||
description: {
|
||||
en_US: 'The pattern to match against the message (prefix, regex, keyword, or exact text)',
|
||||
zh_Hans: '用于匹配消息的模式(前缀、正则表达式、关键词或精确文本)',
|
||||
},
|
||||
required: false,
|
||||
default: '',
|
||||
show_if: {
|
||||
field: 'match_type',
|
||||
operator: 'neq',
|
||||
value: 'all',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'ignore_bot_messages',
|
||||
name: 'ignore_bot_messages',
|
||||
type: DynamicFormItemType.BOOLEAN,
|
||||
label: {
|
||||
en_US: 'Ignore Bot Messages',
|
||||
zh_Hans: '忽略机器人消息',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Do not trigger for messages sent by bots',
|
||||
zh_Hans: '不对机器人发送的消息触发',
|
||||
},
|
||||
required: false,
|
||||
default: true,
|
||||
},
|
||||
],
|
||||
defaultConfig: {
|
||||
match_type: 'all',
|
||||
match_pattern: '',
|
||||
ignore_bot_messages: true,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Cron Trigger Node
|
||||
* Triggers workflow on a schedule
|
||||
*/
|
||||
export const cronTriggerConfig: NodeConfigMeta = {
|
||||
nodeType: 'cron_trigger',
|
||||
label: {
|
||||
en_US: 'Scheduled Trigger',
|
||||
zh_Hans: '定时触发',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Trigger workflow on a scheduled time using cron expression',
|
||||
zh_Hans: '使用 Cron 表达式按计划时间触发工作流',
|
||||
},
|
||||
icon: 'Clock',
|
||||
category: 'trigger',
|
||||
color: '#f59e0b',
|
||||
isEntryPoint: true,
|
||||
inputs: [],
|
||||
outputs: [
|
||||
createOutput('trigger_time', 'datetime', {
|
||||
description: 'The time when the trigger fired',
|
||||
label: { en_US: 'Trigger Time', zh_Hans: '触发时间' },
|
||||
}),
|
||||
createOutput('context', 'object', {
|
||||
description: 'Trigger context information',
|
||||
label: { en_US: 'Context', zh_Hans: '上下文' },
|
||||
}),
|
||||
],
|
||||
configSchema: [
|
||||
{
|
||||
id: 'cron_expression',
|
||||
name: 'cron_expression',
|
||||
type: DynamicFormItemType.STRING,
|
||||
label: {
|
||||
en_US: 'Cron Expression',
|
||||
zh_Hans: 'Cron 表达式',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Standard cron expression (e.g., "0 9 * * *" for 9 AM daily)',
|
||||
zh_Hans: '标准 Cron 表达式(例如 "0 9 * * *" 表示每天上午 9 点)',
|
||||
},
|
||||
required: true,
|
||||
default: '0 9 * * *',
|
||||
},
|
||||
{
|
||||
id: 'timezone',
|
||||
name: 'timezone',
|
||||
type: DynamicFormItemType.SELECT,
|
||||
label: {
|
||||
en_US: 'Timezone',
|
||||
zh_Hans: '时区',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Timezone for the cron schedule',
|
||||
zh_Hans: 'Cron 计划的时区',
|
||||
},
|
||||
required: true,
|
||||
default: 'Asia/Shanghai',
|
||||
options: [
|
||||
{ name: 'UTC', label: { en_US: 'UTC', zh_Hans: 'UTC' } },
|
||||
{ name: 'Asia/Shanghai', label: { en_US: 'Asia/Shanghai (UTC+8)', zh_Hans: '亚洲/上海 (UTC+8)' } },
|
||||
{ name: 'Asia/Tokyo', label: { en_US: 'Asia/Tokyo (UTC+9)', zh_Hans: '亚洲/东京 (UTC+9)' } },
|
||||
{ name: 'America/New_York', label: { en_US: 'America/New_York (EST)', zh_Hans: '美国/纽约 (EST)' } },
|
||||
{ name: 'America/Los_Angeles', label: { en_US: 'America/Los_Angeles (PST)', zh_Hans: '美国/洛杉矶 (PST)' } },
|
||||
{ name: 'Europe/London', label: { en_US: 'Europe/London (GMT)', zh_Hans: '欧洲/伦敦 (GMT)' } },
|
||||
{ name: 'Europe/Berlin', label: { en_US: 'Europe/Berlin (CET)', zh_Hans: '欧洲/柏林 (CET)' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'description',
|
||||
name: 'description',
|
||||
type: DynamicFormItemType.STRING,
|
||||
label: {
|
||||
en_US: 'Description',
|
||||
zh_Hans: '描述',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Optional description for this scheduled trigger',
|
||||
zh_Hans: '此定时触发器的可选描述',
|
||||
},
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
id: 'enabled',
|
||||
name: 'enabled',
|
||||
type: DynamicFormItemType.BOOLEAN,
|
||||
label: {
|
||||
en_US: 'Enabled',
|
||||
zh_Hans: '启用',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Whether this scheduled trigger is active',
|
||||
zh_Hans: '此定时触发器是否激活',
|
||||
},
|
||||
required: false,
|
||||
default: true,
|
||||
},
|
||||
],
|
||||
defaultConfig: {
|
||||
cron_expression: '0 9 * * *',
|
||||
timezone: 'Asia/Shanghai',
|
||||
description: '',
|
||||
enabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Webhook Trigger Node
|
||||
* Triggers workflow via HTTP webhook
|
||||
*/
|
||||
export const webhookTriggerConfig: NodeConfigMeta = {
|
||||
nodeType: 'webhook_trigger',
|
||||
label: {
|
||||
en_US: 'Webhook Trigger',
|
||||
zh_Hans: 'Webhook 触发',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Trigger workflow when an HTTP request is received at the webhook URL',
|
||||
zh_Hans: '当在 Webhook URL 收到 HTTP 请求时触发工作流',
|
||||
},
|
||||
icon: 'Webhook',
|
||||
category: 'trigger',
|
||||
color: '#f59e0b',
|
||||
isEntryPoint: true,
|
||||
inputs: [],
|
||||
outputs: [
|
||||
createOutput('body', 'object', {
|
||||
description: 'Request body data',
|
||||
label: { en_US: 'Body', zh_Hans: '请求体' },
|
||||
}),
|
||||
createOutput('headers', 'object', {
|
||||
description: 'Request headers',
|
||||
label: { en_US: 'Headers', zh_Hans: '请求头' },
|
||||
}),
|
||||
createOutput('query', 'object', {
|
||||
description: 'Query parameters',
|
||||
label: { en_US: 'Query', zh_Hans: '查询参数' },
|
||||
}),
|
||||
createOutput('method', 'string', {
|
||||
description: 'HTTP method',
|
||||
label: { en_US: 'Method', zh_Hans: 'HTTP 方法' },
|
||||
}),
|
||||
],
|
||||
configSchema: [
|
||||
{
|
||||
id: 'webhook_path',
|
||||
name: 'webhook_path',
|
||||
type: DynamicFormItemType.STRING,
|
||||
label: {
|
||||
en_US: 'Webhook Path',
|
||||
zh_Hans: 'Webhook 路径',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Unique path for this webhook (e.g., "my-workflow")',
|
||||
zh_Hans: '此 Webhook 的唯一路径(例如 "my-workflow")',
|
||||
},
|
||||
required: true,
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
id: 'auth_type',
|
||||
name: 'auth_type',
|
||||
type: DynamicFormItemType.SELECT,
|
||||
label: {
|
||||
en_US: 'Authentication',
|
||||
zh_Hans: '认证方式',
|
||||
},
|
||||
description: {
|
||||
en_US: 'How to authenticate incoming webhook requests',
|
||||
zh_Hans: '如何验证传入的 Webhook 请求',
|
||||
},
|
||||
required: true,
|
||||
default: 'none',
|
||||
options: [
|
||||
{ name: 'none', label: { en_US: 'None', zh_Hans: '无' } },
|
||||
{ name: 'token', label: { en_US: 'Bearer Token', zh_Hans: 'Bearer 令牌' } },
|
||||
{ name: 'signature', label: { en_US: 'Signature', zh_Hans: '签名验证' } },
|
||||
{ name: 'basic', label: { en_US: 'Basic Auth', zh_Hans: '基本认证' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'auth_token',
|
||||
name: 'auth_token',
|
||||
type: DynamicFormItemType.SECRET,
|
||||
label: {
|
||||
en_US: 'Auth Token',
|
||||
zh_Hans: '认证令牌',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Token or secret for authentication',
|
||||
zh_Hans: '用于认证的令牌或密钥',
|
||||
},
|
||||
required: true,
|
||||
default: '',
|
||||
show_if: {
|
||||
field: 'auth_type',
|
||||
operator: 'in',
|
||||
value: ['token', 'signature', 'basic'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'content_type',
|
||||
name: 'content_type',
|
||||
type: DynamicFormItemType.SELECT,
|
||||
label: {
|
||||
en_US: 'Content Type',
|
||||
zh_Hans: '内容类型',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Expected Content-Type of the request',
|
||||
zh_Hans: '请求预期的 Content-Type',
|
||||
},
|
||||
required: false,
|
||||
default: 'application/json',
|
||||
options: [
|
||||
{ name: 'application/json', label: { en_US: 'application/json', zh_Hans: 'JSON' } },
|
||||
{ name: 'application/x-www-form-urlencoded', label: { en_US: 'application/x-www-form-urlencoded', zh_Hans: '表单编码' } },
|
||||
{ name: 'multipart/form-data', label: { en_US: 'multipart/form-data', zh_Hans: '表单数据' } },
|
||||
{ name: 'text/plain', label: { en_US: 'text/plain', zh_Hans: '纯文本' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'validation',
|
||||
name: 'validation',
|
||||
type: DynamicFormItemType.TEXT,
|
||||
label: {
|
||||
en_US: 'Validation Rules',
|
||||
zh_Hans: '验证规则',
|
||||
},
|
||||
description: {
|
||||
en_US: 'JSON validation rules for request body (optional)',
|
||||
zh_Hans: '请求体的 JSON 验证规则(可选)',
|
||||
},
|
||||
required: false,
|
||||
default: '{}',
|
||||
},
|
||||
{
|
||||
id: 'timeout',
|
||||
name: 'timeout',
|
||||
type: DynamicFormItemType.INT,
|
||||
label: {
|
||||
en_US: 'Timeout (seconds)',
|
||||
zh_Hans: '超时时间(秒)',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Request timeout in seconds',
|
||||
zh_Hans: '请求超时时间(秒)',
|
||||
},
|
||||
required: false,
|
||||
default: 30,
|
||||
},
|
||||
],
|
||||
defaultConfig: {
|
||||
webhook_path: '',
|
||||
auth_type: 'none',
|
||||
auth_token: '',
|
||||
content_type: 'application/json',
|
||||
validation: '{}',
|
||||
timeout: 30,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Event Trigger Node
|
||||
* Triggers workflow on system events
|
||||
*/
|
||||
export const eventTriggerConfig: NodeConfigMeta = {
|
||||
nodeType: 'event_trigger',
|
||||
label: {
|
||||
en_US: 'Event Trigger',
|
||||
zh_Hans: '事件触发',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Trigger workflow when a system event occurs',
|
||||
zh_Hans: '当系统事件发生时触发工作流',
|
||||
},
|
||||
icon: 'Zap',
|
||||
category: 'trigger',
|
||||
color: '#f59e0b',
|
||||
isEntryPoint: true,
|
||||
inputs: [],
|
||||
outputs: [
|
||||
createOutput('event', 'object', {
|
||||
description: 'The event data',
|
||||
label: { en_US: 'Event', zh_Hans: '事件' },
|
||||
}),
|
||||
createOutput('event_type', 'string', {
|
||||
description: 'Type of the event',
|
||||
label: { en_US: 'Event Type', zh_Hans: '事件类型' },
|
||||
}),
|
||||
createOutput('context', 'object', {
|
||||
description: 'Event context information',
|
||||
label: { en_US: 'Context', zh_Hans: '上下文' },
|
||||
}),
|
||||
],
|
||||
configSchema: [
|
||||
{
|
||||
id: 'event_type',
|
||||
name: 'event_type',
|
||||
type: DynamicFormItemType.SELECT,
|
||||
label: {
|
||||
en_US: 'Event Type',
|
||||
zh_Hans: '事件类型',
|
||||
},
|
||||
description: {
|
||||
en_US: 'The type of system event to listen for',
|
||||
zh_Hans: '要监听的系统事件类型',
|
||||
},
|
||||
required: true,
|
||||
default: 'member_join',
|
||||
options: [
|
||||
{ name: 'member_join', label: { en_US: 'Member Join', zh_Hans: '成员加入' } },
|
||||
{ name: 'member_leave', label: { en_US: 'Member Leave', zh_Hans: '成员离开' } },
|
||||
{ name: 'message_recall', label: { en_US: 'Message Recall', zh_Hans: '消息撤回' } },
|
||||
{ name: 'group_created', label: { en_US: 'Group Created', zh_Hans: '群组创建' } },
|
||||
{ name: 'group_disbanded', label: { en_US: 'Group Disbanded', zh_Hans: '群组解散' } },
|
||||
{ name: 'bot_added', label: { en_US: 'Bot Added to Group', zh_Hans: '机器人被添加到群' } },
|
||||
{ name: 'bot_removed', label: { en_US: 'Bot Removed from Group', zh_Hans: '机器人被移出群' } },
|
||||
{ name: 'friend_request', label: { en_US: 'Friend Request', zh_Hans: '好友请求' } },
|
||||
{ name: 'group_request', label: { en_US: 'Group Join Request', zh_Hans: '入群请求' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
defaultConfig: {
|
||||
event_type: 'member_join',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* All trigger node configurations
|
||||
*/
|
||||
export const triggerConfigs: NodeConfigMeta[] = [
|
||||
messageTriggerConfig,
|
||||
cronTriggerConfig,
|
||||
webhookTriggerConfig,
|
||||
eventTriggerConfig,
|
||||
];
|
||||
|
||||
/**
|
||||
* Get trigger config by type
|
||||
*/
|
||||
export function getTriggerConfig(nodeType: string): NodeConfigMeta | undefined {
|
||||
return triggerConfigs.find((config) => config.nodeType === nodeType);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Workflow Node Configuration Types
|
||||
*
|
||||
* This module defines the types used for node configuration metadata.
|
||||
* It extends the existing dynamic form system to support workflow-specific features.
|
||||
*/
|
||||
|
||||
import { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic';
|
||||
import { I18nObject } from '@/app/infra/entities/common';
|
||||
import { NodeCategory, PortDefinition } from '@/app/infra/entities/workflow';
|
||||
|
||||
/**
|
||||
* Extended port configuration with additional metadata
|
||||
*/
|
||||
export interface ExtendedPortDefinition extends PortDefinition {
|
||||
label?: I18nObject;
|
||||
}
|
||||
|
||||
/**
|
||||
* Node configuration metadata
|
||||
* Defines all aspects of a node type including its appearance, ports, and configuration options
|
||||
*/
|
||||
export interface NodeConfigMeta {
|
||||
/** Unique node type identifier */
|
||||
nodeType: string;
|
||||
|
||||
/** Display name for the node */
|
||||
label: I18nObject;
|
||||
|
||||
/** Description of what the node does */
|
||||
description: I18nObject;
|
||||
|
||||
/** Icon name (from lucide-react) */
|
||||
icon: string;
|
||||
|
||||
/** Node category for organization */
|
||||
category: NodeCategory;
|
||||
|
||||
/** Color for the node header */
|
||||
color?: string;
|
||||
|
||||
/** Input port definitions */
|
||||
inputs: ExtendedPortDefinition[];
|
||||
|
||||
/** Output port definitions */
|
||||
outputs: ExtendedPortDefinition[];
|
||||
|
||||
/** Configuration schema using the dynamic form system */
|
||||
configSchema: IDynamicFormItemSchema[];
|
||||
|
||||
/** Default configuration values */
|
||||
defaultConfig?: Record<string, unknown>;
|
||||
|
||||
/** Whether this node can be the starting point of a workflow */
|
||||
isEntryPoint?: boolean;
|
||||
|
||||
/** Maximum number of this node type allowed in a workflow (undefined = unlimited) */
|
||||
maxInstances?: number;
|
||||
|
||||
/** Documentation URL */
|
||||
docsUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry of all node configurations by type
|
||||
*/
|
||||
export type NodeConfigRegistry = Record<string, NodeConfigMeta>;
|
||||
|
||||
/**
|
||||
* Helper function to create a consistent port definition
|
||||
*/
|
||||
export function createPort(
|
||||
name: string,
|
||||
type: string,
|
||||
options?: {
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
label?: I18nObject;
|
||||
}
|
||||
): ExtendedPortDefinition {
|
||||
return {
|
||||
name,
|
||||
type,
|
||||
description: options?.description,
|
||||
required: options?.required ?? false,
|
||||
label: options?.label,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to create input port
|
||||
*/
|
||||
export function createInput(
|
||||
name: string,
|
||||
type: string,
|
||||
options?: {
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
label?: I18nObject;
|
||||
}
|
||||
): ExtendedPortDefinition {
|
||||
return createPort(name, type, { ...options, required: options?.required ?? true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to create output port
|
||||
*/
|
||||
export function createOutput(
|
||||
name: string,
|
||||
type: string,
|
||||
options?: {
|
||||
description?: string;
|
||||
label?: I18nObject;
|
||||
}
|
||||
): ExtendedPortDefinition {
|
||||
return createPort(name, type, { ...options, required: false });
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* Shared constants for the Workflow editor.
|
||||
*
|
||||
* Centralises `nodeTypeI18nKeys`, `nodeIcons`, and palette category
|
||||
* colours that were previously duplicated across WorkflowNodeComponent,
|
||||
* NodePalette, and useWorkflowStore.
|
||||
*/
|
||||
|
||||
import {
|
||||
MessageSquare,
|
||||
Timer,
|
||||
Webhook,
|
||||
Bot,
|
||||
Brain,
|
||||
Search,
|
||||
Code,
|
||||
FileText,
|
||||
GitBranch,
|
||||
Repeat,
|
||||
GitMerge,
|
||||
PauseCircle,
|
||||
AlertCircle,
|
||||
Variable,
|
||||
Send,
|
||||
Database,
|
||||
Zap,
|
||||
Globe,
|
||||
Settings,
|
||||
Bell,
|
||||
ArrowRightLeft,
|
||||
Split,
|
||||
Layers,
|
||||
Clock,
|
||||
ListFilter,
|
||||
Workflow,
|
||||
MessageCircle,
|
||||
Cpu,
|
||||
Play,
|
||||
Plug,
|
||||
ExternalLink,
|
||||
} from 'lucide-react';
|
||||
|
||||
import i18n from 'i18next';
|
||||
import { resolveI18nLabel, maybeTranslateKey } from './workflow-i18n';
|
||||
|
||||
// ─── Node type → i18n key mapping ──────────────────────────────────
|
||||
//
|
||||
// Single source of truth. Used by WorkflowNodeComponent,
|
||||
// NodePalette, and useWorkflowStore.
|
||||
|
||||
export const NODE_TYPE_I18N_KEYS: Record<string, { labelKey: string; descriptionKey: string }> = {
|
||||
// Trigger
|
||||
'trigger.message_trigger': { labelKey: 'workflows.nodes.messageTrigger', descriptionKey: 'workflows.nodes.messageTriggerDescription' },
|
||||
'trigger.cron_trigger': { labelKey: 'workflows.nodes.cronTrigger', descriptionKey: 'workflows.nodes.cronTriggerDescription' },
|
||||
'trigger.webhook_trigger': { labelKey: 'workflows.nodes.webhookTrigger', descriptionKey: 'workflows.nodes.webhookTriggerDescription' },
|
||||
'trigger.event_trigger': { labelKey: 'workflows.nodes.eventTrigger', descriptionKey: 'workflows.nodes.eventTriggerDescription' },
|
||||
// Process / AI
|
||||
'process.llm_call': { labelKey: 'workflows.nodes.llmCall', descriptionKey: 'workflows.nodes.llmCallDescription' },
|
||||
'process.question_classifier': { labelKey: 'workflows.nodes.questionClassifier', descriptionKey: 'workflows.nodes.questionClassifierDescription' },
|
||||
'process.parameter_extractor': { labelKey: 'workflows.nodes.parameterExtractor', descriptionKey: 'workflows.nodes.parameterExtractorDescription' },
|
||||
'process.knowledge_retrieval': { labelKey: 'workflows.nodes.knowledgeRetrieval', descriptionKey: 'workflows.nodes.knowledgeRetrievalDescription' },
|
||||
'process.code_executor': { labelKey: 'workflows.nodes.codeExecutor', descriptionKey: 'workflows.nodes.codeExecutorDescription' },
|
||||
'process.http_request': { labelKey: 'workflows.nodes.httpRequest', descriptionKey: 'workflows.nodes.httpRequestDescription' },
|
||||
'process.data_transform': { labelKey: 'workflows.nodes.dataTransform', descriptionKey: 'workflows.nodes.dataTransformDescription' },
|
||||
'process.text_template': { labelKey: 'workflows.nodes.textTemplate', descriptionKey: 'workflows.nodes.textTemplateDescription' },
|
||||
'process.json_transform': { labelKey: 'workflows.nodes.jsonTransform', descriptionKey: 'workflows.nodes.jsonTransformDescription' },
|
||||
'process.data_aggregator': { labelKey: 'workflows.nodes.dataAggregator', descriptionKey: 'workflows.nodes.dataAggregatorDescription' },
|
||||
'process.text_splitter': { labelKey: 'workflows.nodes.textSplitter', descriptionKey: 'workflows.nodes.textSplitterDescription' },
|
||||
'process.variable_assignment': { labelKey: 'workflows.nodes.variableAssignment', descriptionKey: 'workflows.nodes.variableAssignmentDescription' },
|
||||
// Control
|
||||
'control.condition': { labelKey: 'workflows.nodes.condition', descriptionKey: 'workflows.nodes.conditionDescription' },
|
||||
'control.switch': { labelKey: 'workflows.nodes.switch', descriptionKey: 'workflows.nodes.switchDescription' },
|
||||
'control.loop': { labelKey: 'workflows.nodes.loop', descriptionKey: 'workflows.nodes.loopDescription' },
|
||||
'control.iterator': { labelKey: 'workflows.nodes.iterator', descriptionKey: 'workflows.nodes.iteratorDescription' },
|
||||
'control.parallel': { labelKey: 'workflows.nodes.parallel', descriptionKey: 'workflows.nodes.parallelDescription' },
|
||||
'control.wait': { labelKey: 'workflows.nodes.wait', descriptionKey: 'workflows.nodes.waitDescription' },
|
||||
'control.merge': { labelKey: 'workflows.nodes.merge', descriptionKey: 'workflows.nodes.mergeDescription' },
|
||||
'control.variable_aggregator': { labelKey: 'workflows.nodes.variableAggregator', descriptionKey: 'workflows.nodes.variableAggregatorDescription' },
|
||||
// Action
|
||||
'action.send_message': { labelKey: 'workflows.nodes.sendMessage', descriptionKey: 'workflows.nodes.sendMessageDescription' },
|
||||
'action.reply_message': { labelKey: 'workflows.nodes.replyMessage', descriptionKey: 'workflows.nodes.replyMessageDescription' },
|
||||
'action.store_data': { labelKey: 'workflows.nodes.storeData', descriptionKey: 'workflows.nodes.storeDataDescription' },
|
||||
'action.call_pipeline': { labelKey: 'workflows.nodes.callPipeline', descriptionKey: 'workflows.nodes.callPipelineDescription' },
|
||||
'action.set_variable': { labelKey: 'workflows.nodes.setVariable', descriptionKey: 'workflows.nodes.setVariableDescription' },
|
||||
'action.opening_statement': { labelKey: 'workflows.nodes.openingStatement', descriptionKey: 'workflows.nodes.openingStatementDescription' },
|
||||
'action.end': { labelKey: 'workflows.nodes.end', descriptionKey: 'workflows.nodes.endDescription' },
|
||||
// Integration – external services
|
||||
'integration.dify_workflow': { labelKey: 'workflows.nodes.difyWorkflow', descriptionKey: 'workflows.nodes.difyWorkflowDescription' },
|
||||
'integration.dify_knowledge_query': { labelKey: 'workflows.nodes.difyKnowledgeQuery', descriptionKey: 'workflows.nodes.difyKnowledgeQueryDescription' },
|
||||
'integration.n8n_workflow': { labelKey: 'workflows.nodes.n8nWorkflow', descriptionKey: 'workflows.nodes.n8nWorkflowDescription' },
|
||||
'integration.langflow_flow': { labelKey: 'workflows.nodes.langflowFlow', descriptionKey: 'workflows.nodes.langflowFlowDescription' },
|
||||
'integration.coze_bot': { labelKey: 'workflows.nodes.cozeBot', descriptionKey: 'workflows.nodes.cozeBotDescription' },
|
||||
// Integration – data & tools
|
||||
'integration.database_query': { labelKey: 'workflows.nodes.databaseQuery', descriptionKey: 'workflows.nodes.databaseQueryDescription' },
|
||||
'integration.redis_operation': { labelKey: 'workflows.nodes.redisOperation', descriptionKey: 'workflows.nodes.redisOperationDescription' },
|
||||
'integration.mcp_tool': { labelKey: 'workflows.nodes.mcpTool', descriptionKey: 'workflows.nodes.mcpToolDescription' },
|
||||
'integration.memory_store': { labelKey: 'workflows.nodes.memoryStore', descriptionKey: 'workflows.nodes.memoryStoreDescription' },
|
||||
};
|
||||
|
||||
// Flat version: type → label key only (convenience for store / node component)
|
||||
export const NODE_TYPE_LABEL_KEYS: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(NODE_TYPE_I18N_KEYS).map(([k, v]) => [k, v.labelKey]),
|
||||
);
|
||||
|
||||
// Category i18n
|
||||
export const CATEGORY_I18N_KEYS: Record<string, { labelKey: string }> = {
|
||||
trigger: { labelKey: 'workflows.nodes.trigger' },
|
||||
process: { labelKey: 'workflows.nodes.process' },
|
||||
control: { labelKey: 'workflows.nodes.control' },
|
||||
action: { labelKey: 'workflows.nodes.action' },
|
||||
integration: { labelKey: 'workflows.nodes.integration' },
|
||||
};
|
||||
|
||||
// ─── Icon mapping ───────────────────────────────────────────────────
|
||||
|
||||
export const NODE_ICONS: Record<string, React.ElementType> = {
|
||||
// Trigger
|
||||
'trigger.message': MessageSquare,
|
||||
'trigger.message_trigger': MessageSquare,
|
||||
'trigger.schedule': Timer,
|
||||
'trigger.cron': Timer,
|
||||
'trigger.cron_trigger': Timer,
|
||||
'trigger.webhook': Webhook,
|
||||
'trigger.webhook_trigger': Webhook,
|
||||
'trigger.manual': Zap,
|
||||
'trigger.event': Bell,
|
||||
'trigger.event_trigger': Bell,
|
||||
// Process / AI
|
||||
'process.llm': Brain,
|
||||
'process.llm_call': Brain,
|
||||
'process.knowledge': Search,
|
||||
'process.knowledge_retrieval': Search,
|
||||
'process.code': Code,
|
||||
'process.code_executor': Code,
|
||||
'process.template': FileText,
|
||||
'process.text_template': FileText,
|
||||
'process.data_transform': ArrowRightLeft,
|
||||
'process.http': Globe,
|
||||
'process.http_request': Globe,
|
||||
'process.question_classifier': ListFilter,
|
||||
'process.parameter_extractor': Variable,
|
||||
'process.json_transform': ArrowRightLeft,
|
||||
'process.data_aggregator': Layers,
|
||||
'process.text_splitter': Code,
|
||||
'process.variable_assignment': Variable,
|
||||
// Control
|
||||
'control.condition': GitBranch,
|
||||
'control.switch': Split,
|
||||
'control.loop': Repeat,
|
||||
'control.iterator': Repeat,
|
||||
'control.parallel': Layers,
|
||||
'control.merge': GitMerge,
|
||||
'control.variable_aggregator': GitMerge,
|
||||
'control.delay': Timer,
|
||||
'control.wait': Clock,
|
||||
'control.error_handler': AlertCircle,
|
||||
// Action
|
||||
'action.reply': Send,
|
||||
'action.reply_message': Send,
|
||||
'action.send_message': MessageCircle,
|
||||
'action.variable': Variable,
|
||||
'action.set_variable': Variable,
|
||||
'action.store_data': Database,
|
||||
'action.database': Database,
|
||||
'action.notify': Bell,
|
||||
'action.external': Globe,
|
||||
'action.call_pipeline': Workflow,
|
||||
'action.opening_statement': MessageSquare,
|
||||
'action.end': PauseCircle,
|
||||
// Integration – external services
|
||||
'integration.dify': Bot,
|
||||
'integration.dify_workflow': Bot,
|
||||
'integration.dify_knowledge_query': Search,
|
||||
'integration.n8n': Settings,
|
||||
'integration.n8n_workflow': Settings,
|
||||
'integration.langflow': Workflow,
|
||||
'integration.langflow_flow': Workflow,
|
||||
'integration.coze': Bot,
|
||||
'integration.coze_bot': Bot,
|
||||
// Integration – data & tools
|
||||
'integration.database_query': Database,
|
||||
'integration.redis_operation': Cpu,
|
||||
'integration.mcp_tool': Settings,
|
||||
'integration.memory_store': Layers,
|
||||
};
|
||||
|
||||
// ─── Category palette colours ───────────────────────────────────────
|
||||
|
||||
export const PALETTE_CATEGORY_COLORS: Record<string, string> = {
|
||||
trigger: 'text-amber-600 dark:text-amber-400',
|
||||
process: 'text-blue-600 dark:text-blue-400',
|
||||
control: 'text-purple-600 dark:text-purple-400',
|
||||
action: 'text-green-600 dark:text-green-400',
|
||||
integration: 'text-pink-600 dark:text-pink-400',
|
||||
};
|
||||
|
||||
export const PALETTE_CATEGORY_BG: Record<string, string> = {
|
||||
trigger: 'bg-amber-100 dark:bg-amber-900/30 hover:bg-amber-200 dark:hover:bg-amber-900/50',
|
||||
process: 'bg-blue-100 dark:bg-blue-900/30 hover:bg-blue-200 dark:hover:bg-blue-900/50',
|
||||
control: 'bg-purple-100 dark:bg-purple-900/30 hover:bg-purple-200 dark:hover:bg-purple-900/50',
|
||||
action: 'bg-green-100 dark:bg-green-900/30 hover:bg-green-200 dark:hover:bg-green-900/50',
|
||||
integration: 'bg-pink-100 dark:bg-pink-900/30 hover:bg-pink-200 dark:hover:bg-pink-900/50',
|
||||
};
|
||||
|
||||
export const PALETTE_CATEGORY_BORDER: Record<string, string> = {
|
||||
trigger: 'border-amber-300 dark:border-amber-700',
|
||||
process: 'border-blue-300 dark:border-blue-700',
|
||||
control: 'border-purple-300 dark:border-purple-700',
|
||||
action: 'border-green-300 dark:border-green-700',
|
||||
integration: 'border-pink-300 dark:border-pink-700',
|
||||
};
|
||||
|
||||
export const CATEGORY_ICONS: Record<string, React.ElementType> = {
|
||||
trigger: Zap,
|
||||
process: Cpu,
|
||||
control: GitBranch,
|
||||
action: Play,
|
||||
integration: Plug,
|
||||
};
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Find the i18n keys for a node type, with fuzzy matching so both
|
||||
* "trigger.message_trigger" and "message_trigger" work.
|
||||
*/
|
||||
export function findNodeI18nKeys(type: string): { labelKey: string; descriptionKey: string } | undefined {
|
||||
let keys = NODE_TYPE_I18N_KEYS[type];
|
||||
if (keys) return keys;
|
||||
|
||||
// Try stripping or adding category prefix
|
||||
const parts = type.split('.');
|
||||
const typeName = parts[parts.length - 1];
|
||||
if (parts.length > 1) {
|
||||
keys = NODE_TYPE_I18N_KEYS[type]; // already tried
|
||||
} else {
|
||||
for (const cat of ['trigger', 'process', 'control', 'action', 'integration']) {
|
||||
keys = NODE_TYPE_I18N_KEYS[`${cat}.${typeName}`];
|
||||
if (keys) return keys;
|
||||
}
|
||||
}
|
||||
|
||||
// Scan all entries for a tail match
|
||||
for (const [k, v] of Object.entries(NODE_TYPE_I18N_KEYS)) {
|
||||
if (k.endsWith(`.${typeName}`) || k === typeName) return v;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a human-readable label for a node type.
|
||||
* Priority: i18n key → backend label dict → prettified type string.
|
||||
*/
|
||||
export function getNodeTypeLabel(
|
||||
type: string,
|
||||
labelDict?: Record<string, string>,
|
||||
): string {
|
||||
// 1. i18n key
|
||||
const keys = findNodeI18nKeys(type);
|
||||
if (keys) {
|
||||
const translated = i18n.t(keys.labelKey, { defaultValue: '' });
|
||||
if (translated) return translated;
|
||||
}
|
||||
|
||||
// 2. Backend label dict
|
||||
if (labelDict) {
|
||||
const label = resolveI18nLabel(labelDict);
|
||||
if (label) return label;
|
||||
}
|
||||
|
||||
// 3. Prettify type string
|
||||
const base = type.includes('.') ? type.split('.').slice(1).join('.') : type;
|
||||
return base
|
||||
.split('_')
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Unified i18n utilities for the Workflow module.
|
||||
*
|
||||
* The backend API returns label dicts with keys like `zh-CN`, `en`,
|
||||
* while node-configs use `zh_Hans`, `en_US`, and the i18next system
|
||||
* uses `zh-Hans`, `en-US`. This module normalises **all** variants
|
||||
* into a single lookup so every consumer gets the right value without
|
||||
* maintaining its own fallback chain.
|
||||
*/
|
||||
|
||||
import i18n from 'i18next';
|
||||
import { I18nObject } from '@/app/infra/entities/common';
|
||||
|
||||
// ─── Key normalisation ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* All known aliases for a given canonical locale, ordered by priority.
|
||||
* When the user's language starts with a prefix we try every alias in
|
||||
* order until one hits.
|
||||
*/
|
||||
const ZH_KEYS = ['zh-CN', 'zh_Hans', 'zh-Hans', 'zh_CN', 'zh'] as const;
|
||||
const EN_KEYS = ['en-US', 'en_US', 'en'] as const;
|
||||
|
||||
/**
|
||||
* Resolve a translated string from a label dict that may use **any**
|
||||
* combination of `zh-CN`, `zh_Hans`, `en`, `en-US`, `en_US` etc.
|
||||
*
|
||||
* Works with both `Record<string, string>` (backend) and the typed
|
||||
* `I18nObject` (node-configs).
|
||||
*
|
||||
* Optionally falls through to `i18n.t(value)` when the stored value
|
||||
* itself looks like an i18n key (e.g. `"workflows.nodes.llmCall"`).
|
||||
*/
|
||||
export function resolveI18nLabel(
|
||||
obj: Record<string, string> | I18nObject | undefined | null,
|
||||
): string {
|
||||
if (!obj || typeof obj !== 'object') return '';
|
||||
|
||||
const record = obj as Record<string, string>;
|
||||
const lang = i18n.language; // e.g. "zh-Hans", "en-US"
|
||||
|
||||
// 1. Try exact match with current language
|
||||
if (record[lang]) return maybeTranslateKey(record[lang]);
|
||||
|
||||
// 2. Try aliases for the current language family
|
||||
const primary = lang.startsWith('zh') ? ZH_KEYS : EN_KEYS;
|
||||
const fallback = lang.startsWith('zh') ? EN_KEYS : ZH_KEYS;
|
||||
|
||||
for (const k of primary) {
|
||||
if (record[k]) return maybeTranslateKey(record[k]);
|
||||
}
|
||||
for (const k of fallback) {
|
||||
if (record[k]) return maybeTranslateKey(record[k]);
|
||||
}
|
||||
|
||||
// 3. Last resort – grab the first non-empty value in the dict
|
||||
const first = Object.values(record).find((v) => typeof v === 'string' && v);
|
||||
return first ? maybeTranslateKey(first) : '';
|
||||
}
|
||||
|
||||
// ─── i18n key detection ─────────────────────────────────────────────
|
||||
|
||||
const I18N_KEY_PREFIXES = ['workflows.', 'common.', 'bots.', 'models.'];
|
||||
|
||||
/**
|
||||
* If `value` looks like an i18n key (e.g. `"workflows.nodes.llmCall"`)
|
||||
* translate it via i18next; otherwise return it unchanged.
|
||||
*/
|
||||
export function maybeTranslateKey(value: string): string {
|
||||
if (!value) return value;
|
||||
if (I18N_KEY_PREFIXES.some((p) => value.startsWith(p))) {
|
||||
const translated = i18n.t(value);
|
||||
if (translated !== value) return translated;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import type {
|
||||
WorkflowNodeTypeMetadata,
|
||||
WorkflowPortDefinition,
|
||||
} from '@/app/infra/entities/api';
|
||||
import type { I18nObject } from '@/app/infra/entities/common';
|
||||
import {
|
||||
getNodeConfig,
|
||||
type NodeConfigMeta,
|
||||
} from './node-configs';
|
||||
|
||||
export const WORKFLOW_NODE_CATEGORIES = [
|
||||
'trigger',
|
||||
'process',
|
||||
'control',
|
||||
'action',
|
||||
'integration',
|
||||
] as const;
|
||||
|
||||
const DEFAULT_INPUT_PORT: WorkflowPortDefinition = {
|
||||
name: 'input',
|
||||
type: 'any',
|
||||
label: 'workflows.nodeInputs.input',
|
||||
};
|
||||
|
||||
const DEFAULT_OUTPUT_PORT: WorkflowPortDefinition = {
|
||||
name: 'output',
|
||||
type: 'any',
|
||||
label: 'workflows.nodeOutputs.output',
|
||||
};
|
||||
|
||||
function ensurePortLabelKey(
|
||||
prefix: 'workflows.nodeInputs' | 'workflows.nodeOutputs',
|
||||
portName: string,
|
||||
label?: string | Record<string, string>,
|
||||
): string {
|
||||
const key = `${prefix}.${portName}`;
|
||||
|
||||
if (typeof label === 'string') {
|
||||
return label.startsWith(prefix) ? label : key;
|
||||
}
|
||||
|
||||
if (label && typeof label === 'object') {
|
||||
const existing = Object.values(label).find(
|
||||
(value) => typeof value === 'string' && value.startsWith(prefix),
|
||||
);
|
||||
if (existing) return existing;
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
function normalizePort(
|
||||
prefix: 'workflows.nodeInputs' | 'workflows.nodeOutputs',
|
||||
port: WorkflowPortDefinition,
|
||||
): WorkflowPortDefinition {
|
||||
return {
|
||||
...port,
|
||||
label: ensurePortLabelKey(prefix, port.name, port.label),
|
||||
};
|
||||
}
|
||||
|
||||
function toBackendI18nObject(value?: I18nObject): Record<string, string> | undefined {
|
||||
if (!value) return undefined;
|
||||
|
||||
return {
|
||||
'en-US': value.en_US,
|
||||
en: value.en_US,
|
||||
'zh-Hans': value.zh_Hans,
|
||||
'zh-CN': value.zh_Hans,
|
||||
};
|
||||
}
|
||||
|
||||
function toWorkflowPortDefinition(
|
||||
prefix: 'workflows.nodeInputs' | 'workflows.nodeOutputs',
|
||||
port: NodeConfigMeta['inputs'][number] | NodeConfigMeta['outputs'][number],
|
||||
): WorkflowPortDefinition {
|
||||
return normalizePort(prefix, {
|
||||
name: port.name,
|
||||
type: port.type,
|
||||
label: `${prefix}.${port.name}`,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveNodeTypeCategory(type: string): string {
|
||||
if (type.includes('.')) {
|
||||
return type.split('.')[0];
|
||||
}
|
||||
return 'process';
|
||||
}
|
||||
|
||||
function getLocalConfigVariants(type: string): string[] {
|
||||
const variants = new Set<string>([type]);
|
||||
|
||||
if (type.includes('.')) {
|
||||
variants.add(type.split('.').slice(1).join('.'));
|
||||
variants.add(type.replace(/\./g, '_'));
|
||||
} else {
|
||||
for (const category of WORKFLOW_NODE_CATEGORIES) {
|
||||
variants.add(`${category}.${type}`);
|
||||
}
|
||||
}
|
||||
|
||||
return [...variants];
|
||||
}
|
||||
|
||||
export function getLocalNodeTypeMeta(type: string): WorkflowNodeTypeMetadata | null {
|
||||
let localConfig: NodeConfigMeta | undefined;
|
||||
|
||||
for (const variant of getLocalConfigVariants(type)) {
|
||||
localConfig = getNodeConfig(variant);
|
||||
if (localConfig) break;
|
||||
}
|
||||
|
||||
if (!localConfig) return null;
|
||||
|
||||
return {
|
||||
type,
|
||||
category: localConfig.category,
|
||||
label: toBackendI18nObject(localConfig.label) ?? {},
|
||||
description: toBackendI18nObject(localConfig.description),
|
||||
icon: localConfig.icon,
|
||||
color: localConfig.color,
|
||||
config_schema: localConfig.configSchema,
|
||||
inputs: localConfig.inputs.map((input) =>
|
||||
toWorkflowPortDefinition('workflows.nodeInputs', input),
|
||||
),
|
||||
outputs: localConfig.outputs.map((output) =>
|
||||
toWorkflowPortDefinition('workflows.nodeOutputs', output),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeWorkflowNodeTypeMeta(
|
||||
type: string,
|
||||
nodeType?: WorkflowNodeTypeMetadata | null,
|
||||
): WorkflowNodeTypeMetadata {
|
||||
const localMeta = getLocalNodeTypeMeta(type);
|
||||
const category =
|
||||
nodeType?.category || localMeta?.category || resolveNodeTypeCategory(type);
|
||||
|
||||
const inputs =
|
||||
nodeType?.inputs?.length
|
||||
? nodeType.inputs.map((input) =>
|
||||
normalizePort('workflows.nodeInputs', input),
|
||||
)
|
||||
: localMeta?.inputs?.length
|
||||
? localMeta.inputs
|
||||
: [DEFAULT_INPUT_PORT];
|
||||
|
||||
const outputs =
|
||||
nodeType?.outputs?.length
|
||||
? nodeType.outputs.map((output) =>
|
||||
normalizePort('workflows.nodeOutputs', output),
|
||||
)
|
||||
: localMeta?.outputs?.length
|
||||
? localMeta.outputs
|
||||
: [DEFAULT_OUTPUT_PORT];
|
||||
|
||||
const configSchema =
|
||||
nodeType?.config_schema?.length
|
||||
? nodeType.config_schema
|
||||
: localMeta?.config_schema?.length
|
||||
? localMeta.config_schema
|
||||
: [];
|
||||
|
||||
return {
|
||||
type,
|
||||
category,
|
||||
label: nodeType?.label || localMeta?.label || {},
|
||||
description: nodeType?.description || localMeta?.description,
|
||||
icon: nodeType?.icon || localMeta?.icon,
|
||||
color: nodeType?.color || localMeta?.color,
|
||||
config_schema: configSchema,
|
||||
config_schema_source: nodeType?.config_schema_source,
|
||||
config_stages: nodeType?.config_stages,
|
||||
inputs,
|
||||
outputs,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,722 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { backendClient } from '@/app/infra/http';
|
||||
import { WorkflowExecution } from '@/app/infra/entities/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
RefreshCw,
|
||||
Play,
|
||||
XCircle,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
FileText,
|
||||
RotateCcw,
|
||||
Filter,
|
||||
TrendingUp,
|
||||
Calendar,
|
||||
ChevronDown,
|
||||
ChevronUp
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from '@/components/ui/tabs';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface WorkflowExecutionsTabProps {
|
||||
workflowId: string;
|
||||
}
|
||||
|
||||
interface ExecutionLog {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
level: string;
|
||||
node_id?: string;
|
||||
message: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface WorkflowStats {
|
||||
total_executions: number;
|
||||
successful_executions: number;
|
||||
failed_executions: number;
|
||||
success_rate: number;
|
||||
average_duration_ms: number;
|
||||
last_execution_time?: string;
|
||||
}
|
||||
|
||||
const statusIcons: Record<string, React.ElementType> = {
|
||||
pending: Clock,
|
||||
running: Loader2,
|
||||
completed: CheckCircle2,
|
||||
failed: AlertCircle,
|
||||
cancelled: XCircle,
|
||||
};
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
pending: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
|
||||
running: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
|
||||
completed: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
|
||||
failed: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
|
||||
cancelled: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',
|
||||
};
|
||||
|
||||
const logLevelColors: Record<string, string> = {
|
||||
info: 'text-blue-600 dark:text-blue-400',
|
||||
warning: 'text-yellow-600 dark:text-yellow-400',
|
||||
error: 'text-red-600 dark:text-red-400',
|
||||
debug: 'text-gray-600 dark:text-gray-400',
|
||||
};
|
||||
|
||||
export default function WorkflowExecutionsTab({
|
||||
workflowId,
|
||||
}: WorkflowExecutionsTabProps) {
|
||||
const { t } = useTranslation();
|
||||
const [executions, setExecutions] = useState<WorkflowExecution[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedExecution, setSelectedExecution] = useState<WorkflowExecution | null>(null);
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
// Filters
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [dateFilter, setDateFilter] = useState<string>('all');
|
||||
|
||||
// Statistics
|
||||
const [stats, setStats] = useState<WorkflowStats | null>(null);
|
||||
const [statsLoading, setStatsLoading] = useState(false);
|
||||
const [showStats, setShowStats] = useState(true);
|
||||
|
||||
// Logs
|
||||
const [executionLogs, setExecutionLogs] = useState<ExecutionLog[]>([]);
|
||||
const [logsLoading, setLogsLoading] = useState(false);
|
||||
const [selectedTab, setSelectedTab] = useState('details');
|
||||
|
||||
// Rerun
|
||||
const [rerunning, setRerunning] = useState<string | null>(null);
|
||||
|
||||
// Load executions
|
||||
const loadExecutions = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const resp = await backendClient.getWorkflowExecutions(workflowId, 50, 0);
|
||||
setExecutions(resp.executions || []);
|
||||
setTotal(resp.total ?? resp.executions?.length ?? 0);
|
||||
} catch (err) {
|
||||
// Silently handle connection errors — don't spam console on backend down
|
||||
setExecutions([]);
|
||||
setTotal(0);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [workflowId]);
|
||||
|
||||
// Load statistics
|
||||
const loadStats = useCallback(async () => {
|
||||
setStatsLoading(true);
|
||||
try {
|
||||
const resp = await backendClient.getWorkflowStats(workflowId);
|
||||
setStats(resp ?? null);
|
||||
} catch {
|
||||
// Backend might not support stats endpoint yet — just show empty
|
||||
setStats(null);
|
||||
} finally {
|
||||
setStatsLoading(false);
|
||||
}
|
||||
}, [workflowId]);
|
||||
|
||||
// Load execution logs
|
||||
const loadExecutionLogs = useCallback(async (executionUuid: string) => {
|
||||
setLogsLoading(true);
|
||||
try {
|
||||
const resp = await backendClient.getWorkflowExecutionLogs(workflowId, executionUuid, 200, 0);
|
||||
setExecutionLogs(resp.logs);
|
||||
} catch (err) {
|
||||
console.error('Failed to load execution logs:', err);
|
||||
setExecutionLogs([]);
|
||||
} finally {
|
||||
setLogsLoading(false);
|
||||
}
|
||||
}, [workflowId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadExecutions();
|
||||
loadStats();
|
||||
}, [loadExecutions, loadStats]);
|
||||
|
||||
// Filter executions
|
||||
const filteredExecutions = useMemo(() => {
|
||||
let filtered = executions;
|
||||
|
||||
// Status filter
|
||||
if (statusFilter !== 'all') {
|
||||
filtered = filtered.filter(e => e.status === statusFilter);
|
||||
}
|
||||
|
||||
// Date filter
|
||||
if (dateFilter !== 'all') {
|
||||
const now = new Date();
|
||||
let startDate: Date;
|
||||
|
||||
switch (dateFilter) {
|
||||
case 'today':
|
||||
startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
break;
|
||||
case 'week':
|
||||
startDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
case 'month':
|
||||
startDate = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
default:
|
||||
startDate = new Date(0);
|
||||
}
|
||||
|
||||
filtered = filtered.filter(e => {
|
||||
if (!e.started_at) return false;
|
||||
return new Date(e.started_at) >= startDate;
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [executions, statusFilter, dateFilter]);
|
||||
|
||||
// Manual trigger
|
||||
const handleManualTrigger = useCallback(async () => {
|
||||
try {
|
||||
await backendClient.executeWorkflow(workflowId, {
|
||||
trigger_type: 'manual',
|
||||
});
|
||||
toast.success(t('workflows.manualTrigger') + ' ✓');
|
||||
loadExecutions();
|
||||
loadStats();
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { msg?: string })?.msg || String(err);
|
||||
toast.error(`${t('workflows.manualTrigger')}: ${msg}`);
|
||||
}
|
||||
}, [workflowId, loadExecutions, loadStats, t]);
|
||||
|
||||
// View execution details
|
||||
const handleViewDetails = useCallback(async (executionUuid: string) => {
|
||||
try {
|
||||
const resp = await backendClient.getWorkflowExecution(workflowId, executionUuid);
|
||||
setSelectedExecution(resp.execution);
|
||||
setSelectedTab('details');
|
||||
loadExecutionLogs(executionUuid);
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { msg?: string })?.msg || String(err);
|
||||
toast.error(`${t('workflows.executionDetails')}: ${msg}`);
|
||||
}
|
||||
}, [workflowId, loadExecutionLogs, t]);
|
||||
|
||||
// Cancel execution
|
||||
const handleCancel = useCallback(async (executionUuid: string) => {
|
||||
try {
|
||||
await backendClient.cancelWorkflowExecution(workflowId, executionUuid);
|
||||
toast.success(t('common.cancel') + ' ✓');
|
||||
loadExecutions();
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { msg?: string })?.msg || String(err);
|
||||
toast.error(`${t('common.cancel')}: ${msg}`);
|
||||
}
|
||||
}, [workflowId, loadExecutions, t]);
|
||||
|
||||
// Rerun execution
|
||||
const handleRerun = useCallback(async (executionUuid: string) => {
|
||||
setRerunning(executionUuid);
|
||||
try {
|
||||
await backendClient.rerunWorkflowExecution(workflowId, executionUuid);
|
||||
toast.success(t('workflows.rerun') + ' ✓');
|
||||
loadExecutions();
|
||||
loadStats();
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { msg?: string })?.msg || String(err);
|
||||
toast.error(`${t('workflows.rerun')}: ${msg}`);
|
||||
} finally {
|
||||
setRerunning(null);
|
||||
}
|
||||
}, [workflowId, loadExecutions, loadStats, t]);
|
||||
|
||||
// Format duration
|
||||
const formatDuration = (seconds: number): string => {
|
||||
if (seconds === null || seconds === undefined || isNaN(seconds)) return '0.0s';
|
||||
if (seconds < 60) return `${seconds.toFixed(1)}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${minutes}m ${secs.toFixed(0)}s`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Statistics Panel */}
|
||||
<Collapsible open={showStats} onOpenChange={setShowStats}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" className="w-full justify-between p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp className="size-4" />
|
||||
<span className="font-medium">{t('workflows.statistics')}</span>
|
||||
</div>
|
||||
{showStats ? <ChevronUp className="size-4" /> : <ChevronDown className="size-4" />}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
{statsLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : stats ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 p-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{t('workflows.totalExecutions', { count: stats.total_executions ?? 0 })}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.total_executions ?? 0}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t('workflows.successfulCount', { count: stats.successful_executions ?? 0 })}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{t('workflows.successRate')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-green-600 dark:text-green-400">
|
||||
{((stats.success_rate ?? 0) * 100).toFixed(1)}%
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{stats.successful_executions ?? 0} / {stats.total_executions ?? 0}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{t('workflows.averageDuration')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatDuration((stats.average_duration_ms ?? 0) / 1000)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t('workflows.perExecution')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{t('workflows.failedExecutions')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-red-600 dark:text-red-400">
|
||||
{stats.failed_executions ?? 0}
|
||||
</div>
|
||||
{stats.last_execution_time && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t('workflows.lastExecution')}: {new Date(stats.last_execution_time).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-4 text-sm text-muted-foreground">
|
||||
{t('workflows.noExecutions')}
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* Toolbar with Filters */}
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="size-4 text-muted-foreground" />
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder={t('workflows.filterByStatus')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t('workflows.allStatuses')}</SelectItem>
|
||||
<SelectItem value="completed">{t('workflows.status.completed')}</SelectItem>
|
||||
<SelectItem value="running">{t('workflows.status.running')}</SelectItem>
|
||||
<SelectItem value="failed">{t('workflows.status.failed')}</SelectItem>
|
||||
<SelectItem value="cancelled">{t('workflows.status.cancelled')}</SelectItem>
|
||||
<SelectItem value="pending">{t('workflows.status.pending')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="size-4 text-muted-foreground" />
|
||||
<Select value={dateFilter} onValueChange={setDateFilter}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder={t('workflows.filterByDate')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t('workflows.allTime')}</SelectItem>
|
||||
<SelectItem value="today">{t('workflows.today')}</SelectItem>
|
||||
<SelectItem value="week">{t('workflows.lastWeek')}</SelectItem>
|
||||
<SelectItem value="month">{t('workflows.lastMonth')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t('workflows.showingExecutions', {
|
||||
shown: filteredExecutions.length,
|
||||
total: total
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => { loadExecutions(); loadStats(); }} disabled={loading}>
|
||||
<RefreshCw className={`size-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleManualTrigger}>
|
||||
<Play className="size-4 mr-2" />
|
||||
{t('workflows.manualTrigger')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Executions Table */}
|
||||
<div className="border rounded-lg">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('workflows.executionId')}</TableHead>
|
||||
<TableHead>{t('workflows.status')}</TableHead>
|
||||
<TableHead>{t('workflows.triggerType')}</TableHead>
|
||||
<TableHead>{t('workflows.startedAt')}</TableHead>
|
||||
<TableHead>{t('workflows.duration')}</TableHead>
|
||||
<TableHead>{t('common.actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredExecutions.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
|
||||
{loading ? t('common.loading') : t('workflows.noExecutions')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredExecutions.map((execution) => {
|
||||
const StatusIcon = statusIcons[execution.status] || Clock;
|
||||
const duration = execution.completed_at && execution.started_at
|
||||
? Math.round((new Date(execution.completed_at).getTime() - new Date(execution.started_at).getTime()) / 1000)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<TableRow key={execution.uuid}>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{execution.uuid.slice(0, 8)}...
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={statusColors[execution.status]}>
|
||||
<StatusIcon className={`size-3 mr-1 ${execution.status === 'running' ? 'animate-spin' : ''}`} />
|
||||
{t(`workflows.status.${execution.status}`)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{execution.trigger_type || '-'}</TableCell>
|
||||
<TableCell>
|
||||
{execution.started_at
|
||||
? new Date(execution.started_at).toLocaleString()
|
||||
: '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{duration !== null ? `${duration}s` : '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleViewDetails(execution.uuid)}
|
||||
>
|
||||
{t('common.details')}
|
||||
</Button>
|
||||
{execution.status === 'running' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleCancel(execution.uuid)}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
)}
|
||||
{(execution.status === 'completed' || execution.status === 'failed') && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRerun(execution.uuid)}
|
||||
disabled={rerunning === execution.uuid}
|
||||
>
|
||||
{rerunning === execution.uuid ? (
|
||||
<Loader2 className="size-3 animate-spin mr-1" />
|
||||
) : (
|
||||
<RotateCcw className="size-3 mr-1" />
|
||||
)}
|
||||
{t('workflows.rerun')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Execution Details Dialog */}
|
||||
<Dialog open={!!selectedExecution} onOpenChange={() => setSelectedExecution(null)}>
|
||||
<DialogContent className="max-w-3xl max-h-[85vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('workflows.executionDetails')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{selectedExecution?.uuid}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{selectedExecution && (
|
||||
<Tabs value={selectedTab} onValueChange={setSelectedTab} className="flex-1 flex flex-col overflow-hidden">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="details">{t('workflows.details')}</TabsTrigger>
|
||||
<TabsTrigger value="nodes">{t('workflows.nodeExecutions')}</TabsTrigger>
|
||||
<TabsTrigger value="logs">
|
||||
<FileText className="size-3 mr-1" />
|
||||
{t('workflows.logs')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="details" className="flex-1 overflow-auto space-y-4 mt-4">
|
||||
{/* Summary */}
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t('workflows.status')}:</span>
|
||||
<Badge className={`ml-2 ${statusColors[selectedExecution.status]}`}>
|
||||
{t(`workflows.status.${selectedExecution.status}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t('workflows.triggerType')}:</span>
|
||||
<span className="ml-2">{selectedExecution.trigger_type || '-'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t('workflows.startedAt')}:</span>
|
||||
<span className="ml-2">
|
||||
{selectedExecution.started_at
|
||||
? new Date(selectedExecution.started_at).toLocaleString()
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t('workflows.completedAt')}:</span>
|
||||
<span className="ml-2">
|
||||
{selectedExecution.completed_at
|
||||
? new Date(selectedExecution.completed_at).toLocaleString()
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{selectedExecution.error && (
|
||||
<div className="bg-destructive/10 border border-destructive/20 rounded p-3">
|
||||
<div className="text-sm font-medium text-destructive mb-1">
|
||||
{t('workflows.error')}
|
||||
</div>
|
||||
<div className="text-sm text-destructive/80 font-mono whitespace-pre-wrap">
|
||||
{selectedExecution.error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Result */}
|
||||
{selectedExecution.result && (
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">{t('workflows.result')}</h4>
|
||||
<pre className="bg-muted p-3 rounded text-xs overflow-x-auto max-h-[200px]">
|
||||
{JSON.stringify(selectedExecution.result, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rerun button */}
|
||||
<div className="flex justify-end pt-4">
|
||||
<Button
|
||||
onClick={() => {
|
||||
handleRerun(selectedExecution.uuid);
|
||||
setSelectedExecution(null);
|
||||
}}
|
||||
disabled={selectedExecution.status === 'running'}
|
||||
>
|
||||
<RotateCcw className="size-4 mr-2" />
|
||||
{t('workflows.rerunExecution')}
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="nodes" className="flex-1 overflow-auto mt-4">
|
||||
{selectedExecution.node_executions && selectedExecution.node_executions.length > 0 ? (
|
||||
<ScrollArea className="h-[400px]">
|
||||
<div className="space-y-2 pr-4">
|
||||
{selectedExecution.node_executions.map((nodeExec) => {
|
||||
const NodeStatusIcon = statusIcons[nodeExec.status] || Clock;
|
||||
const isFailedNode = nodeExec.status === 'failed';
|
||||
return (
|
||||
<div
|
||||
key={nodeExec.node_id}
|
||||
className={`border rounded p-3 text-sm ${
|
||||
isFailedNode
|
||||
? 'border-red-300 bg-red-50/70 dark:border-red-800 dark:bg-red-950/20'
|
||||
: 'border-border'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className={isFailedNode ? 'font-medium text-red-700 dark:text-red-300' : 'font-medium'}>
|
||||
{nodeExec.node_id}
|
||||
</span>
|
||||
<Badge className={statusColors[nodeExec.status]}>
|
||||
<NodeStatusIcon className="size-3 mr-1" />
|
||||
{nodeExec.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div
|
||||
className={`text-xs mt-1 ${
|
||||
isFailedNode
|
||||
? 'text-red-600/80 dark:text-red-400/80'
|
||||
: 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{nodeExec.node_type}
|
||||
</div>
|
||||
{nodeExec.inputs && Object.keys(nodeExec.inputs).length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="text-xs text-muted-foreground mb-1">{t('workflows.inputs')}:</div>
|
||||
<pre className="bg-muted p-2 rounded text-xs overflow-x-auto max-h-[100px]">
|
||||
{JSON.stringify(nodeExec.inputs, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{nodeExec.outputs && Object.keys(nodeExec.outputs).length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="text-xs text-muted-foreground mb-1">{t('workflows.outputs')}:</div>
|
||||
<pre className="bg-muted p-2 rounded text-xs overflow-x-auto max-h-[100px]">
|
||||
{JSON.stringify(nodeExec.outputs, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{nodeExec.error && (
|
||||
<div className="text-destructive text-xs mt-2 bg-destructive/10 p-2 rounded">
|
||||
{nodeExec.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{t('workflows.noNodeExecutions')}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="logs" className="flex-1 overflow-hidden mt-4">
|
||||
{logsLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : executionLogs.length > 0 ? (
|
||||
<ScrollArea className="h-[400px] border rounded">
|
||||
<div className="p-2 space-y-1 font-mono text-xs">
|
||||
{executionLogs.map((log) => (
|
||||
<div
|
||||
key={log.id}
|
||||
className={`flex gap-2 p-1 hover:bg-muted/50 rounded ${logLevelColors[log.level]}`}
|
||||
>
|
||||
<span className="text-muted-foreground shrink-0">
|
||||
{new Date(log.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
<span className="uppercase w-12 shrink-0 font-semibold">
|
||||
[{log.level}]
|
||||
</span>
|
||||
{log.node_id && (
|
||||
<span className="text-muted-foreground shrink-0">
|
||||
[{log.node_id}]
|
||||
</span>
|
||||
)}
|
||||
<span className="flex-1 break-all">{log.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{t('workflows.noLogs')}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Workflow } from '@/app/infra/entities/api';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Trash2, AlertTriangle } from 'lucide-react';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import EmojiPicker from '@/components/ui/emoji-picker';
|
||||
|
||||
interface WorkflowFormComponentProps {
|
||||
workflow: Workflow | null;
|
||||
onWorkflowChange: (workflow: Workflow) => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export default function WorkflowFormComponent({
|
||||
workflow,
|
||||
onWorkflowChange,
|
||||
onDelete,
|
||||
}: WorkflowFormComponentProps) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState(workflow?.name || '');
|
||||
const [description, setDescription] = useState(workflow?.description || '');
|
||||
const [emoji, setEmoji] = useState(workflow?.emoji || '🔄');
|
||||
const [isEnabled, setIsEnabled] = useState(workflow?.is_enabled ?? true);
|
||||
const isSyncingFromProp = useRef(false);
|
||||
|
||||
// Sync with workflow prop
|
||||
useEffect(() => {
|
||||
if (workflow) {
|
||||
isSyncingFromProp.current = true;
|
||||
setName(workflow.name || '');
|
||||
setDescription(workflow.description || '');
|
||||
setEmoji(workflow.emoji || '🔄');
|
||||
setIsEnabled(workflow.is_enabled ?? true);
|
||||
}
|
||||
}, [workflow?.uuid, workflow?.version]);
|
||||
|
||||
// Update parent when values change (skip if the change came from prop sync)
|
||||
useEffect(() => {
|
||||
if (isSyncingFromProp.current) {
|
||||
isSyncingFromProp.current = false;
|
||||
return;
|
||||
}
|
||||
if (workflow) {
|
||||
onWorkflowChange({
|
||||
...workflow,
|
||||
name,
|
||||
description,
|
||||
emoji,
|
||||
is_enabled: isEnabled,
|
||||
});
|
||||
}
|
||||
}, [name, description, emoji, isEnabled]);
|
||||
|
||||
if (!workflow) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
||||
{t('workflows.loading')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-6">
|
||||
{/* Basic Info Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('workflows.basicInfo')}</CardTitle>
|
||||
<CardDescription>{t('workflows.basicInfoDesc')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Name with Emoji */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="workflow-name">{t('workflows.name')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<EmojiPicker value={emoji} onChange={setEmoji} />
|
||||
<Input
|
||||
id="workflow-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t('workflows.namePlaceholder')}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="workflow-description">{t('workflows.description')}</Label>
|
||||
<Textarea
|
||||
id="workflow-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={t('workflows.descriptionPlaceholder')}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Enabled toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>{t('workflows.enabled')}</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('workflows.enabledDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={isEnabled}
|
||||
onCheckedChange={setIsEnabled}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Workflow Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('workflows.info')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t('workflows.uuid')}:</span>
|
||||
<span className="ml-2 font-mono">{workflow.uuid}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t('workflows.version')}:</span>
|
||||
<span className="ml-2">{workflow.version || 1}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t('workflows.createdAt')}:</span>
|
||||
<span className="ml-2">
|
||||
{workflow.created_at
|
||||
? new Date(workflow.created_at).toLocaleString()
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t('workflows.updatedAt')}:</span>
|
||||
<span className="ml-2">
|
||||
{workflow.updated_at
|
||||
? new Date(workflow.updated_at).toLocaleString()
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Danger Zone */}
|
||||
<Card className="border-destructive/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive flex items-center gap-2">
|
||||
<AlertTriangle className="size-5" />
|
||||
{t('workflows.dangerZone')}
|
||||
</CardTitle>
|
||||
<CardDescription>{t('workflows.dangerZoneDesc')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive">
|
||||
<Trash2 className="size-4 mr-2" />
|
||||
{t('workflows.deleteWorkflow')}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('workflows.deleteConfirm')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('workflows.deleteConfirmDesc', { name: workflow.name })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={onDelete}>
|
||||
{t('common.delete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
web/src/app/home/workflows/page.tsx
Normal file
19
web/src/app/home/workflows/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import WorkflowDetailContent from './WorkflowDetailContent';
|
||||
|
||||
export default function WorkflowPage() {
|
||||
const { t } = useTranslation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const detailId = searchParams.get('id');
|
||||
|
||||
if (detailId) {
|
||||
return <WorkflowDetailContent id={detailId} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
<p>{t('workflows.selectFromSidebar')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
567
web/src/app/home/workflows/store/useWorkflowStore.ts
Normal file
567
web/src/app/home/workflows/store/useWorkflowStore.ts
Normal file
@@ -0,0 +1,567 @@
|
||||
import { create } from 'zustand';
|
||||
import { Node, Edge, Connection, addEdge, applyNodeChanges, applyEdgeChanges, NodeChange, EdgeChange } from '@xyflow/react';
|
||||
import {
|
||||
Workflow,
|
||||
WorkflowNodeDefinition,
|
||||
WorkflowEdgeDefinition,
|
||||
WorkflowNodeTypeMetadata,
|
||||
WorkflowNodeCategory,
|
||||
} from '@/app/infra/entities/api';
|
||||
import { getNodeTypeLabel as sharedGetNodeTypeLabel } from '../components/workflow-editor/workflow-constants';
|
||||
import { normalizeWorkflowNodeTypeMeta } from '../components/workflow-editor/workflow-node-metadata';
|
||||
import i18n from '@/i18n';
|
||||
|
||||
export interface WorkflowNode extends Node {
|
||||
data: {
|
||||
label: string;
|
||||
type: string;
|
||||
config: Record<string, unknown>;
|
||||
inputs?: { name: string; label?: string; type?: string }[];
|
||||
outputs?: { name: string; label?: string; type?: string }[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface WorkflowEdge extends Edge {
|
||||
data?: {
|
||||
label?: string;
|
||||
condition?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Debug related types
|
||||
export type DebugState = 'idle' | 'running' | 'paused' | 'completed' | 'error';
|
||||
|
||||
export interface NodeExecutionResult {
|
||||
nodeId: string;
|
||||
status: 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
|
||||
inputs?: Record<string, unknown>;
|
||||
outputs?: Record<string, unknown>;
|
||||
error?: string;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export interface DebugLog {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
level: 'info' | 'warning' | 'error' | 'debug';
|
||||
nodeId?: string;
|
||||
message: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface DebugContext {
|
||||
messageContent: string;
|
||||
senderId: string;
|
||||
senderName: string;
|
||||
platform: string;
|
||||
conversationId: string;
|
||||
isGroup: boolean;
|
||||
customVariables: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface WorkflowState {
|
||||
// Current workflow being edited
|
||||
currentWorkflow: Workflow | null;
|
||||
|
||||
// React Flow nodes and edges
|
||||
nodes: WorkflowNode[];
|
||||
edges: WorkflowEdge[];
|
||||
|
||||
// Node type metadata from backend
|
||||
nodeTypes: WorkflowNodeTypeMetadata[];
|
||||
nodeCategories: WorkflowNodeCategory[];
|
||||
|
||||
// Selection state
|
||||
selectedNodeId: string | null;
|
||||
selectedEdgeId: string | null;
|
||||
|
||||
// UI state
|
||||
isDirty: boolean;
|
||||
isSaving: boolean;
|
||||
isLoading: boolean;
|
||||
|
||||
// Undo/Redo history
|
||||
history: { nodes: WorkflowNode[]; edges: WorkflowEdge[] }[];
|
||||
historyIndex: number;
|
||||
|
||||
// Debug state
|
||||
debugMode: boolean;
|
||||
debugState: DebugState;
|
||||
debugExecutionId: string | null;
|
||||
currentNodeId: string | null;
|
||||
nodeExecutionResults: Record<string, NodeExecutionResult>;
|
||||
breakpoints: Record<string, boolean>;
|
||||
debugLogs: DebugLog[];
|
||||
debugContext: DebugContext;
|
||||
watchedVariables: string[];
|
||||
|
||||
// Actions
|
||||
setCurrentWorkflow: (workflow: Workflow | null) => void;
|
||||
setNodes: (nodes: WorkflowNode[]) => void;
|
||||
setEdges: (edges: WorkflowEdge[]) => void;
|
||||
setNodeTypes: (types: WorkflowNodeTypeMetadata[], categories: WorkflowNodeCategory[]) => void;
|
||||
|
||||
// Node operations
|
||||
onNodesChange: (changes: NodeChange<WorkflowNode>[]) => void;
|
||||
onEdgesChange: (changes: EdgeChange<WorkflowEdge>[]) => void;
|
||||
onConnect: (connection: Connection) => void;
|
||||
|
||||
addNode: (type: string, position: { x: number; y: number }) => void;
|
||||
updateNodeConfig: (nodeId: string, config: Record<string, unknown>) => void;
|
||||
updateNodeLabel: (nodeId: string, label: string) => void;
|
||||
deleteNode: (nodeId: string) => void;
|
||||
|
||||
// Edge operations
|
||||
deleteEdge: (edgeId: string) => void;
|
||||
updateEdgeCondition: (edgeId: string, condition: string) => void;
|
||||
|
||||
// Selection
|
||||
selectNode: (nodeId: string | null) => void;
|
||||
selectEdge: (edgeId: string | null) => void;
|
||||
clearSelection: () => void;
|
||||
|
||||
// State management
|
||||
setDirty: (dirty: boolean) => void;
|
||||
setSaving: (saving: boolean) => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
|
||||
// Undo/Redo
|
||||
undo: () => void;
|
||||
redo: () => void;
|
||||
pushHistory: () => void;
|
||||
|
||||
// Convert to/from API format
|
||||
toWorkflowDefinition: () => { nodes: WorkflowNodeDefinition[]; edges: WorkflowEdgeDefinition[] };
|
||||
fromWorkflowDefinition: (nodes: WorkflowNodeDefinition[], edges: WorkflowEdgeDefinition[]) => void;
|
||||
|
||||
// Reset
|
||||
reset: () => void;
|
||||
|
||||
// Debug actions
|
||||
setDebugMode: (enabled: boolean) => void;
|
||||
setDebugState: (state: DebugState) => void;
|
||||
setDebugExecutionId: (executionId: string | null) => void;
|
||||
setCurrentNodeId: (nodeId: string | null) => void;
|
||||
updateNodeExecutionResult: (nodeId: string, result: Partial<NodeExecutionResult>) => void;
|
||||
clearNodeExecutionResults: () => void;
|
||||
toggleBreakpoint: (nodeId: string) => void;
|
||||
clearBreakpoints: () => void;
|
||||
addDebugLog: (log: Omit<DebugLog, 'id' | 'timestamp'>) => void;
|
||||
clearDebugLogs: () => void;
|
||||
setDebugContext: (context: Partial<DebugContext>) => void;
|
||||
resetDebugContext: () => void;
|
||||
addWatchedVariable: (variable: string) => void;
|
||||
removeWatchedVariable: (variable: string) => void;
|
||||
clearWatchedVariables: () => void;
|
||||
resetDebugState: () => void;
|
||||
}
|
||||
|
||||
const generateUuidLikeId = () => {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 11)}`;
|
||||
};
|
||||
|
||||
const generateNodeId = () => `node_${generateUuidLikeId()}`;
|
||||
const generateEdgeId = () => `edge_${generateUuidLikeId()}`;
|
||||
|
||||
const defaultDebugContext: DebugContext = {
|
||||
messageContent: '',
|
||||
senderId: `user_${Date.now().toString(36)}`,
|
||||
senderName: '',
|
||||
platform: '',
|
||||
conversationId: `session_${Date.now().toString(36)}`,
|
||||
isGroup: false,
|
||||
customVariables: {},
|
||||
};
|
||||
|
||||
const generateLogId = () => `log_${generateUuidLikeId()}`;
|
||||
|
||||
export const useWorkflowStore = create<WorkflowState>((set, get) => ({
|
||||
// Initial state
|
||||
currentWorkflow: null,
|
||||
nodes: [],
|
||||
edges: [],
|
||||
nodeTypes: [],
|
||||
nodeCategories: [],
|
||||
selectedNodeId: null,
|
||||
selectedEdgeId: null,
|
||||
isDirty: false,
|
||||
isSaving: false,
|
||||
isLoading: false,
|
||||
history: [],
|
||||
historyIndex: -1,
|
||||
|
||||
// Debug initial state
|
||||
debugMode: false,
|
||||
debugState: 'idle',
|
||||
debugExecutionId: null,
|
||||
currentNodeId: null,
|
||||
nodeExecutionResults: {},
|
||||
breakpoints: {} as Record<string, boolean>,
|
||||
debugLogs: [],
|
||||
debugContext: { ...defaultDebugContext },
|
||||
watchedVariables: [],
|
||||
|
||||
// Setters
|
||||
setCurrentWorkflow: (workflow) => set({ currentWorkflow: workflow }),
|
||||
setNodes: (nodes) => set({ nodes, isDirty: true }),
|
||||
setEdges: (edges) => set({ edges, isDirty: true }),
|
||||
setNodeTypes: (types, categories) => set({ nodeTypes: types, nodeCategories: categories }),
|
||||
|
||||
// Node change handlers
|
||||
onNodesChange: (changes) => {
|
||||
set((state) => ({
|
||||
nodes: applyNodeChanges(changes, state.nodes) as WorkflowNode[],
|
||||
isDirty: true,
|
||||
}));
|
||||
},
|
||||
|
||||
onEdgesChange: (changes) => {
|
||||
set((state) => ({
|
||||
edges: applyEdgeChanges(changes, state.edges) as WorkflowEdge[],
|
||||
isDirty: true,
|
||||
}));
|
||||
},
|
||||
|
||||
onConnect: (connection) => {
|
||||
const newEdge: WorkflowEdge = {
|
||||
...connection,
|
||||
id: generateEdgeId(),
|
||||
type: 'smoothstep',
|
||||
} as WorkflowEdge;
|
||||
|
||||
set((state) => ({
|
||||
edges: addEdge(newEdge, state.edges) as WorkflowEdge[],
|
||||
isDirty: true,
|
||||
}));
|
||||
get().pushHistory();
|
||||
},
|
||||
|
||||
// Add new node
|
||||
addNode: (type, position) => {
|
||||
const { nodeTypes } = get();
|
||||
const nodeType = normalizeWorkflowNodeTypeMeta(
|
||||
type,
|
||||
nodeTypes.find((t) => t.type === type),
|
||||
);
|
||||
|
||||
const getNodeLabel = (
|
||||
nodeT: WorkflowNodeTypeMetadata | undefined,
|
||||
nodeType_str: string,
|
||||
): string => {
|
||||
return sharedGetNodeTypeLabel(nodeType_str, nodeT?.label);
|
||||
};
|
||||
|
||||
const newNode: WorkflowNode = {
|
||||
id: generateNodeId(),
|
||||
type: 'workflowNode',
|
||||
position,
|
||||
data: {
|
||||
label: getNodeLabel(nodeType, type),
|
||||
type,
|
||||
config: {},
|
||||
inputs: (nodeType.inputs || []).map((input) => ({
|
||||
name: input.name,
|
||||
type: input.type,
|
||||
label: input.label,
|
||||
})),
|
||||
outputs: (nodeType.outputs || []).map((output) => ({
|
||||
name: output.name,
|
||||
type: output.type,
|
||||
label: output.label,
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
set((state) => ({
|
||||
nodes: [...state.nodes, newNode],
|
||||
isDirty: true,
|
||||
}));
|
||||
get().pushHistory();
|
||||
},
|
||||
|
||||
// Update node config
|
||||
updateNodeConfig: (nodeId, config) => {
|
||||
set((state) => ({
|
||||
nodes: state.nodes.map((node) =>
|
||||
node.id === nodeId
|
||||
? { ...node, data: { ...node.data, config } }
|
||||
: node
|
||||
),
|
||||
isDirty: true,
|
||||
}));
|
||||
},
|
||||
|
||||
// Update node label
|
||||
updateNodeLabel: (nodeId, label) => {
|
||||
set((state) => ({
|
||||
nodes: state.nodes.map((node) =>
|
||||
node.id === nodeId
|
||||
? { ...node, data: { ...node.data, label } }
|
||||
: node
|
||||
),
|
||||
isDirty: true,
|
||||
}));
|
||||
},
|
||||
|
||||
// Delete node
|
||||
deleteNode: (nodeId) => {
|
||||
set((state) => ({
|
||||
nodes: state.nodes.filter((node) => node.id !== nodeId),
|
||||
edges: state.edges.filter(
|
||||
(edge) => edge.source !== nodeId && edge.target !== nodeId
|
||||
),
|
||||
selectedNodeId: state.selectedNodeId === nodeId ? null : state.selectedNodeId,
|
||||
isDirty: true,
|
||||
}));
|
||||
get().pushHistory();
|
||||
},
|
||||
|
||||
// Delete edge
|
||||
deleteEdge: (edgeId) => {
|
||||
set((state) => ({
|
||||
edges: state.edges.filter((edge) => edge.id !== edgeId),
|
||||
selectedEdgeId: state.selectedEdgeId === edgeId ? null : state.selectedEdgeId,
|
||||
isDirty: true,
|
||||
}));
|
||||
get().pushHistory();
|
||||
},
|
||||
|
||||
// Update edge condition
|
||||
updateEdgeCondition: (edgeId, condition) => {
|
||||
set((state) => ({
|
||||
edges: state.edges.map((edge) =>
|
||||
edge.id === edgeId
|
||||
? { ...edge, data: { ...edge.data, condition } }
|
||||
: edge
|
||||
),
|
||||
isDirty: true,
|
||||
}));
|
||||
},
|
||||
|
||||
// Selection
|
||||
selectNode: (nodeId) => set({ selectedNodeId: nodeId, selectedEdgeId: null }),
|
||||
selectEdge: (edgeId) => set({ selectedEdgeId: edgeId, selectedNodeId: null }),
|
||||
clearSelection: () => set({ selectedNodeId: null, selectedEdgeId: null }),
|
||||
|
||||
// State management
|
||||
setDirty: (dirty) => set({ isDirty: dirty }),
|
||||
setSaving: (saving) => set({ isSaving: saving }),
|
||||
setLoading: (loading) => set({ isLoading: loading }),
|
||||
|
||||
// Undo
|
||||
undo: () => {
|
||||
const { history, historyIndex } = get();
|
||||
if (historyIndex > 0) {
|
||||
const newIndex = historyIndex - 1;
|
||||
const { nodes, edges } = history[newIndex];
|
||||
set({ nodes, edges, historyIndex: newIndex, isDirty: true });
|
||||
}
|
||||
},
|
||||
|
||||
// Redo
|
||||
redo: () => {
|
||||
const { history, historyIndex } = get();
|
||||
if (historyIndex < history.length - 1) {
|
||||
const newIndex = historyIndex + 1;
|
||||
const { nodes, edges } = history[newIndex];
|
||||
set({ nodes, edges, historyIndex: newIndex, isDirty: true });
|
||||
}
|
||||
},
|
||||
|
||||
// Push current state to history
|
||||
pushHistory: () => {
|
||||
const { nodes, edges, history, historyIndex } = get();
|
||||
const newHistory = history.slice(0, historyIndex + 1);
|
||||
newHistory.push({ nodes: [...nodes], edges: [...edges] });
|
||||
|
||||
// Keep history limited to 50 entries
|
||||
if (newHistory.length > 50) {
|
||||
newHistory.shift();
|
||||
}
|
||||
|
||||
set({ history: newHistory, historyIndex: newHistory.length - 1 });
|
||||
},
|
||||
|
||||
// Convert to API format
|
||||
toWorkflowDefinition: () => {
|
||||
const { nodes, edges } = get();
|
||||
|
||||
const workflowNodes: WorkflowNodeDefinition[] = nodes.map((node) => ({
|
||||
id: node.id,
|
||||
type: node.data.type,
|
||||
position: node.position,
|
||||
config: node.data.config,
|
||||
label: node.data.label,
|
||||
inputs: node.data.inputs,
|
||||
outputs: node.data.outputs,
|
||||
}));
|
||||
|
||||
const workflowEdges: WorkflowEdgeDefinition[] = edges.map((edge) => ({
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
source_port: edge.sourceHandle || undefined,
|
||||
target_port: edge.targetHandle || undefined,
|
||||
label: edge.data?.label,
|
||||
condition: edge.data?.condition,
|
||||
}));
|
||||
|
||||
return { nodes: workflowNodes, edges: workflowEdges };
|
||||
},
|
||||
|
||||
// Load from API format
|
||||
fromWorkflowDefinition: (apiNodes, apiEdges) => {
|
||||
const nodes: WorkflowNode[] = apiNodes.map((node) => ({
|
||||
id: node.id,
|
||||
type: 'workflowNode',
|
||||
position: node.position,
|
||||
data: {
|
||||
label: node.label || node.type,
|
||||
type: node.type,
|
||||
config: node.config,
|
||||
inputs: node.inputs,
|
||||
outputs: node.outputs,
|
||||
},
|
||||
}));
|
||||
|
||||
const edges: WorkflowEdge[] = apiEdges.map((edge) => ({
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
sourceHandle: edge.source_port,
|
||||
targetHandle: edge.target_port,
|
||||
type: 'smoothstep',
|
||||
data: {
|
||||
label: edge.label,
|
||||
condition: edge.condition,
|
||||
},
|
||||
}));
|
||||
|
||||
set({ nodes, edges, isDirty: false });
|
||||
get().pushHistory();
|
||||
},
|
||||
|
||||
// Reset store
|
||||
reset: () => set({
|
||||
currentWorkflow: null,
|
||||
nodes: [],
|
||||
edges: [],
|
||||
selectedNodeId: null,
|
||||
selectedEdgeId: null,
|
||||
isDirty: false,
|
||||
isSaving: false,
|
||||
isLoading: false,
|
||||
history: [],
|
||||
historyIndex: -1,
|
||||
// Reset debug state
|
||||
debugMode: false,
|
||||
debugState: 'idle',
|
||||
debugExecutionId: null,
|
||||
currentNodeId: null,
|
||||
nodeExecutionResults: {},
|
||||
breakpoints: {} as Record<string, boolean>,
|
||||
debugLogs: [],
|
||||
debugContext: { ...defaultDebugContext },
|
||||
watchedVariables: [],
|
||||
}),
|
||||
|
||||
// Debug actions
|
||||
setDebugMode: (enabled) => set({ debugMode: enabled }),
|
||||
|
||||
setDebugState: (state) => set({ debugState: state }),
|
||||
|
||||
setDebugExecutionId: (executionId) => set({ debugExecutionId: executionId }),
|
||||
|
||||
setCurrentNodeId: (nodeId) => set({ currentNodeId: nodeId }),
|
||||
|
||||
updateNodeExecutionResult: (nodeId, result) => {
|
||||
set((state) => ({
|
||||
nodeExecutionResults: {
|
||||
...state.nodeExecutionResults,
|
||||
[nodeId]: {
|
||||
...(state.nodeExecutionResults[nodeId] || { nodeId, status: 'pending' }),
|
||||
...result,
|
||||
},
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
clearNodeExecutionResults: () => set({ nodeExecutionResults: {} }),
|
||||
|
||||
toggleBreakpoint: (nodeId) => {
|
||||
set((state) => {
|
||||
const newBreakpoints = { ...state.breakpoints };
|
||||
if (newBreakpoints[nodeId]) {
|
||||
delete newBreakpoints[nodeId];
|
||||
} else {
|
||||
newBreakpoints[nodeId] = true;
|
||||
}
|
||||
return { breakpoints: newBreakpoints };
|
||||
});
|
||||
},
|
||||
|
||||
clearBreakpoints: () => set({ breakpoints: {} as Record<string, boolean> }),
|
||||
|
||||
addDebugLog: (log) => {
|
||||
set((state) => ({
|
||||
debugLogs: [
|
||||
...state.debugLogs,
|
||||
{
|
||||
...log,
|
||||
id: generateLogId(),
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
].slice(-500), // Keep only last 500 logs
|
||||
}));
|
||||
},
|
||||
|
||||
clearDebugLogs: () => set({ debugLogs: [] }),
|
||||
|
||||
setDebugContext: (context) => {
|
||||
set((state) => ({
|
||||
debugContext: { ...state.debugContext, ...context },
|
||||
}));
|
||||
},
|
||||
|
||||
resetDebugContext: () => set({
|
||||
debugContext: {
|
||||
messageContent: '',
|
||||
senderId: `user_${Date.now().toString(36)}`,
|
||||
senderName: '',
|
||||
platform: '',
|
||||
conversationId: `session_${Date.now().toString(36)}`,
|
||||
isGroup: false,
|
||||
customVariables: {},
|
||||
},
|
||||
}),
|
||||
|
||||
addWatchedVariable: (variable) => {
|
||||
set((state) => ({
|
||||
watchedVariables: state.watchedVariables.includes(variable)
|
||||
? state.watchedVariables
|
||||
: [...state.watchedVariables, variable],
|
||||
}));
|
||||
},
|
||||
|
||||
removeWatchedVariable: (variable) => {
|
||||
set((state) => ({
|
||||
watchedVariables: state.watchedVariables.filter((v) => v !== variable),
|
||||
}));
|
||||
},
|
||||
|
||||
clearWatchedVariables: () => set({ watchedVariables: [] }),
|
||||
|
||||
resetDebugState: () => set({
|
||||
debugState: 'idle',
|
||||
debugExecutionId: null,
|
||||
currentNodeId: null,
|
||||
nodeExecutionResults: {},
|
||||
debugLogs: [],
|
||||
}),
|
||||
}));
|
||||
Reference in New Issue
Block a user