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

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:
iven
2026-03-25 00:52:12 +08:00
parent 0179f947aa
commit 9c781f5f2a
30 changed files with 6944 additions and 24 deletions

View File

@@ -0,0 +1,534 @@
/**
* ClassroomPreviewer - 课堂预览器组件
*
* 预览 classroom-generator Pipeline 生成的课堂内容:
* - 幻灯片导航
* - 大纲视图
* - 场景切换
* - 全屏播放模式
* - AI 教师讲解展示
*/
import { useState, useCallback, useEffect } from 'react';
import {
ChevronLeft,
ChevronRight,
Play,
Pause,
Maximize,
Minimize,
List,
Grid,
Volume2,
VolumeX,
Settings,
Download,
Share2,
} from 'lucide-react';
import { useToast } from './ui/Toast';
// === Types ===
export interface ClassroomScene {
id: string;
title: string;
type: 'title' | 'content' | 'quiz' | 'summary' | 'interactive';
content: {
heading?: string;
bullets?: string[];
image?: string;
explanation?: string;
quiz?: {
question: string;
options: string[];
answer: number;
};
};
narration?: string;
duration?: number; // seconds
}
export interface ClassroomData {
id: string;
title: string;
subject: string;
difficulty: '初级' | '中级' | '高级';
duration: number; // minutes
scenes: ClassroomScene[];
outline: {
sections: {
title: string;
scenes: string[];
}[];
};
createdAt: string;
}
interface ClassroomPreviewerProps {
data: ClassroomData;
onClose?: () => void;
onExport?: (format: 'pptx' | 'html' | 'pdf') => void;
}
// === Sub-Components ===
interface SceneRendererProps {
scene: ClassroomScene;
isPlaying: boolean;
showNarration: boolean;
}
function SceneRenderer({ scene, isPlaying, showNarration }: SceneRendererProps) {
const renderContent = () => {
switch (scene.type) {
case 'title':
return (
<div className="flex flex-col items-center justify-center h-full text-center p-8">
<h1 className="text-4xl font-bold text-white mb-4">
{scene.content.heading || scene.title}
</h1>
{scene.content.bullets && (
<p className="text-xl text-white/80">
{scene.content.bullets[0]}
</p>
)}
</div>
);
case 'content':
return (
<div className="p-8">
<h2 className="text-3xl font-bold text-white mb-6">
{scene.content.heading || scene.title}
</h2>
{scene.content.bullets && (
<ul className="space-y-4">
{scene.content.bullets.map((bullet, index) => (
<li
key={index}
className="flex items-start gap-3 text-lg text-white/90"
>
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-blue-500 flex items-center justify-center text-sm font-medium">
{index + 1}
</span>
<span>{bullet}</span>
</li>
))}
</ul>
)}
{scene.content.image && (
<div className="mt-6">
<img
src={scene.content.image}
alt={scene.title}
className="max-h-48 rounded-lg shadow-lg"
/>
</div>
)}
</div>
);
case 'quiz':
return (
<div className="p-8">
<h2 className="text-2xl font-bold text-white mb-6">
📝
</h2>
{scene.content.quiz && (
<div className="space-y-4">
<p className="text-xl text-white">
{scene.content.quiz.question}
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-4">
{scene.content.quiz.options.map((option, index) => (
<button
key={index}
className="p-4 bg-white/10 hover:bg-white/20 rounded-lg text-left text-white transition-colors"
>
<span className="font-medium mr-2">
{String.fromCharCode(65 + index)}.
</span>
{option}
</button>
))}
</div>
</div>
)}
</div>
);
case 'summary':
return (
<div className="p-8">
<h2 className="text-3xl font-bold text-white mb-6">
📋
</h2>
{scene.content.bullets && (
<ul className="space-y-3">
{scene.content.bullets.map((bullet, index) => (
<li
key={index}
className="flex items-center gap-2 text-lg text-white/90"
>
<span className="text-green-400"></span>
{bullet}
</li>
))}
</ul>
)}
</div>
);
default:
return (
<div className="p-8">
<h2 className="text-2xl font-bold text-white">
{scene.title}
</h2>
<p className="text-white/80 mt-4">{scene.content.explanation}</p>
</div>
);
}
};
return (
<div className="relative h-full bg-gradient-to-br from-blue-600 via-purple-600 to-indigo-700">
{/* Scene Content */}
<div className="h-full overflow-auto">
{renderContent()}
</div>
{/* Narration Overlay */}
{showNarration && scene.narration && (
<div className="absolute bottom-0 left-0 right-0 bg-black/70 p-4">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-blue-500 flex items-center justify-center">
<Volume2 className="w-5 h-5 text-white" />
</div>
<p className="text-white/90 text-sm leading-relaxed">
{scene.narration}
</p>
</div>
</div>
)}
</div>
);
}
interface OutlinePanelProps {
outline: ClassroomData['outline'];
scenes: ClassroomScene[];
currentIndex: number;
onSelectScene: (index: number) => void;
}
function OutlinePanel({
outline,
scenes,
currentIndex,
onSelectScene,
}: OutlinePanelProps) {
return (
<div className="h-full overflow-auto bg-gray-50 dark:bg-gray-800 p-4">
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
</h3>
<div className="space-y-2">
{outline.sections.map((section, sectionIndex) => (
<div key={sectionIndex}>
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
{section.title}
</p>
<div className="space-y-1">
{section.scenes.map((sceneId, sceneIndex) => {
const globalIndex = scenes.findIndex(s => s.id === sceneId);
const isActive = globalIndex === currentIndex;
const scene = scenes.find(s => s.id === sceneId);
return (
<button
key={sceneId}
onClick={() => onSelectScene(globalIndex)}
className={`w-full text-left px-3 py-2 rounded-md text-sm transition-colors ${
isActive
? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300'
: 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-300'
}`}
>
<span className="truncate">{scene?.title || sceneId}</span>
</button>
);
})}
</div>
</div>
))}
</div>
</div>
);
}
// === Main Component ===
export function ClassroomPreviewer({
data,
onClose,
onExport,
}: ClassroomPreviewerProps) {
const [currentSceneIndex, setCurrentSceneIndex] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const [showNarration, setShowNarration] = useState(true);
const [showOutline, setShowOutline] = useState(true);
const [isFullscreen, setIsFullscreen] = useState(false);
const [viewMode, setViewMode] = useState<'slides' | 'grid'>('slides');
const { showToast } = useToast();
const currentScene = data.scenes[currentSceneIndex];
const totalScenes = data.scenes.length;
// Navigation
const goToScene = useCallback((index: number) => {
if (index >= 0 && index < totalScenes) {
setCurrentSceneIndex(index);
}
}, [totalScenes]);
const nextScene = useCallback(() => {
goToScene(currentSceneIndex + 1);
}, [currentSceneIndex, goToScene]);
const prevScene = useCallback(() => {
goToScene(currentSceneIndex - 1);
}, [currentSceneIndex, goToScene]);
// Auto-play
useEffect(() => {
if (!isPlaying) return;
const duration = currentScene?.duration ? currentScene.duration * 1000 : 5000;
const timer = setTimeout(() => {
if (currentSceneIndex < totalScenes - 1) {
nextScene();
} else {
setIsPlaying(false);
showToast('课堂播放完成', 'success');
}
}, duration);
return () => clearTimeout(timer);
}, [isPlaying, currentSceneIndex, currentScene, totalScenes, nextScene, showToast]);
// Keyboard navigation
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case 'ArrowRight':
case ' ':
e.preventDefault();
nextScene();
break;
case 'ArrowLeft':
e.preventDefault();
prevScene();
break;
case 'Escape':
if (isFullscreen) {
setIsFullscreen(false);
}
break;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [nextScene, prevScene, isFullscreen]);
// Fullscreen toggle
const toggleFullscreen = () => {
setIsFullscreen(!isFullscreen);
};
// Export handler
const handleExport = (format: 'pptx' | 'html' | 'pdf') => {
if (onExport) {
onExport(format);
} else {
showToast(`导出 ${format.toUpperCase()} 功能开发中...`, 'info');
}
};
return (
<div className={`bg-white dark:bg-gray-900 rounded-lg shadow-xl overflow-hidden ${
isFullscreen ? 'fixed inset-0 z-50 rounded-none' : 'max-w-5xl w-full'
}`}>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
{data.title}
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">
{data.subject} · {data.difficulty} · {data.duration}
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleExport('pptx')}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 rounded-md hover:bg-orange-200 dark:hover:bg-orange-900/50 transition-colors"
>
<Download className="w-4 h-4" />
PPTX
</button>
<button
onClick={() => handleExport('html')}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-md hover:bg-blue-200 dark:hover:bg-blue-900/50 transition-colors"
>
<Share2 className="w-4 h-4" />
HTML
</button>
</div>
</div>
{/* Main Content */}
<div className="flex h-[500px]">
{/* Outline Panel */}
{showOutline && (
<div className="w-64 border-r border-gray-200 dark:border-gray-700 flex-shrink-0">
<OutlinePanel
outline={data.outline}
scenes={data.scenes}
currentIndex={currentSceneIndex}
onSelectScene={goToScene}
/>
</div>
)}
{/* Slide Area */}
<div className="flex-1 flex flex-col">
{/* Scene Renderer */}
<div className="flex-1 relative">
{viewMode === 'slides' ? (
<SceneRenderer
scene={currentScene}
isPlaying={isPlaying}
showNarration={showNarration}
/>
) : (
<div className="h-full overflow-auto p-4 bg-gray-100 dark:bg-gray-800">
<div className="grid grid-cols-3 gap-3">
{data.scenes.map((scene, index) => (
<button
key={scene.id}
onClick={() => goToScene(index)}
className={`aspect-video rounded-lg overflow-hidden border-2 transition-colors ${
index === currentSceneIndex
? 'border-blue-500'
: 'border-transparent hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
<div className="h-full bg-gradient-to-br from-blue-600 to-purple-600 p-2">
<p className="text-xs text-white font-medium truncate">
{scene.title}
</p>
</div>
</button>
))}
</div>
</div>
)}
</div>
{/* Control Bar */}
<div className="flex items-center justify-between p-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
{/* Left Controls */}
<div className="flex items-center gap-2">
<button
onClick={() => setShowOutline(!showOutline)}
className={`p-2 rounded-md transition-colors ${
showOutline
? 'bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400'
: 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-300'
}`}
title="大纲"
>
<List className="w-5 h-5" />
</button>
<button
onClick={() => setViewMode(viewMode === 'slides' ? 'grid' : 'slides')}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md text-gray-600 dark:text-gray-300"
title={viewMode === 'slides' ? '网格视图' : '幻灯片视图'}
>
<Grid className="w-5 h-5" />
</button>
</div>
{/* Center Controls */}
<div className="flex items-center gap-3">
<button
onClick={prevScene}
disabled={currentSceneIndex === 0}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md text-gray-600 dark:text-gray-300 disabled:opacity-50"
>
<ChevronLeft className="w-5 h-5" />
</button>
<button
onClick={() => setIsPlaying(!isPlaying)}
className="p-3 bg-blue-600 hover:bg-blue-700 text-white rounded-full transition-colors"
>
{isPlaying ? (
<Pause className="w-5 h-5" />
) : (
<Play className="w-5 h-5" />
)}
</button>
<button
onClick={nextScene}
disabled={currentSceneIndex === totalScenes - 1}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md text-gray-600 dark:text-gray-300 disabled:opacity-50"
>
<ChevronRight className="w-5 h-5" />
</button>
<span className="text-sm text-gray-500 dark:text-gray-400 min-w-[60px] text-center">
{currentSceneIndex + 1} / {totalScenes}
</span>
</div>
{/* Right Controls */}
<div className="flex items-center gap-2">
<button
onClick={() => setShowNarration(!showNarration)}
className={`p-2 rounded-md transition-colors ${
showNarration
? 'bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400'
: 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-300'
}`}
title={showNarration ? '隐藏讲解' : '显示讲解'}
>
{showNarration ? (
<Volume2 className="w-5 h-5" />
) : (
<VolumeX className="w-5 h-5" />
)}
</button>
<button
onClick={toggleFullscreen}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md text-gray-600 dark:text-gray-300"
title={isFullscreen ? '退出全屏' : '全屏'}
>
{isFullscreen ? (
<Minimize className="w-5 h-5" />
) : (
<Maximize className="w-5 h-5" />
)}
</button>
</div>
</div>
</div>
</div>
</div>
);
}
export default ClassroomPreviewer;

View File

@@ -0,0 +1,339 @@
/**
* PipelineResultPreview - Pipeline 执行结果预览组件
*
* 展示 Pipeline 执行完成后的结果,支持多种预览模式:
* - JSON 数据预览
* - Markdown 渲染
* - 文件下载列表
* - 课堂预览器(特定 Pipeline
*/
import { useState } from 'react';
import {
FileText,
Download,
ExternalLink,
Copy,
Check,
Code,
File,
Presentation,
FileSpreadsheet,
X,
} from 'lucide-react';
import { PipelineRunResponse } from '../lib/pipeline-client';
import { useToast } from './ui/Toast';
// === Types ===
interface PipelineResultPreviewProps {
result: PipelineRunResponse;
pipelineId: string;
onClose?: () => void;
}
type PreviewMode = 'auto' | 'json' | 'markdown' | 'classroom';
// === Utility Functions ===
function getFileIcon(filename: string): React.ReactNode {
const ext = filename.split('.').pop()?.toLowerCase();
switch (ext) {
case 'pptx':
case 'ppt':
return <Presentation className="w-5 h-5 text-orange-500" />;
case 'xlsx':
case 'xls':
return <FileSpreadsheet className="w-5 h-5 text-green-500" />;
case 'pdf':
return <FileText className="w-5 h-5 text-red-500" />;
case 'html':
return <Code className="w-5 h-5 text-blue-500" />;
case 'md':
case 'markdown':
return <FileText className="w-5 h-5 text-gray-500" />;
default:
return <File className="w-5 h-5 text-gray-400" />;
}
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
// === Sub-Components ===
interface FileDownloadCardProps {
file: {
name: string;
url: string;
size?: number;
};
}
function FileDownloadCard({ file }: FileDownloadCardProps) {
const handleDownload = () => {
// Create download link
const link = document.createElement('a');
link.href = file.url;
link.download = file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
return (
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
{getFileIcon(file.name)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
{file.name}
</p>
{file.size && (
<p className="text-xs text-gray-500 dark:text-gray-400">
{formatFileSize(file.size)}
</p>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => window.open(file.url, '_blank')}
className="p-1.5 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
title="在新窗口打开"
>
<ExternalLink className="w-4 h-4" />
</button>
<button
onClick={handleDownload}
className="flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-md transition-colors"
>
<Download className="w-4 h-4" />
</button>
</div>
</div>
);
}
interface JsonPreviewProps {
data: unknown;
}
function JsonPreview({ data }: JsonPreviewProps) {
const [copied, setCopied] = useState(false);
const { showToast } = useToast();
const jsonString = JSON.stringify(data, null, 2);
const handleCopy = async () => {
await navigator.clipboard.writeText(jsonString);
setCopied(true);
showToast('已复制到剪贴板', 'success');
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="relative">
<button
onClick={handleCopy}
className="absolute top-2 right-2 p-1.5 bg-gray-200 dark:bg-gray-700 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
title="复制"
>
{copied ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
</button>
<pre className="p-4 bg-gray-900 text-gray-100 rounded-lg overflow-auto text-sm max-h-96">
{jsonString}
</pre>
</div>
);
}
interface MarkdownPreviewProps {
content: string;
}
function MarkdownPreview({ content }: MarkdownPreviewProps) {
// Simple markdown rendering (for production, use a proper markdown library)
const renderMarkdown = (md: string): string => {
return md
// Headers
.replace(/^### (.*$)/gim, '<h3 class="text-lg font-semibold mt-4 mb-2">$1</h3>')
.replace(/^## (.*$)/gim, '<h2 class="text-xl font-semibold mt-4 mb-2">$1</h2>')
.replace(/^# (.*$)/gim, '<h1 class="text-2xl font-bold mt-4 mb-2">$1</h1>')
// Bold
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
// Italic
.replace(/\*(.*?)\*/g, '<em>$1</em>')
// Lists
.replace(/^- (.*$)/gim, '<li class="ml-4">$1</li>')
// Paragraphs
.replace(/\n\n/g, '</p><p class="my-2">')
// Line breaks
.replace(/\n/g, '<br>');
};
return (
<div
className="prose dark:prose-invert max-w-none p-4 bg-white dark:bg-gray-800 rounded-lg"
dangerouslySetInnerHTML={{ __html: renderMarkdown(content) }}
/>
);
}
// === Main Component ===
export function PipelineResultPreview({
result,
pipelineId,
onClose,
}: PipelineResultPreviewProps) {
const [mode, setMode] = useState<PreviewMode>('auto');
const { showToast } = useToast();
// Determine the best preview mode
const outputs = result.outputs as Record<string, unknown> | undefined;
const exportFiles = (outputs?.export_files as Array<{ name: string; url: string; size?: number }>) || [];
// Check if this is a classroom pipeline
const isClassroom = pipelineId === 'classroom-generator' || pipelineId.includes('classroom');
// Auto-detect preview mode
const autoMode: PreviewMode = isClassroom ? 'classroom' :
exportFiles.length > 0 ? 'files' :
typeof outputs === 'object' ? 'json' : 'json';
const activeMode = mode === 'auto' ? autoMode : mode;
// Render based on mode
const renderContent = () => {
switch (activeMode) {
case 'json':
return <JsonPreview data={outputs} />;
case 'markdown':
const mdContent = (outputs?.summary || outputs?.report || JSON.stringify(outputs, null, 2)) as string;
return <MarkdownPreview content={mdContent} />;
case 'classroom':
// Will be handled by ClassroomPreviewer component
return (
<div className="text-center py-8 text-gray-500">
<Presentation className="w-12 h-12 mx-auto mb-3 text-gray-400" />
<p>...</p>
<p className="text-sm mt-2"></p>
</div>
);
default:
return <JsonPreview data={outputs} />;
}
};
return (
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl max-w-3xl w-full max-h-[90vh] overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Pipeline
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">
{result.pipelineId} · {result.status === 'completed' ? '成功' : result.status}
</p>
</div>
{onClose && (
<button
onClick={onClose}
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-800 rounded"
>
<X className="w-5 h-5 text-gray-500" />
</button>
)}
</div>
{/* Mode Tabs */}
<div className="flex items-center gap-2 p-2 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
<button
onClick={() => setMode('auto')}
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
mode === 'auto'
? 'bg-white dark:bg-gray-700 text-blue-600 dark:text-blue-400 shadow-sm'
: 'text-gray-600 dark:text-gray-300 hover:bg-white dark:hover:bg-gray-700'
}`}
>
</button>
<button
onClick={() => setMode('json')}
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
mode === 'json'
? 'bg-white dark:bg-gray-700 text-blue-600 dark:text-blue-400 shadow-sm'
: 'text-gray-600 dark:text-gray-300 hover:bg-white dark:hover:bg-gray-700'
}`}
>
JSON
</button>
<button
onClick={() => setMode('markdown')}
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
mode === 'markdown'
? 'bg-white dark:bg-gray-700 text-blue-600 dark:text-blue-400 shadow-sm'
: 'text-gray-600 dark:text-gray-300 hover:bg-white dark:hover:bg-gray-700'
}`}
>
Markdown
</button>
{isClassroom && (
<button
onClick={() => setMode('classroom')}
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
mode === 'classroom'
? 'bg-white dark:bg-gray-700 text-blue-600 dark:text-blue-400 shadow-sm'
: 'text-gray-600 dark:text-gray-300 hover:bg-white dark:hover:bg-gray-700'
}`}
>
</button>
)}
</div>
{/* Content */}
<div className="p-4 overflow-auto max-h-96">
{renderContent()}
</div>
{/* Export Files */}
{exportFiles.length > 0 && (
<div className="p-4 border-t border-gray-200 dark:border-gray-700">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
({exportFiles.length})
</h3>
<div className="space-y-2">
{exportFiles.map((file, index) => (
<FileDownloadCard key={index} file={file} />
))}
</div>
</div>
)}
{/* Footer */}
<div className="flex items-center justify-end gap-3 p-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
<span className="text-xs text-gray-500 dark:text-gray-400">
: {new Date(result.startedAt).toLocaleString()}
</span>
{onClose && (
<button
onClick={onClose}
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md"
>
</button>
)}
</div>
</div>
);
}
export default PipelineResultPreview;

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

View File

@@ -0,0 +1,447 @@
/**
* Pipeline Client (Tauri)
*
* Client for discovering, running, and monitoring Pipelines.
* Pipelines are DSL-based workflows that orchestrate Skills and Hands.
*/
import { invoke } from '@tauri-apps/api/core';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
// Re-export UnlistenFn for external use
export type { UnlistenFn };
// === Types ===
export interface PipelineInputInfo {
name: string;
inputType: string;
required: boolean;
label: string;
placeholder?: string;
default?: unknown;
options: string[];
}
export interface PipelineInfo {
id: string;
displayName: string;
description: string;
category: string;
tags: string[];
icon: string;
version: string;
author: string;
inputs: PipelineInputInfo[];
}
export interface RunPipelineRequest {
pipelineId: string;
inputs: Record<string, unknown>;
}
export interface RunPipelineResponse {
runId: string;
pipelineId: string;
status: string;
}
export interface PipelineRunResponse {
runId: string;
pipelineId: string;
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
currentStep?: string;
percentage: number;
message: string;
outputs?: unknown;
error?: string;
startedAt: string;
endedAt?: string;
}
export interface PipelineCompleteEvent {
runId: string;
pipelineId: string;
status: string;
outputs?: unknown;
error?: string;
}
// === Pipeline Client ===
export class PipelineClient {
/**
* List all available pipelines
*/
static async listPipelines(options?: {
category?: string;
}): Promise<PipelineInfo[]> {
try {
const pipelines = await invoke<PipelineInfo[]>('pipeline_list', {
category: options?.category || null,
});
return pipelines;
} catch (error) {
console.error('Failed to list pipelines:', error);
throw new Error(`Failed to list pipelines: ${error}`);
}
}
/**
* Get a specific pipeline by ID
*/
static async getPipeline(pipelineId: string): Promise<PipelineInfo> {
try {
const pipeline = await invoke<PipelineInfo>('pipeline_get', {
pipelineId,
});
return pipeline;
} catch (error) {
console.error(`Failed to get pipeline ${pipelineId}:`, error);
throw new Error(`Failed to get pipeline: ${error}`);
}
}
/**
* Run a pipeline with the given inputs
*/
static async runPipeline(request: RunPipelineRequest): Promise<RunPipelineResponse> {
try {
const response = await invoke<RunPipelineResponse>('pipeline_run', {
request,
});
return response;
} catch (error) {
console.error('Failed to run pipeline:', error);
throw new Error(`Failed to run pipeline: ${error}`);
}
}
/**
* Get the progress of a running pipeline
*/
static async getProgress(runId: string): Promise<PipelineRunResponse> {
try {
const progress = await invoke<PipelineRunResponse>('pipeline_progress', {
runId,
});
return progress;
} catch (error) {
console.error(`Failed to get progress for run ${runId}:`, error);
throw new Error(`Failed to get progress: ${error}`);
}
}
/**
* Get the result of a completed pipeline run
*/
static async getResult(runId: string): Promise<PipelineRunResponse> {
try {
const result = await invoke<PipelineRunResponse>('pipeline_result', {
runId,
});
return result;
} catch (error) {
console.error(`Failed to get result for run ${runId}:`, error);
throw new Error(`Failed to get result: ${error}`);
}
}
/**
* Cancel a running pipeline
*/
static async cancel(runId: string): Promise<void> {
try {
await invoke('pipeline_cancel', { runId });
} catch (error) {
console.error(`Failed to cancel run ${runId}:`, error);
throw new Error(`Failed to cancel run: ${error}`);
}
}
/**
* List all runs
*/
static async listRuns(): Promise<PipelineRunResponse[]> {
try {
const runs = await invoke<PipelineRunResponse[]>('pipeline_runs');
return runs;
} catch (error) {
console.error('Failed to list runs:', error);
throw new Error(`Failed to list runs: ${error}`);
}
}
/**
* Refresh pipeline discovery (rescan filesystem)
*/
static async refresh(): Promise<PipelineInfo[]> {
try {
const pipelines = await invoke<PipelineInfo[]>('pipeline_refresh');
return pipelines;
} catch (error) {
console.error('Failed to refresh pipelines:', error);
throw new Error(`Failed to refresh pipelines: ${error}`);
}
}
/**
* Subscribe to pipeline completion events
*/
static async onComplete(
callback: (event: PipelineCompleteEvent) => void
): Promise<UnlistenFn> {
return listen<PipelineCompleteEvent>('pipeline-complete', (event) => {
callback(event.payload);
});
}
/**
* Run a pipeline and wait for completion
* Returns the final result
*/
static async runAndWait(
request: RunPipelineRequest,
onProgress?: (progress: PipelineRunResponse) => void,
pollIntervalMs: number = 1000
): Promise<PipelineRunResponse> {
// Start the pipeline
const { runId } = await this.runPipeline(request);
// Poll for progress until completion
let result = await this.getProgress(runId);
while (result.status === 'running' || result.status === 'pending') {
if (onProgress) {
onProgress(result);
}
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
result = await this.getProgress(runId);
}
return result;
}
}
// === Utility Functions ===
/**
* Format pipeline input type for display
*/
export function formatInputType(type: string): string {
const typeMap: Record<string, string> = {
string: '文本',
number: '数字',
boolean: '布尔值',
select: '单选',
'multi-select': '多选',
file: '文件',
text: '多行文本',
};
return typeMap[type] || type;
}
/**
* Get default value for input type
*/
export function getDefaultForType(type: string): unknown {
switch (type) {
case 'string':
case 'text':
return '';
case 'number':
return 0;
case 'boolean':
return false;
case 'select':
return null;
case 'multi-select':
return [];
case 'file':
return null;
default:
return null;
}
}
/**
* Validate pipeline inputs against schema
*/
export function validateInputs(
inputs: PipelineInputInfo[],
values: Record<string, unknown>
): { valid: boolean; errors: string[] } {
const errors: string[] = [];
for (const input of inputs) {
const value = values[input.name];
// Check required
if (input.required && (value === undefined || value === null || value === '')) {
errors.push(`${input.label || input.name} 是必填项`);
continue;
}
// Skip validation if not provided and not required
if (value === undefined || value === null) {
continue;
}
// Type-specific validation
switch (input.inputType) {
case 'number':
if (typeof value !== 'number') {
errors.push(`${input.label || input.name} 必须是数字`);
}
break;
case 'boolean':
if (typeof value !== 'boolean') {
errors.push(`${input.label || input.name} 必须是布尔值`);
}
break;
case 'select':
if (input.options.length > 0 && !input.options.includes(String(value))) {
errors.push(`${input.label || input.name} 必须是有效选项`);
}
break;
case 'multi-select':
if (!Array.isArray(value)) {
errors.push(`${input.label || input.name} 必须是数组`);
} else if (input.options.length > 0) {
const invalid = value.filter((v) => !input.options.includes(String(v)));
if (invalid.length > 0) {
errors.push(`${input.label || input.name} 包含无效选项`);
}
}
break;
}
}
return {
valid: errors.length === 0,
errors,
};
}
// === React Hook ===
import { useState, useEffect, useCallback } from 'react';
export interface UsePipelineOptions {
category?: string;
autoRefresh?: boolean;
refreshInterval?: number;
}
export function usePipelines(options: UsePipelineOptions = {}) {
const [pipelines, setPipelines] = useState<PipelineInfo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadPipelines = useCallback(async () => {
setLoading(true);
setError(null);
try {
const result = await PipelineClient.listPipelines({
category: options.category,
});
setPipelines(result);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, [options.category]);
const refresh = useCallback(async () => {
setLoading(true);
setError(null);
try {
const result = await PipelineClient.refresh();
// Filter by category if specified
const filtered = options.category
? result.filter((p) => p.category === options.category)
: result;
setPipelines(filtered);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, [options.category]);
useEffect(() => {
loadPipelines();
}, [loadPipelines]);
useEffect(() => {
if (options.autoRefresh && options.refreshInterval) {
const interval = setInterval(loadPipelines, options.refreshInterval);
return () => clearInterval(interval);
}
}, [options.autoRefresh, options.refreshInterval, loadPipelines]);
return {
pipelines,
loading,
error,
refresh,
reload: loadPipelines,
};
}
export interface UsePipelineRunOptions {
onComplete?: (result: PipelineRunResponse) => void;
onProgress?: (progress: PipelineRunResponse) => void;
}
export function usePipelineRun(options: UsePipelineRunOptions = {}) {
const [running, setRunning] = useState(false);
const [progress, setProgress] = useState<PipelineRunResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const run = useCallback(
async (pipelineId: string, inputs: Record<string, unknown>) => {
setRunning(true);
setError(null);
setProgress(null);
try {
const result = await PipelineClient.runAndWait(
{ pipelineId, inputs },
(p) => {
setProgress(p);
options.onProgress?.(p);
}
);
setProgress(result);
options.onComplete?.(result);
return result;
} catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err);
setError(errorMsg);
throw err;
} finally {
setRunning(false);
}
},
[options]
);
const cancel = useCallback(async () => {
if (progress?.runId) {
await PipelineClient.cancel(progress.runId);
setRunning(false);
}
}, [progress?.runId]);
return {
run,
cancel,
running,
progress,
error,
};
}

View File

@@ -0,0 +1,297 @@
/**
* Pipeline Recommender Service
*
* Analyzes user messages to recommend relevant Pipelines.
* Used by Agent conversation flow to proactively suggest workflows.
*/
import { PipelineInfo, PipelineClient } from './pipeline-client';
// === Types ===
export interface PipelineRecommendation {
pipeline: PipelineInfo;
confidence: number; // 0-1
matchedKeywords: string[];
suggestedInputs: Record<string, unknown>;
reason: string;
}
export interface IntentPattern {
keywords: RegExp[];
category?: string;
pipelineId?: string;
minConfidence: number;
inputSuggestions?: (message: string) => Record<string, unknown>;
}
// === Intent Patterns ===
const INTENT_PATTERNS: IntentPattern[] = [
// Education - Classroom
{
keywords: [
/课件|教案|备课|课堂|教学|ppt|幻灯片/i,
/上课|讲课|教材/i,
/生成.*课件|制作.*课件|创建.*课件/i,
],
category: 'education',
pipelineId: 'classroom-generator',
minConfidence: 0.75,
},
// Marketing - Campaign
{
keywords: [
/营销|推广|宣传|市场.*方案|营销.*策略/i,
/产品.*推广|品牌.*宣传/i,
/广告.*方案|营销.*计划/i,
/生成.*营销|制作.*营销/i,
],
category: 'marketing',
pipelineId: 'marketing-campaign',
minConfidence: 0.72,
},
// Legal - Contract Review
{
keywords: [
/合同.*审查|合同.*风险|合同.*检查/i,
/审查.*合同|检查.*合同|分析.*合同/i,
/法律.*审查|合规.*检查/i,
/合同.*条款|条款.*风险/i,
],
category: 'legal',
pipelineId: 'contract-review',
minConfidence: 0.78,
},
// Research - Literature Review
{
keywords: [
/文献.*综述|文献.*分析|文献.*检索/i,
/研究.*综述|学术.*综述/i,
/论文.*综述|论文.*调研/i,
/文献.*搜索|文献.*查找/i,
],
category: 'research',
pipelineId: 'literature-review',
minConfidence: 0.73,
},
// Productivity - Meeting Summary
{
keywords: [
/会议.*纪要|会议.*总结|会议.*记录/i,
/整理.*会议|总结.*会议/i,
/会议.*整理|纪要.*生成/i,
/待办.*事项|行动.*项/i,
],
category: 'productivity',
pipelineId: 'meeting-summary',
minConfidence: 0.70,
},
// Generic patterns for each category
{
keywords: [/帮我.*生成|帮我.*制作|帮我.*创建|自动.*生成/i],
minConfidence: 0.5,
},
];
// === Pipeline Recommender Class ===
export class PipelineRecommender {
private pipelines: PipelineInfo[] = [];
private initialized = false;
/**
* Initialize the recommender by loading pipelines
*/
async initialize(): Promise<void> {
if (this.initialized) return;
try {
this.pipelines = await PipelineClient.listPipelines();
this.initialized = true;
} catch (error) {
console.error('[PipelineRecommender] Failed to load pipelines:', error);
}
}
/**
* Refresh pipeline list
*/
async refresh(): Promise<void> {
try {
this.pipelines = await PipelineClient.refresh();
} catch (error) {
console.error('[PipelineRecommender] Failed to refresh pipelines:', error);
}
}
/**
* Analyze a user message and return pipeline recommendations
*/
async recommend(message: string): Promise<PipelineRecommendation[]> {
if (!this.initialized) {
await this.initialize();
}
const recommendations: PipelineRecommendation[] = [];
const messageLower = message.toLowerCase();
for (const pattern of INTENT_PATTERNS) {
const matches = pattern.keywords
.map(regex => regex.test(message))
.filter(Boolean);
if (matches.length === 0) continue;
const confidence = Math.min(
pattern.minConfidence + (matches.length - 1) * 0.05,
0.95
);
// Find matching pipeline
let matchingPipelines: PipelineInfo[] = [];
if (pattern.pipelineId) {
matchingPipelines = this.pipelines.filter(p => p.id === pattern.pipelineId);
} else if (pattern.category) {
matchingPipelines = this.pipelines.filter(p => p.category === pattern.category);
}
// If no specific pipeline found, recommend based on category or all
if (matchingPipelines.length === 0 && !pattern.pipelineId && !pattern.category) {
// Generic match - recommend top pipelines
matchingPipelines = this.pipelines.slice(0, 3);
}
for (const pipeline of matchingPipelines) {
const matchedKeywords = pattern.keywords
.filter(regex => regex.test(message))
.map(regex => regex.source);
const suggestion: PipelineRecommendation = {
pipeline,
confidence,
matchedKeywords,
suggestedInputs: pattern.inputSuggestions?.(message) ?? {},
reason: this.generateReason(pipeline, matchedKeywords, confidence),
};
// Avoid duplicates
if (!recommendations.find(r => r.pipeline.id === pipeline.id)) {
recommendations.push(suggestion);
}
}
}
// Sort by confidence and return top recommendations
return recommendations
.sort((a, b) => b.confidence - a.confidence)
.slice(0, 3);
}
/**
* Generate a human-readable reason for the recommendation
*/
private generateReason(
pipeline: PipelineInfo,
matchedKeywords: string[],
confidence: number
): string {
const confidenceText =
confidence >= 0.8 ? '非常适合' :
confidence >= 0.7 ? '适合' :
confidence >= 0.6 ? '可能适合' : '或许可以尝试';
if (matchedKeywords.length > 0) {
return `您的需求与【${pipeline.displayName}${confidenceText}。这个 Pipeline 可以帮助您自动化完成相关任务。`;
}
return `${pipeline.displayName}】可能对您有帮助。需要我为您启动吗?`;
}
/**
* Format recommendation for Agent message
*/
formatRecommendationForAgent(rec: PipelineRecommendation): string {
const pipeline = rec.pipeline;
let message = `我可以使用【${pipeline.displayName}】为你自动完成这个任务。\n\n`;
message += `**功能说明**: ${pipeline.description}\n\n`;
if (Object.keys(rec.suggestedInputs).length > 0) {
message += `**我已识别到以下信息**:\n`;
for (const [key, value] of Object.entries(rec.suggestedInputs)) {
message += `- ${key}: ${value}\n`;
}
message += '\n';
}
message += `需要开始吗?`;
return message;
}
/**
* Check if a message might benefit from a pipeline
*/
mightNeedPipeline(message: string): boolean {
const pipelineKeywords = [
'生成', '创建', '制作', '分析', '审查', '整理',
'总结', '归纳', '提取', '转换', '自动化',
'帮我', '请帮我', '能不能', '可以',
];
return pipelineKeywords.some(kw => message.includes(kw));
}
}
// === Singleton Instance ===
export const pipelineRecommender = new PipelineRecommender();
// === React Hook ===
import { useState, useEffect, useCallback } from 'react';
export interface UsePipelineRecommendationOptions {
autoInit?: boolean;
minConfidence?: number;
}
export function usePipelineRecommendation(options: UsePipelineRecommendationOptions = {}) {
const [recommender] = useState(() => new PipelineRecommender());
const [initialized, setInitialized] = useState(false);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (options.autoInit !== false) {
recommender.initialize().then(() => setInitialized(true));
}
}, [recommender, options.autoInit]);
const recommend = useCallback(async (message: string) => {
setLoading(true);
try {
const results = await recommender.recommend(message);
const minConf = options.minConfidence ?? 0.6;
return results.filter(r => r.confidence >= minConf);
} finally {
setLoading(false);
}
}, [recommender, options.minConfidence]);
return {
recommend,
initialized,
loading,
refresh: recommender.refresh.bind(recommender),
mightNeedPipeline: recommender.mightNeedPipeline,
formatRecommendationForAgent: recommender.formatRecommendationForAgent.bind(recommender),
};
}
export default pipelineRecommender;