Просмотр исходного кода

feat(speaking): split beginStudentStream into begin → commit/abort

Placeholders are pushed at commit() (when user clicks 完成), not at
begin() (which now only opens the WS). abort() is a pure no-op on
the message list. Final-round commits push only studentMsg.
attachStudentBlob is removed (folded into commit). Eliminates the
misleading AI typing-bubble that previously animated during recording.
jimmylee 2 месяцев назад
Родитель
Сommit
666555dd29
1 измененных файлов с 56 добавлено и 54 удалено
  1. 56 54
      src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts

+ 56 - 54
src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts

@@ -273,51 +273,39 @@ export function useDialogueEngine() {
 
   // ==================== Streaming Speak (WebSocket) ====================
 
-  /** 流式开始:立即 push 学生占位消息,返回 controller 供外部推 chunk / 结束 */
+  /** 流式开始:打开 WS,返回 { pushChunk, commit, abort };占位消息在 commit() 时才 push */
   function beginStudentStream(opts: {
     sampleRate: number
     bits?: number
     channels?: number
   }) {
-    if (!sessionId.value || isProcessing.value) {
-      return null
-    }
+    if (!sessionId.value || isProcessing.value) return null
 
-    // 立即占位:录音按完成的那一刻 UI 就已经显示学生泡泡 + AI placeholder
-    const studentMsg = reactive<PreviewChatMessage>({
-      id: crypto.randomUUID(),
-      role: 'student',
-      content: '',
-      timestamp: new Date(),
-      status: 'loading',
-    })
-    messages.value.push(studentMsg)
+    const turnId = crypto.randomUUID()
 
-    const aiMsg = reactive<PreviewChatMessage>({
-      id: crypto.randomUUID(),
-      role: 'ai',
-      content: '',
-      timestamp: new Date(),
-      status: 'loading',
-    })
-    messages.value.push(aiMsg)
+    // No messages pushed yet. They are pushed in commit().
+    let studentMsg: PreviewChatMessage | null = null
+    let aiMsg: PreviewChatMessage | null = null
 
     const wsUrl = buildWsUrl('/speak-stream')
     const ws = new WebSocket(wsUrl)
     ws.binaryType = 'arraybuffer'
 
     let aborted = false
+    let committed = false
     let chunkQueue: ArrayBuffer[] = []
     let open = false
 
-    const finalizeError = (msg: string) => {
-      if (studentMsg.status === 'loading') {
+    const finalizeError = (raw: string) => {
+      const text = friendlyErrorMessage(raw)
+      if (studentMsg && studentMsg.status === 'loading') {
         studentMsg.status = 'error'
-        studentMsg.error = msg
-      }
-      else if (aiMsg.status === 'loading') {
+        studentMsg.error = text
+        studentMsg.recovery = classifyError(raw, undefined, 'student')
+      } else if (aiMsg && aiMsg.status === 'loading') {
         aiMsg.status = 'error'
-        aiMsg.error = msg
+        aiMsg.error = text
+        aiMsg.recovery = classifyError(raw, undefined, 'ai')
       }
     }
 
@@ -326,44 +314,47 @@ export function useDialogueEngine() {
       ws.send(JSON.stringify({
         type: 'start',
         sessionId: sessionId.value,
+        turnId,
         sampleRate: opts.sampleRate,
         bits: opts.bits ?? 16,
         channels: opts.channels ?? 1,
       }))
-      // flush 队列里攒的 chunk
       for (const c of chunkQueue) ws.send(c)
       chunkQueue = []
     }
 
     ws.onmessage = (e: MessageEvent) => {
+      if (!committed) return  // tokens shouldn't arrive before commit; defensive
       try {
         const data = JSON.parse(e.data)
-        if (data.type === 'transcript') {
+        if (data.type === 'transcript' && studentMsg) {
           studentMsg.content = data.text
           studentMsg.status = 'done'
         }
-        else if (data.type === 'token') {
+        else if (data.type === 'token' && aiMsg) {
           aiMsg.content += data.content
         }
         else if (data.type === 'done') {
-          aiMsg.status = 'done'
+          if (aiMsg) aiMsg.status = 'done'
+          else if (studentMsg && studentMsg.status === 'loading') studentMsg.status = 'done'
           isComplete.value = !!data.isComplete
           if (!data.isComplete) currentRound.value++
           ws.close()
         }
         else if (data.type === 'error') {
-          finalizeError(friendlyErrorMessage(data.message))
+          finalizeError(data.message)
           ws.close()
         }
       } catch { /* ignore */ }
     }
 
     ws.onerror = () => {
-      if (!aborted) finalizeError(friendlyErrorMessage('WebSocket error'))
+      if (!aborted && committed) finalizeError('WebSocket error')
     }
     ws.onclose = () => {
-      if (studentMsg.status === 'loading') finalizeError(friendlyErrorMessage('Connection closed'))
-      else if (aiMsg.status === 'loading') finalizeError(friendlyErrorMessage('Connection closed'))
+      if (!committed || aborted) return
+      if (studentMsg?.status === 'loading') finalizeError('Connection closed')
+      else if (aiMsg?.status === 'loading') finalizeError('Connection closed')
     }
 
     const pushChunk = (chunk: ArrayBuffer) => {
@@ -372,24 +363,47 @@ export function useDialogueEngine() {
       else chunkQueue.push(chunk)
     }
 
-    const finish = () => {
-      if (aborted) return
+    const commit = (blob: Blob) => {
+      if (committed || aborted) return
+      committed = true
+
+      studentMsg = reactive<PreviewChatMessage>({
+        id: crypto.randomUUID(),
+        role: 'student',
+        content: '',
+        timestamp: new Date(),
+        status: 'loading',
+        audioBlob: blob,
+        turnId,
+      })
+      messages.value.push(studentMsg)
+
+      if (!isFinalRound.value) {
+        aiMsg = reactive<PreviewChatMessage>({
+          id: crypto.randomUUID(),
+          role: 'ai',
+          content: '',
+          timestamp: new Date(),
+          status: 'loading',
+          turnId,
+        })
+        messages.value.push(aiMsg)
+      }
+
       if (open && ws.readyState === WebSocket.OPEN) {
         ws.send(JSON.stringify({ type: 'stop' }))
       } else {
-        // 还没 open 就被要求结束 → 废话不说 直接 close
         ws.close()
       }
     }
 
-    const abortStream = () => {
+    const abort = () => {
       aborted = true
       try { ws.close() } catch { /* ignore */ }
-      finalizeError('Aborted')
+      // No messages were pushed (commit not called) → nothing to clean up.
     }
 
-    // 便于重试:保存关联的学生消息 id
-    return { studentMsgId: studentMsg.id, aiMsgId: aiMsg.id, pushChunk, finish, abortStream }
+    return { turnId, pushChunk, commit, abort }
   }
 
   /**
@@ -403,17 +417,6 @@ export function useDialogueEngine() {
     await sendStudentMessage(audioBlob)
   }
 
-  /**
-   * Attach the recorded audio blob to a student message that was
-   * pushed by `beginStudentStream` (which doesn't know the final
-   * blob until the user clicks 完成). Lets click-replay work in
-   * the WebSocket path the same way it does for HTTP.
-   */
-  function attachStudentBlob(messageId: string, blob: Blob) {
-    const msg = messages.value.find(m => m.id === messageId)
-    if (msg && msg.role === 'student') msg.audioBlob = blob
-  }
-
   // ==================== Cleanup ====================
 
   onUnmounted(() => {
@@ -445,7 +448,6 @@ export function useDialogueEngine() {
     getReport,
     completeSession,
     abort,
-    attachStudentBlob,
   }
 }