import type { DialogueAPI, LatestSessionResponse, SSEEvent, SessionConfig, SessionInfo, GreetingInfo, TaskHint, DialogueReport, SentenceEvaluation, } from '@/types/englishSpeaking' import { SPEAKING_DIALOGUE_API_BASE_URL } from './speakingApiConfig' const API_BASE = SPEAKING_DIALOGUE_API_BASE_URL 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, audioDuration: parsed.audioDuration ?? null, } } else if (eventType === 'token') { yield { type: 'token', text: parsed.content ?? parsed.text } } else if (eventType === 'done') { yield { type: 'done', isComplete: parsed.isComplete } } else if (eventType === 'error') { yield { type: 'error', message: parsed.message } } } 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 audioDuration: number | null // NEW: 秒,由后端 /report 透传 evaluation?: BackendEvaluation } interface BackendReportResponse { sessionId: string topic: string status: 'evaluating' | 'ready' | 'failed' | 'incomplete' rounds: BackendRound[] overall: BackendOverall | null summary: string | null totalDurationSeconds: number | 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, audioDuration: r.audioDuration ?? 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: raw.totalDurationSeconds ?? 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, durationMinutes: config.durationMinutes, roleId: config.roleId, configId: config.configId ?? null, userId: config.userId ?? null, }), }) 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 getLatestSession(configId: string, userId: string): Promise { const params = new URLSearchParams({ configId, userId }) const res = await fetch(`${API_BASE}/sessions/latest?${params.toString()}`, { method: 'GET', credentials: 'include', }) if (!res.ok) { throw new DialogueApiError(`getLatestSession failed: ${res.status}`, res.status) } return res.json() } async completeSession(sessionId: string): Promise { const res = await fetch(`${API_BASE}/session/${encodeURIComponent(sessionId)}/complete`, { method: 'POST', credentials: 'include', }) if (!res.ok) { throw new DialogueApiError(`completeSession failed: ${res.status}`, res.status) } } async generateGreeting(sessionId: string, turnId: string, signal?: AbortSignal): Promise { const res = await fetch(`${API_BASE}/session/${sessionId}/greeting`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', signal, body: JSON.stringify({ turnId }), }) 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, turnId: string): AsyncGenerator { const formData = new FormData() formData.append('sessionId', sessionId) formData.append('audio', audioBlob, 'recording.webm') formData.append('turnId', turnId) 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) } } export function createDialogueApi(): DialogueAPI { return new RealDialogueAPI() }