Files
zclaw_openfang/desktop/src/components/WorkflowEditor.tsx
iven ce562e8bfc feat: complete Phase 1-3 architecture optimization
Phase 1 - Security:
- Add AES-GCM encryption for localStorage fallback
- Enforce WSS protocol for non-localhost WebSocket connections
- Add URL sanitization to prevent XSS in markdown links

Phase 2 - Domain Reorganization:
- Create Intelligence Domain with Valtio store and caching
- Add unified intelligence-client for Rust backend integration
- Migrate from legacy agent-memory, heartbeat, reflection modules

Phase 3 - Core Optimization:
- Add virtual scrolling for ChatArea with react-window
- Implement LRU cache with TTL for intelligence operations
- Add message virtualization utilities

Additional:
- Add OpenFang compatibility test suite
- Update E2E test fixtures
- Add audit logging infrastructure
- Update project documentation and plans

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 22:11:50 +08:00

480 lines
17 KiB
TypeScript

/**
* WorkflowEditor - OpenFang Workflow Editor Component
*
* Allows creating and editing multi-step workflows that chain
* multiple Hands together for complex task automation.
*
* Design based on OpenFang Dashboard v0.4.0
*/
import { useState, useEffect, useCallback } from 'react';
import { useHandStore, type Hand } from '../store/handStore';
import { useWorkflowStore, type Workflow } from '../store/workflowStore';
import {
X,
Plus,
Trash2,
GripVertical,
ChevronDown,
ChevronUp,
ChevronLeft,
ChevronRight,
Save,
Loader2,
AlertCircle,
GitBranch,
} from 'lucide-react';
import { safeJsonParse } from '../lib/json-utils';
// === Types ===
interface WorkflowStep {
id: string;
handName: string;
name?: string;
params?: Record<string, unknown>;
condition?: string;
}
interface WorkflowEditorProps {
workflow?: Workflow; // If provided, edit mode; otherwise create mode
isOpen: boolean;
onClose: () => void;
onSave: (data: {
name: string;
description?: string;
steps: Array<{
handName: string;
name?: string;
params?: Record<string, unknown>;
condition?: string;
}>;
}) => Promise<void>;
isSaving: boolean;
}
// === Step Editor Component ===
interface StepEditorProps {
step: WorkflowStep;
hands: Hand[];
index: number;
onUpdate: (step: WorkflowStep) => void;
onRemove: () => void;
onMoveUp?: () => void;
onMoveDown?: () => void;
}
function StepEditor({ step, hands, index, onUpdate, onRemove, onMoveUp, onMoveDown }: StepEditorProps) {
const [isExpanded, setIsExpanded] = useState(true);
const selectedHand = hands.find(h => h.name === step.handName);
return (
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700">
{/* Header */}
<div className="flex items-center gap-2 p-3 border-b border-gray-200 dark:border-gray-700">
<GripVertical className="w-4 h-4 text-gray-400 cursor-grab" />
<span className="flex-shrink-0 w-6 h-6 bg-blue-100 dark:bg-blue-900/30 rounded-full flex items-center justify-center text-xs font-medium text-blue-600 dark:text-blue-400">
{index + 1}
</span>
<div className="flex-1 min-w-0">
<select
value={step.handName}
onChange={(e) => onUpdate({ ...step, handName: e.target.value })}
className="w-full px-2 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value=""> Hand...</option>
{hands.map(hand => (
<option key={hand.id} value={hand.id}>
{hand.name} - {hand.description}
</option>
))}
</select>
</div>
<button
onClick={() => setIsExpanded(!isExpanded)}
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
{isExpanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
{onMoveUp && (
<button
onClick={onMoveUp}
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded"
title="上移"
>
<ChevronLeft className="w-4 h-4" />
</button>
)}
{onMoveDown && (
<button
onClick={onMoveDown}
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded"
title="下移"
>
<ChevronRight className="w-4 h-4" />
</button>
)}
<button
onClick={onRemove}
className="p-1 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
{/* Expanded Content */}
{isExpanded && (
<div className="p-3 space-y-3">
{/* Step Name */}
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
()
</label>
<input
type="text"
value={step.name || ''}
onChange={(e) => onUpdate({ ...step, name: e.target.value || undefined })}
placeholder={`${step.handName || '步骤'} ${index + 1}`}
className="w-full px-2 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* Condition */}
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
()
</label>
<input
type="text"
value={step.condition || ''}
onChange={(e) => onUpdate({ ...step, condition: e.target.value || undefined })}
placeholder="例如: previous_result.success == true"
className="w-full px-2 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
使
</p>
</div>
{/* Parameters */}
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
(JSON )
</label>
<textarea
value={step.params ? JSON.stringify(step.params, null, 2) : ''}
onChange={(e) => {
const text = e.target.value.trim();
if (text) {
const result = safeJsonParse<Record<string, unknown>>(text);
if (result.success) {
onUpdate({ ...step, params: result.data });
}
// If parse fails, keep current params
} else {
onUpdate({ ...step, params: undefined });
}
}}
placeholder='{"key": "value"}'
rows={3}
className="w-full px-2 py-1.5 text-sm font-mono border border-gray-200 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* Hand Info */}
{selectedHand && (
<div className="p-2 bg-blue-50 dark:bg-blue-900/20 rounded-md text-xs text-blue-700 dark:text-blue-400">
<div className="font-medium mb-1">{selectedHand.description}</div>
{selectedHand.toolCount && (
<div>: {selectedHand.toolCount}</div>
)}
</div>
)}
</div>
)}
</div>
);
}
// === Main WorkflowEditor Component ===
export function WorkflowEditor({ workflow, isOpen, onClose, onSave, isSaving }: WorkflowEditorProps) {
const hands = useHandStore((s) => s.hands);
const loadHands = useHandStore((s) => s.loadHands);
const getWorkflowDetail = useWorkflowStore((s) => s.getWorkflowDetail);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [steps, setSteps] = useState<WorkflowStep[]>([]);
const [error, setError] = useState<string | null>(null);
const isEditMode = !!workflow;
// Load hands on mount
useEffect(() => {
loadHands();
}, [loadHands]);
// Initialize form when workflow changes (edit mode)
useEffect(() => {
if (workflow) {
setName(workflow.name);
setDescription(workflow.description || '');
// Load full workflow details including steps
getWorkflowDetail(workflow.id)
.then((detail: { steps: Array<{ handName: string; name?: string; params?: Record<string, unknown>; condition?: string }> } | undefined) => {
if (detail && Array.isArray(detail.steps)) {
const editorSteps: WorkflowStep[] = detail.steps.map((step: { handName: string; name?: string; params?: Record<string, unknown>; condition?: string }, index: number) => ({
id: `step-${workflow.id}-${index}`,
handName: step.handName || '',
name: step.name,
params: step.params,
condition: step.condition,
}));
setSteps(editorSteps);
} else {
setSteps([]);
}
})
.catch(() => setSteps([]));
} else {
setName('');
setDescription('');
setSteps([]);
}
setError(null);
}, [workflow, getWorkflowDetail]);
// Add new step
const handleAddStep = useCallback(() => {
const newStep: WorkflowStep = {
id: `step-${Date.now()}`,
handName: '',
};
setSteps(prev => [...prev, newStep]);
}, []);
// Update step
const handleUpdateStep = useCallback((index: number, updatedStep: WorkflowStep) => {
setSteps(prev => prev.map((s, i) => i === index ? updatedStep : s));
}, []);
// Remove step
const handleRemoveStep = useCallback((index: number) => {
setSteps(prev => prev.filter((_, i) => i !== index));
}, []);
// Move step up
const handleMoveStepUp = useCallback((index: number) => {
if (index === 0) return;
setSteps(prev => {
const newSteps = [...prev];
[newSteps[index - 1], newSteps[index]] = [newSteps[index], newSteps[index - 1]];
return newSteps;
});
}, []);
// Move step down
const handleMoveStepDown = useCallback((index: number) => {
if (index === steps.length - 1) return;
setSteps(prev => {
const newSteps = [...prev];
[newSteps[index], newSteps[index + 1]] = [newSteps[index + 1], newSteps[index]];
return newSteps;
});
}, [steps.length]);
// Validate and save
const handleSave = async () => {
setError(null);
// Validation
if (!name.trim()) {
setError('请输入工作流名称');
return;
}
if (steps.length === 0) {
setError('请至少添加一个步骤');
return;
}
const invalidSteps = steps.filter(s => !s.handName);
if (invalidSteps.length > 0) {
setError('所有步骤都必须选择一个 Hand');
return;
}
try {
await onSave({
name: name.trim(),
description: description.trim() || undefined,
steps: steps.map(s => ({
handName: s.handName,
name: s.name,
params: s.params,
condition: s.condition,
})),
});
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : '保存失败');
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="relative bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-2xl mx-4 max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center">
<GitBranch className="w-4 h-4 text-blue-600 dark:text-blue-400" />
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
{isEditMode ? '编辑工作流' : '新建工作流'}
</h2>
{workflow && (
<p className="text-sm text-gray-500 dark:text-gray-400">{workflow.name}</p>
)}
</div>
</div>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 p-1"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* Error */}
{error && (
<div className="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400 text-sm flex items-center gap-2">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
{error}
</div>
)}
{/* Basic Info */}
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
*
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="我的工作流"
className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="描述这个工作流的用途..."
rows={2}
className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
{/* Steps */}
<div>
<div className="flex items-center justify-between mb-3">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
({steps.length})
</label>
<button
onClick={handleAddStep}
className="flex items-center gap-1 px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 rounded-md hover:bg-blue-200 dark:hover:bg-blue-900/50"
>
<Plus className="w-3 h-3" />
</button>
</div>
{steps.length === 0 ? (
<div className="p-8 text-center border-2 border-dashed border-gray-200 dark:border-gray-700 rounded-lg">
<GitBranch className="w-8 h-8 mx-auto text-gray-300 dark:text-gray-600 mb-2" />
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">
</p>
<button
onClick={handleAddStep}
className="inline-flex items-center gap-1 px-3 py-1.5 text-sm bg-gray-700 dark:bg-gray-600 text-white rounded-lg hover:bg-gray-800 dark:hover:bg-gray-500"
>
<Plus className="w-4 h-4" />
</button>
</div>
) : (
<div className="space-y-2">
{steps.map((step, index) => (
<StepEditor
key={step.id}
step={step}
hands={hands}
index={index}
onUpdate={(s) => handleUpdateStep(index, s)}
onRemove={() => handleRemoveStep(index)}
onMoveUp={index > 0 ? () => handleMoveStepUp(index) : undefined}
onMoveDown={index < steps.length - 1 ? () => handleMoveStepDown(index) : undefined}
/>
))}
</div>
)}
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 p-4 border-t border-gray-200 dark:border-gray-700 flex-shrink-0">
<button
onClick={onClose}
className="px-4 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
>
</button>
<button
onClick={handleSave}
disabled={isSaving}
className="px-4 py-2 text-sm bg-gray-700 dark:bg-gray-600 text-white rounded-lg hover:bg-gray-800 dark:hover:bg-gray-500 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{isSaving ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
...
</>
) : (
<>
<Save className="w-4 h-4" />
</>
)}
</button>
</div>
</div>
</div>
);
}
export default WorkflowEditor;