llmService.ts 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  1. import type {
  2. DialogueAPI,
  3. SSEEvent,
  4. SessionConfig,
  5. SessionInfo,
  6. GreetingInfo,
  7. DialogueReport,
  8. SentenceEvaluation,
  9. } from '@/types/englishSpeaking'
  10. const API_BASE = 'http://localhost:8000/api/speaking/dialogue'
  11. export class DialogueApiError extends Error {
  12. status: number
  13. constructor(message: string, status: number) {
  14. super(message)
  15. this.status = status
  16. this.name = 'DialogueApiError'
  17. }
  18. }
  19. // ==================== SSE 解析 ====================
  20. async function* parseSSEStream(reader: ReadableStreamDefaultReader<Uint8Array>): AsyncGenerator<SSEEvent> {
  21. const decoder = new TextDecoder()
  22. let buffer = ''
  23. try {
  24. while (true) {
  25. const { done, value } = await reader.read()
  26. if (done) break
  27. buffer += decoder.decode(value, { stream: true })
  28. const lines = buffer.split('\n')
  29. buffer = lines.pop() || ''
  30. let eventType = ''
  31. let data = ''
  32. for (const line of lines) {
  33. if (line.startsWith('event:')) {
  34. eventType = line.slice(6).trim()
  35. } else if (line.startsWith('data:')) {
  36. data = line.slice(5).trim()
  37. } else if (line === '' && eventType && data) {
  38. try {
  39. const parsed = JSON.parse(data)
  40. if (eventType === 'transcript') {
  41. yield { type: 'transcript', text: parsed.text }
  42. } else if (eventType === 'token') {
  43. yield { type: 'token', text: parsed.content ?? parsed.text }
  44. } else if (eventType === 'done') {
  45. yield { type: 'done', isComplete: parsed.isComplete }
  46. }
  47. } catch {
  48. // skip malformed JSON
  49. }
  50. eventType = ''
  51. data = ''
  52. }
  53. }
  54. }
  55. } finally {
  56. reader.releaseLock()
  57. }
  58. }
  59. // ==================== Backend shape types ====================
  60. interface BackendEvaluation {
  61. status: 'pending' | 'completed' | 'failed'
  62. accuracyScore: number | null
  63. fluencyScore: number | null
  64. completenessScore: number | null
  65. prosodyScore: number | null
  66. wordAnalysis: unknown
  67. contentFeedback: {
  68. highlights: string[]
  69. corrections: { original: string; corrected: string; explanation: string }[]
  70. suggestions: 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'
  84. rounds: BackendRound[]
  85. summary: string | null
  86. }
  87. function adaptReport(raw: BackendReportResponse): DialogueReport {
  88. const sentenceEvaluations: SentenceEvaluation[] = raw.rounds.map((r, idx) => ({
  89. id: `${raw.sessionId}-${idx}`,
  90. round: r.round,
  91. role: r.role,
  92. content: r.content,
  93. audioUrl: r.audioUrl ?? undefined,
  94. pronunciation: r.evaluation && r.role === 'student'
  95. ? {
  96. accuracy: r.evaluation.accuracyScore ?? 0,
  97. fluency: r.evaluation.fluencyScore ?? 0,
  98. intonation: r.evaluation.prosodyScore ?? 0,
  99. stress: r.evaluation.completenessScore ?? 0,
  100. }
  101. : undefined,
  102. feedback: r.evaluation?.contentFeedback ?? undefined,
  103. }))
  104. const studentEvals = sentenceEvaluations.filter(s => s.role === 'student' && s.pronunciation)
  105. const avg = studentEvals.length > 0
  106. ? Math.round(
  107. studentEvals.reduce(
  108. (sum, s) => sum + (s.pronunciation!.accuracy + s.pronunciation!.fluency + s.pronunciation!.intonation + s.pronunciation!.stress) / 4,
  109. 0,
  110. ) / studentEvals.length,
  111. )
  112. : 0
  113. return {
  114. evaluation: {
  115. overallScore: avg,
  116. scoreLevel: avg >= 85 ? 'excellent' : avg >= 70 ? 'good' : avg >= 60 ? 'fair' : 'needsWork',
  117. percentile: 0,
  118. dimensions: { fluency: 0, interaction: 0, vocabulary: 0, grammar: 0 },
  119. aiComment: raw.summary ?? '',
  120. highlights: [],
  121. improvements: [],
  122. nextChallenge: {},
  123. statistics: {
  124. totalRounds: Math.max(...sentenceEvaluations.map(s => s.round), 0),
  125. averageScore: avg,
  126. highestScore: 0,
  127. highestRound: 0,
  128. grammarErrors: 0,
  129. excellentExpressions: 0,
  130. totalDuration: 0,
  131. },
  132. sentenceEvaluations,
  133. },
  134. }
  135. }
  136. // ==================== Real API ====================
  137. export class RealDialogueAPI implements DialogueAPI {
  138. async createSession(config: SessionConfig): Promise<SessionInfo> {
  139. const res = await fetch(`${API_BASE}/session`, {
  140. method: 'POST',
  141. headers: { 'Content-Type': 'application/json' },
  142. credentials: 'include',
  143. body: JSON.stringify({
  144. topic: config.topic,
  145. totalRounds: config.totalRounds,
  146. roleId: config.roleId,
  147. }),
  148. })
  149. if (!res.ok) {
  150. throw new DialogueApiError(`createSession failed: ${res.status}`, res.status)
  151. }
  152. const body = await res.json()
  153. return {
  154. sessionId: body.sessionId,
  155. totalRounds: body.totalRounds,
  156. currentRound: body.currentRound,
  157. expiresAt: body.expiresAt ?? null,
  158. }
  159. }
  160. async generateGreeting(sessionId: string, signal?: AbortSignal): Promise<GreetingInfo> {
  161. const res = await fetch(`${API_BASE}/session/${sessionId}/greeting`, {
  162. method: 'POST',
  163. credentials: 'include',
  164. signal,
  165. })
  166. if (!res.ok) {
  167. const text = await res.text().catch(() => '')
  168. throw new DialogueApiError(
  169. `greeting failed: ${res.status}${text ? ` (${text.slice(0, 100)})` : ''}`,
  170. res.status,
  171. )
  172. }
  173. const body = await res.json()
  174. return { aiMessage: body.aiMessage }
  175. }
  176. async *speak(sessionId: string, audioBlob: Blob, signal: AbortSignal): AsyncGenerator<SSEEvent> {
  177. const formData = new FormData()
  178. formData.append('sessionId', sessionId)
  179. formData.append('audio', audioBlob, 'recording.webm')
  180. const res = await fetch(`${API_BASE}/speak`, {
  181. method: 'POST',
  182. credentials: 'include',
  183. body: formData,
  184. signal,
  185. })
  186. if (!res.ok) throw new Error(`speak failed: ${res.status}`)
  187. if (!res.body) throw new Error('No response body')
  188. yield* parseSSEStream(res.body.getReader())
  189. }
  190. async getReport(sessionId: string): Promise<DialogueReport> {
  191. const res = await fetch(`${API_BASE}/report?sessionId=${encodeURIComponent(sessionId)}`, {
  192. credentials: 'include',
  193. })
  194. if (!res.ok) throw new Error(`getReport failed: ${res.status}`)
  195. const raw: BackendReportResponse = await res.json()
  196. return adaptReport(raw)
  197. }
  198. }
  199. // ==================== Mock API ====================
  200. const MOCK_AI_REPLIES = [
  201. 'Pandas are adorable! Have you seen them at the zoo?',
  202. "That's great! What do pandas like to eat?",
  203. "Bamboo is their favorite! You did a wonderful job talking about animals today!",
  204. ]
  205. export class MockDialogueAPI implements DialogueAPI {
  206. private roundIndex = 0
  207. async createSession(_config: SessionConfig): Promise<SessionInfo> {
  208. this.roundIndex = 0
  209. return {
  210. sessionId: 'mock-session-' + Date.now(),
  211. totalRounds: _config.totalRounds,
  212. currentRound: 1,
  213. expiresAt: null,
  214. }
  215. }
  216. async generateGreeting(_sessionId: string, signal?: AbortSignal): Promise<GreetingInfo> {
  217. await delay(300, signal)
  218. return { aiMessage: "Hi! What's your favorite animal?" }
  219. }
  220. async *speak(_sessionId: string, _audioBlob: Blob, signal: AbortSignal): AsyncGenerator<SSEEvent> {
  221. const mockStudentTexts = [
  222. 'I like pandas. They are very cute!',
  223. 'Yes, I went to the zoo last month.',
  224. 'They like to eat bamboo.',
  225. ]
  226. // Simulate transcript
  227. await delay(300, signal)
  228. yield { type: 'transcript', text: mockStudentTexts[this.roundIndex] || 'I think so.' }
  229. // Simulate token streaming
  230. const aiReply = MOCK_AI_REPLIES[this.roundIndex] || 'That is very interesting!'
  231. const words = aiReply.split(' ')
  232. for (const word of words) {
  233. await delay(80, signal)
  234. yield { type: 'token', text: word + ' ' }
  235. }
  236. this.roundIndex++
  237. const isComplete = this.roundIndex >= MOCK_AI_REPLIES.length
  238. await delay(100, signal)
  239. yield { type: 'done', isComplete }
  240. }
  241. async getReport(_sessionId: string): Promise<DialogueReport> {
  242. return {
  243. evaluation: {
  244. overallScore: 85,
  245. scoreLevel: 'good',
  246. percentile: 78,
  247. dimensions: { fluency: 82, interaction: 88, vocabulary: 76, grammar: 90 },
  248. aiComment: 'Great job! Your pronunciation was clear and your responses were relevant.',
  249. highlights: ['Clear pronunciation', 'Good use of complete sentences', 'Natural flow'],
  250. improvements: ['Try more adjectives', 'Practice linking words', 'Expand vocabulary'],
  251. nextChallenge: { difficulty: 'Medium', unlockedTopic: 'My Dream Job' },
  252. statistics: {
  253. totalRounds: 3, averageScore: 83, highestScore: 92,
  254. highestRound: 2, grammarErrors: 2, excellentExpressions: 4, totalDuration: 180,
  255. },
  256. sentenceEvaluations: [],
  257. },
  258. }
  259. }
  260. }
  261. export function createDialogueApi(mode: 'preview' | 'real'): DialogueAPI {
  262. return mode === 'real' ? new RealDialogueAPI() : new MockDialogueAPI()
  263. }
  264. function delay(ms: number, signal?: AbortSignal): Promise<void> {
  265. return new Promise((resolve, reject) => {
  266. if (signal?.aborted) { reject(new DOMException('Aborted', 'AbortError')); return }
  267. const timer = setTimeout(resolve, ms)
  268. signal?.addEventListener('abort', () => { clearTimeout(timer); reject(new DOMException('Aborted', 'AbortError')) }, { once: true })
  269. })
  270. }