'use client'; // Learner assistant — floating panel on the learner classroom (design doc // §4.4). Single-agent Q&A over the courseware knowledge pack, with optional // web search (shown as tool chips) and read-aloud via /api/tts (browser // speech fallback). Deliberately self-contained: no multi-agent chat runtime. import { useEffect, useRef, useState } from 'react'; import { Bot, Loader2, Search, Send, Sparkles, Volume2, VolumeX, X } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { useI18n } from '@/lib/hooks/use-i18n'; import { useQaChat, type QaMessage } from '@/lib/qa/use-qa-chat'; export interface AssistantPanelProps { coursewareId: string; } export function AssistantPanel({ coursewareId }: AssistantPanelProps) { const { t } = useI18n(); const [open, setOpen] = useState(false); const [input, setInput] = useState(''); const scrollRef = useRef(null); const { messages, streaming, voiceOn, setVoiceOn, send, stop } = useQaChat({ coursewareId }); useEffect(() => { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }); }, [messages, streaming]); const submit = () => { if (!input.trim() || streaming) return; void send(input); setInput(''); }; return ( <> {/* Floating toggle */} {open && (
{/* Header */}
{t('qa.title')}
{/* Messages */}
{messages.length === 0 && (
{t('qa.welcome')}
)} {messages.map((message) => ( ))} {streaming && (
{t('qa.streaming')}
)}
{/* Input */}
setInput(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') submit(); }} placeholder={t('qa.placeholder')} disabled={streaming} className="flex-1 h-9 rounded-full border border-border/70 bg-muted/40 px-3.5 text-sm outline-none focus:ring-2 focus:ring-violet-500/40 disabled:opacity-60" /> {streaming ? ( ) : ( )}
)} ); } function MessageBubble({ message }: { message: QaMessage }) { const { t } = useI18n(); const isUser = message.role === 'user'; if (isUser) { return (
{message.content}
); } const toolSearch = message.toolEvents?.find((e) => e.tool === 'web_search' && e.status === 'done'); return (
{toolSearch && (
{t('qa.searched')} {toolSearch.error ? `(${toolSearch.error})` : ''}
)}
{message.content || …} {message.error && (
{t('qa.error')}: {message.error}
)}
{message.sources && message.sources.length > 0 && (
{message.sources.slice(0, 3).map((source) => ( 《{source.title}》 ))}
)}
); }