212 lines
6.5 KiB
TypeScript
212 lines
6.5 KiB
TypeScript
'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<Record<string, unknown>> {
|
|
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<string, unknown>;
|
|
} catch {
|
|
// ignore malformed frames
|
|
}
|
|
}
|
|
}
|
|
} finally {
|
|
reader.releaseLock();
|
|
}
|
|
}
|
|
|
|
export function useQaChat(options: UseQaChatOptions) {
|
|
const { coursewareId, userProfile, webSearch = true } = options;
|
|
const [messages, setMessages] = useState<QaMessage[]>([]);
|
|
const [streaming, setStreaming] = useState(false);
|
|
const [voiceOn, setVoiceOn] = useState(true);
|
|
const abortRef = useRef<AbortController | null>(null);
|
|
const speakingRef = useRef<HTMLAudioElement | null>(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 };
|
|
}
|