import type { DialogueAPI, SSEEvent, SessionConfig, SessionInfo, GreetingInfo, DialogueReport, SentenceEvaluation, } from '@/types/englishSpeaking' const API_BASE = 'http://localhost:8000/api/speaking/dialogue' export class DialogueApiError extends Error { status: number constructor(message: string, status: number) { super(message) this.status = status this.name = 'DialogueApiError' } } // ==================== SSE 解析 ==================== async function* parseSSEStream(reader: ReadableStreamDefaultReader): AsyncGenerator { const decoder = new TextDecoder() let buffer = '' try { while (true) { const { done, value } = await reader.read() if (done) break buffer += decoder.decode(value, { stream: true }) const lines = buffer.split('\n') buffer = lines.pop() || '' let eventType = '' let data = '' for (const line of lines) { if (line.startsWith('event:')) { eventType = line.slice(6).trim() } else if (line.startsWith('data:')) { data = line.slice(5).trim() } else if (line === '' && eventType && data) { try { const parsed = JSON.parse(data) if (eventType === 'transcript') { yield { type: 'transcript', text: parsed.text } } else if (eventType === 'token') { yield { type: 'token', text: parsed.content ?? parsed.text } } else if (eventType === 'done') { yield { type: 'done', isComplete: parsed.isComplete } } } catch { // skip malformed JSON } eventType = '' data = '' } } } } finally { reader.releaseLock() } } // ==================== Backend shape types ==================== interface BackendEvaluation { status: 'pending' | 'completed' | 'failed' accuracyScore: number | null fluencyScore: number | null completenessScore: number | null prosodyScore: number | null wordAnalysis: unknown contentFeedback: { highlights: string[] corrections: { original: string; corrected: string; explanation: string }[] suggestions: string[] } | null } interface BackendRound { round: number role: 'ai' | 'student' content: string audioUrl: string | null evaluation?: BackendEvaluation } interface BackendReportResponse { sessionId: string topic: string status: 'evaluating' | 'ready' rounds: BackendRound[] summary: string | null } function adaptReport(raw: BackendReportResponse): DialogueReport { const sentenceEvaluations: SentenceEvaluation[] = raw.rounds.map((r, idx) => ({ id: `${raw.sessionId}-${idx}`, round: r.round, role: r.role, content: r.content, audioUrl: r.audioUrl ?? undefined, pronunciation: r.evaluation && r.role === 'student' ? { accuracy: r.evaluation.accuracyScore ?? 0, fluency: r.evaluation.fluencyScore ?? 0, intonation: r.evaluation.prosodyScore ?? 0, stress: r.evaluation.completenessScore ?? 0, } : undefined, feedback: r.evaluation?.contentFeedback ?? undefined, })) const studentEvals = sentenceEvaluations.filter(s => s.role === 'student' && s.pronunciation) const avg = studentEvals.length > 0 ? Math.round( studentEvals.reduce( (sum, s) => sum + (s.pronunciation!.accuracy + s.pronunciation!.fluency + s.pronunciation!.intonation + s.pronunciation!.stress) / 4, 0, ) / studentEvals.length, ) : 0 return { evaluation: { overallScore: avg, scoreLevel: avg >= 85 ? 'excellent' : avg >= 70 ? 'good' : avg >= 60 ? 'fair' : 'needsWork', percentile: 0, dimensions: { fluency: 0, interaction: 0, vocabulary: 0, grammar: 0 }, aiComment: raw.summary ?? '', highlights: [], improvements: [], nextChallenge: {}, statistics: { totalRounds: Math.max(...sentenceEvaluations.map(s => s.round), 0), averageScore: avg, highestScore: 0, highestRound: 0, grammarErrors: 0, excellentExpressions: 0, totalDuration: 0, }, sentenceEvaluations, }, } } // ==================== Real API ==================== export class RealDialogueAPI implements DialogueAPI { async createSession(config: SessionConfig): Promise { const res = await fetch(`${API_BASE}/session`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ topic: config.topic, totalRounds: config.totalRounds, roleId: config.roleId, }), }) if (!res.ok) { throw new DialogueApiError(`createSession failed: ${res.status}`, res.status) } const body = await res.json() return { sessionId: body.sessionId, totalRounds: body.totalRounds, currentRound: body.currentRound, expiresAt: body.expiresAt ?? null, } } async generateGreeting(sessionId: string, signal?: AbortSignal): Promise { const res = await fetch(`${API_BASE}/session/${sessionId}/greeting`, { method: 'POST', credentials: 'include', signal, }) if (!res.ok) { const text = await res.text().catch(() => '') throw new DialogueApiError( `greeting failed: ${res.status}${text ? ` (${text.slice(0, 100)})` : ''}`, res.status, ) } const body = await res.json() return { aiMessage: body.aiMessage } } async *speak(sessionId: string, audioBlob: Blob, signal: AbortSignal): AsyncGenerator { const formData = new FormData() formData.append('sessionId', sessionId) formData.append('audio', audioBlob, 'recording.webm') const res = await fetch(`${API_BASE}/speak`, { method: 'POST', credentials: 'include', body: formData, signal, }) if (!res.ok) throw new Error(`speak failed: ${res.status}`) if (!res.body) throw new Error('No response body') yield* parseSSEStream(res.body.getReader()) } async getReport(sessionId: string): Promise { const res = await fetch(`${API_BASE}/report?sessionId=${encodeURIComponent(sessionId)}`, { credentials: 'include', }) if (!res.ok) throw new Error(`getReport failed: ${res.status}`) const raw: BackendReportResponse = await res.json() return adaptReport(raw) } } // ==================== Mock API ==================== const MOCK_AI_REPLIES = [ 'Pandas are adorable! Have you seen them at the zoo?', "That's great! What do pandas like to eat?", "Bamboo is their favorite! You did a wonderful job talking about animals today!", ] export class MockDialogueAPI implements DialogueAPI { private roundIndex = 0 async createSession(_config: SessionConfig): Promise { this.roundIndex = 0 return { sessionId: 'mock-session-' + Date.now(), totalRounds: _config.totalRounds, currentRound: 1, expiresAt: null, } } async generateGreeting(_sessionId: string, signal?: AbortSignal): Promise { await delay(300, signal) return { aiMessage: "Hi! What's your favorite animal?" } } async *speak(_sessionId: string, _audioBlob: Blob, signal: AbortSignal): AsyncGenerator { const mockStudentTexts = [ 'I like pandas. They are very cute!', 'Yes, I went to the zoo last month.', 'They like to eat bamboo.', ] // Simulate transcript await delay(300, signal) yield { type: 'transcript', text: mockStudentTexts[this.roundIndex] || 'I think so.' } // Simulate token streaming const aiReply = MOCK_AI_REPLIES[this.roundIndex] || 'That is very interesting!' const words = aiReply.split(' ') for (const word of words) { await delay(80, signal) yield { type: 'token', text: word + ' ' } } this.roundIndex++ const isComplete = this.roundIndex >= MOCK_AI_REPLIES.length await delay(100, signal) yield { type: 'done', isComplete } } async getReport(_sessionId: string): Promise { return { evaluation: { overallScore: 85, scoreLevel: 'good', percentile: 78, dimensions: { fluency: 82, interaction: 88, vocabulary: 76, grammar: 90 }, aiComment: 'Great job! Your pronunciation was clear and your responses were relevant.', highlights: ['Clear pronunciation', 'Good use of complete sentences', 'Natural flow'], improvements: ['Try more adjectives', 'Practice linking words', 'Expand vocabulary'], nextChallenge: { difficulty: 'Medium', unlockedTopic: 'My Dream Job' }, statistics: { totalRounds: 3, averageScore: 83, highestScore: 92, highestRound: 2, grammarErrors: 2, excellentExpressions: 4, totalDuration: 180, }, sentenceEvaluations: [], }, } } } export function createDialogueApi(mode: 'preview' | 'real'): DialogueAPI { return mode === 'real' ? new RealDialogueAPI() : new MockDialogueAPI() } function delay(ms: number, signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { if (signal?.aborted) { reject(new DOMException('Aborted', 'AbortError')); return } const timer = setTimeout(resolve, ms) signal?.addEventListener('abort', () => { clearTimeout(timer); reject(new DOMException('Aborted', 'AbortError')) }, { once: true }) }) }