后端没修完版

This commit is contained in:
Typer_Body
2026-05-05 15:08:04 +08:00
parent a8fba46040
commit e7c9bc69d3
156 changed files with 34633 additions and 2149 deletions

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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)}
/>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -0,0 +1 @@
export { default as WorkflowDebugger } from './WorkflowDebugger';

View File

@@ -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>
);
}

View File

@@ -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;
}

View File

@@ -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>
);
}

View File

@@ -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);

View File

@@ -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';

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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 });
}

View File

@@ -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(' ');
}

View File

@@ -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;
}

View File

@@ -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,
};
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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: [],
}),
}));