'use client'; // Learner assistant client — consumes the /api/qa SSE stream and /api/tts // voice synthesis. Self-contained (no dependency on the classroom chat // runtime): messages, tool events (web search), sources, and optional // read-aloud with a browser-speech fallback. import { useCallback, useRef, useState } from 'react'; export interface QaSource { sceneId: string; order: number; title: string; type: string; score: number; } export interface QaToolEvent { tool: 'web_search'; query: string; status: 'started' | 'done'; resultCount?: number; error?: string; } export interface QaMessage { id: string; role: 'user' | 'assistant'; content: string; toolEvents?: QaToolEvent[]; sources?: QaSource[]; error?: string; } export interface UseQaChatOptions { coursewareId: string; /** Compact learner profile digest (optional). */ userProfile?: string; /** Allow the agent's web_search tool. */ webSearch?: boolean; } function nextId(): string { return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } /** Parse `data: {json}` SSE frames from the response body. */ async function* readSseEvents(response: Response): AsyncGenerator> { const reader = response.body?.getReader(); if (!reader) return; const decoder = new TextDecoder(); let buffer = ''; try { for (;;) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); let sep: number; while ((sep = buffer.indexOf('\n\n')) !== -1) { const frame = buffer.slice(0, sep); buffer = buffer.slice(sep + 2); const line = frame .split('\n') .find((l) => l.startsWith('data:')); if (!line) continue; try { yield JSON.parse(line.slice(5).trim()) as Record; } catch { // ignore malformed frames } } } } finally { reader.releaseLock(); } } export function useQaChat(options: UseQaChatOptions) { const { coursewareId, userProfile, webSearch = true } = options; const [messages, setMessages] = useState([]); const [streaming, setStreaming] = useState(false); const [voiceOn, setVoiceOn] = useState(true); const abortRef = useRef(null); const speakingRef = useRef(null); /** Synthesize and play an answer; browser speech as fallback. */ const speak = useCallback(async (text: string) => { if (!text.trim()) return; speakingRef.current?.pause(); try { const response = await fetch('/api/tts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: text.slice(0, 2000) }), }); if (response.ok) { const blob = await response.blob(); const url = URL.createObjectURL(blob); const audio = new Audio(url); speakingRef.current = audio; await audio.play(); return; } } catch { // fall through to browser speech } if ('speechSynthesis' in window) { window.speechSynthesis.cancel(); const utterance = new SpeechSynthesisUtterance(text); utterance.lang = 'zh-CN'; window.speechSynthesis.speak(utterance); } }, []); const send = useCallback( async (text: string) => { const trimmed = text.trim(); if (!trimmed || streaming || !coursewareId) return; abortRef.current?.abort(); const controller = new AbortController(); abortRef.current = controller; const userMessage: QaMessage = { id: nextId(), role: 'user', content: trimmed }; const assistantId = nextId(); setMessages((prev) => [...prev, userMessage]); setMessages((prev) => [ ...prev, { id: assistantId, role: 'assistant', content: '', toolEvents: [] }, ]); setStreaming(true); let finalText = ''; const toolEvents: QaToolEvent[] = []; let sources: QaSource[] = []; let error: string | undefined; try { const response = await fetch('/api/qa', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ coursewareId, messages: [ ...(userProfile ? [{ role: 'user' as const, content: userProfile }] : []), { role: 'user', content: trimmed }, ], webSearch, }), signal: controller.signal, }); if (!response.ok) { const body = (await response.json().catch(() => ({}))) as { error?: string; details?: string; }; error = body.details ?? body.error ?? `HTTP ${response.status}`; } else { for await (const event of readSseEvents(response)) { if (event.type === 'text' && typeof event.delta === 'string') { finalText += event.delta; setMessages((prev) => prev.map((m) => (m.id === assistantId ? { ...m, content: finalText } : m)), ); } else if (event.type === 'tool') { toolEvents.push(event as unknown as QaToolEvent); setMessages((prev) => prev.map((m) => (m.id === assistantId ? { ...m, toolEvents: [...toolEvents] } : m)), ); } else if (event.type === 'done') { sources = (event.sources as QaSource[]) ?? []; setMessages((prev) => prev.map((m) => (m.id === assistantId ? { ...m, sources } : m)), ); } else if (event.type === 'error') { error = typeof event.error === 'string' ? event.error : 'Assistant error'; } } } } catch (err) { if ((err as Error).name !== 'AbortError') { error = err instanceof Error ? err.message : String(err); } } finally { setStreaming(false); setMessages((prev) => prev.map((m) => m.id === assistantId ? { ...m, content: finalText || m.content, toolEvents, sources, error } : m, ), ); if (!error && finalText && voiceOn) { void speak(finalText); } } }, [coursewareId, userProfile, webSearch, streaming, voiceOn, speak], ); const stop = useCallback(() => { abortRef.current?.abort(); }, []); const clear = useCallback(() => { abortRef.current?.abort(); setMessages([]); }, []); return { messages, streaming, voiceOn, setVoiceOn, send, stop, clear }; }