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;