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:
iven
2026-04-02 19:24:44 +08:00
parent d40c4605b2
commit 28299807b6
70 changed files with 4938 additions and 618 deletions

View File

@@ -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}
/>

View File

@@ -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"

View File

@@ -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);
};

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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)}

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

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

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

View 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} />
);
}
}
}

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

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

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