Files
zclaw_openfang/desktop/src/components/PipelinesPanel.tsx
iven ee29b7b752 fix(pipeline): BREAK-04 接入 pipeline-complete 事件监听
PipelinesPanel 新增 useEffect 订阅 PipelineClient.onComplete(),
处理用户导航离开后的后台 Pipeline 完成通知。

- 后台完成时 toast 提示成功/失败
- 跳过当前选中 pipeline 的重复通知(轮询路径已处理)
- 组件卸载时自动清理监听器
2026-03-29 23:51:55 +08:00

618 lines
22 KiB
TypeScript

/**
* 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 } from 'react';
import {
Play,
RefreshCw,
Search,
Loader2,
XCircle,
Package,
Filter,
X,
} from 'lucide-react';
import {
PipelineClient,
PipelineInfo,
PipelineRunResponse,
usePipelines,
validateInputs,
getDefaultForType,
formatInputType,
} from '../lib/pipeline-client';
import { useToast } from './ui/Toast';
import { PresentationContainer } from './presentation';
// === 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 Result Modal ===
interface ResultModalProps {
result: PipelineRunResponse;
pipeline: PipelineInfo;
onClose: () => void;
}
function ResultModal({ result, pipeline, onClose }: ResultModalProps) {
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 w-[90vw] max-w-4xl h-[85vh] flex flex-col mx-4">
{/* 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">
: {result.status === 'completed' ? '已完成' : '失败'}
</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>
{/* Content */}
<div className="flex-1 overflow-hidden">
{result.outputs ? (
<PresentationContainer
data={result.outputs}
pipelineId={pipeline.id}
supportedTypes={['document', 'chart', 'quiz', 'slideshow']}
/>
) : result.error ? (
<div className="p-6 text-center text-red-500">
<XCircle className="w-8 h-8 mx-auto mb-2" />
<p>{result.error}</p>
</div>
) : (
<div className="p-6 text-center text-gray-500">
<Package className="w-8 h-8 mx-auto mb-2" />
<p></p>
</div>
)}
</div>
</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 [runResult, setRunResult] = useState<{ result: PipelineRunResponse; pipeline: PipelineInfo } | null>(null);
const { toast } = useToast();
// Subscribe to pipeline-complete push events (for background completion)
useEffect(() => {
let unlisten: (() => void) | undefined;
PipelineClient.onComplete((event) => {
// Only show notification if we're not already tracking this run
// (the polling path handles in-flight runs via handleRunComplete)
if (selectedPipeline?.id === event.pipelineId) return;
if (event.status === 'completed') {
toast(`Pipeline "${event.pipelineId}" 后台执行完成`, 'success');
} else if (event.status === 'failed') {
toast(`Pipeline "${event.pipelineId}" 后台执行失败: ${event.error ?? ''}`, 'error');
}
}).then((fn) => { unlisten = fn; });
return () => { unlisten?.(); };
}, [selectedPipeline, toast]);
// Fetch all pipelines without filtering
const { pipelines, loading, error, refresh } = usePipelines({});
// Get unique categories from ALL pipelines (not filtered)
const categories = Array.from(
new Set(pipelines.map((p) => p.category).filter(Boolean))
);
// Filter pipelines by selected category and search
const filteredPipelines = pipelines.filter((p) => {
// Category filter
if (selectedCategory && p.category !== selectedCategory) {
return false;
}
// Search filter
if (searchQuery) {
const query = searchQuery.toLowerCase();
return (
p.displayName.toLowerCase().includes(query) ||
p.description.toLowerCase().includes(query) ||
p.tags.some((t) => t.toLowerCase().includes(query))
);
}
return true;
});
const handleRunPipeline = (pipeline: PipelineInfo) => {
setSelectedPipeline(pipeline);
};
const handleRunComplete = (result: PipelineRunResponse) => {
setSelectedPipeline(null);
if (result.status === 'completed') {
toast('Pipeline 执行完成', 'success');
setRunResult({ result, pipeline: selectedPipeline! });
} else {
toast(`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}
/>
)}
{/* Result Modal */}
{runResult && (
<ResultModal
result={runResult.result}
pipeline={runResult.pipeline}
onClose={() => setRunResult(null)}
/>
)}
</div>
);
}
export default PipelinesPanel;