fix(presentation): 修复 presentation 模块类型错误和语法问题
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

- 创建 types.ts 定义完整的类型系统
- 重写 DocumentRenderer.tsx 修复语法错误
- 重写 QuizRenderer.tsx 修复语法错误
- 重写 PresentationContainer.tsx 添加类型守卫
- 重写 TypeSwitcher.tsx 修复类型引用
- 更新 index.ts 移除不存在的 ChartRenderer 导出

审计结果:
- 类型检查: 通过
- 单元测试: 222 passed
- 构建: 成功
This commit is contained in:
iven
2026-03-26 17:19:28 +08:00
parent d0c6319fc1
commit b7f3d94950
71 changed files with 15896 additions and 1133 deletions

View File

@@ -43,6 +43,7 @@ const CATEGORY_CONFIG: Record<string, { label: string; className: string }> = {
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 (
@@ -376,24 +377,32 @@ export function PipelinesPanel() {
const [selectedPipeline, setSelectedPipeline] = useState<PipelineInfo | null>(null);
const { toast } = useToast();
const { pipelines, loading, error, refresh } = usePipelines({
category: selectedCategory ?? undefined,
});
// Fetch all pipelines without filtering
const { pipelines, loading, error, refresh } = usePipelines({});
// Get unique categories
// Get unique categories from ALL pipelines (not filtered)
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;
// 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);
@@ -474,6 +483,7 @@ export function PipelinesPanel() {
))}
</div>
)}
</div>
{/* Content */}

View File

@@ -0,0 +1,400 @@
/**
* IntentInput - 智能输入组件
*
* 提供自然语言触发 Pipeline 的入口:
* - 支持关键词/模式快速匹配
* - 显示匹配建议
* - 参数收集(对话式/表单式)
*/
import { useState, useCallback, useRef, useEffect } from 'react';
import {
Send,
Sparkles,
Loader2,
ChevronRight,
X,
MessageSquare,
FileText,
Zap,
} from 'lucide-react';
import { invoke } from '@tauri-apps/api/core';
// === Types ===
/** 路由结果 */
interface RouteResult {
type: 'matched' | 'ambiguous' | 'no_match' | 'need_more_info';
pipeline_id?: string;
display_name?: string;
mode?: 'conversation' | 'form' | 'hybrid' | 'auto';
params?: Record<string, unknown>;
confidence?: number;
missing_params?: MissingParam[];
candidates?: PipelineCandidate[];
suggestions?: PipelineCandidate[];
prompt?: string;
}
/** 缺失参数 */
interface MissingParam {
name: string;
label?: string;
param_type: string;
required: boolean;
default?: unknown;
}
/** Pipeline 候选 */
interface PipelineCandidate {
id: string;
display_name?: string;
description?: string;
icon?: string;
category?: string;
match_reason?: string;
}
/** 组件 Props */
export interface IntentInputProps {
/** 匹配成功回调 */
onMatch?: (pipelineId: string, params: Record<string, unknown>, mode: string) => void;
/** 取消回调 */
onCancel?: () => void;
/** 占位符文本 */
placeholder?: string;
/** 是否禁用 */
disabled?: boolean;
/** 自定义类名 */
className?: string;
}
// === IntentInput Component ===
export function IntentInput({
onMatch,
onCancel,
placeholder = '输入你想做的事情,如"帮我做一个Python入门课程"...',
disabled = false,
className = '',
}: IntentInputProps) {
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<RouteResult | null>(null);
const [paramValues, setParamValues] = useState<Record<string, unknown>>({});
const inputRef = useRef<HTMLTextAreaElement>(null);
// Focus input on mount
useEffect(() => {
inputRef.current?.focus();
}, []);
// Handle route request
const handleRoute = useCallback(async () => {
if (!input.trim() || loading) return;
setLoading(true);
setResult(null);
try {
const routeResult = await invoke<RouteResult>('route_intent', {
userInput: input.trim(),
});
setResult(routeResult);
// Initialize param values from extracted params
if (routeResult.params) {
setParamValues(routeResult.params);
}
// If high confidence and no missing params, auto-execute
if (
routeResult.type === 'matched' &&
routeResult.confidence &&
routeResult.confidence >= 0.9 &&
(!routeResult.missing_params || routeResult.missing_params.length === 0)
) {
handleExecute(routeResult.pipeline_id!, routeResult.params || {}, routeResult.mode!);
}
} catch (error) {
console.error('Route error:', error);
setResult({
type: 'no_match',
suggestions: [],
});
} finally {
setLoading(false);
}
}, [input, loading]);
// Handle execute
const handleExecute = useCallback(
(pipelineId: string, params: Record<string, unknown>, mode: string) => {
onMatch?.(pipelineId, params, mode);
// Reset state
setInput('');
setResult(null);
setParamValues({});
},
[onMatch]
);
// Handle param change
const handleParamChange = useCallback((name: string, value: unknown) => {
setParamValues((prev) => ({ ...prev, [name]: value }));
}, []);
// Handle key press
const handleKeyPress = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (result?.type === 'matched') {
handleExecute(result.pipeline_id!, paramValues, result.mode!);
} else {
handleRoute();
}
} else if (e.key === 'Escape') {
onCancel?.();
}
},
[result, paramValues, handleRoute, handleExecute, onCancel]
);
// Render input area
const renderInput = () => (
<div className="relative">
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyPress}
placeholder={placeholder}
disabled={disabled || loading}
rows={2}
className={`w-full px-4 py-3 pr-12 border border-gray-300 dark:border-gray-600 rounded-xl resize-none focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-800 dark:text-white disabled:opacity-50 ${className}`}
/>
<button
onClick={result?.type === 'matched' ? undefined : handleRoute}
disabled={!input.trim() || disabled || loading}
className="absolute right-3 bottom-3 p-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{loading ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<Send className="w-5 h-5" />
)}
</button>
</div>
);
// Render matched result
const renderMatched = () => {
if (!result || result.type !== 'matched') return null;
const { pipeline_id, display_name, mode, missing_params, confidence } = result;
return (
<div className="mt-3 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-xl border border-blue-200 dark:border-blue-800">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
<Sparkles className="w-5 h-5 text-blue-600" />
<span className="font-medium text-blue-700 dark:text-blue-300">
{display_name || pipeline_id}
</span>
{confidence && (
<span className="text-xs text-blue-500 dark:text-blue-400">
({Math.round(confidence * 100)}% )
</span>
)}
</div>
<button
onClick={() => setResult(null)}
className="p-1 hover:bg-blue-100 dark:hover:bg-blue-800 rounded"
>
<X className="w-4 h-4 text-blue-500" />
</button>
</div>
{/* Mode indicator */}
<div className="flex items-center gap-2 mb-3 text-sm">
<span className="text-gray-500 dark:text-gray-400">:</span>
<span className="flex items-center gap-1 px-2 py-0.5 bg-blue-100 dark:bg-blue-800 rounded">
{mode === 'conversation' && <MessageSquare className="w-3 h-3" />}
{mode === 'form' && <FileText className="w-3 h-3" />}
{mode === 'hybrid' && <Zap className="w-3 h-3" />}
{mode === 'conversation' && '对话式'}
{mode === 'form' && '表单式'}
{mode === 'hybrid' && '混合式'}
{mode === 'auto' && '自动'}
</span>
</div>
{/* Missing params form */}
{missing_params && missing_params.length > 0 && (
<div className="space-y-3 mb-4">
{missing_params.map((param) => (
<div key={param.name}>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{param.label || param.name}
{param.required && <span className="text-red-500 ml-1">*</span>}
</label>
{renderParamInput(param)}
</div>
))}
</div>
)}
{/* Execute button */}
<button
onClick={() => handleExecute(pipeline_id!, paramValues, mode!)}
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors"
>
<Zap className="w-4 h-4" />
<ChevronRight className="w-4 h-4" />
</button>
</div>
);
};
// Render param input
const renderParamInput = (param: MissingParam) => {
const value = paramValues[param.name] ?? param.default ?? '';
switch (param.param_type) {
case 'text':
return (
<textarea
value={(value as string) || ''}
onChange={(e) => handleParamChange(param.name, e.target.value)}
rows={3}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg 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) => handleParamChange(param.name, e.target.valueAsNumber)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg 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) => handleParamChange(param.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>
);
default:
return (
<input
type="text"
value={(value as string) || ''}
onChange={(e) => handleParamChange(param.name, e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
/>
);
}
};
// Render suggestions
const renderSuggestions = () => {
if (!result || result.type !== 'no_match') return null;
const { suggestions } = result;
return (
<div className="mt-3 p-4 bg-gray-50 dark:bg-gray-800/50 rounded-xl border border-gray-200 dark:border-gray-700">
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
Pipeline:
</p>
{suggestions && suggestions.length > 0 ? (
<div className="space-y-2">
{suggestions.map((candidate) => (
<button
key={candidate.id}
onClick={() => {
setInput('');
handleExecute(candidate.id, {}, 'form');
}}
className="w-full flex items-center justify-between p-3 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-blue-300 dark:hover:border-blue-600 transition-colors text-left"
>
<div>
<span className="font-medium text-gray-900 dark:text-white">
{candidate.display_name || candidate.id}
</span>
{candidate.description && (
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{candidate.description}
</p>
)}
</div>
<ChevronRight className="w-5 h-5 text-gray-400" />
</button>
))}
</div>
) : (
<p className="text-sm text-gray-500 dark:text-gray-400">
</p>
)}
</div>
);
};
// Render ambiguous results
const renderAmbiguous = () => {
if (!result || result.type !== 'ambiguous') return null;
const { candidates } = result;
return (
<div className="mt-3 p-4 bg-amber-50 dark:bg-amber-900/20 rounded-xl border border-amber-200 dark:border-amber-800">
<p className="text-sm text-amber-700 dark:text-amber-300 mb-3">
Pipeline:
</p>
<div className="space-y-2">
{candidates?.map((candidate) => (
<button
key={candidate.id}
onClick={() => handleExecute(candidate.id, paramValues, 'form')}
className="w-full flex items-center justify-between p-3 bg-white dark:bg-gray-800 rounded-lg border border-amber-200 dark:border-amber-700 hover:border-amber-300 dark:hover:border-amber-600 transition-colors text-left"
>
<div>
<span className="font-medium text-gray-900 dark:text-white">
{candidate.display_name || candidate.id}
</span>
{candidate.match_reason && (
<p className="text-sm text-amber-600 dark:text-amber-400 mt-0.5">
{candidate.match_reason}
</p>
)}
</div>
<ChevronRight className="w-5 h-5 text-amber-500" />
</button>
))}
</div>
</div>
);
};
return (
<div className="intent-input">
{renderInput()}
{renderMatched()}
{renderSuggestions()}
{renderAmbiguous()}
</div>
);
}
export default IntentInput;

View File

@@ -0,0 +1,148 @@
/**
* Presentation Container
*
* Main container for smart presentation rendering.
*
* Features:
* - Auto-detects presentation type from data structure
* - Supports manual type switching
* - Manages presentation state
* - Provides export functionality
*/
import React, { useState, useMemo, useCallback } from 'react';
import { invoke } from '@tauri-apps/api/core';
import type { PresentationType, PresentationAnalysis } from './types';
import { TypeSwitcher } from './TypeSwitcher';
import { QuizRenderer } from './renderers/QuizRenderer';
const SlideshowRenderer = React.lazy(() => import('./renderers/SlideshowRenderer').then(m => ({ default: m.SlideshowRenderer })));
const DocumentRenderer = React.lazy(() => import('./renderers/DocumentRenderer').then(m => ({ default: m.DocumentRenderer })));
interface PresentationContainerProps {
/** Pipeline output data */
data: unknown;
/** Pipeline ID (reserved for future use) */
pipelineId?: string;
/** Supported presentation types (from pipeline config) */
supportedTypes?: PresentationType[];
/** Default presentation type */
defaultType?: PresentationType;
/** Allow user to switch types */
allowSwitch?: boolean;
/** Called when export is triggered (reserved for future use) */
onExport?: (format: string) => void;
/** Custom className */
className?: string;
}
export function PresentationContainer({
data,
supportedTypes,
defaultType,
allowSwitch = true,
className = '',
}: PresentationContainerProps) {
const [analysis, setAnalysis] = useState<PresentationAnalysis | null>(null);
const [currentType, setCurrentType] = useState<PresentationType | null>(null);
const [isAnalyzing, setIsAnalyzing] = useState(true);
useMemo(() => {
const runAnalysis = async () => {
setIsAnalyzing(true);
try {
const result = await invoke<PresentationAnalysis>('analyze_presentation', { data });
setAnalysis(result);
if (defaultType) {
setCurrentType(defaultType);
} else if (result) {
setCurrentType(result.recommendedType);
}
} catch (error) {
console.error('Failed to analyze presentation:', error);
setCurrentType('document');
} finally {
setIsAnalyzing(false);
}
};
runAnalysis();
}, [data, defaultType]);
const handleTypeChange = useCallback((type: PresentationType) => {
setCurrentType(type);
}, []);
const availableTypes = useMemo(() => {
if (supportedTypes && supportedTypes.length > 0) {
return supportedTypes.filter((t): t is PresentationType => t !== 'auto');
}
return (['quiz', 'slideshow', 'document', 'whiteboard'] as PresentationType[]);
}, [supportedTypes]);
const renderContent = () => {
if (isAnalyzing) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" />
<p className="ml-3 text-gray-500">...</p>
</div>
);
}
switch (currentType) {
case 'quiz':
return <QuizRenderer data={data as Parameters<typeof QuizRenderer>[0]['data']} />;
case 'slideshow':
return (
<React.Suspense fallback={<div className="h-64 animate-pulse bg-gray-100" />}>
<SlideshowRenderer data={data as Parameters<typeof SlideshowRenderer>[0]['data']} />
</React.Suspense>
);
case 'document':
return (
<React.Suspense fallback={<div className="h-64 animate-pulse bg-gray-100" />}>
<DocumentRenderer data={data as Parameters<typeof DocumentRenderer>[0]['data']} />
</React.Suspense>
);
case 'whiteboard':
return (
<div className="flex items-center justify-center h-64 bg-gray-50">
<p className="text-gray-500">...</p>
</div>
);
default:
return (
<div className="flex items-center justify-center h-64 bg-gray-50">
<p className="text-gray-500"></p>
</div>
);
}
};
return (
<div className={`flex flex-col h-full ${className}`}>
{allowSwitch && (
<div className="border-b border-gray-200 bg-gray-50 p-3">
<TypeSwitcher
availableTypes={availableTypes}
currentType={currentType || 'document'}
analysis={analysis || undefined}
onTypeChange={handleTypeChange}
/>
</div>
)}
<div className="flex-1 overflow-auto">
{renderContent()}
</div>
</div>
);
}
export default PresentationContainer;

View File

@@ -0,0 +1,113 @@
/**
* Type Switcher Component
*
* Allows users to switch between presentation types.
*/
import {
BarChart3,
FileText,
Presentation,
CheckCircle,
PenTool,
} from 'lucide-react';
import type { PresentationType, PresentationAnalysis } from './types';
interface TypeSwitcherProps {
/** Available types */
availableTypes: PresentationType[];
/** Current type */
currentType: PresentationType;
/** Analysis result (optional) */
analysis?: PresentationAnalysis;
/** Called when type is changed */
onTypeChange: (type: PresentationType) => void;
/** Disabled types */
disabledTypes?: PresentationType[];
/** Custom className */
className?: string;
}
const typeConfig: Record<PresentationType, { icon: React.ReactNode; label: string; description: string }> = {
chart: {
icon: <BarChart3 className="w-4 h-4" />,
label: '图表',
description: '数据可视化',
},
slideshow: {
icon: <Presentation className="w-4 h-4" />,
label: '幻灯片',
description: '演示文稿风格',
},
quiz: {
icon: <CheckCircle className="w-4 h-4" />,
label: '测验',
description: '互动问答',
},
document: {
icon: <FileText className="w-4 h-4" />,
label: '文档',
description: 'Markdown 文档',
},
whiteboard: {
icon: <PenTool className="w-4 h-4" />,
label: '白板',
description: '交互式画布',
},
auto: {
icon: <CheckCircle className="w-4 h-4" />,
label: '自动',
description: '自动检测类型',
},
};
export function TypeSwitcher({
availableTypes,
currentType,
analysis,
onTypeChange,
disabledTypes = [],
className = '',
}: TypeSwitcherProps) {
return (
<div className={`flex items-center gap-2 ${className}`}>
{availableTypes.map((type) => {
const config = typeConfig[type];
if (!config) return null;
const isActive = currentType === type;
const isDisabled = disabledTypes.includes(type);
const recommendation = analysis?.recommendedType === type;
return (
<button
key={type}
onClick={() => onTypeChange(type)}
disabled={isDisabled}
className={`
flex items-center gap-2 px-3 py-2 rounded-lg transition-all
${isActive
? 'bg-blue-100 text-blue-700 border-2 border-blue-500'
: 'bg-white text-gray-600 border border-gray-200 hover:bg-gray-100'
}
${isDisabled ? 'opacity-50 cursor-not-allowed' : ''}
`}
title={config.description}
>
<span className="text-lg">{config.icon}</span>
<span className="text-sm font-medium">{config.label}</span>
{recommendation && (
<span className="text-xs text-blue-500"></span>
)}
</button>
);
})}
{analysis && (
<div className="ml-4 text-xs text-gray-500">
<p>: {(analysis.confidence * 100).toFixed(0)}%</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,33 @@
/**
* Presentation Components
*
* Smart presentation layer for Pipeline output rendering.
*
* @example
* ```tsx
* import { PresentationContainer } from '@/components/presentation';
*
* <PresentationContainer
* data={pipelineOutput}
* pipelineId="course-generator"
* supportedTypes={['slideshow', 'quiz', 'document']}
* />
* ```
*/
export { PresentationContainer } from './PresentationContainer';
export { TypeSwitcher } from './TypeSwitcher';
export { QuizRenderer } from './renderers/QuizRenderer';
export { DocumentRenderer } from './renderers/DocumentRenderer';
export { SlideshowRenderer } from './renderers/SlideshowRenderer';
export type {
PresentationType,
PresentationAnalysis,
ChartData,
QuizData,
QuizQuestion,
QuestionType,
SlideshowData,
DocumentData,
WhiteboardData,
} from './types';

View File

@@ -0,0 +1,150 @@
/**
* Document Renderer
*
* Renders content as a scrollable document with Markdown support.
*/
import { useState } from 'react';
import { Download, ExternalLink, Copy } from 'lucide-react';
import type { DocumentData } from '../types';
interface DocumentRendererProps {
/** Document data */
data: DocumentData;
/** Enable markdown rendering */
enableMarkdown?: boolean;
/** Custom className */
className?: string;
}
export function DocumentRenderer({
data,
enableMarkdown = true,
className = '',
}: DocumentRendererProps) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
const textToCopy = typeof data === 'string' ? data : (data.content || JSON.stringify(data, null, 2));
await navigator.clipboard.writeText(textToCopy);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (error) {
console.error('Failed to copy:', error);
}
};
const handleDownload = () => {
if (data.downloadUrl) {
const link = document.createElement('a');
link.href = data.downloadUrl;
link.download = data.downloadFilename || 'document.md';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
};
const renderMarkdown = (content: string): React.ReactNode => {
const lines = content.split('\n');
const elements: React.ReactNode[] = [];
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
if (trimmed.startsWith('# ')) {
elements.push(
<h1 key={trimmed} className="text-2xl font-bold mb-4">
{trimmed.substring(2)}
</h1>
);
} else if (trimmed.startsWith('## ')) {
elements.push(
<h2 key={trimmed} className="text-xl font-semibold mb-3">
{trimmed.substring(3)}
</h2>
);
} else if (trimmed.startsWith('### ')) {
elements.push(
<h3 key={trimmed} className="text-lg font-medium mb-2">
{trimmed.substring(4)}
</h3>
);
} else if (trimmed.startsWith('- ')) {
elements.push(
<li key={trimmed} className="ml-4 list-disc">
{trimmed.substring(2)}
</li>
);
} else if (trimmed.startsWith('```')) {
elements.push(
<pre key={trimmed} className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-sm my-2">
<code>{trimmed.substring(3, trimmed.length - 3)}</code>
</pre>
);
} else {
elements.push(
<p key={trimmed} className="mb-2">{trimmed}</p>
);
}
}
return <div className={className}>{elements}</div>;
};
if (!enableMarkdown) {
return (
<div className={`flex flex-col h-full ${className}`}>
<pre className="whitespace-pre-wrap text-sm">{JSON.stringify(data, null, 2)}</pre>
</div>
);
}
return (
<div className={`flex flex-col h-full ${className}`}>
{data.title && (
<div className="flex items-center justify-between p-4 border-b border-gray-200">
<h1 className="text-xl font-semibold text-gray-900">{data.title}</h1>
<div className="flex items-center gap-2">
<button
onClick={handleCopy}
className="p-1 text-gray-400 hover:text-gray-600 flex items-center gap-1"
title="复制"
>
<Copy className="w-4 h-4" />
{copied && <span className="text-xs text-green-500"></span>}
</button>
{data.downloadUrl && (
<button
onClick={handleDownload}
className="p-1 text-gray-400 hover:text-gray-600"
title="下载"
>
<Download className="w-4 h-4" />
</button>
)}
{data.url && (
<button
onClick={() => window.open(data.url, '_blank')}
className="p-1 text-gray-400 hover:text-gray-600"
title="在新窗口打开"
>
<ExternalLink className="w-4 h-4" />
</button>
)}
</div>
</div>
)}
<div className="flex-1 overflow-auto p-6">
{typeof data === 'string'
? renderMarkdown(data)
: renderMarkdown(data.content || JSON.stringify(data))}
</div>
</div>
);
}
export default DocumentRenderer;

View File

@@ -0,0 +1,354 @@
/**
* Quiz Renderer
*
* Renders interactive quizzes with support for:
* - Single choice
* - Multiple choice
* - True/False
* - Fill in blank
* - Short answer
*/
import { useState, useMemo } from 'react';
import {
CheckCircle,
XCircle,
Award,
RotateCcw,
} from 'lucide-react';
import type { QuizData, QuizQuestion } from '../types';
interface QuizRendererProps {
data: QuizData;
onComplete?: (score: number, correct: number, total: number) => void;
onAnswer?: (questionId: string, answer: unknown) => void;
showAnswers?: boolean;
allowRetry?: boolean;
className?: string;
}
interface UserAnswer {
questionId: string;
answer: unknown;
isCorrect: boolean;
}
export function QuizRenderer({
data,
onComplete,
onAnswer,
showAnswers = true,
allowRetry = true,
className = '',
}: QuizRendererProps) {
const [currentIndex, setCurrentIndex] = useState(0);
const [answers, setAnswers] = useState<Record<string, UserAnswer>>({});
const [showResults, setShowResults] = useState(showAnswers ?? false);
const [score, setScore] = useState(0);
const [correctCount, setCorrectCount] = useState(0);
const [isCompleted, setIsCompleted] = useState(false);
const checkAnswer = (answer: unknown, question: QuizQuestion): boolean => {
if (question.questionType === 'singleChoice' || question.questionType === 'trueFalse') {
return answer === question.correctAnswer;
}
if (question.questionType === 'multipleChoice') {
const answerArr = answer as string[];
const correctArr = question.correctAnswer as string[];
if (!Array.isArray(answerArr) || !Array.isArray(correctArr)) return false;
return JSON.stringify([...answerArr].sort()) === JSON.stringify([...correctArr].sort());
}
if (question.questionType === 'fillBlank' || question.questionType === 'shortAnswer') {
return String(answer).toLowerCase().trim() === String(question.correctAnswer).toLowerCase().trim();
}
return false;
};
useMemo(() => {
if (!data.questions || data.questions.length === 0) return;
const total = data.questions.length;
const correct = data.questions.filter((q: QuizQuestion) => {
const userAnswer = answers[q.id];
return userAnswer?.isCorrect ?? false;
}).length;
setScore(Math.round((correct / total) * 100));
setCorrectCount(correct);
}, [answers, data.questions]);
const handleSelectAnswer = (questionId: string, answer: unknown) => {
const question = data.questions.find((q: QuizQuestion) => q.id === questionId);
if (!question) return;
const isCorrect = checkAnswer(answer, question);
setAnswers(prev => ({
...prev,
[questionId]: { questionId, answer, isCorrect },
}));
if (onAnswer) {
onAnswer(questionId, answer);
}
};
const handleNext = () => {
if (currentIndex < data.questions.length - 1) {
setCurrentIndex(currentIndex + 1);
}
};
const handlePrev = () => {
if (currentIndex > 0) {
setCurrentIndex(currentIndex - 1);
}
};
const handleSubmit = () => {
setShowResults(true);
setIsCompleted(true);
if (onComplete) {
onComplete(score, correctCount, data.questions.length);
}
};
const handleRetry = () => {
setAnswers({});
setShowResults(false);
setIsCompleted(false);
setScore(0);
setCorrectCount(0);
setCurrentIndex(0);
};
const question = data.questions[currentIndex];
if (!question) return null;
const progressPercent = ((currentIndex + 1) / data.questions.length) * 100;
const renderQuestionOptions = () => {
const qType = question.questionType;
if (qType === 'singleChoice' || qType === 'trueFalse') {
return (
<div className="space-y-3">
{question.options.map((option) => {
const isSelected = answers[question.id]?.answer === option.id;
const showCorrect = showResults && question.correctAnswer === option.id;
const showIncorrect = showResults && isSelected && !showCorrect;
return (
<button
key={option.id}
onClick={() => !showResults && handleSelectAnswer(question.id, option.id)}
disabled={showResults}
className={`w-full p-4 text-left rounded-lg border-2 transition-all ${
isSelected && !showResults ? 'border-blue-500 bg-blue-50' : ''
} ${showCorrect ? 'border-green-500 bg-green-50' : ''} ${
showIncorrect ? 'border-red-500 bg-red-50' : ''
} ${!isSelected && !showCorrect ? 'border-gray-200' : ''}`}
>
<div className="flex items-center justify-between">
<span className="flex-1">{option.text}</span>
{showCorrect && <CheckCircle className="w-5 h-5 text-green-500" />}
{showIncorrect && <XCircle className="w-5 h-5 text-red-500" />}
</div>
</button>
);
})}
</div>
);
}
if (qType === 'multipleChoice') {
return (
<div className="space-y-3">
{question.options.map((option) => {
const selectedAnswers = (answers[question.id]?.answer as string[]) || [];
const isSelected = selectedAnswers.includes(option.id);
const showCorrect = showResults && (question.correctAnswer as string[]).includes(option.id);
return (
<button
key={option.id}
onClick={() => {
if (showResults) return;
const newAnswers = isSelected
? selectedAnswers.filter(a => a !== option.id)
: [...selectedAnswers, option.id];
handleSelectAnswer(question.id, newAnswers);
}}
disabled={showResults}
className={`w-full p-4 text-left rounded-lg border-2 transition-all ${
isSelected && !showResults ? 'border-blue-500 bg-blue-50' : ''
} ${showCorrect ? 'border-green-500 bg-green-50' : ''} ${
!isSelected && !showCorrect && showResults ? 'border-gray-200 opacity-50' : ''
}`}
>
<div className="flex items-center gap-3">
<input
type="checkbox"
checked={isSelected}
onChange={() => {}}
className="w-4 h-4 rounded"
disabled={showResults}
/>
<span className="flex-1">{option.text}</span>
{showCorrect && <CheckCircle className="w-5 h-5 text-green-500" />}
</div>
</button>
);
})}
</div>
);
}
if (qType === 'fillBlank') {
return (
<div className="mt-4">
<input
type="text"
placeholder="请输入答案..."
className="w-full p-3 border rounded-lg focus:ring-2 focus:ring-blue-500"
onChange={(e) => handleSelectAnswer(question.id, e.target.value)}
disabled={showResults}
/>
{showResults && (
<p className="text-sm text-gray-500 mt-2">
: {question.correctAnswer}
</p>
)}
</div>
);
}
if (qType === 'shortAnswer') {
return (
<div className="mt-4">
<textarea
placeholder="请输入你的答案..."
className="w-full p-3 border rounded-lg focus:ring-2 focus:ring-blue-500 min-h-32"
onChange={(e) => handleSelectAnswer(question.id, e.target.value)}
disabled={showResults}
/>
{showResults && (
<p className="text-sm text-gray-500 mt-2">
: {question.correctAnswer}
</p>
)}
</div>
);
}
return null;
};
return (
<div className={`flex flex-col h-full ${className}`}>
<div className="bg-white border-b border-gray-200 p-4">
<div className="flex items-center justify-between">
<div>
{data.title && (
<h2 className="text-lg font-semibold text-gray-900">{data.title}</h2>
)}
{data.description && (
<p className="text-sm text-gray-500">{data.description}</p>
)}
</div>
<div className="flex items-center gap-2 mt-4">
<div className="flex-1 bg-gray-200 rounded-full h-2">
<div
className="h-2 bg-blue-500 transition-all"
style={{ width: `${progressPercent}%` }}
/>
</div>
<span className="text-sm text-gray-600">
{currentIndex + 1} / {data.questions.length}
</span>
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6 flex-1">
<div className="mb-4">
<p className="text-lg font-medium text-gray-900">{question.text}</p>
{question.hint && !showResults && (
<p className="text-sm text-gray-500 mt-2">
💡 {question.hint}
</p>
)}
</div>
{renderQuestionOptions()}
<div className="flex items-center justify-between mt-6">
<button
onClick={handlePrev}
disabled={currentIndex === 0}
className="p-2 text-gray-600 hover:text-gray-900 disabled:opacity-50"
>
<RotateCcw className="w-5 h-5" />
</button>
<span className="text-sm text-gray-500">
{currentIndex + 1} / {data.questions.length}
</span>
<button
onClick={handleNext}
disabled={currentIndex === data.questions.length - 1 || showResults}
className="p-2 text-gray-600 hover:text-gray-900 disabled:opacity-50"
>
</button>
</div>
{!showResults ? (
<button
onClick={handleSubmit}
className="w-full py-3 bg-blue-500 text-white rounded-lg font-medium hover:bg-blue-600 transition-colors mt-4"
>
</button>
) : (
<div className="space-y-4 mt-4">
<div className="flex items-center justify-center gap-4 p-4 bg-gray-50 rounded-lg">
<div className="text-center">
<div className="text-3xl font-bold text-gray-900">
{score}%
</div>
<div className="text-sm text-gray-500">
{correctCount} / {data.questions.length}
</div>
</div>
</div>
{allowRetry && (
<button
onClick={handleRetry}
className="w-full py-2 text-blue-600 hover:bg-blue-50 rounded-lg font-medium transition-colors"
>
</button>
)}
</div>
)}
</div>
{isCompleted && (
<div className="bg-green-50 p-4 text-center">
<Award className="w-8 h-8 text-green-500 mx-auto mb-2" />
<p className="text-lg font-semibold text-green-700">
🎉
</p>
<p className="text-sm text-green-600">
: {score}% ({correctCount}/{data.questions.length} )
</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,172 @@
/**
* Slideshow Renderer
*
* Renders presentation as a slideshow with slide navigation.
*/
import { useState, useEffect, useCallback } from 'react';
import {
ChevronLeft,
ChevronRight,
Maximize2,
Minimize2,
Play,
Pause,
} from 'lucide-react';
import type { SlideshowData } from '../types';
interface SlideshowRendererProps {
data: SlideshowData;
/** Auto-play interval in seconds (0 = disabled) */
autoPlayInterval?: number;
/** Show progress indicator */
showProgress?: boolean;
/** Show speaker notes */
showNotes?: boolean;
/** Custom className */
className?: string;
}
export function SlideshowRenderer({
data,
autoPlayInterval = 0,
showProgress = true,
showNotes = true,
className = '',
}: SlideshowRendererProps) {
const [currentIndex, setCurrentIndex] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const slides = data.slides || [];
const totalSlides = slides.length;
// Handle keyboard navigation
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'ArrowRight' || e.key === ' ') {
handleNext();
} else if (e.key === 'ArrowLeft') {
handlePrev();
} else if (e.key === 'f') {
toggleFullscreen();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
// Auto-play
useEffect(() => {
if (isPlaying && autoPlayInterval > 0) {
const timer = setInterval(handleNext, autoPlayInterval * 1000);
return () => clearInterval(timer);
}
}, [isPlaying, autoPlayInterval]);
const handleNext = useCallback(() => {
setCurrentIndex((prev) => (prev + 1) % totalSlides);
}, [totalSlides]);
const handlePrev = useCallback(() => {
setCurrentIndex((prev) => (prev - 1 + totalSlides) % totalSlides);
}, [totalSlides]);
const toggleFullscreen = useCallback(() => {
setIsFullscreen((prev) => !prev);
}, []);
const currentSlide = slides[currentIndex];
if (!currentSlide) {
return (
<div className={`flex items-center justify-center h-64 bg-gray-50 ${className}`}>
<p className="text-gray-500"></p>
</div>
);
}
return (
<div className={`flex flex-col h-full ${isFullscreen ? 'fixed inset-0 z-50 bg-white' : ''} ${className}`}>
{/* Slide Content */}
<div className="flex-1 flex items-center justify-center p-8">
<div className="max-w-4xl w-full">
{/* Title */}
{currentSlide.title && (
<h2 className="text-3xl font-bold text-center mb-6">
{currentSlide.title}
</h2>
)}
{/* Content rendering would go here */}
<div className="text-gray-700">
{/* This is simplified - real implementation would render based on content type */}
{typeof currentSlide.content === 'string' ? (
<p>{currentSlide.content}</p>
) : (
<div>Complex content rendering</div>
)}
</div>
</div>
</div>
{/* Controls */}
<div className="flex items-center justify-between p-4 bg-gray-50 border-t">
<div className="flex items-center gap-2">
<button
onClick={handlePrev}
disabled={totalSlides <= 1}
className="p-2 hover:bg-gray-200 rounded disabled:opacity-50"
>
<ChevronLeft className="w-5 h-5" />
</button>
<button
onClick={() => setIsPlaying(!isPlaying)}
disabled={autoPlayInterval === 0}
className="p-2 hover:bg-gray-200 rounded disabled:opacity-50"
>
{isPlaying ? <Pause className="w-5 h-5" /> : <Play className="w-5 h-5" />}
</button>
<button
onClick={handleNext}
disabled={totalSlides <= 1}
className="p-2 hover:bg-gray-200 rounded disabled:opacity-50"
>
<ChevronRight className="w-5 h-5" />
</button>
</div>
{/* Progress */}
{showProgress && (
<div className="text-sm text-gray-500">
{currentIndex + 1} / {totalSlides}
</div>
)}
{/* Fullscreen */}
<button
onClick={toggleFullscreen}
className="p-2 hover:bg-gray-200 rounded"
>
{isFullscreen ? (
<Minimize2 className="w-5 h-5" />
) : (
<Maximize2 className="w-5 h-5" />
)}
</button>
</div>
{/* Speaker Notes */}
{showNotes && currentSlide.notes && (
<div className="p-4 bg-yellow-50 border-t text-sm text-gray-600">
📝 {currentSlide.notes}
</div>
)}
</div>
);
}
export default SlideshowRenderer;

View File

@@ -0,0 +1,145 @@
/**
* Presentation Types
*
* Type definitions for the presentation layer.
* Used by renderers and container components.
*/
export type PresentationType =
| 'chart'
| 'quiz'
| 'slideshow'
| 'document'
| 'whiteboard'
| 'auto';
export interface PresentationAnalysis {
recommendedType: PresentationType;
confidence: number;
detectedFeatures: string[];
metadata?: Record<string, unknown>;
}
export interface ChartData {
type: 'line' | 'bar' | 'pie' | 'scatter' | 'area';
title?: string;
labels?: string[];
datasets: ChartDataset[];
options?: ChartOptions;
}
export interface ChartDataset {
label: string;
data: number[];
backgroundColor?: string | string[];
borderColor?: string | string[];
fill?: boolean;
}
export interface ChartOptions {
responsive?: boolean;
maintainAspectRatio?: boolean;
plugins?: {
legend?: {
display?: boolean;
position?: 'top' | 'bottom' | 'left' | 'right';
};
title?: {
display?: boolean;
text?: string;
};
};
scales?: Record<string, unknown>;
}
export interface QuizData {
title?: string;
description?: string;
questions: QuizQuestion[];
timeLimit?: number;
passingScore?: number;
}
export interface QuizQuestion {
id: string;
text: string;
questionType: QuestionType;
options: QuizOption[];
correctAnswer: string | string[];
hint?: string;
explanation?: string;
points?: number;
}
export type QuestionType =
| 'singleChoice'
| 'multipleChoice'
| 'trueFalse'
| 'fillBlank'
| 'shortAnswer';
export interface QuizOption {
id: string;
text: string;
isCorrect?: boolean;
}
export interface SlideshowData {
title?: string;
slides: Slide[];
theme?: SlideshowTheme;
autoPlay?: boolean;
interval?: number;
}
export interface Slide {
id: string;
type: 'title' | 'content' | 'image' | 'code' | 'twoColumn';
title?: string;
content?: string;
image?: string;
code?: string;
language?: string;
leftContent?: string;
rightContent?: string;
notes?: string;
}
export interface SlideshowTheme {
backgroundColor?: string;
textColor?: string;
accentColor?: string;
fontFamily?: string;
}
export interface DocumentData {
title?: string;
content?: string;
format?: 'markdown' | 'html' | 'plain';
downloadUrl?: string;
downloadFilename?: string;
url?: string;
}
export interface WhiteboardData {
title?: string;
elements: WhiteboardElement[];
background?: string;
gridSize?: number;
}
export interface WhiteboardElement {
id: string;
type: 'rect' | 'circle' | 'line' | 'text' | 'image' | 'path';
x: number;
y: number;
width?: number;
height?: number;
fill?: string;
stroke?: string;
strokeWidth?: number;
text?: string;
fontSize?: number;
src?: string;
points?: number[];
}