fix(desktop): DeerFlow UI — ChatArea refactor + ai-elements + dead CSS cleanup
ChatArea retry button uses setInput instead of direct sendToGateway, fix bootstrap spinner stuck for non-logged-in users, remove dead CSS (aurora-title/sidebar-open/quick-action-chips), add ai components (ReasoningBlock/StreamingText/ChatMode/ModelSelector/TaskProgress), add ClassroomPlayer + ResizableChatLayout + artifact panel Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback, useMemo, type MutableRefObjec
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { List, type ListImperativeAPI } from 'react-window';
|
||||
import { useChatStore, Message } from '../store/chatStore';
|
||||
import { useArtifactStore } from '../store/chat/artifactStore';
|
||||
import { useConnectionStore } from '../store/connectionStore';
|
||||
import { useAgentStore } from '../store/agentStore';
|
||||
import { useConfigStore } from '../store/configStore';
|
||||
@@ -12,6 +13,8 @@ import { ArtifactPanel } from './ai/ArtifactPanel';
|
||||
import { ToolCallChain } from './ai/ToolCallChain';
|
||||
import { listItemVariants, defaultTransition, fadeInVariants } from '../lib/animations';
|
||||
import { FirstConversationPrompt } from './FirstConversationPrompt';
|
||||
import { ClassroomPlayer } from './classroom_player';
|
||||
import { useClassroomStore } from '../store/classroomStore';
|
||||
// MessageSearch temporarily removed during DeerFlow redesign
|
||||
import { OfflineIndicator } from './OfflineIndicator';
|
||||
import {
|
||||
@@ -45,11 +48,14 @@ export function ChatArea() {
|
||||
messages, currentAgent, isStreaming, isLoading, currentModel,
|
||||
sendMessage: sendToGateway, setCurrentModel, initStreamListener,
|
||||
newConversation, chatMode, setChatMode, suggestions,
|
||||
artifacts, selectedArtifactId, artifactPanelOpen,
|
||||
selectArtifact, setArtifactPanelOpen,
|
||||
totalInputTokens, totalOutputTokens,
|
||||
} = useChatStore();
|
||||
const {
|
||||
artifacts, selectedArtifactId, artifactPanelOpen,
|
||||
selectArtifact, setArtifactPanelOpen,
|
||||
} = useArtifactStore();
|
||||
const connectionState = useConnectionStore((s) => s.connectionState);
|
||||
const { activeClassroom, classroomOpen, closeClassroom, generating, progressPercent, progressActivity, error: classroomError, clearError: clearClassroomError } = useClassroomStore();
|
||||
const clones = useAgentStore((s) => s.clones);
|
||||
const models = useConfigStore((s) => s.models);
|
||||
|
||||
@@ -203,9 +209,76 @@ export function ChatArea() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative h-full">
|
||||
{/* Generation progress overlay */}
|
||||
<AnimatePresence>
|
||||
{generating && (
|
||||
<motion.div
|
||||
key="generation-overlay"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="absolute inset-0 z-40 bg-white/80 dark:bg-gray-900/80 backdrop-blur-sm flex items-center justify-center"
|
||||
>
|
||||
<div className="text-center space-y-4">
|
||||
<div className="w-12 h-12 border-4 border-indigo-200 border-t-indigo-500 rounded-full animate-spin mx-auto" />
|
||||
<div>
|
||||
<p className="text-lg font-medium text-gray-900 dark:text-white">
|
||||
正在生成课堂...
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{progressActivity || '准备中...'}
|
||||
</p>
|
||||
</div>
|
||||
{progressPercent > 0 && (
|
||||
<div className="w-64 mx-auto">
|
||||
<div className="h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-indigo-500 rounded-full transition-all duration-500"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-1">{progressPercent}%</p>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => useClassroomStore.getState().cancelGeneration()}
|
||||
className="px-4 py-2 text-sm text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* ClassroomPlayer overlay */}
|
||||
<AnimatePresence>
|
||||
{classroomOpen && activeClassroom && (
|
||||
<motion.div
|
||||
key="classroom-overlay"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="absolute inset-0 z-50 bg-white dark:bg-gray-900"
|
||||
>
|
||||
<ClassroomPlayer
|
||||
onClose={closeClassroom}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<ResizableChatLayout
|
||||
chatPanel={
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Classroom generation error banner */}
|
||||
{classroomError && (
|
||||
<div className="mx-4 mt-2 px-4 py-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg flex items-center justify-between text-sm">
|
||||
<span className="text-red-600 dark:text-red-400">课堂生成失败: {classroomError}</span>
|
||||
<button onClick={clearClassroomError} className="text-red-400 hover:text-red-600 ml-3 text-xs">关闭</button>
|
||||
</div>
|
||||
)}
|
||||
{/* Header — DeerFlow-style: minimal */}
|
||||
<div className="h-14 border-b border-transparent flex items-center justify-between px-6 flex-shrink-0 bg-white dark:bg-gray-900">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
@@ -298,6 +371,7 @@ export function ChatArea() {
|
||||
getHeight={getHeight}
|
||||
onHeightChange={setHeight}
|
||||
messageRefs={messageRefs}
|
||||
setInput={setInput}
|
||||
/>
|
||||
) : (
|
||||
messages.map((message) => (
|
||||
@@ -310,7 +384,7 @@ export function ChatArea() {
|
||||
layout
|
||||
transition={defaultTransition}
|
||||
>
|
||||
<MessageBubble message={message} />
|
||||
<MessageBubble message={message} setInput={setInput} />
|
||||
</motion.div>
|
||||
))
|
||||
)}
|
||||
@@ -433,19 +507,16 @@ export function ChatArea() {
|
||||
rightPanelOpen={artifactPanelOpen}
|
||||
onRightPanelToggle={setArtifactPanelOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageBubble({ message }: { message: Message }) {
|
||||
// Tool messages are now absorbed into the assistant message's toolSteps chain.
|
||||
// Legacy standalone tool messages (from older sessions) still render as before.
|
||||
function MessageBubble({ message, setInput }: { message: Message; setInput: (text: string) => void }) {
|
||||
if (message.role === 'tool') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isUser = message.role === 'user';
|
||||
|
||||
// 思考中状态:streaming 且内容为空时显示思考指示器
|
||||
const isThinking = message.streaming && !message.content;
|
||||
|
||||
// Download message as Markdown file
|
||||
@@ -518,7 +589,20 @@ function MessageBubble({ message }: { message: Message }) {
|
||||
: '...'}
|
||||
</div>
|
||||
{message.error && (
|
||||
<p className="text-xs text-red-500 mt-2">{message.error}</p>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<p className="text-xs text-red-500">{message.error}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
const text = typeof message.content === 'string' ? message.content : '';
|
||||
if (text) {
|
||||
setInput(text);
|
||||
}
|
||||
}}
|
||||
className="text-xs px-2 py-0.5 rounded bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 hover:bg-red-200 dark:hover:bg-red-900/50 transition-colors"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* Download button for AI messages - show on hover */}
|
||||
{!isUser && message.content && !message.streaming && (
|
||||
@@ -543,6 +627,7 @@ interface VirtualizedMessageRowProps {
|
||||
message: Message;
|
||||
onHeightChange: (height: number) => void;
|
||||
messageRefs: MutableRefObject<Map<string, HTMLDivElement>>;
|
||||
setInput: (text: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -553,6 +638,7 @@ function VirtualizedMessageRow({
|
||||
message,
|
||||
onHeightChange,
|
||||
messageRefs,
|
||||
setInput,
|
||||
style,
|
||||
ariaAttributes,
|
||||
}: VirtualizedMessageRowProps & {
|
||||
@@ -587,7 +673,7 @@ function VirtualizedMessageRow({
|
||||
className="py-3"
|
||||
{...ariaAttributes}
|
||||
>
|
||||
<MessageBubble message={message} />
|
||||
<MessageBubble message={message} setInput={setInput} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -598,6 +684,7 @@ interface VirtualizedMessageListProps {
|
||||
getHeight: (id: string, role: string) => number;
|
||||
onHeightChange: (id: string, height: number) => void;
|
||||
messageRefs: MutableRefObject<Map<string, HTMLDivElement>>;
|
||||
setInput: (text: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -610,6 +697,7 @@ function VirtualizedMessageList({
|
||||
getHeight,
|
||||
onHeightChange,
|
||||
messageRefs,
|
||||
setInput,
|
||||
}: VirtualizedMessageListProps) {
|
||||
// Row component for react-window v2
|
||||
const RowComponent = (props: {
|
||||
@@ -625,6 +713,7 @@ function VirtualizedMessageList({
|
||||
message={messages[props.index]}
|
||||
onHeightChange={(h) => onHeightChange(messages[props.index].id, h)}
|
||||
messageRefs={messageRefs}
|
||||
setInput={setInput}
|
||||
style={props.style}
|
||||
ariaAttributes={props.ariaAttributes}
|
||||
/>
|
||||
|
||||
@@ -67,6 +67,7 @@ interface ClassroomPreviewerProps {
|
||||
data: ClassroomData;
|
||||
onClose?: () => void;
|
||||
onExport?: (format: 'pptx' | 'html' | 'pdf') => void;
|
||||
onOpenFullPlayer?: () => void;
|
||||
}
|
||||
|
||||
// === Sub-Components ===
|
||||
@@ -271,6 +272,7 @@ function OutlinePanel({
|
||||
export function ClassroomPreviewer({
|
||||
data,
|
||||
onExport,
|
||||
onOpenFullPlayer,
|
||||
}: ClassroomPreviewerProps) {
|
||||
const [currentSceneIndex, setCurrentSceneIndex] = useState(0);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
@@ -398,6 +400,15 @@ export function ClassroomPreviewer({
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{onOpenFullPlayer && (
|
||||
<button
|
||||
onClick={onOpenFullPlayer}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-indigo-100 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300 rounded-md hover:bg-indigo-200 dark:hover:bg-indigo-900/50 transition-colors"
|
||||
>
|
||||
<Play className="w-4 h-4" />
|
||||
完整播放器
|
||||
</button>
|
||||
)}
|
||||
<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"
|
||||
|
||||
@@ -22,13 +22,16 @@ import {
|
||||
} from '../lib/personality-presets';
|
||||
import type { Clone } from '../store/agentStore';
|
||||
import { useChatStore } from '../store/chatStore';
|
||||
import { useClassroomStore } from '../store/classroomStore';
|
||||
import { useHandStore } from '../store/handStore';
|
||||
|
||||
// Quick action chip definitions — DeerFlow-style colored pills
|
||||
// handId maps to actual Hand names in the runtime
|
||||
const QUICK_ACTIONS = [
|
||||
{ key: 'surprise', label: '小惊喜', icon: Sparkles, color: 'text-orange-500' },
|
||||
{ key: 'write', label: '写作', icon: PenLine, color: 'text-blue-500' },
|
||||
{ key: 'research', label: '研究', icon: Microscope, color: 'text-purple-500' },
|
||||
{ key: 'collect', label: '收集', icon: Layers, color: 'text-green-500' },
|
||||
{ key: 'research', label: '研究', icon: Microscope, color: 'text-purple-500', handId: 'researcher' },
|
||||
{ key: 'collect', label: '收集', icon: Layers, color: 'text-green-500', handId: 'collector' },
|
||||
{ key: 'learn', label: '学习', icon: GraduationCap, color: 'text-indigo-500' },
|
||||
];
|
||||
|
||||
@@ -69,6 +72,41 @@ export function FirstConversationPrompt({
|
||||
});
|
||||
|
||||
const handleQuickAction = (key: string) => {
|
||||
if (key === 'learn') {
|
||||
// Trigger classroom generation flow
|
||||
const classroomStore = useClassroomStore.getState();
|
||||
// Extract a clean topic from the prompt
|
||||
const prompt = QUICK_ACTION_PROMPTS[key] || '';
|
||||
const topic = prompt
|
||||
.replace(/^[你我].*?(想了解|想学|了解|学习|分析|研究|探索)\s*/g, '')
|
||||
.replace(/[,。?!].*$/g, '')
|
||||
.replace(/^(能|帮|请|可不可以).*/g, '')
|
||||
.trim() || '互动课堂';
|
||||
classroomStore.startGeneration({
|
||||
topic,
|
||||
style: 'lecture',
|
||||
level: 'intermediate',
|
||||
language: 'zh-CN',
|
||||
}).catch(() => {
|
||||
// Error is already stored in classroomStore.error and displayed in ChatArea
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this action maps to a Hand
|
||||
const actionDef = QUICK_ACTIONS.find((a) => a.key === key);
|
||||
if (actionDef?.handId) {
|
||||
const handStore = useHandStore.getState();
|
||||
handStore.triggerHand(actionDef.handId, {
|
||||
action: key === 'research' ? 'report' : 'collect',
|
||||
query: { query: QUICK_ACTION_PROMPTS[key] || '' },
|
||||
}).catch(() => {
|
||||
// Fallback: fill prompt into input bar
|
||||
onSelectSuggestion?.(QUICK_ACTION_PROMPTS[key] || '你好!');
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const prompt = QUICK_ACTION_PROMPTS[key] || '你好!';
|
||||
onSelectSuggestion?.(prompt);
|
||||
};
|
||||
|
||||
@@ -25,6 +25,8 @@ import { PipelineRunResponse } from '../lib/pipeline-client';
|
||||
import { useToast } from './ui/Toast';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { ClassroomPreviewer, type ClassroomData } from './ClassroomPreviewer';
|
||||
import { useClassroomStore } from '../store/classroomStore';
|
||||
import { adaptToClassroom } from '../lib/classroom-adapter';
|
||||
|
||||
// === Types ===
|
||||
|
||||
@@ -286,6 +288,11 @@ export function PipelineResultPreview({
|
||||
// Handle export
|
||||
handleClassroomExport(format, classroomData);
|
||||
}}
|
||||
onOpenFullPlayer={() => {
|
||||
const classroom = adaptToClassroom(classroomData);
|
||||
useClassroomStore.getState().setActiveClassroom(classroom);
|
||||
useClassroomStore.getState().openClassroom();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -109,7 +109,7 @@ export function Conversation({ children, className = '' }: ConversationProps) {
|
||||
<div
|
||||
ref={containerRef}
|
||||
onScroll={handleScroll}
|
||||
className={`overflow-y-auto custom-scrollbar ${className}`}
|
||||
className={`overflow-y-auto custom-scrollbar min-h-0 ${className}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -62,7 +62,7 @@ export function ResizableChatLayout({
|
||||
|
||||
if (!rightPanelOpen || !rightPanel) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden relative">
|
||||
<div className="h-full flex flex-col overflow-hidden relative">
|
||||
{chatPanel}
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
@@ -76,7 +76,7 @@ export function ResizableChatLayout({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
<Group
|
||||
orientation="horizontal"
|
||||
onLayoutChanged={(layout) => savePanelSizes(layout)}
|
||||
|
||||
121
desktop/src/components/classroom_player/AgentChat.tsx
Normal file
121
desktop/src/components/classroom_player/AgentChat.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* AgentChat — Multi-agent chat panel for classroom interaction.
|
||||
*
|
||||
* Displays chat bubbles from different agents (teacher, assistant, students)
|
||||
* with distinct colors and avatars. Users can send messages.
|
||||
*/
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import type { ClassroomChatMessage as ChatMessage, AgentProfile } from '../../types/classroom';
|
||||
|
||||
interface AgentChatProps {
|
||||
messages: ChatMessage[];
|
||||
agents: AgentProfile[];
|
||||
loading: boolean;
|
||||
onSend: (message: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function AgentChat({ messages, loading, onSend }: AgentChatProps) {
|
||||
const [input, setInput] = useState('');
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-scroll to bottom
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
const handleSend = async () => {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed || loading) return;
|
||||
|
||||
setInput('');
|
||||
await onSend(trimmed);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-80 border-l border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
|
||||
{/* Header */}
|
||||
<div className="px-3 py-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Classroom Chat
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div ref={scrollRef} className="flex-1 overflow-auto p-3 space-y-3">
|
||||
{messages.length === 0 ? (
|
||||
<div className="text-center text-xs text-gray-400 py-8">
|
||||
Start a conversation with the classroom
|
||||
</div>
|
||||
) : (
|
||||
messages.map((msg) => {
|
||||
const isUser = msg.role === 'user';
|
||||
|
||||
return (
|
||||
<div key={msg.id} className={`flex gap-2 ${isUser ? 'justify-end' : ''}`}>
|
||||
{/* Avatar */}
|
||||
{!isUser && (
|
||||
<span
|
||||
className="flex-shrink-0 w-7 h-7 rounded-full flex items-center justify-center text-xs"
|
||||
style={{ backgroundColor: msg.color + '20' }}
|
||||
>
|
||||
{msg.agentAvatar}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Message bubble */}
|
||||
<div className={`max-w-[200px] ${isUser ? 'text-right' : ''}`}>
|
||||
{!isUser && (
|
||||
<span className="text-xs font-medium" style={{ color: msg.color }}>
|
||||
{msg.agentName}
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
className={`text-sm px-3 py-1.5 rounded-lg ${
|
||||
isUser
|
||||
? 'bg-indigo-500 text-white'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
{msg.content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="px-3 py-2 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask a question..."
|
||||
disabled={loading}
|
||||
className="flex-1 px-2 py-1.5 text-sm rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-indigo-400 disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={loading || !input.trim()}
|
||||
className="px-3 py-1.5 text-sm rounded bg-indigo-500 text-white disabled:opacity-50 hover:bg-indigo-600"
|
||||
>
|
||||
{loading ? '...' : 'Send'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
231
desktop/src/components/classroom_player/ClassroomPlayer.tsx
Normal file
231
desktop/src/components/classroom_player/ClassroomPlayer.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* ClassroomPlayer — Full-screen interactive classroom player.
|
||||
*
|
||||
* Layout: Notes sidebar | Main stage | Chat panel
|
||||
* Top: Title + Agent avatars
|
||||
* Bottom: Scene navigation + playback controls
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useClassroom } from '../../hooks/useClassroom';
|
||||
import { SceneRenderer } from './SceneRenderer';
|
||||
import { AgentChat } from './AgentChat';
|
||||
import { NotesSidebar } from './NotesSidebar';
|
||||
import { TtsPlayer } from './TtsPlayer';
|
||||
import { Download } from 'lucide-react';
|
||||
|
||||
interface ClassroomPlayerProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ClassroomPlayer({ onClose }: ClassroomPlayerProps) {
|
||||
const {
|
||||
activeClassroom,
|
||||
chatMessages,
|
||||
chatLoading,
|
||||
sendChatMessage,
|
||||
} = useClassroom();
|
||||
|
||||
const [currentSceneIndex, setCurrentSceneIndex] = useState(0);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const [chatOpen, setChatOpen] = useState(true);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const classroom = activeClassroom;
|
||||
const scenes = classroom?.scenes ?? [];
|
||||
const agents = classroom?.agents ?? [];
|
||||
const currentScene = scenes[currentSceneIndex] ?? null;
|
||||
|
||||
// Navigate to next/prev scene
|
||||
const goNext = useCallback(() => {
|
||||
setCurrentSceneIndex((i) => Math.min(i + 1, scenes.length - 1));
|
||||
}, [scenes.length]);
|
||||
|
||||
const goPrev = useCallback(() => {
|
||||
setCurrentSceneIndex((i) => Math.max(i - 1, 0));
|
||||
}, []);
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'ArrowRight') goNext();
|
||||
else if (e.key === 'ArrowLeft') goPrev();
|
||||
else if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [goNext, goPrev, onClose]);
|
||||
|
||||
// Chat handler
|
||||
const handleChatSend = useCallback(async (message: string) => {
|
||||
const sceneContext = currentScene?.content.title;
|
||||
await sendChatMessage(message, sceneContext);
|
||||
}, [sendChatMessage, currentScene]);
|
||||
|
||||
// Export handler
|
||||
const handleExport = useCallback(async (format: 'html' | 'markdown' | 'json') => {
|
||||
if (!classroom) return;
|
||||
setExporting(true);
|
||||
try {
|
||||
const result = await invoke<{ content: string; filename: string; mimeType: string }>(
|
||||
'classroom_export',
|
||||
{ request: { classroomId: classroom.id, format } }
|
||||
);
|
||||
// Download the exported file
|
||||
const blob = new Blob([result.content], { type: result.mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = result.filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
console.error('Export failed:', e);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}, [classroom]);
|
||||
|
||||
if (!classroom) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-gray-500">
|
||||
No classroom loaded
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-gray-50 dark:bg-gray-900">
|
||||
{/* Header */}
|
||||
<header className="flex items-center justify-between px-4 py-2 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
aria-label="Close classroom"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<h1 className="text-lg font-semibold text-gray-900 dark:text-white truncate max-w-md">
|
||||
{classroom.title}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Agent avatars */}
|
||||
<div className="flex items-center gap-1">
|
||||
{agents.map((agent) => (
|
||||
<span
|
||||
key={agent.id}
|
||||
className="inline-flex items-center justify-center w-8 h-8 rounded-full text-sm"
|
||||
style={{ backgroundColor: agent.color + '20', color: agent.color }}
|
||||
title={agent.name}
|
||||
>
|
||||
{agent.avatar}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
className={`px-2 py-1 rounded text-xs ${sidebarOpen ? 'bg-indigo-100 text-indigo-700' : 'text-gray-500'}`}
|
||||
>
|
||||
Notes
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setChatOpen(!chatOpen)}
|
||||
className={`px-2 py-1 rounded text-xs ${chatOpen ? 'bg-indigo-100 text-indigo-700' : 'text-gray-500'}`}
|
||||
>
|
||||
Chat
|
||||
</button>
|
||||
{/* Export dropdown */}
|
||||
<div className="relative group">
|
||||
<button
|
||||
disabled={exporting}
|
||||
className="px-2 py-1 rounded text-xs text-gray-500 hover:text-gray-700 flex items-center gap-1"
|
||||
title="导出课堂"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
{exporting ? '...' : '导出'}
|
||||
</button>
|
||||
<div className="absolute right-0 top-full mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded shadow-lg hidden group-hover:block z-10">
|
||||
<button onClick={() => handleExport('html')} className="block w-full text-left px-3 py-1.5 text-xs text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">HTML</button>
|
||||
<button onClick={() => handleExport('markdown')} className="block w-full text-left px-3 py-1.5 text-xs text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">Markdown</button>
|
||||
<button onClick={() => handleExport('json')} className="block w-full text-left px-3 py-1.5 text-xs text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">JSON</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Notes sidebar */}
|
||||
{sidebarOpen && (
|
||||
<NotesSidebar
|
||||
scenes={scenes}
|
||||
currentIndex={currentSceneIndex}
|
||||
onSelectScene={setCurrentSceneIndex}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main stage */}
|
||||
<main className="flex-1 overflow-auto p-4">
|
||||
{currentScene ? (
|
||||
<SceneRenderer key={currentScene.id} scene={currentScene} agents={agents} />
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-gray-400">
|
||||
No scenes available
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Chat panel */}
|
||||
{chatOpen && (
|
||||
<AgentChat
|
||||
messages={chatMessages}
|
||||
agents={agents}
|
||||
loading={chatLoading}
|
||||
onSend={handleChatSend}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<footer className="flex items-center justify-between px-4 py-2 border-t border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={goPrev}
|
||||
disabled={currentSceneIndex === 0}
|
||||
className="px-3 py-1 rounded text-sm bg-gray-100 dark:bg-gray-700 disabled:opacity-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-sm text-gray-500">
|
||||
{currentSceneIndex + 1} / {scenes.length}
|
||||
</span>
|
||||
<button
|
||||
onClick={goNext}
|
||||
disabled={currentSceneIndex >= scenes.length - 1}
|
||||
className="px-3 py-1 rounded text-sm bg-gray-100 dark:bg-gray-700 disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* TTS + Scene info */}
|
||||
<div className="flex items-center gap-3">
|
||||
{currentScene?.content.notes && (
|
||||
<TtsPlayer text={currentScene.content.notes} />
|
||||
)}
|
||||
<div className="text-xs text-gray-400">
|
||||
{currentScene?.content.sceneType ?? ''}
|
||||
{currentScene?.content.durationSeconds
|
||||
? ` · ${Math.floor(currentScene.content.durationSeconds / 60)}:${String(currentScene.content.durationSeconds % 60).padStart(2, '0')}`
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
desktop/src/components/classroom_player/NotesSidebar.tsx
Normal file
71
desktop/src/components/classroom_player/NotesSidebar.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* NotesSidebar — Scene outline navigation + notes.
|
||||
*
|
||||
* Left panel showing all scenes as clickable items with notes.
|
||||
*/
|
||||
|
||||
import type { GeneratedScene } from '../../types/classroom';
|
||||
|
||||
interface NotesSidebarProps {
|
||||
scenes: GeneratedScene[];
|
||||
currentIndex: number;
|
||||
onSelectScene: (index: number) => void;
|
||||
}
|
||||
|
||||
export function NotesSidebar({ scenes, currentIndex, onSelectScene }: NotesSidebarProps) {
|
||||
return (
|
||||
<div className="w-64 border-r border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 overflow-auto">
|
||||
<div className="px-3 py-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
||||
Outline
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<nav className="py-1">
|
||||
{scenes.map((scene, i) => {
|
||||
const isActive = i === currentIndex;
|
||||
const typeColor = getTypeColor(scene.content.sceneType);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={scene.id}
|
||||
onClick={() => onSelectScene(i)}
|
||||
className={`w-full text-left px-3 py-2 text-sm border-l-2 transition-colors ${
|
||||
isActive
|
||||
? 'border-indigo-500 bg-indigo-50 dark:bg-indigo-900/20'
|
||||
: 'border-transparent hover:bg-gray-50 dark:hover:bg-gray-700/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="inline-block w-1.5 h-1.5 rounded-full"
|
||||
style={{ backgroundColor: typeColor }}
|
||||
/>
|
||||
<span className={`font-medium ${isActive ? 'text-indigo-700 dark:text-indigo-300' : 'text-gray-700 dark:text-gray-300'}`}>
|
||||
{i + 1}. {scene.content.title}
|
||||
</span>
|
||||
</div>
|
||||
{scene.content.notes && (
|
||||
<p className="text-xs text-gray-400 mt-0.5 ml-3.5 line-clamp-2">
|
||||
{scene.content.notes}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getTypeColor(type: string): string {
|
||||
switch (type) {
|
||||
case 'slide': return '#6366F1';
|
||||
case 'quiz': return '#F59E0B';
|
||||
case 'discussion': return '#10B981';
|
||||
case 'interactive': return '#8B5CF6';
|
||||
case 'pbl': return '#EF4444';
|
||||
case 'media': return '#06B6D4';
|
||||
default: return '#9CA3AF';
|
||||
}
|
||||
}
|
||||
219
desktop/src/components/classroom_player/SceneRenderer.tsx
Normal file
219
desktop/src/components/classroom_player/SceneRenderer.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* SceneRenderer — Renders a single classroom scene.
|
||||
*
|
||||
* Supports scene types: slide, quiz, discussion, interactive, text, pbl, media.
|
||||
* Executes scene actions (speech, whiteboard, quiz, discussion).
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type { GeneratedScene, SceneContent, SceneAction, AgentProfile } from '../../types/classroom';
|
||||
|
||||
interface SceneRendererProps {
|
||||
scene: GeneratedScene;
|
||||
agents: AgentProfile[];
|
||||
autoPlay?: boolean;
|
||||
}
|
||||
|
||||
export function SceneRenderer({ scene, agents, autoPlay = true }: SceneRendererProps) {
|
||||
const { content } = scene;
|
||||
const [actionIndex, setActionIndex] = useState(0);
|
||||
const [isPlaying, setIsPlaying] = useState(autoPlay);
|
||||
const [whiteboardItems, setWhiteboardItems] = useState<Array<{
|
||||
type: string;
|
||||
data: SceneAction;
|
||||
}>>([]);
|
||||
|
||||
const actions = content.actions ?? [];
|
||||
const currentAction = actions[actionIndex] ?? null;
|
||||
|
||||
// Auto-advance through actions
|
||||
useEffect(() => {
|
||||
if (!isPlaying || actions.length === 0) return;
|
||||
if (actionIndex >= actions.length) {
|
||||
setIsPlaying(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const delay = getActionDelay(actions[actionIndex]);
|
||||
const timer = setTimeout(() => {
|
||||
processAction(actions[actionIndex]);
|
||||
setActionIndex((i) => i + 1);
|
||||
}, delay);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [actionIndex, isPlaying, actions]);
|
||||
|
||||
const processAction = useCallback((action: SceneAction) => {
|
||||
switch (action.type) {
|
||||
case 'whiteboard_draw_text':
|
||||
case 'whiteboard_draw_shape':
|
||||
case 'whiteboard_draw_chart':
|
||||
case 'whiteboard_draw_latex':
|
||||
setWhiteboardItems((prev) => [...prev, { type: action.type, data: action }]);
|
||||
break;
|
||||
case 'whiteboard_clear':
|
||||
setWhiteboardItems([]);
|
||||
break;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Render scene based on type
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Scene title */}
|
||||
<div className="mb-4">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{content.title}
|
||||
</h2>
|
||||
{content.notes && (
|
||||
<p className="text-sm text-gray-500 mt-1">{content.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main content area */}
|
||||
<div className="flex-1 flex gap-4 overflow-hidden">
|
||||
{/* Content panel */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{renderContent(content)}
|
||||
</div>
|
||||
|
||||
{/* Whiteboard area */}
|
||||
{whiteboardItems.length > 0 && (
|
||||
<div className="w-80 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 p-2 overflow-auto">
|
||||
<svg viewBox="0 0 800 600" className="w-full h-full">
|
||||
{whiteboardItems.map((item, i) => (
|
||||
<g key={i}>{renderWhiteboardItem(item)}</g>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Current action indicator */}
|
||||
{currentAction && (
|
||||
<div className="mt-4 p-3 rounded-lg bg-indigo-50 dark:bg-indigo-900/20 border border-indigo-100 dark:border-indigo-800">
|
||||
{renderCurrentAction(currentAction, agents)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Playback controls */}
|
||||
<div className="flex items-center justify-center gap-2 mt-4">
|
||||
<button
|
||||
onClick={() => { setActionIndex(0); setWhiteboardItems([]); }}
|
||||
className="px-2 py-1 text-xs rounded bg-gray-100 dark:bg-gray-700"
|
||||
>
|
||||
Restart
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsPlaying(!isPlaying)}
|
||||
className="px-3 py-1 text-sm rounded bg-indigo-500 text-white"
|
||||
>
|
||||
{isPlaying ? 'Pause' : 'Play'}
|
||||
</button>
|
||||
<span className="text-xs text-gray-400">
|
||||
Action {Math.min(actionIndex + 1, actions.length)} / {actions.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getActionDelay(action: SceneAction): number {
|
||||
switch (action.type) {
|
||||
case 'speech': return 2000;
|
||||
case 'whiteboard_draw_text': return 800;
|
||||
case 'whiteboard_draw_shape': return 600;
|
||||
case 'quiz_show': return 5000;
|
||||
case 'discussion': return 10000;
|
||||
default: return 1000;
|
||||
}
|
||||
}
|
||||
|
||||
function renderContent(content: SceneContent) {
|
||||
const data = content.content;
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
|
||||
// Handle slide content
|
||||
const keyPoints = data.key_points as string[] | undefined;
|
||||
const description = data.description as string | undefined;
|
||||
const slides = data.slides as Array<{ title: string; content: string }> | undefined;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{description && (
|
||||
<p className="text-gray-700 dark:text-gray-300 leading-relaxed">{description}</p>
|
||||
)}
|
||||
{keyPoints && keyPoints.length > 0 && (
|
||||
<ul className="space-y-2">
|
||||
{keyPoints.map((point, i) => (
|
||||
<li key={i} className="flex items-start gap-2">
|
||||
<span className="text-indigo-500 mt-0.5">●</span>
|
||||
<span className="text-gray-700 dark:text-gray-300">{point}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{slides && slides.map((slide, i) => (
|
||||
<div key={i} className="p-3 rounded border border-gray-200 dark:border-gray-700">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white">{slide.title}</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">{slide.content}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderCurrentAction(action: SceneAction, agents: AgentProfile[]) {
|
||||
switch (action.type) {
|
||||
case 'speech': {
|
||||
const agent = agents.find(a => a.role === action.agentRole);
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-lg">{agent?.avatar ?? '💬'}</span>
|
||||
<div>
|
||||
<span className="text-xs font-medium text-gray-600">{agent?.name ?? action.agentRole}</span>
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300">{action.text}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case 'quiz_show':
|
||||
return <div className="text-sm text-amber-600">Quiz: {action.quizId}</div>;
|
||||
case 'discussion':
|
||||
return <div className="text-sm text-green-600">Discussion: {action.topic}</div>;
|
||||
default:
|
||||
return <div className="text-xs text-gray-400">{action.type}</div>;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function renderWhiteboardItem(item: { type: string; data: Record<string, unknown> }) {
|
||||
switch (item.type) {
|
||||
case 'whiteboard_draw_text': {
|
||||
const d = item.data;
|
||||
if ('text' in d && 'x' in d && 'y' in d) {
|
||||
return (
|
||||
<text x={typeof d.x === 'number' ? d.x : 100} y={typeof d.y === 'number' ? d.y : 100} fontSize={typeof d.fontSize === 'number' ? d.fontSize : 16} fill={typeof d.color === 'string' ? d.color : '#333'}>
|
||||
{String(d.text ?? '')}
|
||||
</text>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
case 'whiteboard_draw_shape': {
|
||||
const d = item.data as Record<string, unknown>;
|
||||
const x = typeof d.x === 'number' ? d.x : 0;
|
||||
const y = typeof d.y === 'number' ? d.y : 0;
|
||||
const w = typeof d.width === 'number' ? d.width : 100;
|
||||
const h = typeof d.height === 'number' ? d.height : 50;
|
||||
const fill = typeof d.fill === 'string' ? d.fill : '#e5e5e5';
|
||||
return (
|
||||
<rect x={x} y={y} width={w} height={h} fill={fill} />
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
155
desktop/src/components/classroom_player/TtsPlayer.tsx
Normal file
155
desktop/src/components/classroom_player/TtsPlayer.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* TtsPlayer — Text-to-Speech playback controls for classroom narration.
|
||||
*
|
||||
* Uses the browser's built-in SpeechSynthesis API.
|
||||
* Provides play/pause, speed, and volume controls.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Volume2, VolumeX, Pause, Play, Gauge } from 'lucide-react';
|
||||
|
||||
interface TtsPlayerProps {
|
||||
text: string;
|
||||
autoPlay?: boolean;
|
||||
onEnd?: () => void;
|
||||
}
|
||||
|
||||
export function TtsPlayer({ text, autoPlay = false, onEnd }: TtsPlayerProps) {
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [rate, setRate] = useState(1.0);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const utteranceRef = useRef<SpeechSynthesisUtterance | null>(null);
|
||||
|
||||
const speak = useCallback(() => {
|
||||
if (!text || typeof window === 'undefined') return;
|
||||
|
||||
window.speechSynthesis.cancel();
|
||||
|
||||
const utterance = new SpeechSynthesisUtterance(text);
|
||||
utterance.lang = 'zh-CN';
|
||||
utterance.rate = rate;
|
||||
utterance.volume = isMuted ? 0 : 1;
|
||||
|
||||
utterance.onend = () => {
|
||||
setIsPlaying(false);
|
||||
setIsPaused(false);
|
||||
onEnd?.();
|
||||
};
|
||||
utterance.onerror = () => {
|
||||
setIsPlaying(false);
|
||||
setIsPaused(false);
|
||||
};
|
||||
|
||||
utteranceRef.current = utterance;
|
||||
window.speechSynthesis.speak(utterance);
|
||||
setIsPlaying(true);
|
||||
setIsPaused(false);
|
||||
}, [text, rate, isMuted, onEnd]);
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
if (isPlaying && !isPaused) {
|
||||
window.speechSynthesis.pause();
|
||||
setIsPaused(true);
|
||||
} else if (isPaused) {
|
||||
window.speechSynthesis.resume();
|
||||
setIsPaused(false);
|
||||
} else {
|
||||
speak();
|
||||
}
|
||||
}, [isPlaying, isPaused, speak]);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
window.speechSynthesis.cancel();
|
||||
setIsPlaying(false);
|
||||
setIsPaused(false);
|
||||
}, []);
|
||||
|
||||
// Auto-play when text changes
|
||||
useEffect(() => {
|
||||
if (autoPlay && text) {
|
||||
speak();
|
||||
}
|
||||
return () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.speechSynthesis.cancel();
|
||||
}
|
||||
};
|
||||
}, [text, autoPlay, speak]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.speechSynthesis.cancel();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!text) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-3 py-2 rounded-lg bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
|
||||
{/* Play/Pause button */}
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-full bg-indigo-500 text-white hover:bg-indigo-600 transition-colors"
|
||||
aria-label={isPlaying && !isPaused ? '暂停' : '播放'}
|
||||
>
|
||||
{isPlaying && !isPaused ? (
|
||||
<Pause className="w-4 h-4" />
|
||||
) : (
|
||||
<Play className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Stop button */}
|
||||
{isPlaying && (
|
||||
<button
|
||||
onClick={stop}
|
||||
className="w-6 h-6 flex items-center justify-center rounded text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
aria-label="停止"
|
||||
>
|
||||
<VolumeX className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Speed control */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Gauge className="w-3.5 h-3.5 text-gray-400" />
|
||||
<select
|
||||
value={rate}
|
||||
onChange={(e) => setRate(Number(e.target.value))}
|
||||
className="text-xs bg-transparent border-none text-gray-600 dark:text-gray-400 cursor-pointer"
|
||||
>
|
||||
<option value={0.5}>0.5x</option>
|
||||
<option value={0.75}>0.75x</option>
|
||||
<option value={1}>1x</option>
|
||||
<option value={1.25}>1.25x</option>
|
||||
<option value={1.5}>1.5x</option>
|
||||
<option value={2}>2x</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Mute toggle */}
|
||||
<button
|
||||
onClick={() => setIsMuted(!isMuted)}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
aria-label={isMuted ? '取消静音' : '静音'}
|
||||
>
|
||||
{isMuted ? (
|
||||
<VolumeX className="w-4 h-4" />
|
||||
) : (
|
||||
<Volume2 className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Status indicator */}
|
||||
{isPlaying && (
|
||||
<span className="text-xs text-gray-400">
|
||||
{isPaused ? '已暂停' : '朗读中...'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
295
desktop/src/components/classroom_player/WhiteboardCanvas.tsx
Normal file
295
desktop/src/components/classroom_player/WhiteboardCanvas.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* WhiteboardCanvas — SVG-based whiteboard for classroom scene rendering.
|
||||
*
|
||||
* Supports incremental drawing operations:
|
||||
* - Text (positioned labels)
|
||||
* - Shapes (rectangles, circles, arrows)
|
||||
* - Charts (bar/line/pie via simple SVG)
|
||||
* - LaTeX (rendered as styled text blocks)
|
||||
*/
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import type { SceneAction } from '../../types/classroom';
|
||||
|
||||
interface WhiteboardCanvasProps {
|
||||
items: WhiteboardItem[];
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export interface WhiteboardItem {
|
||||
type: string;
|
||||
data: SceneAction;
|
||||
}
|
||||
|
||||
export function WhiteboardCanvas({
|
||||
items,
|
||||
width = 800,
|
||||
height = 600,
|
||||
}: WhiteboardCanvasProps) {
|
||||
const renderItem = useCallback((item: WhiteboardItem, index: number) => {
|
||||
switch (item.type) {
|
||||
case 'whiteboard_draw_text':
|
||||
return <TextItem key={index} data={item.data as TextDrawData} />;
|
||||
case 'whiteboard_draw_shape':
|
||||
return <ShapeItem key={index} data={item.data as ShapeDrawData} />;
|
||||
case 'whiteboard_draw_chart':
|
||||
return <ChartItem key={index} data={item.data as ChartDrawData} />;
|
||||
case 'whiteboard_draw_latex':
|
||||
return <LatexItem key={index} data={item.data as LatexDrawData} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-900 overflow-auto">
|
||||
<svg
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className="w-full h-full"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
{/* Grid background */}
|
||||
<defs>
|
||||
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
|
||||
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="#f0f0f0" strokeWidth="0.5" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width={width} height={height} fill="url(#grid)" />
|
||||
|
||||
{/* Rendered items */}
|
||||
{items.map((item, i) => renderItem(item, i))}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TextDrawData {
|
||||
type: 'whiteboard_draw_text';
|
||||
x: number;
|
||||
y: number;
|
||||
text: string;
|
||||
fontSize?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
function TextItem({ data }: { data: TextDrawData }) {
|
||||
return (
|
||||
<text
|
||||
x={data.x}
|
||||
y={data.y}
|
||||
fontSize={data.fontSize ?? 16}
|
||||
fill={data.color ?? '#333333'}
|
||||
fontFamily="system-ui, sans-serif"
|
||||
>
|
||||
{data.text}
|
||||
</text>
|
||||
);
|
||||
}
|
||||
|
||||
interface ShapeDrawData {
|
||||
type: 'whiteboard_draw_shape';
|
||||
shape: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
fill?: string;
|
||||
}
|
||||
|
||||
function ShapeItem({ data }: { data: ShapeDrawData }) {
|
||||
switch (data.shape) {
|
||||
case 'circle':
|
||||
return (
|
||||
<ellipse
|
||||
cx={data.x + data.width / 2}
|
||||
cy={data.y + data.height / 2}
|
||||
rx={data.width / 2}
|
||||
ry={data.height / 2}
|
||||
fill={data.fill ?? '#e5e7eb'}
|
||||
stroke="#9ca3af"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
);
|
||||
case 'arrow':
|
||||
return (
|
||||
<g>
|
||||
<line
|
||||
x1={data.x}
|
||||
y1={data.y + data.height / 2}
|
||||
x2={data.x + data.width}
|
||||
y2={data.y + data.height / 2}
|
||||
stroke={data.fill ?? '#6b7280'}
|
||||
strokeWidth={2}
|
||||
markerEnd="url(#arrowhead)"
|
||||
/>
|
||||
<defs>
|
||||
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill={data.fill ?? '#6b7280'} />
|
||||
</marker>
|
||||
</defs>
|
||||
</g>
|
||||
);
|
||||
default: // rectangle
|
||||
return (
|
||||
<rect
|
||||
x={data.x}
|
||||
y={data.y}
|
||||
width={data.width}
|
||||
height={data.height}
|
||||
fill={data.fill ?? '#e5e7eb'}
|
||||
stroke="#9ca3af"
|
||||
strokeWidth={1}
|
||||
rx={4}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface ChartDrawData {
|
||||
type: 'whiteboard_draw_chart';
|
||||
chartType: string;
|
||||
data: Record<string, unknown>;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
function ChartItem({ data }: { data: ChartDrawData }) {
|
||||
const chartData = data.data;
|
||||
const labels = (chartData?.labels as string[]) ?? [];
|
||||
const values = (chartData?.values as number[]) ?? [];
|
||||
|
||||
if (labels.length === 0 || values.length === 0) return null;
|
||||
|
||||
switch (data.chartType) {
|
||||
case 'bar':
|
||||
return <BarChart data={data} labels={labels} values={values} />;
|
||||
case 'line':
|
||||
return <LineChart data={data} labels={labels} values={values} />;
|
||||
default:
|
||||
return <BarChart data={data} labels={labels} values={values} />;
|
||||
}
|
||||
}
|
||||
|
||||
function BarChart({ data, labels, values }: {
|
||||
data: ChartDrawData;
|
||||
labels: string[];
|
||||
values: number[];
|
||||
}) {
|
||||
const maxVal = Math.max(...values, 1);
|
||||
const barWidth = data.width / (labels.length * 2);
|
||||
const chartHeight = data.height - 30;
|
||||
|
||||
return (
|
||||
<g transform={`translate(${data.x}, ${data.y})`}>
|
||||
{values.map((val, i) => {
|
||||
const barHeight = (val / maxVal) * chartHeight;
|
||||
return (
|
||||
<g key={i}>
|
||||
<rect
|
||||
x={i * (barWidth * 2) + barWidth / 2}
|
||||
y={chartHeight - barHeight}
|
||||
width={barWidth}
|
||||
height={barHeight}
|
||||
fill="#6366f1"
|
||||
rx={2}
|
||||
/>
|
||||
<text
|
||||
x={i * (barWidth * 2) + barWidth}
|
||||
y={data.height - 5}
|
||||
textAnchor="middle"
|
||||
fontSize={10}
|
||||
fill="#666"
|
||||
>
|
||||
{labels[i]}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function LineChart({ data, labels, values }: {
|
||||
data: ChartDrawData;
|
||||
labels: string[];
|
||||
values: number[];
|
||||
}) {
|
||||
const maxVal = Math.max(...values, 1);
|
||||
const chartHeight = data.height - 30;
|
||||
const stepX = data.width / Math.max(labels.length - 1, 1);
|
||||
|
||||
const points = values.map((val, i) => {
|
||||
const x = i * stepX;
|
||||
const y = chartHeight - (val / maxVal) * chartHeight;
|
||||
return `${x},${y}`;
|
||||
}).join(' ');
|
||||
|
||||
return (
|
||||
<g transform={`translate(${data.x}, ${data.y})`}>
|
||||
<polyline
|
||||
points={points}
|
||||
fill="none"
|
||||
stroke="#6366f1"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
{values.map((val, i) => {
|
||||
const x = i * stepX;
|
||||
const y = chartHeight - (val / maxVal) * chartHeight;
|
||||
return (
|
||||
<g key={i}>
|
||||
<circle cx={x} cy={y} r={3} fill="#6366f1" />
|
||||
<text
|
||||
x={x}
|
||||
y={data.height - 5}
|
||||
textAnchor="middle"
|
||||
fontSize={10}
|
||||
fill="#666"
|
||||
>
|
||||
{labels[i]}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
interface LatexDrawData {
|
||||
type: 'whiteboard_draw_latex';
|
||||
latex: string;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
function LatexItem({ data }: { data: LatexDrawData }) {
|
||||
return (
|
||||
<g transform={`translate(${data.x}, ${data.y})`}>
|
||||
<rect
|
||||
x={-4}
|
||||
y={-20}
|
||||
width={data.latex.length * 10 + 8}
|
||||
height={28}
|
||||
fill="#fef3c7"
|
||||
stroke="#f59e0b"
|
||||
strokeWidth={1}
|
||||
rx={4}
|
||||
/>
|
||||
<text
|
||||
x={0}
|
||||
y={0}
|
||||
fontSize={14}
|
||||
fill="#92400e"
|
||||
fontFamily="'Courier New', monospace"
|
||||
>
|
||||
{data.latex}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
12
desktop/src/components/classroom_player/index.ts
Normal file
12
desktop/src/components/classroom_player/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Classroom Player Components
|
||||
*
|
||||
* Re-exports all classroom player components.
|
||||
*/
|
||||
|
||||
export { ClassroomPlayer } from './ClassroomPlayer';
|
||||
export { SceneRenderer } from './SceneRenderer';
|
||||
export { AgentChat } from './AgentChat';
|
||||
export { NotesSidebar } from './NotesSidebar';
|
||||
export { WhiteboardCanvas } from './WhiteboardCanvas';
|
||||
export { TtsPlayer } from './TtsPlayer';
|
||||
Reference in New Issue
Block a user