llmService.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383
  1. import type {
  2. DialogueAPI,
  3. SSEEvent,
  4. SessionConfig,
  5. SessionInfo,
  6. GreetingInfo,
  7. TaskHint,
  8. DialogueReport,
  9. SentenceEvaluation,
  10. } from '@/types/englishSpeaking'
  11. const API_BASE = 'http://localhost:8000/api/speaking/dialogue'
  12. export class DialogueApiError extends Error {
  13. status: number
  14. constructor(message: string, status: number) {
  15. super(message)
  16. this.status = status
  17. this.name = 'DialogueApiError'
  18. }
  19. }
  20. // ==================== SSE 解析 ====================
  21. async function* parseSSEStream(reader: ReadableStreamDefaultReader<Uint8Array>): AsyncGenerator<SSEEvent> {
  22. const decoder = new TextDecoder()
  23. let buffer = ''
  24. try {
  25. while (true) {
  26. const { done, value } = await reader.read()
  27. if (done) break
  28. buffer += decoder.decode(value, { stream: true })
  29. const lines = buffer.split('\n')
  30. buffer = lines.pop() || ''
  31. let eventType = ''
  32. let data = ''
  33. for (const line of lines) {
  34. if (line.startsWith('event:')) {
  35. eventType = line.slice(6).trim()
  36. } else if (line.startsWith('data:')) {
  37. data = line.slice(5).trim()
  38. } else if (line === '' && eventType && data) {
  39. try {
  40. const parsed = JSON.parse(data)
  41. if (eventType === 'transcript') {
  42. yield { type: 'transcript', text: parsed.text }
  43. } else if (eventType === 'token') {
  44. yield { type: 'token', text: parsed.content ?? parsed.text }
  45. } else if (eventType === 'done') {
  46. yield { type: 'done', isComplete: parsed.isComplete }
  47. }
  48. } catch {
  49. // skip malformed JSON
  50. }
  51. eventType = ''
  52. data = ''
  53. }
  54. }
  55. }
  56. } finally {
  57. reader.releaseLock()
  58. }
  59. }
  60. // ==================== Backend shape types ====================
  61. interface BackendEvaluation {
  62. status: 'pending' | 'completed' | 'failed'
  63. accuracyScore: number | null
  64. fluencyScore: number | null
  65. completenessScore: number | null
  66. prosodyScore: number | null
  67. wordAnalysis: unknown
  68. contentFeedback: {
  69. comment: string
  70. betterExpression: string
  71. } | null
  72. }
  73. interface BackendRound {
  74. round: number
  75. role: 'ai' | 'student'
  76. content: string
  77. audioUrl: string | null
  78. evaluation?: BackendEvaluation
  79. }
  80. interface BackendReportResponse {
  81. sessionId: string
  82. topic: string
  83. status: 'evaluating' | 'ready' | 'failed' | 'incomplete'
  84. rounds: BackendRound[]
  85. overall: BackendOverall | null
  86. summary: string | null
  87. }
  88. interface BackendOverall {
  89. aiComment: string
  90. highlights: string[]
  91. improvements: string[]
  92. }
  93. function hasCompleteScores(evaluation?: BackendEvaluation): evaluation is BackendEvaluation & {
  94. accuracyScore: number
  95. fluencyScore: number
  96. completenessScore: number
  97. prosodyScore: number
  98. } {
  99. return !!evaluation
  100. && evaluation.status === 'completed'
  101. && typeof evaluation.accuracyScore === 'number'
  102. && typeof evaluation.fluencyScore === 'number'
  103. && typeof evaluation.completenessScore === 'number'
  104. && typeof evaluation.prosodyScore === 'number'
  105. }
  106. function adaptReport(raw: BackendReportResponse): DialogueReport {
  107. const sentenceEvaluations: SentenceEvaluation[] = raw.rounds.map((r, idx) => {
  108. const pronunciation = r.role === 'student' && hasCompleteScores(r.evaluation)
  109. ? {
  110. accuracy: r.evaluation.accuracyScore,
  111. fluency: r.evaluation.fluencyScore,
  112. intonation: r.evaluation.prosodyScore,
  113. stress: r.evaluation.completenessScore,
  114. }
  115. : undefined
  116. return {
  117. id: `${raw.sessionId}-${idx}`,
  118. round: r.round,
  119. role: r.role,
  120. content: r.content,
  121. audioUrl: r.audioUrl ?? undefined,
  122. score: pronunciation
  123. ? Math.round((pronunciation.accuracy + pronunciation.fluency + pronunciation.intonation + pronunciation.stress) / 4)
  124. : undefined,
  125. pronunciation,
  126. feedback: r.evaluation?.contentFeedback ?? undefined,
  127. }
  128. })
  129. const studentEvals = sentenceEvaluations.filter(s => s.role === 'student' && s.pronunciation)
  130. const avg = studentEvals.length > 0
  131. ? Math.round(
  132. studentEvals.reduce(
  133. (sum, s) => sum + (s.pronunciation!.accuracy + s.pronunciation!.fluency + s.pronunciation!.intonation + s.pronunciation!.stress) / 4,
  134. 0,
  135. ) / studentEvals.length,
  136. )
  137. : 0
  138. const avgDim = (key: 'accuracy' | 'fluency' | 'intonation' | 'stress') => {
  139. if (studentEvals.length === 0) return 0
  140. return Math.round(studentEvals.reduce((sum, s) => sum + (s.pronunciation?.[key] ?? 0), 0) / studentEvals.length)
  141. }
  142. const overall = raw.overall
  143. const highest = studentEvals.reduce<SentenceEvaluation | null>(
  144. (best, s) => (!best || (s.score ?? 0) > (best.score ?? 0) ? s : best),
  145. null,
  146. )
  147. return {
  148. status: raw.status,
  149. evaluation: {
  150. overallScore: avg,
  151. scoreLevel: avg >= 85 ? 'excellent' : avg >= 70 ? 'good' : avg >= 60 ? 'fair' : 'needsWork',
  152. percentile: 0,
  153. dimensions: {
  154. fluency: avgDim('fluency'),
  155. interaction: avgDim('intonation'),
  156. vocabulary: avgDim('stress'),
  157. grammar: avgDim('accuracy'),
  158. },
  159. aiComment: overall?.aiComment ?? raw.summary ?? '',
  160. highlights: overall?.highlights ?? [],
  161. improvements: overall?.improvements ?? [],
  162. nextChallenge: {},
  163. statistics: {
  164. totalRounds: sentenceEvaluations.length ? Math.max(...sentenceEvaluations.map(s => s.round)) : 0,
  165. averageScore: avg,
  166. highestScore: highest?.score ?? 0,
  167. highestRound: highest?.round ?? 0,
  168. grammarErrors: 0,
  169. excellentExpressions: 0,
  170. totalDuration: 0,
  171. },
  172. sentenceEvaluations,
  173. },
  174. }
  175. }
  176. // ==================== Real API ====================
  177. export class RealDialogueAPI implements DialogueAPI {
  178. async createSession(config: SessionConfig): Promise<SessionInfo> {
  179. const res = await fetch(`${API_BASE}/session`, {
  180. method: 'POST',
  181. headers: { 'Content-Type': 'application/json' },
  182. credentials: 'include',
  183. body: JSON.stringify({
  184. topic: config.topic,
  185. grade: config.grade,
  186. vocabulary: config.vocabulary ?? [],
  187. sentences: config.sentences ?? [],
  188. totalRounds: config.totalRounds,
  189. roleId: config.roleId,
  190. }),
  191. })
  192. if (!res.ok) {
  193. throw new DialogueApiError(`createSession failed: ${res.status}`, res.status)
  194. }
  195. const body = await res.json()
  196. return {
  197. sessionId: body.sessionId,
  198. totalRounds: body.totalRounds,
  199. currentRound: body.currentRound,
  200. expiresAt: body.expiresAt ?? null,
  201. }
  202. }
  203. async generateGreeting(sessionId: string, signal?: AbortSignal): Promise<GreetingInfo> {
  204. const res = await fetch(`${API_BASE}/session/${sessionId}/greeting`, {
  205. method: 'POST',
  206. credentials: 'include',
  207. signal,
  208. })
  209. if (!res.ok) {
  210. const text = await res.text().catch(() => '')
  211. throw new DialogueApiError(
  212. `greeting failed: ${res.status}${text ? ` (${text.slice(0, 100)})` : ''}`,
  213. res.status,
  214. )
  215. }
  216. const body = await res.json()
  217. return { aiMessage: body.aiMessage }
  218. }
  219. async generateTaskHint(sessionId: string): Promise<TaskHint> {
  220. const res = await fetch(`${API_BASE}/session/${sessionId}/task-hint`, {
  221. method: 'POST',
  222. credentials: 'include',
  223. })
  224. if (!res.ok) {
  225. throw new DialogueApiError(`task hint failed: ${res.status}`, res.status)
  226. }
  227. return await res.json()
  228. }
  229. async *speak(sessionId: string, audioBlob: Blob, signal: AbortSignal): AsyncGenerator<SSEEvent> {
  230. const formData = new FormData()
  231. formData.append('sessionId', sessionId)
  232. formData.append('audio', audioBlob, 'recording.webm')
  233. const res = await fetch(`${API_BASE}/speak`, {
  234. method: 'POST',
  235. credentials: 'include',
  236. body: formData,
  237. signal,
  238. })
  239. if (!res.ok) throw new Error(`speak failed: ${res.status}`)
  240. if (!res.body) throw new Error('No response body')
  241. yield* parseSSEStream(res.body.getReader())
  242. }
  243. async getReport(sessionId: string): Promise<DialogueReport> {
  244. const res = await fetch(`${API_BASE}/report?sessionId=${encodeURIComponent(sessionId)}`, {
  245. credentials: 'include',
  246. })
  247. if (!res.ok) throw new Error(`getReport failed: ${res.status}`)
  248. const raw: BackendReportResponse = await res.json()
  249. return adaptReport(raw)
  250. }
  251. }
  252. // ==================== Mock API ====================
  253. const MOCK_AI_REPLIES = [
  254. 'Pandas are adorable! Have you seen them at the zoo?',
  255. "That's great! What do pandas like to eat?",
  256. "Bamboo is their favorite! You did a wonderful job talking about animals today!",
  257. ]
  258. export class MockDialogueAPI implements DialogueAPI {
  259. private roundIndex = 0
  260. async createSession(_config: SessionConfig): Promise<SessionInfo> {
  261. this.roundIndex = 0
  262. return {
  263. sessionId: 'mock-session-' + Date.now(),
  264. totalRounds: _config.totalRounds,
  265. currentRound: 1,
  266. expiresAt: null,
  267. }
  268. }
  269. async generateGreeting(_sessionId: string, signal?: AbortSignal): Promise<GreetingInfo> {
  270. await delay(300, signal)
  271. return { aiMessage: "Hi! What's your favorite animal?" }
  272. }
  273. async generateTaskHint(_sessionId: string): Promise<TaskHint> {
  274. await delay(250)
  275. return {
  276. practice_level: 'grade5-1',
  277. conversation_topic: '我最喜欢的动物',
  278. current_question: '和 Tom 聊一聊「我最喜欢的动物」,试着用今天的重点词汇和句型表达你的想法。',
  279. example_sentences: [
  280. { english: 'I like pandas best because they are very cute.', chinese: '我最喜欢大熊猫,因为它们非常可爱。' },
  281. { english: "My favorite animal is the elephant. It's really smart!", chinese: '我最喜欢的动物是大象,它真的很聪明!' },
  282. { english: 'I enjoy watching animals at the zoo with my family.', chinese: '我喜欢和家人一起在动物园看动物。' },
  283. ],
  284. key_vocabulary: [
  285. { word: 'favorite', meaning: '最喜欢的' },
  286. { word: 'adorable', meaning: '可爱的' },
  287. { word: 'bamboo', meaning: '竹子' },
  288. { word: 'habitat', meaning: '栖息地' },
  289. ],
  290. }
  291. }
  292. async *speak(_sessionId: string, _audioBlob: Blob, signal: AbortSignal): AsyncGenerator<SSEEvent> {
  293. const mockStudentTexts = [
  294. 'I like pandas. They are very cute!',
  295. 'Yes, I went to the zoo last month.',
  296. 'They like to eat bamboo.',
  297. ]
  298. // Simulate transcript
  299. await delay(300, signal)
  300. yield { type: 'transcript', text: mockStudentTexts[this.roundIndex] || 'I think so.' }
  301. // Simulate token streaming
  302. const aiReply = MOCK_AI_REPLIES[this.roundIndex] || 'That is very interesting!'
  303. const words = aiReply.split(' ')
  304. for (const word of words) {
  305. await delay(80, signal)
  306. yield { type: 'token', text: word + ' ' }
  307. }
  308. this.roundIndex++
  309. const isComplete = this.roundIndex >= MOCK_AI_REPLIES.length
  310. await delay(100, signal)
  311. yield { type: 'done', isComplete }
  312. }
  313. async getReport(_sessionId: string): Promise<DialogueReport> {
  314. return {
  315. status: 'ready',
  316. evaluation: {
  317. overallScore: 85,
  318. scoreLevel: 'good',
  319. percentile: 78,
  320. dimensions: { fluency: 82, interaction: 88, vocabulary: 76, grammar: 90 },
  321. aiComment: 'Great job! Your pronunciation was clear and your responses were relevant.',
  322. highlights: ['Clear pronunciation', 'Good use of complete sentences', 'Natural flow'],
  323. improvements: ['Try more adjectives', 'Practice linking words', 'Expand vocabulary'],
  324. nextChallenge: { difficulty: 'Medium', unlockedTopic: 'My Dream Job' },
  325. statistics: {
  326. totalRounds: 3, averageScore: 83, highestScore: 92,
  327. highestRound: 2, grammarErrors: 2, excellentExpressions: 4, totalDuration: 180,
  328. },
  329. sentenceEvaluations: [],
  330. },
  331. }
  332. }
  333. }
  334. export function createDialogueApi(mode: 'preview' | 'real'): DialogueAPI {
  335. return mode === 'real' ? new RealDialogueAPI() : new MockDialogueAPI()
  336. }
  337. function delay(ms: number, signal?: AbortSignal): Promise<void> {
  338. return new Promise((resolve, reject) => {
  339. if (signal?.aborted) { reject(new DOMException('Aborted', 'AbortError')); return }
  340. const timer = setTimeout(resolve, ms)
  341. signal?.addEventListener('abort', () => { clearTimeout(timer); reject(new DOMException('Aborted', 'AbortError')) }, { once: true })
  342. })
  343. }