llmService.ts 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320
  1. import type {
  2. DialogueAPI,
  3. LatestSessionResponse,
  4. SSEEvent,
  5. SessionConfig,
  6. SessionInfo,
  7. GreetingInfo,
  8. TaskHint,
  9. DialogueReport,
  10. SentenceEvaluation,
  11. } from '@/types/englishSpeaking'
  12. import { SPEAKING_DIALOGUE_API_BASE_URL } from './speakingApiConfig'
  13. const API_BASE = SPEAKING_DIALOGUE_API_BASE_URL
  14. export class DialogueApiError extends Error {
  15. status: number
  16. constructor(message: string, status: number) {
  17. super(message)
  18. this.status = status
  19. this.name = 'DialogueApiError'
  20. }
  21. }
  22. // ==================== SSE 解析 ====================
  23. async function* parseSSEStream(reader: ReadableStreamDefaultReader<Uint8Array>): AsyncGenerator<SSEEvent> {
  24. const decoder = new TextDecoder()
  25. let buffer = ''
  26. try {
  27. while (true) {
  28. const { done, value } = await reader.read()
  29. if (done) break
  30. buffer += decoder.decode(value, { stream: true })
  31. const lines = buffer.split('\n')
  32. buffer = lines.pop() || ''
  33. let eventType = ''
  34. let data = ''
  35. for (const line of lines) {
  36. if (line.startsWith('event:')) {
  37. eventType = line.slice(6).trim()
  38. } else if (line.startsWith('data:')) {
  39. data = line.slice(5).trim()
  40. } else if (line === '' && eventType && data) {
  41. try {
  42. const parsed = JSON.parse(data)
  43. if (eventType === 'transcript') {
  44. yield {
  45. type: 'transcript',
  46. text: parsed.text,
  47. audioDuration: parsed.audioDuration ?? null,
  48. }
  49. } else if (eventType === 'token') {
  50. yield { type: 'token', text: parsed.content ?? parsed.text }
  51. } else if (eventType === 'done') {
  52. yield { type: 'done', isComplete: parsed.isComplete }
  53. } else if (eventType === 'error') {
  54. yield { type: 'error', message: parsed.message }
  55. }
  56. } catch {
  57. // skip malformed JSON
  58. }
  59. eventType = ''
  60. data = ''
  61. }
  62. }
  63. }
  64. } finally {
  65. reader.releaseLock()
  66. }
  67. }
  68. // ==================== Backend shape types ====================
  69. interface BackendEvaluation {
  70. status: 'pending' | 'completed' | 'failed'
  71. accuracyScore: number | null
  72. fluencyScore: number | null
  73. completenessScore: number | null
  74. prosodyScore: number | null
  75. wordAnalysis: unknown
  76. contentFeedback: {
  77. comment: string
  78. betterExpression: string
  79. } | null
  80. }
  81. interface BackendRound {
  82. round: number
  83. role: 'ai' | 'student'
  84. content: string
  85. audioUrl: string | null
  86. audioDuration: number | null // NEW: 秒,由后端 /report 透传
  87. evaluation?: BackendEvaluation
  88. }
  89. interface BackendReportResponse {
  90. sessionId: string
  91. topic: string
  92. status: 'evaluating' | 'ready' | 'failed' | 'incomplete'
  93. rounds: BackendRound[]
  94. overall: BackendOverall | null
  95. summary: string | null
  96. totalDurationSeconds: number | null
  97. }
  98. interface BackendOverall {
  99. aiComment: string
  100. highlights: string[]
  101. improvements: string[]
  102. }
  103. function hasCompleteScores(evaluation?: BackendEvaluation): evaluation is BackendEvaluation & {
  104. accuracyScore: number
  105. fluencyScore: number
  106. completenessScore: number
  107. prosodyScore: number
  108. } {
  109. return !!evaluation
  110. && evaluation.status === 'completed'
  111. && typeof evaluation.accuracyScore === 'number'
  112. && typeof evaluation.fluencyScore === 'number'
  113. && typeof evaluation.completenessScore === 'number'
  114. && typeof evaluation.prosodyScore === 'number'
  115. }
  116. function adaptReport(raw: BackendReportResponse): DialogueReport {
  117. const sentenceEvaluations: SentenceEvaluation[] = raw.rounds.map((r, idx) => {
  118. const pronunciation = r.role === 'student' && hasCompleteScores(r.evaluation)
  119. ? {
  120. accuracy: r.evaluation.accuracyScore,
  121. fluency: r.evaluation.fluencyScore,
  122. intonation: r.evaluation.prosodyScore,
  123. stress: r.evaluation.completenessScore,
  124. }
  125. : undefined
  126. return {
  127. id: `${raw.sessionId}-${idx}`,
  128. round: r.round,
  129. role: r.role,
  130. content: r.content,
  131. audioUrl: r.audioUrl ?? undefined,
  132. audioDuration: r.audioDuration ?? undefined,
  133. score: pronunciation
  134. ? Math.round((pronunciation.accuracy + pronunciation.fluency + pronunciation.intonation + pronunciation.stress) / 4)
  135. : undefined,
  136. pronunciation,
  137. feedback: r.evaluation?.contentFeedback ?? undefined,
  138. }
  139. })
  140. const studentEvals = sentenceEvaluations.filter(s => s.role === 'student' && s.pronunciation)
  141. const avg = studentEvals.length > 0
  142. ? Math.round(
  143. studentEvals.reduce(
  144. (sum, s) => sum + (s.pronunciation!.accuracy + s.pronunciation!.fluency + s.pronunciation!.intonation + s.pronunciation!.stress) / 4,
  145. 0,
  146. ) / studentEvals.length,
  147. )
  148. : 0
  149. const avgDim = (key: 'accuracy' | 'fluency' | 'intonation' | 'stress') => {
  150. if (studentEvals.length === 0) return 0
  151. return Math.round(studentEvals.reduce((sum, s) => sum + (s.pronunciation?.[key] ?? 0), 0) / studentEvals.length)
  152. }
  153. const overall = raw.overall
  154. const highest = studentEvals.reduce<SentenceEvaluation | null>(
  155. (best, s) => (!best || (s.score ?? 0) > (best.score ?? 0) ? s : best),
  156. null,
  157. )
  158. return {
  159. status: raw.status,
  160. evaluation: {
  161. overallScore: avg,
  162. scoreLevel: avg >= 85 ? 'excellent' : avg >= 70 ? 'good' : avg >= 60 ? 'fair' : 'needsWork',
  163. percentile: 0,
  164. dimensions: {
  165. fluency: avgDim('fluency'),
  166. interaction: avgDim('intonation'),
  167. vocabulary: avgDim('stress'),
  168. grammar: avgDim('accuracy'),
  169. },
  170. aiComment: overall?.aiComment ?? raw.summary ?? '',
  171. highlights: overall?.highlights ?? [],
  172. improvements: overall?.improvements ?? [],
  173. nextChallenge: {},
  174. statistics: {
  175. totalRounds: sentenceEvaluations.length ? Math.max(...sentenceEvaluations.map(s => s.round)) : 0,
  176. averageScore: avg,
  177. highestScore: highest?.score ?? 0,
  178. highestRound: highest?.round ?? 0,
  179. grammarErrors: 0,
  180. excellentExpressions: 0,
  181. totalDuration: raw.totalDurationSeconds ?? 0,
  182. },
  183. sentenceEvaluations,
  184. },
  185. }
  186. }
  187. // ==================== Real API ====================
  188. export class RealDialogueAPI implements DialogueAPI {
  189. async createSession(config: SessionConfig): Promise<SessionInfo> {
  190. const res = await fetch(`${API_BASE}/session`, {
  191. method: 'POST',
  192. headers: { 'Content-Type': 'application/json' },
  193. credentials: 'include',
  194. body: JSON.stringify({
  195. topic: config.topic,
  196. grade: config.grade,
  197. vocabulary: config.vocabulary ?? [],
  198. sentences: config.sentences ?? [],
  199. totalRounds: config.totalRounds,
  200. durationMinutes: config.durationMinutes,
  201. roleId: config.roleId,
  202. configId: config.configId ?? null,
  203. userId: config.userId ?? null,
  204. }),
  205. })
  206. if (!res.ok) {
  207. throw new DialogueApiError(`createSession failed: ${res.status}`, res.status)
  208. }
  209. const body = await res.json()
  210. return {
  211. sessionId: body.sessionId,
  212. totalRounds: body.totalRounds,
  213. currentRound: body.currentRound,
  214. expiresAt: body.expiresAt ?? null,
  215. }
  216. }
  217. async getLatestSession(configId: string, userId: string): Promise<LatestSessionResponse> {
  218. const params = new URLSearchParams({ configId, userId })
  219. const res = await fetch(`${API_BASE}/sessions/latest?${params.toString()}`, {
  220. method: 'GET',
  221. credentials: 'include',
  222. })
  223. if (!res.ok) {
  224. throw new DialogueApiError(`getLatestSession failed: ${res.status}`, res.status)
  225. }
  226. return res.json()
  227. }
  228. async completeSession(sessionId: string): Promise<void> {
  229. const res = await fetch(`${API_BASE}/session/${encodeURIComponent(sessionId)}/complete`, {
  230. method: 'POST',
  231. credentials: 'include',
  232. })
  233. if (!res.ok) {
  234. throw new DialogueApiError(`completeSession failed: ${res.status}`, res.status)
  235. }
  236. }
  237. async generateGreeting(sessionId: string, turnId: string, signal?: AbortSignal): Promise<GreetingInfo> {
  238. const res = await fetch(`${API_BASE}/session/${sessionId}/greeting`, {
  239. method: 'POST',
  240. headers: { 'Content-Type': 'application/json' },
  241. credentials: 'include',
  242. signal,
  243. body: JSON.stringify({ turnId }),
  244. })
  245. if (!res.ok) {
  246. const text = await res.text().catch(() => '')
  247. throw new DialogueApiError(
  248. `greeting failed: ${res.status}${text ? ` (${text.slice(0, 100)})` : ''}`,
  249. res.status,
  250. )
  251. }
  252. const body = await res.json()
  253. return { aiMessage: body.aiMessage }
  254. }
  255. async generateTaskHint(sessionId: string): Promise<TaskHint> {
  256. const res = await fetch(`${API_BASE}/session/${sessionId}/task-hint`, {
  257. method: 'POST',
  258. credentials: 'include',
  259. })
  260. if (!res.ok) {
  261. throw new DialogueApiError(`task hint failed: ${res.status}`, res.status)
  262. }
  263. return await res.json()
  264. }
  265. async *speak(sessionId: string, audioBlob: Blob, signal: AbortSignal, turnId: string): AsyncGenerator<SSEEvent> {
  266. const formData = new FormData()
  267. formData.append('sessionId', sessionId)
  268. formData.append('audio', audioBlob, 'recording.webm')
  269. formData.append('turnId', turnId)
  270. const res = await fetch(`${API_BASE}/speak`, {
  271. method: 'POST',
  272. credentials: 'include',
  273. body: formData,
  274. signal,
  275. })
  276. if (!res.ok) throw new Error(`speak failed: ${res.status}`)
  277. if (!res.body) throw new Error('No response body')
  278. yield* parseSSEStream(res.body.getReader())
  279. }
  280. async getReport(sessionId: string): Promise<DialogueReport> {
  281. const res = await fetch(`${API_BASE}/report?sessionId=${encodeURIComponent(sessionId)}`, {
  282. credentials: 'include',
  283. })
  284. if (!res.ok) throw new Error(`getReport failed: ${res.status}`)
  285. const raw: BackendReportResponse = await res.json()
  286. return adaptReport(raw)
  287. }
  288. }
  289. export function createDialogueApi(): DialogueAPI {
  290. return new RealDialogueAPI()
  291. }