| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320 |
- 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<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,
- 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<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: raw.totalDurationSeconds ?? 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,
- 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<LatestSessionResponse> {
- 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<void> {
- 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<GreetingInfo> {
- 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<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, turnId: string): AsyncGenerator<SSEEvent> {
- 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<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)
- }
- }
- export function createDialogueApi(): DialogueAPI {
- return new RealDialogueAPI()
- }
|