157 lines
6.1 KiB
TypeScript
157 lines
6.1 KiB
TypeScript
'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<HTMLDivElement>(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 */}
|
||
<button
|
||
onClick={() => setOpen(!open)}
|
||
className="fixed bottom-5 right-5 z-50 w-12 h-12 rounded-full bg-violet-600 hover:bg-violet-500 text-white shadow-lg flex items-center justify-center transition-all"
|
||
aria-label={t('qa.toggle')}
|
||
title={t('qa.toggle')}
|
||
>
|
||
{open ? <X className="w-5 h-5" /> : <Bot className="w-5 h-5" />}
|
||
</button>
|
||
|
||
{open && (
|
||
<div className="fixed bottom-20 right-5 z-50 w-[360px] max-w-[calc(100vw-2.5rem)] h-[480px] max-h-[70vh] flex flex-col rounded-2xl border border-border/70 bg-white dark:bg-zinc-900 shadow-2xl overflow-hidden">
|
||
{/* Header */}
|
||
<div className="flex items-center gap-2 px-4 py-3 border-b border-border/60 bg-violet-50/60 dark:bg-violet-950/30">
|
||
<Sparkles className="w-4 h-4 text-violet-500" />
|
||
<span className="font-medium text-sm flex-1">{t('qa.title')}</span>
|
||
<button
|
||
onClick={() => setVoiceOn(!voiceOn)}
|
||
className="p-1.5 rounded-full hover:bg-white dark:hover:bg-zinc-800 text-gray-500"
|
||
title={voiceOn ? t('qa.voiceOn') : t('qa.voiceOff')}
|
||
>
|
||
{voiceOn ? <Volume2 className="w-4 h-4" /> : <VolumeX className="w-4 h-4" />}
|
||
</button>
|
||
</div>
|
||
|
||
{/* Messages */}
|
||
<div ref={scrollRef} className="flex-1 overflow-y-auto px-3 py-3 space-y-3">
|
||
{messages.length === 0 && (
|
||
<div className="text-xs text-muted-foreground text-center py-8 px-4 leading-relaxed">
|
||
{t('qa.welcome')}
|
||
</div>
|
||
)}
|
||
{messages.map((message) => (
|
||
<MessageBubble key={message.id} message={message} />
|
||
))}
|
||
{streaming && (
|
||
<div className="flex items-center gap-2 text-xs text-muted-foreground pl-2">
|
||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||
{t('qa.streaming')}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Input */}
|
||
<div className="border-t border-border/60 p-3 flex items-center gap-2">
|
||
<input
|
||
value={input}
|
||
onChange={(e) => 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 ? (
|
||
<Button size="icon" variant="outline" onClick={stop} className="shrink-0">
|
||
<X className="w-4 h-4" />
|
||
</Button>
|
||
) : (
|
||
<Button size="icon" onClick={submit} disabled={!input.trim()} className="shrink-0">
|
||
<Send className="w-4 h-4" />
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
function MessageBubble({ message }: { message: QaMessage }) {
|
||
const { t } = useI18n();
|
||
const isUser = message.role === 'user';
|
||
|
||
if (isUser) {
|
||
return (
|
||
<div className="flex justify-end">
|
||
<div className="max-w-[85%] rounded-2xl rounded-br-md bg-violet-600 text-white px-3.5 py-2 text-sm whitespace-pre-wrap break-words">
|
||
{message.content}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const toolSearch = message.toolEvents?.find((e) => e.tool === 'web_search' && e.status === 'done');
|
||
|
||
return (
|
||
<div className="flex justify-start">
|
||
<div className="max-w-[92%] space-y-2">
|
||
{toolSearch && (
|
||
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground bg-muted/50 rounded-full px-2.5 py-1 w-fit">
|
||
<Search className="w-3 h-3" />
|
||
{t('qa.searched')}
|
||
{toolSearch.error ? `(${toolSearch.error})` : ''}
|
||
</div>
|
||
)}
|
||
<div className="rounded-2xl rounded-bl-md bg-muted/50 dark:bg-zinc-800 px-3.5 py-2 text-sm whitespace-pre-wrap break-words">
|
||
{message.content || <span className="text-muted-foreground">…</span>}
|
||
{message.error && (
|
||
<div className="mt-1.5 text-xs text-red-500">{t('qa.error')}: {message.error}</div>
|
||
)}
|
||
</div>
|
||
{message.sources && message.sources.length > 0 && (
|
||
<div className="flex flex-wrap gap-1">
|
||
{message.sources.slice(0, 3).map((source) => (
|
||
<span
|
||
key={source.sceneId}
|
||
className="text-[10px] text-muted-foreground bg-muted/60 border border-border/50 rounded-full px-2 py-0.5"
|
||
title={`${t('qa.sceneRef')} #${source.order}`}
|
||
>
|
||
《{source.title}》
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|