feat(pipeline): implement Pipeline DSL system for automated workflows
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
Add complete Pipeline DSL system including:
- Rust backend (zclaw-pipeline crate) with parser, executor, and state management
- Frontend components: PipelinesPanel, PipelineResultPreview, ClassroomPreviewer
- Pipeline recommender for Agent conversation integration
- 5 pipeline templates: education, marketing, legal, research, productivity
- Documentation for Pipeline DSL architecture
Pipeline DSL enables declarative workflow definitions with:
- YAML-based configuration
- Expression resolution (${inputs.topic}, ${steps.step1.output})
- LLM integration, parallel execution, file export
- Agent smart recommendations in conversations
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
525
desktop/src/components/PipelinesPanel.tsx
Normal file
525
desktop/src/components/PipelinesPanel.tsx
Normal file
@@ -0,0 +1,525 @@
|
||||
/**
|
||||
* PipelinesPanel - Pipeline Discovery and Execution UI
|
||||
*
|
||||
* Displays available Pipelines (DSL-based workflows) with
|
||||
* category filtering, search, and execution capabilities.
|
||||
*
|
||||
* Pipelines orchestrate Skills and Hands to accomplish complex tasks.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Play,
|
||||
RefreshCw,
|
||||
Search,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
Package,
|
||||
Filter,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
PipelineClient,
|
||||
PipelineInfo,
|
||||
PipelineRunResponse,
|
||||
usePipelines,
|
||||
usePipelineRun,
|
||||
validateInputs,
|
||||
getDefaultForType,
|
||||
formatInputType,
|
||||
} from '../lib/pipeline-client';
|
||||
import { useToast } from './ui/Toast';
|
||||
|
||||
// === Category Badge Component ===
|
||||
|
||||
const CATEGORY_CONFIG: Record<string, { label: string; className: string }> = {
|
||||
education: { label: '教育', className: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' },
|
||||
marketing: { label: '营销', className: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' },
|
||||
legal: { label: '法律', className: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400' },
|
||||
productivity: { label: '生产力', className: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' },
|
||||
research: { label: '研究', className: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-400' },
|
||||
sales: { label: '销售', className: 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400' },
|
||||
hr: { label: '人力', className: 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-400' },
|
||||
finance: { label: '财务', className: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' },
|
||||
default: { label: '其他', className: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400' },
|
||||
};
|
||||
|
||||
function CategoryBadge({ category }: { category: string }) {
|
||||
const config = CATEGORY_CONFIG[category] || CATEGORY_CONFIG.default;
|
||||
return (
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${config.className}`}>
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// === Pipeline Card Component ===
|
||||
|
||||
interface PipelineCardProps {
|
||||
pipeline: PipelineInfo;
|
||||
onRun: (pipeline: PipelineInfo) => void;
|
||||
}
|
||||
|
||||
function PipelineCard({ pipeline, onRun }: PipelineCardProps) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">{pipeline.icon}</span>
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 dark:text-white">
|
||||
{pipeline.displayName}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{pipeline.id} · v{pipeline.version}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<CategoryBadge category={pipeline.category} />
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 mb-3 line-clamp-2">
|
||||
{pipeline.description}
|
||||
</p>
|
||||
|
||||
{pipeline.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mb-3">
|
||||
{pipeline.tags.slice(0, 3).map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 rounded text-xs text-gray-600 dark:text-gray-300"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{pipeline.tags.length > 3 && (
|
||||
<span className="px-1.5 py-0.5 text-xs text-gray-400">
|
||||
+{pipeline.tags.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between pt-2 border-t border-gray-100 dark:border-gray-700">
|
||||
<span className="text-xs text-gray-400">
|
||||
{pipeline.inputs.length} 个输入参数
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onRun(pipeline)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-md transition-colors"
|
||||
>
|
||||
<Play className="w-4 h-4" />
|
||||
运行
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// === Pipeline Run Modal ===
|
||||
|
||||
interface RunModalProps {
|
||||
pipeline: PipelineInfo;
|
||||
onClose: () => void;
|
||||
onComplete: (result: PipelineRunResponse) => void;
|
||||
}
|
||||
|
||||
function RunModal({ pipeline, onClose, onComplete }: RunModalProps) {
|
||||
const [values, setValues] = useState<Record<string, unknown>>(() => {
|
||||
const defaults: Record<string, unknown> = {};
|
||||
pipeline.inputs.forEach((input) => {
|
||||
defaults[input.name] = input.default ?? getDefaultForType(input.inputType);
|
||||
});
|
||||
return defaults;
|
||||
});
|
||||
const [errors, setErrors] = useState<string[]>([]);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [progress, setProgress] = useState<PipelineRunResponse | null>(null);
|
||||
|
||||
const handleInputChange = (name: string, value: unknown) => {
|
||||
setValues((prev) => ({ ...prev, [name]: value }));
|
||||
setErrors([]);
|
||||
};
|
||||
|
||||
const handleRun = async () => {
|
||||
// Validate inputs
|
||||
const validation = validateInputs(pipeline.inputs, values);
|
||||
if (!validation.valid) {
|
||||
setErrors(validation.errors);
|
||||
return;
|
||||
}
|
||||
|
||||
setRunning(true);
|
||||
setProgress(null);
|
||||
|
||||
try {
|
||||
const result = await PipelineClient.runAndWait(
|
||||
{ pipelineId: pipeline.id, inputs: values },
|
||||
(p) => setProgress(p)
|
||||
);
|
||||
|
||||
if (result.status === 'completed') {
|
||||
onComplete(result);
|
||||
} else if (result.error) {
|
||||
setErrors([result.error]);
|
||||
}
|
||||
} catch (err) {
|
||||
setErrors([err instanceof Error ? err.message : String(err)]);
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderInput = (input: typeof pipeline.inputs[0]) => {
|
||||
const value = values[input.name];
|
||||
|
||||
switch (input.inputType) {
|
||||
case 'string':
|
||||
case 'text':
|
||||
return input.inputType === 'text' ? (
|
||||
<textarea
|
||||
value={(value as string) || ''}
|
||||
onChange={(e) => handleInputChange(input.name, e.target.value)}
|
||||
placeholder={input.placeholder}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={(value as string) || ''}
|
||||
onChange={(e) => handleInputChange(input.name, e.target.value)}
|
||||
placeholder={input.placeholder}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
/>
|
||||
);
|
||||
|
||||
case 'number':
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
value={(value as number) ?? ''}
|
||||
onChange={(e) => handleInputChange(input.name, e.target.valueAsNumber || 0)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
/>
|
||||
);
|
||||
|
||||
case 'boolean':
|
||||
return (
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(value as boolean) || false}
|
||||
onChange={(e) => handleInputChange(input.name, e.target.checked)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-300">启用</span>
|
||||
</label>
|
||||
);
|
||||
|
||||
case 'select':
|
||||
return (
|
||||
<select
|
||||
value={(value as string) || ''}
|
||||
onChange={(e) => handleInputChange(input.name, e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
>
|
||||
<option value="">请选择...</option>
|
||||
{input.options.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
|
||||
case 'multi-select':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{input.options.map((opt) => (
|
||||
<label key={opt} className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={((value as string[]) || []).includes(opt)}
|
||||
onChange={(e) => {
|
||||
const current = (value as string[]) || [];
|
||||
const updated = e.target.checked
|
||||
? [...current, opt]
|
||||
: current.filter((v) => v !== opt);
|
||||
handleInputChange(input.name, updated);
|
||||
}}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-300">{opt}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={(value as string) || ''}
|
||||
onChange={(e) => handleInputChange(input.name, e.target.value)}
|
||||
placeholder={input.placeholder}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">{pipeline.icon}</span>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{pipeline.displayName}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{pipeline.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
|
||||
>
|
||||
<X className="w-5 h-5 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="p-4 space-y-4">
|
||||
{pipeline.inputs.map((input) => (
|
||||
<div key={input.name}>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{input.label}
|
||||
{input.required && <span className="text-red-500 ml-1">*</span>}
|
||||
<span className="text-xs text-gray-400 ml-2">
|
||||
({formatInputType(input.inputType)})
|
||||
</span>
|
||||
</label>
|
||||
{renderInput(input)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{errors.length > 0 && (
|
||||
<div className="p-3 bg-red-50 dark:bg-red-900/20 rounded-md">
|
||||
{errors.map((error, i) => (
|
||||
<p key={i} className="text-sm text-red-600 dark:text-red-400">
|
||||
{error}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress */}
|
||||
{running && progress && (
|
||||
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 rounded-md">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-blue-600" />
|
||||
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">
|
||||
{progress.message || '运行中...'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-blue-200 dark:bg-blue-800 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all"
|
||||
style={{ width: `${progress.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 p-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={running}
|
||||
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md disabled:opacity-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRun}
|
||||
disabled={running}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-md disabled:opacity-50"
|
||||
>
|
||||
{running ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
运行中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-4 h-4" />
|
||||
开始运行
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// === Main Pipelines Panel ===
|
||||
|
||||
export function PipelinesPanel() {
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedPipeline, setSelectedPipeline] = useState<PipelineInfo | null>(null);
|
||||
const { showToast } = useToast();
|
||||
|
||||
const { pipelines, loading, error, refresh } = usePipelines({
|
||||
category: selectedCategory ?? undefined,
|
||||
});
|
||||
|
||||
// Get unique categories
|
||||
const categories = Array.from(
|
||||
new Set(pipelines.map((p) => p.category).filter(Boolean))
|
||||
);
|
||||
|
||||
// Filter pipelines by search
|
||||
const filteredPipelines = searchQuery
|
||||
? pipelines.filter(
|
||||
(p) =>
|
||||
p.displayName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
p.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
p.tags.some((t) => t.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
)
|
||||
: pipelines;
|
||||
|
||||
const handleRunPipeline = (pipeline: PipelineInfo) => {
|
||||
setSelectedPipeline(pipeline);
|
||||
};
|
||||
|
||||
const handleRunComplete = (result: PipelineRunResponse) => {
|
||||
setSelectedPipeline(null);
|
||||
if (result.status === 'completed') {
|
||||
showToast('Pipeline 执行完成', 'success');
|
||||
} else {
|
||||
showToast(`Pipeline 执行失败: ${result.error}`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="w-5 h-5 text-gray-500" />
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Pipelines
|
||||
</h2>
|
||||
<span className="px-2 py-0.5 bg-gray-100 dark:bg-gray-700 rounded-full text-xs text-gray-600 dark:text-gray-300">
|
||||
{pipelines.length}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={refresh}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700 space-y-3">
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索 Pipelines..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-9 pr-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category filters */}
|
||||
{categories.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Filter className="w-4 h-4 text-gray-400" />
|
||||
<button
|
||||
onClick={() => setSelectedCategory(null)}
|
||||
className={`px-2 py-1 text-xs rounded-md transition-colors ${
|
||||
selectedCategory === null
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
{categories.map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setSelectedCategory(cat)}
|
||||
className={`px-2 py-1 text-xs rounded-md transition-colors ${
|
||||
selectedCategory === cat
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
{CATEGORY_CONFIG[cat]?.label || cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-gray-400" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-8 text-red-500">
|
||||
<XCircle className="w-8 h-8 mx-auto mb-2" />
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
) : filteredPipelines.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<Package className="w-8 h-8 mx-auto mb-2" />
|
||||
<p>没有找到 Pipeline</p>
|
||||
{searchQuery && <p className="text-sm mt-1">尝试修改搜索条件</p>}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{filteredPipelines.map((pipeline) => (
|
||||
<PipelineCard
|
||||
key={pipeline.id}
|
||||
pipeline={pipeline}
|
||||
onRun={handleRunPipeline}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Run Modal */}
|
||||
{selectedPipeline && (
|
||||
<RunModal
|
||||
pipeline={selectedPipeline}
|
||||
onClose={() => setSelectedPipeline(null)}
|
||||
onComplete={handleRunComplete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PipelinesPanel;
|
||||
Reference in New Issue
Block a user