| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383 |
- 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<Uint8Array>): AsyncGenerator<SSEEvent> {
- 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<SentenceEvaluation | null>(
- (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<SessionInfo> {
- 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<GreetingInfo> {
- 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<TaskHint> {
- 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<SSEEvent> {
- 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<DialogueReport> {
- 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<SessionInfo> {
- this.roundIndex = 0
- return {
- sessionId: 'mock-session-' + Date.now(),
- totalRounds: _config.totalRounds,
- currentRound: 1,
- expiresAt: null,
- }
- }
- async generateGreeting(_sessionId: string, signal?: AbortSignal): Promise<GreetingInfo> {
- await delay(300, signal)
- return { aiMessage: "Hi! What's your favorite animal?" }
- }
- async generateTaskHint(_sessionId: string): Promise<TaskHint> {
- 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<SSEEvent> {
- 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<DialogueReport> {
- 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<void> {
- 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 })
- })
- }
|