feat: 新增技能编排引擎和工作流构建器组件
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
refactor: 统一Hands系统常量到单个源文件 refactor: 更新Hands中文名称和描述 fix: 修复技能市场在连接状态变化时重新加载 fix: 修复身份变更提案的错误处理逻辑 docs: 更新多个功能文档的验证状态和实现位置 docs: 更新Hands系统文档 test: 添加测试文件验证工作区路径
This commit is contained in:
@@ -30,6 +30,30 @@ import {
|
||||
import { useChatStore } from '../store/chatStore';
|
||||
import { Button, Badge } from './ui';
|
||||
|
||||
// === Error Parsing Utility ===
|
||||
|
||||
type ProposalOperation = 'approval' | 'rejection' | 'restore';
|
||||
|
||||
function parseProposalError(err: unknown, operation: ProposalOperation): string {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
|
||||
if (errorMessage.includes('not found') || errorMessage.includes('不存在')) {
|
||||
return '提案不存在或已被处理,请刷新页面';
|
||||
}
|
||||
if (errorMessage.includes('not pending') || errorMessage.includes('已处理')) {
|
||||
return '该提案已被处理,请刷新页面';
|
||||
}
|
||||
if (errorMessage.includes('network') || errorMessage.includes('fetch') || errorMessage.includes('网络')) {
|
||||
return '网络连接失败,请检查网络后重试';
|
||||
}
|
||||
if (errorMessage.includes('timeout') || errorMessage.includes('超时')) {
|
||||
return '操作超时,请重试';
|
||||
}
|
||||
|
||||
const operationName = operation === 'approval' ? '审批' : operation === 'rejection' ? '拒绝' : '恢复';
|
||||
return `${operationName}失败: ${errorMessage}`;
|
||||
}
|
||||
|
||||
// === Diff View Component ===
|
||||
|
||||
function DiffView({
|
||||
@@ -331,8 +355,7 @@ export function IdentityChangeProposalPanel() {
|
||||
setSnapshots(agentSnapshots);
|
||||
} catch (err) {
|
||||
console.error('[IdentityChangeProposal] Failed to approve:', err);
|
||||
const message = err instanceof Error ? err.message : '审批失败,请重试';
|
||||
setError(`审批失败: ${message}`);
|
||||
setError(parseProposalError(err, 'approval'));
|
||||
} finally {
|
||||
setProcessingId(null);
|
||||
}
|
||||
@@ -349,8 +372,7 @@ export function IdentityChangeProposalPanel() {
|
||||
setProposals(pendingProposals);
|
||||
} catch (err) {
|
||||
console.error('[IdentityChangeProposal] Failed to reject:', err);
|
||||
const message = err instanceof Error ? err.message : '拒绝失败,请重试';
|
||||
setError(`拒绝失败: ${message}`);
|
||||
setError(parseProposalError(err, 'rejection'));
|
||||
} finally {
|
||||
setProcessingId(null);
|
||||
}
|
||||
@@ -367,8 +389,7 @@ export function IdentityChangeProposalPanel() {
|
||||
setSnapshots(agentSnapshots);
|
||||
} catch (err) {
|
||||
console.error('[IdentityChangeProposal] Failed to restore:', err);
|
||||
const message = err instanceof Error ? err.message : '恢复失败,请重试';
|
||||
setError(`恢复失败: ${message}`);
|
||||
setError(parseProposalError(err, 'restore'));
|
||||
} finally {
|
||||
setProcessingId(null);
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ export function RightPanel() {
|
||||
() => clones.find((clone) => clone.id === currentAgent?.id),
|
||||
[clones, currentAgent?.id]
|
||||
);
|
||||
const focusAreas = selectedClone?.scenarios?.length ? selectedClone.scenarios : ['coding', 'research'];
|
||||
const focusAreas = selectedClone?.scenarios?.length ? selectedClone.scenarios : ['coding', 'writing', 'research', 'product', 'data'];
|
||||
const bootstrapFiles = selectedClone?.bootstrapFiles || [];
|
||||
const gatewayUrl = quickConfig.gatewayUrl || getStoredGatewayUrl();
|
||||
|
||||
@@ -172,8 +172,8 @@ export function RightPanel() {
|
||||
const assistantMsgCount = messages.filter(m => m.role === 'assistant').length;
|
||||
const toolCallCount = messages.filter(m => m.role === 'tool').length;
|
||||
const runtimeSummary = connected ? '已连接' : connectionState === 'connecting' ? '连接中...' : connectionState === 'reconnecting' ? '重连中...' : '未连接';
|
||||
const userNameDisplay = selectedClone?.userName || quickConfig.userName || '未设置';
|
||||
const userAddressing = selectedClone?.nickname || selectedClone?.userName || quickConfig.userName || '未设置';
|
||||
const userNameDisplay = selectedClone?.userName || quickConfig.userName || 'User';
|
||||
const userAddressing = selectedClone?.nickname || selectedClone?.userName || quickConfig.userName || 'User';
|
||||
const localTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone || '系统时区';
|
||||
|
||||
// Extract code blocks from all messages (both from codeBlocks property and content parsing)
|
||||
@@ -342,23 +342,27 @@ export function RightPanel() {
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-cyan-400 to-blue-500 flex items-center justify-center text-white text-lg font-semibold">
|
||||
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-orange-400 to-red-500 flex items-center justify-center text-white text-lg font-semibold">
|
||||
{selectedClone?.emoji ? (
|
||||
<span className="text-2xl">{selectedClone.emoji}</span>
|
||||
) : (
|
||||
<span>{(selectedClone?.nickname || currentAgent?.name || 'Z').slice(0, 1)}</span>
|
||||
<span>🦞</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
||||
{selectedClone?.name || currentAgent?.name || 'ZCLAW'}
|
||||
{selectedClone?.personality && (
|
||||
{selectedClone?.name || currentAgent?.name || '全能助手'}
|
||||
{selectedClone?.personality ? (
|
||||
<Badge variant="default" className="text-xs ml-1">
|
||||
{getPersonalityById(selectedClone.personality)?.label || selectedClone.personality}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="default" className="text-xs ml-1">
|
||||
友好亲切
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">{selectedClone?.role || 'AI coworker'}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">{selectedClone?.role || '全能型 AI 助手'}</div>
|
||||
</div>
|
||||
</div>
|
||||
{selectedClone ? (
|
||||
@@ -410,10 +414,10 @@ export function RightPanel() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 text-sm">
|
||||
<AgentRow label="Role" value={selectedClone?.role || '-'} />
|
||||
<AgentRow label="Nickname" value={selectedClone?.nickname || '-'} />
|
||||
<AgentRow label="Role" value={selectedClone?.role || '全能型 AI 助手'} />
|
||||
<AgentRow label="Nickname" value={selectedClone?.nickname || '小龙'} />
|
||||
<AgentRow label="Model" value={selectedClone?.model || currentModel} />
|
||||
<AgentRow label="Emoji" value={selectedClone?.nickname?.slice(0, 1) || '🦞'} />
|
||||
<AgentRow label="Emoji" value={selectedClone?.emoji || '🦞'} />
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { useConfigStore } from '../store/configStore';
|
||||
import { useConnectionStore } from '../store/connectionStore';
|
||||
import {
|
||||
adaptSkillsCatalog,
|
||||
type SkillDisplay,
|
||||
@@ -250,6 +251,9 @@ export function SkillMarket({
|
||||
const loadSkillsCatalog = useConfigStore((s) => s.loadSkillsCatalog);
|
||||
const updateSkill = useConfigStore((s) => s.updateSkill);
|
||||
|
||||
// Watch connection state to reload skills when connected
|
||||
const connectionState = useConnectionStore((s) => s.connectionState);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [categoryFilter, setCategoryFilter] = useState<CategoryFilter>('all');
|
||||
const [expandedSkillId, setExpandedSkillId] = useState<string | null>(null);
|
||||
@@ -258,10 +262,12 @@ export function SkillMarket({
|
||||
// Adapt skills to display format
|
||||
const skills = useMemo(() => adaptSkillsCatalog(skillsCatalog), [skillsCatalog]);
|
||||
|
||||
// Load skills on mount
|
||||
// Load skills on mount and when connection state changes to 'connected'
|
||||
useEffect(() => {
|
||||
loadSkillsCatalog();
|
||||
}, [loadSkillsCatalog]);
|
||||
if (connectionState === 'connected') {
|
||||
loadSkillsCatalog();
|
||||
}
|
||||
}, [loadSkillsCatalog, connectionState]);
|
||||
|
||||
// Filter skills
|
||||
const filteredSkills = useMemo(() => {
|
||||
|
||||
92
desktop/src/components/WorkflowBuilder/NodePalette.tsx
Normal file
92
desktop/src/components/WorkflowBuilder/NodePalette.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Node Palette Component
|
||||
*
|
||||
* Draggable palette of available node types.
|
||||
*/
|
||||
|
||||
import React, { DragEvent } from 'react';
|
||||
import type { NodePaletteItem, NodeCategory } from '../../lib/workflow-builder/types';
|
||||
|
||||
interface NodePaletteProps {
|
||||
categories: Record<NodeCategory, NodePaletteItem[]>;
|
||||
onDragStart: (type: string) => void;
|
||||
onDragEnd: () => void;
|
||||
}
|
||||
|
||||
const categoryLabels: Record<NodeCategory, { label: string; color: string }> = {
|
||||
input: { label: 'Input', color: 'emerald' },
|
||||
ai: { label: 'AI & Skills', color: 'violet' },
|
||||
action: { label: 'Actions', color: 'amber' },
|
||||
control: { label: 'Control Flow', color: 'orange' },
|
||||
output: { label: 'Output', color: 'blue' },
|
||||
};
|
||||
|
||||
export function NodePalette({ categories, onDragStart, onDragEnd }: NodePaletteProps) {
|
||||
const handleDragStart = (event: DragEvent, type: string) => {
|
||||
event.dataTransfer.setData('application/reactflow', type);
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
onDragStart(type);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
onDragEnd();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-64 bg-white border-r border-gray-200 overflow-y-auto">
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<h2 className="font-semibold text-gray-800">Nodes</h2>
|
||||
<p className="text-sm text-gray-500">Drag nodes to canvas</p>
|
||||
</div>
|
||||
|
||||
<div className="p-2">
|
||||
{(Object.keys(categories) as NodeCategory[]).map((category) => {
|
||||
const items = categories[category];
|
||||
if (items.length === 0) return null;
|
||||
|
||||
const { label, color } = categoryLabels[category];
|
||||
|
||||
return (
|
||||
<div key={category} className="mb-4">
|
||||
<h3
|
||||
className={`text-sm font-medium text-${color}-700 mb-2 px-2`}
|
||||
>
|
||||
{label}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-1">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.type}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, item.type)}
|
||||
onDragEnd={handleDragEnd}
|
||||
className={`
|
||||
flex items-center gap-3 px-3 py-2 rounded-lg
|
||||
bg-gray-50 hover:bg-gray-100 cursor-grab
|
||||
border border-transparent hover:border-gray-200
|
||||
transition-all duration-150
|
||||
active:cursor-grabbing
|
||||
`}
|
||||
>
|
||||
<span className="text-lg">{item.icon}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-gray-700 text-sm">
|
||||
{item.label}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 truncate">
|
||||
{item.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NodePalette;
|
||||
295
desktop/src/components/WorkflowBuilder/PropertyPanel.tsx
Normal file
295
desktop/src/components/WorkflowBuilder/PropertyPanel.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* Property Panel Component
|
||||
*
|
||||
* Panel for editing node properties.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import type { WorkflowNodeData } from '../../lib/workflow-builder/types';
|
||||
|
||||
interface PropertyPanelProps {
|
||||
nodeId: string;
|
||||
nodeData: WorkflowNodeData | undefined;
|
||||
onUpdate: (data: Partial<WorkflowNodeData>) => void;
|
||||
onDelete: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function PropertyPanel({
|
||||
nodeId,
|
||||
nodeData,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onClose,
|
||||
}: PropertyPanelProps) {
|
||||
const [localData, setLocalData] = useState<Partial<WorkflowNodeData>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (nodeData) {
|
||||
setLocalData(nodeData);
|
||||
}
|
||||
}, [nodeData]);
|
||||
|
||||
if (!nodeData) return null;
|
||||
|
||||
const handleChange = (field: string, value: unknown) => {
|
||||
const updated = { ...localData, [field]: value };
|
||||
setLocalData(updated);
|
||||
onUpdate({ [field]: value } as Partial<WorkflowNodeData>);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-80 bg-white border-l border-gray-200 overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200">
|
||||
<h2 className="font-semibold text-gray-800">Properties</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Common Fields */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Label
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={localData.label || ''}
|
||||
onChange={(e) => handleChange('label', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Type-specific Fields */}
|
||||
{renderTypeSpecificFields(nodeData.type, localData, handleChange)}
|
||||
|
||||
{/* Delete Button */}
|
||||
<div className="pt-4 border-t border-gray-200">
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="w-full px-4 py-2 text-red-600 bg-red-50 border border-red-200 rounded-lg hover:bg-red-100"
|
||||
>
|
||||
Delete Node
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderTypeSpecificFields(
|
||||
type: string,
|
||||
data: Partial<WorkflowNodeData>,
|
||||
onChange: (field: string, value: unknown) => void
|
||||
) {
|
||||
switch (type) {
|
||||
case 'input':
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Variable Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={(data as any).variableName || ''}
|
||||
onChange={(e) => onChange('variableName', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Default Value
|
||||
</label>
|
||||
<textarea
|
||||
value={(data as any).defaultValue || ''}
|
||||
onChange={(e) => {
|
||||
try {
|
||||
const parsed = JSON.parse(e.target.value);
|
||||
onChange('defaultValue', parsed);
|
||||
} catch {
|
||||
onChange('defaultValue', e.target.value);
|
||||
}
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg font-mono text-sm"
|
||||
rows={3}
|
||||
placeholder="JSON or string value"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
case 'llm':
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Template
|
||||
</label>
|
||||
<textarea
|
||||
value={(data as any).template || ''}
|
||||
onChange={(e) => onChange('template', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg font-mono text-sm"
|
||||
rows={6}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Model Override
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={(data as any).model || ''}
|
||||
onChange={(e) => onChange('model', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||
placeholder="e.g., gpt-4"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Temperature
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="2"
|
||||
step="0.1"
|
||||
value={(data as any).temperature ?? ''}
|
||||
onChange={(e) => onChange('temperature', parseFloat(e.target.value))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(data as any).jsonMode || false}
|
||||
onChange={(e) => onChange('jsonMode', e.target.checked)}
|
||||
className="w-4 h-4 text-blue-600 rounded"
|
||||
/>
|
||||
<label className="text-sm text-gray-700">JSON Mode</label>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
case 'skill':
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Skill ID
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={(data as any).skillId || ''}
|
||||
onChange={(e) => onChange('skillId', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Input Mappings (JSON)
|
||||
</label>
|
||||
<textarea
|
||||
value={JSON.stringify((data as any).inputMappings || {}, null, 2)}
|
||||
onChange={(e) => {
|
||||
try {
|
||||
const parsed = JSON.parse(e.target.value);
|
||||
onChange('inputMappings', parsed);
|
||||
} catch {
|
||||
// Invalid JSON, ignore
|
||||
}
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg font-mono text-sm"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
case 'hand':
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Hand ID
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={(data as any).handId || ''}
|
||||
onChange={(e) => onChange('handId', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Action
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={(data as any).action || ''}
|
||||
onChange={(e) => onChange('action', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
case 'export':
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Formats
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{['json', 'markdown', 'html', 'pptx', 'pdf'].map((format) => (
|
||||
<label key={format} className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={((data as any).formats || []).includes(format)}
|
||||
onChange={(e) => {
|
||||
const formats = (data as any).formats || [];
|
||||
if (e.target.checked) {
|
||||
onChange('formats', [...formats, format]);
|
||||
} else {
|
||||
onChange('formats', formats.filter((f: string) => f !== format));
|
||||
}
|
||||
}}
|
||||
className="w-4 h-4 text-blue-600 rounded"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 capitalize">{format}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Output Directory
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={(data as any).outputDir || ''}
|
||||
onChange={(e) => onChange('outputDir', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||
placeholder="./output"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="text-sm text-gray-500 italic">
|
||||
No additional properties for this node type.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default PropertyPanel;
|
||||
324
desktop/src/components/WorkflowBuilder/WorkflowBuilder.tsx
Normal file
324
desktop/src/components/WorkflowBuilder/WorkflowBuilder.tsx
Normal file
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* Workflow Builder Component
|
||||
*
|
||||
* Visual workflow editor using React Flow for creating and editing
|
||||
* Pipeline DSL configurations.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useRef, useEffect } from 'react';
|
||||
import {
|
||||
ReactFlow,
|
||||
Controls,
|
||||
Background,
|
||||
MiniMap,
|
||||
BackgroundVariant,
|
||||
Connection,
|
||||
addEdge,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
Node,
|
||||
Edge,
|
||||
NodeTypes,
|
||||
Panel,
|
||||
ReactFlowProvider,
|
||||
useReactFlow,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
|
||||
import { useWorkflowBuilderStore, nodePaletteItems, paletteCategories } from '../../store/workflowBuilderStore';
|
||||
import type { WorkflowNodeType, WorkflowNodeData } from '../../lib/workflow-builder/types';
|
||||
import { validateCanvas } from '../../lib/workflow-builder/yaml-converter';
|
||||
|
||||
// Import custom node components
|
||||
import { InputNode } from './nodes/InputNode';
|
||||
import { LlmNode } from './nodes/LlmNode';
|
||||
import { SkillNode } from './nodes/SkillNode';
|
||||
import { HandNode } from './nodes/HandNode';
|
||||
import { ConditionNode } from './nodes/ConditionNode';
|
||||
import { ParallelNode } from './nodes/ParallelNode';
|
||||
import { ExportNode } from './nodes/ExportNode';
|
||||
import { HttpNode } from './nodes/HttpNode';
|
||||
import { OrchestrationNode } from './nodes/OrchestrationNode';
|
||||
|
||||
import { NodePalette } from './NodePalette';
|
||||
import { PropertyPanel } from './PropertyPanel';
|
||||
import { WorkflowToolbar } from './WorkflowToolbar';
|
||||
|
||||
// =============================================================================
|
||||
// Node Types Configuration
|
||||
// =============================================================================
|
||||
|
||||
const nodeTypes: NodeTypes = {
|
||||
input: InputNode,
|
||||
llm: LlmNode,
|
||||
skill: SkillNode,
|
||||
hand: HandNode,
|
||||
condition: ConditionNode,
|
||||
parallel: ParallelNode,
|
||||
export: ExportNode,
|
||||
http: HttpNode,
|
||||
orchestration: OrchestrationNode,
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Main Component
|
||||
// =============================================================================
|
||||
|
||||
export function WorkflowBuilderInternal() {
|
||||
const reactFlowWrapper = useRef<HTMLDivElement>(null);
|
||||
const { screenToFlowPosition, fitView } = useReactFlow();
|
||||
|
||||
const {
|
||||
canvas,
|
||||
isDirty,
|
||||
selectedNodeId,
|
||||
validation,
|
||||
addNode,
|
||||
updateNode,
|
||||
deleteNode,
|
||||
addEdge: addStoreEdge,
|
||||
selectNode,
|
||||
saveWorkflow,
|
||||
validate,
|
||||
setDragging,
|
||||
} = useWorkflowBuilderStore();
|
||||
|
||||
// Local state for React Flow
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
|
||||
|
||||
// Sync canvas state with React Flow
|
||||
useEffect(() => {
|
||||
if (canvas) {
|
||||
setNodes(canvas.nodes.map(n => ({
|
||||
id: n.id,
|
||||
type: n.type,
|
||||
position: n.position,
|
||||
data: n.data,
|
||||
})));
|
||||
setEdges(canvas.edges.map(e => ({
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
type: e.type || 'default',
|
||||
animated: true,
|
||||
})));
|
||||
} else {
|
||||
setNodes([]);
|
||||
setEdges([]);
|
||||
}
|
||||
}, [canvas?.id]);
|
||||
|
||||
// Handle node changes (position, selection)
|
||||
const handleNodesChange = useCallback(
|
||||
(changes) => {
|
||||
onNodesChange(changes);
|
||||
|
||||
// Sync position changes back to store
|
||||
for (const change of changes) {
|
||||
if (change.type === 'position' && change.position) {
|
||||
const node = nodes.find(n => n.id === change.id);
|
||||
if (node) {
|
||||
// Position updates are handled by React Flow internally
|
||||
}
|
||||
}
|
||||
if (change.type === 'select') {
|
||||
selectNode(change.selected ? change.id : null);
|
||||
}
|
||||
}
|
||||
},
|
||||
[onNodesChange, nodes, selectNode]
|
||||
);
|
||||
|
||||
// Handle edge changes
|
||||
const handleEdgesChange = useCallback(
|
||||
(changes) => {
|
||||
onEdgesChange(changes);
|
||||
},
|
||||
[onEdgesChange]
|
||||
);
|
||||
|
||||
// Handle new connections
|
||||
const onConnect = useCallback(
|
||||
(connection: Connection) => {
|
||||
if (connection.source && connection.target) {
|
||||
addStoreEdge(connection.source, connection.target);
|
||||
setEdges((eds) =>
|
||||
addEdge(
|
||||
{
|
||||
...connection,
|
||||
type: 'default',
|
||||
animated: true,
|
||||
},
|
||||
eds
|
||||
)
|
||||
);
|
||||
}
|
||||
},
|
||||
[addStoreEdge, setEdges]
|
||||
);
|
||||
|
||||
// Handle node click
|
||||
const onNodeClick = useCallback(
|
||||
(_event: React.MouseEvent, node: Node) => {
|
||||
selectNode(node.id);
|
||||
},
|
||||
[selectNode]
|
||||
);
|
||||
|
||||
// Handle pane click (deselect)
|
||||
const onPaneClick = useCallback(() => {
|
||||
selectNode(null);
|
||||
}, [selectNode]);
|
||||
|
||||
// Handle drag over for palette items
|
||||
const onDragOver = useCallback((event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
}, []);
|
||||
|
||||
// Handle drop from palette
|
||||
const onDrop = useCallback(
|
||||
(event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
const type = event.dataTransfer.getData('application/reactflow') as WorkflowNodeType;
|
||||
if (!type) return;
|
||||
|
||||
const position = screenToFlowPosition({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
|
||||
addNode(type, position);
|
||||
},
|
||||
[screenToFlowPosition, addNode]
|
||||
);
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// Delete selected node
|
||||
if ((event.key === 'Delete' || event.key === 'Backspace') && selectedNodeId) {
|
||||
deleteNode(selectedNodeId);
|
||||
}
|
||||
|
||||
// Save workflow
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 's') {
|
||||
event.preventDefault();
|
||||
saveWorkflow();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [selectedNodeId, deleteNode, saveWorkflow]);
|
||||
|
||||
if (!canvas) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full bg-gray-50">
|
||||
<div className="text-center">
|
||||
<p className="text-gray-500 mb-4">No workflow loaded</p>
|
||||
<button
|
||||
onClick={() => useWorkflowBuilderStore.getState().createNewWorkflow('New Workflow')}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
|
||||
>
|
||||
Create New Workflow
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
{/* Node Palette */}
|
||||
<NodePalette
|
||||
categories={paletteCategories}
|
||||
onDragStart={(type) => {
|
||||
setDragging(true);
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
setDragging(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Canvas */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
<WorkflowToolbar
|
||||
workflowName={canvas.name}
|
||||
isDirty={isDirty}
|
||||
validation={validation}
|
||||
onSave={saveWorkflow}
|
||||
onValidate={validate}
|
||||
/>
|
||||
|
||||
<div ref={reactFlowWrapper} className="flex-1">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={handleNodesChange}
|
||||
onEdgesChange={handleEdgesChange}
|
||||
onConnect={onConnect}
|
||||
onNodeClick={onNodeClick}
|
||||
onPaneClick={onPaneClick}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
snapToGrid
|
||||
snapGrid={[15, 15]}
|
||||
defaultEdgeOptions={{
|
||||
animated: true,
|
||||
type: 'smoothstep',
|
||||
}}
|
||||
>
|
||||
<Controls />
|
||||
<MiniMap
|
||||
nodeColor={(node) => {
|
||||
switch (node.type) {
|
||||
case 'input':
|
||||
return '#10b981';
|
||||
case 'llm':
|
||||
return '#8b5cf6';
|
||||
case 'skill':
|
||||
return '#f59e0b';
|
||||
case 'hand':
|
||||
return '#ef4444';
|
||||
case 'export':
|
||||
return '#3b82f6';
|
||||
default:
|
||||
return '#6b7280';
|
||||
}
|
||||
}}
|
||||
maskColor="rgba(0, 0, 0, 0.1)"
|
||||
/>
|
||||
<Background variant={BackgroundVariant.Dots} gap={20} size={1} />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Property Panel */}
|
||||
{selectedNodeId && (
|
||||
<PropertyPanel
|
||||
nodeId={selectedNodeId}
|
||||
nodeData={nodes.find(n => n.id === selectedNodeId)?.data as WorkflowNodeData}
|
||||
onUpdate={(data) => updateNode(selectedNodeId, data)}
|
||||
onDelete={() => deleteNode(selectedNodeId)}
|
||||
onClose={() => selectNode(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Export with provider
|
||||
export function WorkflowBuilder() {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<WorkflowBuilderInternal />
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default WorkflowBuilder;
|
||||
166
desktop/src/components/WorkflowBuilder/WorkflowToolbar.tsx
Normal file
166
desktop/src/components/WorkflowBuilder/WorkflowToolbar.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Workflow Toolbar Component
|
||||
*
|
||||
* Toolbar with actions for the workflow builder.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import type { ValidationResult } from '../../lib/workflow-builder/types';
|
||||
import { canvasToYaml } from '../../lib/workflow-builder/yaml-converter';
|
||||
import { useWorkflowBuilderStore } from '../../store/workflowBuilderStore';
|
||||
|
||||
interface WorkflowToolbarProps {
|
||||
workflowName: string;
|
||||
isDirty: boolean;
|
||||
validation: ValidationResult | null;
|
||||
onSave: () => void;
|
||||
onValidate: () => ValidationResult;
|
||||
}
|
||||
|
||||
export function WorkflowToolbar({
|
||||
workflowName,
|
||||
isDirty,
|
||||
validation,
|
||||
onSave,
|
||||
onValidate,
|
||||
}: WorkflowToolbarProps) {
|
||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||
const [yamlPreview, setYamlPreview] = useState('');
|
||||
const canvas = useWorkflowBuilderStore(state => state.canvas);
|
||||
|
||||
const handlePreviewYaml = () => {
|
||||
if (canvas) {
|
||||
const yaml = canvasToYaml(canvas);
|
||||
setYamlPreview(yaml);
|
||||
setIsPreviewOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyYaml = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(yamlPreview);
|
||||
alert('YAML copied to clipboard!');
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadYaml = () => {
|
||||
const blob = new Blob([yamlPreview], { type: 'text/yaml' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${workflowName.replace(/\s+/g, '-').toLowerCase()}.yaml`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-white border-b border-gray-200">
|
||||
{/* Left: Workflow Name */}
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="font-semibold text-gray-800">{workflowName}</h1>
|
||||
{isDirty && (
|
||||
<span className="text-sm text-amber-600 flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-amber-400 rounded-full animate-pulse" />
|
||||
Unsaved
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Center: Validation Status */}
|
||||
{validation && (
|
||||
<div className="flex items-center gap-2">
|
||||
{validation.valid ? (
|
||||
<span className="text-sm text-green-600 flex items-center gap-1">
|
||||
✓ Valid
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-red-600 flex items-center gap-1">
|
||||
✕ {validation.errors.length} error(s)
|
||||
</span>
|
||||
)}
|
||||
{validation.warnings.length > 0 && (
|
||||
<span className="text-sm text-amber-600">
|
||||
{validation.warnings.length} warning(s)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right: Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onValidate}
|
||||
className="px-3 py-1.5 text-sm text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg"
|
||||
>
|
||||
Validate
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handlePreviewYaml}
|
||||
className="px-3 py-1.5 text-sm text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg"
|
||||
>
|
||||
Preview YAML
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={!isDirty}
|
||||
className={`
|
||||
px-4 py-1.5 text-sm rounded-lg font-medium
|
||||
${isDirty
|
||||
? 'bg-blue-500 text-white hover:bg-blue-600'
|
||||
: 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
}
|
||||
`}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* YAML Preview Modal */}
|
||||
{isPreviewOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-white rounded-xl shadow-xl w-[800px] max-h-[80vh] overflow-hidden">
|
||||
{/* Modal Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200">
|
||||
<h2 className="font-semibold text-gray-800">Pipeline YAML</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleCopyYaml}
|
||||
className="px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100 rounded-lg"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDownloadYaml}
|
||||
className="px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100 rounded-lg"
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsPreviewOpen(false)}
|
||||
className="px-3 py-1.5 text-sm text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* YAML Content */}
|
||||
<div className="p-4 overflow-y-auto max-h-[60vh]">
|
||||
<pre className="text-sm font-mono text-gray-800 whitespace-pre-wrap">
|
||||
{yamlPreview}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default WorkflowToolbar;
|
||||
21
desktop/src/components/WorkflowBuilder/index.ts
Normal file
21
desktop/src/components/WorkflowBuilder/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Workflow Builder Components
|
||||
*
|
||||
* Export all workflow builder components.
|
||||
*/
|
||||
|
||||
export { WorkflowBuilder, WorkflowBuilderInternal } from './WorkflowBuilder';
|
||||
export { NodePalette } from './NodePalette';
|
||||
export { PropertyPanel } from './PropertyPanel';
|
||||
export { WorkflowToolbar } from './WorkflowToolbar';
|
||||
|
||||
// Node components
|
||||
export { InputNode } from './nodes/InputNode';
|
||||
export { LlmNode } from './nodes/LlmNode';
|
||||
export { SkillNode } from './nodes/SkillNode';
|
||||
export { HandNode } from './nodes/HandNode';
|
||||
export { ConditionNode } from './nodes/ConditionNode';
|
||||
export { ParallelNode } from './nodes/ParallelNode';
|
||||
export { ExportNode } from './nodes/ExportNode';
|
||||
export { HttpNode } from './nodes/HttpNode';
|
||||
export { OrchestrationNode } from './nodes/OrchestrationNode';
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Condition Node Component
|
||||
*
|
||||
* Node for conditional branching.
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import { Handle, Position, NodeProps } from '@xyflow/react';
|
||||
import type { ConditionNodeData } from '../../../lib/workflow-builder/types';
|
||||
|
||||
export const ConditionNode = memo(({ data, selected }: NodeProps<ConditionNodeData>) => {
|
||||
const branchCount = data.branches.length + (data.hasDefault ? 1 : 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
px-4 py-3 rounded-lg border-2 min-w-[200px]
|
||||
bg-orange-50 border-orange-300
|
||||
${selected ? 'border-orange-500 shadow-lg shadow-orange-200' : ''}
|
||||
`}
|
||||
>
|
||||
{/* Input Handle */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="w-3 h-3 bg-orange-400 border-2 border-white"
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-lg">🔀</span>
|
||||
<span className="font-medium text-orange-800">{data.label}</span>
|
||||
</div>
|
||||
|
||||
{/* Condition Preview */}
|
||||
<div className="text-sm text-orange-600 bg-orange-100 rounded px-2 py-1 font-mono mb-2">
|
||||
{data.condition || 'No condition'}
|
||||
</div>
|
||||
|
||||
{/* Branches */}
|
||||
<div className="space-y-1">
|
||||
{data.branches.map((branch, index) => (
|
||||
<div key={index} className="flex items-center justify-between">
|
||||
<div className="relative">
|
||||
{/* Branch Output Handle */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id={`branch-${index}`}
|
||||
style={{ top: `${((index + 1) / (branchCount + 1)) * 100}%` }}
|
||||
className="w-3 h-3 bg-orange-400 border-2 border-white"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-orange-500 truncate max-w-[120px]">
|
||||
{branch.label || branch.when}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{data.hasDefault && (
|
||||
<div className="flex items-center justify-between">
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id="default"
|
||||
style={{ top: '100%' }}
|
||||
className="w-3 h-3 bg-gray-400 border-2 border-white"
|
||||
/>
|
||||
<span className="text-xs text-gray-500">Default</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ConditionNode.displayName = 'ConditionNode';
|
||||
|
||||
export default ConditionNode;
|
||||
72
desktop/src/components/WorkflowBuilder/nodes/ExportNode.tsx
Normal file
72
desktop/src/components/WorkflowBuilder/nodes/ExportNode.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Export Node Component
|
||||
*
|
||||
* Node for exporting workflow results to various formats.
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import { Handle, Position, NodeProps } from '@xyflow/react';
|
||||
import type { ExportNodeData } from '../../../lib/workflow-builder/types';
|
||||
|
||||
export const ExportNode = memo(({ data, selected }: NodeProps<ExportNodeData>) => {
|
||||
const formatLabels: Record<string, string> = {
|
||||
pptx: 'PowerPoint',
|
||||
html: 'HTML',
|
||||
pdf: 'PDF',
|
||||
markdown: 'Markdown',
|
||||
json: 'JSON',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
px-4 py-3 rounded-lg border-2 min-w-[180px]
|
||||
bg-blue-50 border-blue-300
|
||||
${selected ? 'border-blue-500 shadow-lg shadow-blue-200' : ''}
|
||||
`}
|
||||
>
|
||||
{/* Input Handle */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="w-3 h-3 bg-blue-400 border-2 border-white"
|
||||
/>
|
||||
|
||||
{/* Output Handle */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="w-3 h-3 bg-blue-500 border-2 border-white"
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-lg">📤</span>
|
||||
<span className="font-medium text-blue-800">{data.label}</span>
|
||||
</div>
|
||||
|
||||
{/* Formats */}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{data.formats.map((format) => (
|
||||
<span
|
||||
key={format}
|
||||
className="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded"
|
||||
>
|
||||
{formatLabels[format] || format}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Output Directory */}
|
||||
{data.outputDir && (
|
||||
<div className="text-xs text-blue-500 mt-2 truncate">
|
||||
📁 {data.outputDir}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ExportNode.displayName = 'ExportNode';
|
||||
|
||||
export default ExportNode;
|
||||
74
desktop/src/components/WorkflowBuilder/nodes/HandNode.tsx
Normal file
74
desktop/src/components/WorkflowBuilder/nodes/HandNode.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Hand Node Component
|
||||
*
|
||||
* Node for executing hand actions.
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import { Handle, Position, NodeProps } from '@xyflow/react';
|
||||
import type { HandNodeData } from '../../../lib/workflow-builder/types';
|
||||
|
||||
export const HandNode = memo(({ data, selected }: NodeProps<HandNodeData>) => {
|
||||
const hasHand = Boolean(data.handId);
|
||||
const hasAction = Boolean(data.action);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
px-4 py-3 rounded-lg border-2 min-w-[180px]
|
||||
bg-rose-50 border-rose-300
|
||||
${selected ? 'border-rose-500 shadow-lg shadow-rose-200' : ''}
|
||||
`}
|
||||
>
|
||||
{/* Input Handle */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="w-3 h-3 bg-rose-400 border-2 border-white"
|
||||
/>
|
||||
|
||||
{/* Output Handle */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="w-3 h-3 bg-rose-500 border-2 border-white"
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-lg">✋</span>
|
||||
<span className="font-medium text-rose-800">{data.label}</span>
|
||||
</div>
|
||||
|
||||
{/* Hand Info */}
|
||||
<div className="space-y-1">
|
||||
<div className={`text-sm ${hasHand ? 'text-rose-600' : 'text-rose-400 italic'}`}>
|
||||
{hasHand ? (
|
||||
<span className="font-mono bg-rose-100 px-1.5 py-0.5 rounded">
|
||||
{data.handName || data.handId}
|
||||
</span>
|
||||
) : (
|
||||
'No hand selected'
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasAction && (
|
||||
<div className="text-xs text-rose-500">
|
||||
Action: <span className="font-mono">{data.action}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Params Count */}
|
||||
{Object.keys(data.params).length > 0 && (
|
||||
<div className="text-xs text-rose-500 mt-1">
|
||||
{Object.keys(data.params).length} param(s)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
HandNode.displayName = 'HandNode';
|
||||
|
||||
export default HandNode;
|
||||
81
desktop/src/components/WorkflowBuilder/nodes/HttpNode.tsx
Normal file
81
desktop/src/components/WorkflowBuilder/nodes/HttpNode.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* HTTP Node Component
|
||||
*
|
||||
* Node for making HTTP requests.
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import { Handle, Position, NodeProps } from '@xyflow/react';
|
||||
import type { HttpNodeData } from '../../../lib/workflow-builder/types';
|
||||
|
||||
const methodColors: Record<string, string> = {
|
||||
GET: 'bg-green-100 text-green-700',
|
||||
POST: 'bg-blue-100 text-blue-700',
|
||||
PUT: 'bg-yellow-100 text-yellow-700',
|
||||
DELETE: 'bg-red-100 text-red-700',
|
||||
PATCH: 'bg-purple-100 text-purple-700',
|
||||
};
|
||||
|
||||
export const HttpNode = memo(({ data, selected }: NodeProps<HttpNodeData>) => {
|
||||
const hasUrl = Boolean(data.url);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
px-4 py-3 rounded-lg border-2 min-w-[200px]
|
||||
bg-slate-50 border-slate-300
|
||||
${selected ? 'border-slate-500 shadow-lg shadow-slate-200' : ''}
|
||||
`}
|
||||
>
|
||||
{/* Input Handle */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="w-3 h-3 bg-slate-400 border-2 border-white"
|
||||
/>
|
||||
|
||||
{/* Output Handle */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="w-3 h-3 bg-slate-500 border-2 border-white"
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-lg">🌐</span>
|
||||
<span className="font-medium text-slate-800">{data.label}</span>
|
||||
</div>
|
||||
|
||||
{/* Method Badge */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className={`text-xs font-bold px-2 py-0.5 rounded ${methodColors[data.method]}`}>
|
||||
{data.method}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* URL */}
|
||||
<div className={`text-sm font-mono bg-slate-100 rounded px-2 py-1 truncate ${hasUrl ? 'text-slate-600' : 'text-slate-400 italic'}`}>
|
||||
{hasUrl ? data.url : 'No URL specified'}
|
||||
</div>
|
||||
|
||||
{/* Headers Count */}
|
||||
{Object.keys(data.headers).length > 0 && (
|
||||
<div className="text-xs text-slate-500 mt-2">
|
||||
{Object.keys(data.headers).length} header(s)
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Body Indicator */}
|
||||
{data.body && (
|
||||
<div className="text-xs text-slate-500 mt-1">
|
||||
Has body content
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
HttpNode.displayName = 'HttpNode';
|
||||
|
||||
export default HttpNode;
|
||||
54
desktop/src/components/WorkflowBuilder/nodes/InputNode.tsx
Normal file
54
desktop/src/components/WorkflowBuilder/nodes/InputNode.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Input Node Component
|
||||
*
|
||||
* Node for defining workflow input variables.
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import { Handle, Position, NodeProps } from '@xyflow/react';
|
||||
import type { InputNodeData } from '../../../lib/workflow-builder/types';
|
||||
|
||||
export const InputNode = memo(({ data, selected }: NodeProps<InputNodeData>) => {
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
px-4 py-3 rounded-lg border-2 min-w-[180px]
|
||||
bg-emerald-50 border-emerald-300
|
||||
${selected ? 'border-emerald-500 shadow-lg shadow-emerald-200' : ''}
|
||||
`}
|
||||
>
|
||||
{/* Output Handle */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="w-3 h-3 bg-emerald-500 border-2 border-white"
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-lg">📥</span>
|
||||
<span className="font-medium text-emerald-800">{data.label}</span>
|
||||
</div>
|
||||
|
||||
{/* Variable Name */}
|
||||
<div className="text-sm text-emerald-600">
|
||||
<span className="font-mono bg-emerald-100 px-1.5 py-0.5 rounded">
|
||||
{data.variableName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Default Value Indicator */}
|
||||
{data.defaultValue !== undefined && (
|
||||
<div className="text-xs text-emerald-500 mt-1">
|
||||
default: {typeof data.defaultValue === 'string'
|
||||
? `"${data.defaultValue}"`
|
||||
: JSON.stringify(data.defaultValue)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
InputNode.displayName = 'InputNode';
|
||||
|
||||
export default InputNode;
|
||||
70
desktop/src/components/WorkflowBuilder/nodes/LlmNode.tsx
Normal file
70
desktop/src/components/WorkflowBuilder/nodes/LlmNode.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* LLM Node Component
|
||||
*
|
||||
* Node for LLM generation actions.
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import { Handle, Position, NodeProps } from '@xyflow/react';
|
||||
import type { LlmNodeData } from '../../../lib/workflow-builder/types';
|
||||
|
||||
export const LlmNode = memo(({ data, selected }: NodeProps<LlmNodeData>) => {
|
||||
const templatePreview = data.template.length > 50
|
||||
? data.template.slice(0, 50) + '...'
|
||||
: data.template || 'No template';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
px-4 py-3 rounded-lg border-2 min-w-[200px]
|
||||
bg-violet-50 border-violet-300
|
||||
${selected ? 'border-violet-500 shadow-lg shadow-violet-200' : ''}
|
||||
`}
|
||||
>
|
||||
{/* Input Handle */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="w-3 h-3 bg-violet-400 border-2 border-white"
|
||||
/>
|
||||
|
||||
{/* Output Handle */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="w-3 h-3 bg-violet-500 border-2 border-white"
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-lg">🤖</span>
|
||||
<span className="font-medium text-violet-800">{data.label}</span>
|
||||
{data.jsonMode && (
|
||||
<span className="text-xs bg-violet-200 text-violet-700 px-1.5 py-0.5 rounded">
|
||||
JSON
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Template Preview */}
|
||||
<div className="text-sm text-violet-600 bg-violet-100 rounded px-2 py-1 font-mono">
|
||||
{data.isTemplateFile ? '📄 ' : ''}
|
||||
{templatePreview}
|
||||
</div>
|
||||
|
||||
{/* Model Info */}
|
||||
{(data.model || data.temperature !== undefined) && (
|
||||
<div className="flex gap-2 mt-2 text-xs text-violet-500">
|
||||
{data.model && <span>Model: {data.model}</span>}
|
||||
{data.temperature !== undefined && (
|
||||
<span>Temp: {data.temperature}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
LlmNode.displayName = 'LlmNode';
|
||||
|
||||
export default LlmNode;
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Orchestration Node Component
|
||||
*
|
||||
* Node for executing skill orchestration graphs (DAGs).
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import { Handle, Position, NodeProps } from '@xyflow/react';
|
||||
import type { OrchestrationNodeData } from '../../../lib/workflow-builder/types';
|
||||
|
||||
export const OrchestrationNode = memo(({ data, selected }: NodeProps<OrchestrationNodeData>) => {
|
||||
const hasGraphId = Boolean(data.graphId);
|
||||
const hasGraph = Boolean(data.graph);
|
||||
const inputCount = Object.keys(data.inputMappings).length;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
px-4 py-3 rounded-lg border-2 min-w-[200px]
|
||||
bg-gradient-to-br from-indigo-50 to-purple-50
|
||||
border-indigo-300
|
||||
${selected ? 'border-indigo-500 shadow-lg shadow-indigo-200' : ''}
|
||||
`}
|
||||
>
|
||||
{/* Input Handle */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="w-3 h-3 bg-indigo-400 border-2 border-white"
|
||||
/>
|
||||
|
||||
{/* Output Handle */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="w-3 h-3 bg-indigo-500 border-2 border-white"
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-lg">🔀</span>
|
||||
<span className="font-medium text-indigo-800">{data.label}</span>
|
||||
</div>
|
||||
|
||||
{/* Graph Reference */}
|
||||
<div className={`text-sm mb-2 ${hasGraphId || hasGraph ? 'text-indigo-600' : 'text-indigo-400 italic'}`}>
|
||||
{hasGraphId ? (
|
||||
<div className="flex items-center gap-1.5 bg-indigo-100 rounded px-2 py-1">
|
||||
<span className="text-xs">📋</span>
|
||||
<span className="font-mono text-xs">{data.graphId}</span>
|
||||
</div>
|
||||
) : hasGraph ? (
|
||||
<div className="flex items-center gap-1.5 bg-indigo-100 rounded px-2 py-1">
|
||||
<span className="text-xs">📊</span>
|
||||
<span className="text-xs">Inline graph</span>
|
||||
</div>
|
||||
) : (
|
||||
'No graph configured'
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input Mappings */}
|
||||
{inputCount > 0 && (
|
||||
<div className="text-xs text-indigo-500 mt-2">
|
||||
{inputCount} input mapping(s)
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{data.description && (
|
||||
<div className="text-xs text-indigo-400 mt-2 line-clamp-2">
|
||||
{data.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
OrchestrationNode.displayName = 'OrchestrationNode';
|
||||
|
||||
export default OrchestrationNode;
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Parallel Node Component
|
||||
*
|
||||
* Node for parallel execution of steps.
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import { Handle, Position, NodeProps } from '@xyflow/react';
|
||||
import type { ParallelNodeData } from '../../../lib/workflow-builder/types';
|
||||
|
||||
export const ParallelNode = memo(({ data, selected }: NodeProps<ParallelNodeData>) => {
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
px-4 py-3 rounded-lg border-2 min-w-[180px]
|
||||
bg-cyan-50 border-cyan-300
|
||||
${selected ? 'border-cyan-500 shadow-lg shadow-cyan-200' : ''}
|
||||
`}
|
||||
>
|
||||
{/* Input Handle */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="w-3 h-3 bg-cyan-400 border-2 border-white"
|
||||
/>
|
||||
|
||||
{/* Output Handle */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="w-3 h-3 bg-cyan-500 border-2 border-white"
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-lg">⚡</span>
|
||||
<span className="font-medium text-cyan-800">{data.label}</span>
|
||||
</div>
|
||||
|
||||
{/* Each Expression */}
|
||||
<div className="text-sm text-cyan-600 bg-cyan-100 rounded px-2 py-1 font-mono">
|
||||
each: {data.each || '${inputs.items}'}
|
||||
</div>
|
||||
|
||||
{/* Max Workers */}
|
||||
<div className="text-xs text-cyan-500 mt-2">
|
||||
Max workers: {data.maxWorkers}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ParallelNode.displayName = 'ParallelNode';
|
||||
|
||||
export default ParallelNode;
|
||||
65
desktop/src/components/WorkflowBuilder/nodes/SkillNode.tsx
Normal file
65
desktop/src/components/WorkflowBuilder/nodes/SkillNode.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Skill Node Component
|
||||
*
|
||||
* Node for executing skills.
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import { Handle, Position, NodeProps } from '@xyflow/react';
|
||||
import type { SkillNodeData } from '../../../lib/workflow-builder/types';
|
||||
|
||||
export const SkillNode = memo(({ data, selected }: NodeProps<SkillNodeData>) => {
|
||||
const hasSkill = Boolean(data.skillId);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
px-4 py-3 rounded-lg border-2 min-w-[180px]
|
||||
bg-amber-50 border-amber-300
|
||||
${selected ? 'border-amber-500 shadow-lg shadow-amber-200' : ''}
|
||||
`}
|
||||
>
|
||||
{/* Input Handle */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="w-3 h-3 bg-amber-400 border-2 border-white"
|
||||
/>
|
||||
|
||||
{/* Output Handle */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="w-3 h-3 bg-amber-500 border-2 border-white"
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-lg">⚡</span>
|
||||
<span className="font-medium text-amber-800">{data.label}</span>
|
||||
</div>
|
||||
|
||||
{/* Skill ID */}
|
||||
<div className={`text-sm ${hasSkill ? 'text-amber-600' : 'text-amber-400 italic'}`}>
|
||||
{hasSkill ? (
|
||||
<span className="font-mono bg-amber-100 px-1.5 py-0.5 rounded">
|
||||
{data.skillName || data.skillId}
|
||||
</span>
|
||||
) : (
|
||||
'No skill selected'
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input Mappings Count */}
|
||||
{Object.keys(data.inputMappings).length > 0 && (
|
||||
<div className="text-xs text-amber-500 mt-1">
|
||||
{Object.keys(data.inputMappings).length} input mapping(s)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
SkillNode.displayName = 'SkillNode';
|
||||
|
||||
export default SkillNode;
|
||||
Reference in New Issue
Block a user