Files
openmaic/OpenMAIC/components/qa/assistant-panel.tsx
2026-08-16 14:58:47 +08:00

157 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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