import type { DialogueAPI, SSEEvent, SessionConfig, SessionInfo, GreetingInfo, TaskHint, 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: { comment: string betterExpression: 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' | 'failed' | 'incomplete' rounds: BackendRound[] overall: BackendOverall | null summary: string | null } interface BackendOverall { aiComment: string highlights: string[] improvements: string[] } function hasCompleteScores(evaluation?: BackendEvaluation): evaluation is BackendEvaluation & { accuracyScore: number fluencyScore: number completenessScore: number prosodyScore: number } { return !!evaluation && evaluation.status === 'completed' && typeof evaluation.accuracyScore === 'number' && typeof evaluation.fluencyScore === 'number' && typeof evaluation.completenessScore === 'number' && typeof evaluation.prosodyScore === 'number' } function adaptReport(raw: BackendReportResponse): DialogueReport { const sentenceEvaluations: SentenceEvaluation[] = raw.rounds.map((r, idx) => { const pronunciation = r.role === 'student' && hasCompleteScores(r.evaluation) ? { accuracy: r.evaluation.accuracyScore, fluency: r.evaluation.fluencyScore, intonation: r.evaluation.prosodyScore, stress: r.evaluation.completenessScore, } : undefined return { id: `${raw.sessionId}-${idx}`, round: r.round, role: r.role, content: r.content, audioUrl: r.audioUrl ?? undefined, score: pronunciation ? Math.round((pronunciation.accuracy + pronunciation.fluency + pronunciation.intonation + pronunciation.stress) / 4) : undefined, pronunciation, 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 const avgDim = (key: 'accuracy' | 'fluency' | 'intonation' | 'stress') => { if (studentEvals.length === 0) return 0 return Math.round(studentEvals.reduce((sum, s) => sum + (s.pronunciation?.[key] ?? 0), 0) / studentEvals.length) } const overall = raw.overall const highest = studentEvals.reduce( (best, s) => (!best || (s.score ?? 0) > (best.score ?? 0) ? s : best), null, ) return { status: raw.status, evaluation: { overallScore: avg, scoreLevel: avg >= 85 ? 'excellent' : avg >= 70 ? 'good' : avg >= 60 ? 'fair' : 'needsWork', percentile: 0, dimensions: { fluency: avgDim('fluency'), interaction: avgDim('intonation'), vocabulary: avgDim('stress'), grammar: avgDim('accuracy'), }, aiComment: overall?.aiComment ?? raw.summary ?? '', highlights: overall?.highlights ?? [], improvements: overall?.improvements ?? [], nextChallenge: {}, statistics: { totalRounds: sentenceEvaluations.length ? Math.max(...sentenceEvaluations.map(s => s.round)) : 0, averageScore: avg, highestScore: highest?.score ?? 0, highestRound: highest?.round ?? 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, grade: config.grade, vocabulary: config.vocabulary ?? [], sentences: config.sentences ?? [], 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 generateTaskHint(sessionId: string): Promise { const res = await fetch(`${API_BASE}/session/${sessionId}/task-hint`, { method: 'POST', credentials: 'include', }) if (!res.ok) { throw new DialogueApiError(`task hint failed: ${res.status}`, res.status) } return await res.json() } 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 generateTaskHint(_sessionId: string): Promise { await delay(250) return { practice_level: 'grade5-1', conversation_topic: '我最喜欢的动物', current_question: '和 Tom 聊一聊「我最喜欢的动物」,试着用今天的重点词汇和句型表达你的想法。', example_sentences: [ { english: 'I like pandas best because they are very cute.', chinese: '我最喜欢大熊猫,因为它们非常可爱。' }, { english: "My favorite animal is the elephant. It's really smart!", chinese: '我最喜欢的动物是大象,它真的很聪明!' }, { english: 'I enjoy watching animals at the zoo with my family.', chinese: '我喜欢和家人一起在动物园看动物。' }, ], key_vocabulary: [ { word: 'favorite', meaning: '最喜欢的' }, { word: 'adorable', meaning: '可爱的' }, { word: 'bamboo', meaning: '竹子' }, { word: 'habitat', meaning: '栖息地' }, ], } } 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 { status: 'ready', 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 }) }) }