| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305 |
- 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<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: {
- 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<SessionInfo> {
- 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<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 *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 *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 {
- 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 })
- })
- }
|