Files
openmaic/OpenMAIC/lib/qa/use-qa-chat.ts
2026-08-16 14:58:47 +08:00

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