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

feat(speaking): plumb turnId + recovery through retry/regenerate paths

retryMessage and regenerateAiMessage read turnId from the failed
message and forward it to api.speak via sendStudentMessage(blob,
turnId). All error finalization paths set msg.recovery via
classifyError. Add discardCurrentTurn for the 重录 button.
unrecoverable field references replaced with recovery === 'restart'.
generateGreeting passes greetingTurnId to api.generateGreeting.
jimmylee 2 месяцев назад
Родитель
Сommit
a645d8577f
1 измененных файлов с 47 добавлено и 17 удалено
  1. 47 17
      src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts

+ 47 - 17
src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts

@@ -46,25 +46,28 @@ export function useDialogueEngine() {
     greetingInflight.value = true
     greetingAbortController = new AbortController()
 
+    const greetingTurnId = crypto.randomUUID()
     const aiMsg = reactive<PreviewChatMessage>({
       id: crypto.randomUUID(),
       role: 'ai',
       content: '',
       timestamp: new Date(),
       status: 'loading',
+      turnId: greetingTurnId,
     })
     messages.value.push(aiMsg)
 
     try {
-      const { aiMessage } = await api.generateGreeting(sessionId.value, greetingAbortController.signal)
+      const { aiMessage } = await api.generateGreeting(sessionId.value, greetingTurnId, greetingAbortController.signal)
       aiMsg.content = aiMessage
       aiMsg.status = 'done'
     } catch (err: unknown) {
       if (err instanceof Error && err.name === 'AbortError') return  // 组件卸载:不改 UI
       aiMsg.status = 'error'
-      aiMsg.error = friendlyErrorMessage(err instanceof Error ? err.message : undefined)
       const status = err instanceof DialogueApiError ? err.status : undefined
-      aiMsg.unrecoverable = status === 404 || status === 409
+      const raw = err instanceof Error ? err.message : undefined
+      aiMsg.error = friendlyErrorMessage(raw)
+      aiMsg.recovery = classifyError(raw, status, 'ai')
     } finally {
       greetingInflight.value = false
       greetingAbortController = null
@@ -75,14 +78,14 @@ export function useDialogueEngine() {
   async function retryGreeting() {
     if (greetingInflight.value) return
     const firstAi = messages.value.find(m => m.role === 'ai')
-    if (firstAi?.status !== 'error' || firstAi.unrecoverable) return
+    if (firstAi?.status !== 'error' || firstAi.recovery === 'restart') return
     messages.value = messages.value.filter(m => m.id !== firstAi.id)
     await generateGreeting()
   }
 
   // ==================== Send Message ====================
 
-  async function sendStudentMessage(audioBlob: Blob) {
+  async function sendStudentMessage(audioBlob: Blob, turnId: string) {
     if (!sessionId.value || isProcessing.value) return
 
     // Add student message (loading)
@@ -93,6 +96,7 @@ export function useDialogueEngine() {
       timestamp: new Date(),
       status: 'loading',
       audioBlob,
+      turnId,
     })
     messages.value.push(studentMsg)
 
@@ -103,12 +107,13 @@ export function useDialogueEngine() {
       content: '',
       timestamp: new Date(),
       status: 'loading',
+      turnId,
     })
 
     currentAbortController = new AbortController()
 
     try {
-      const generator = api.speak(sessionId.value, audioBlob, currentAbortController.signal)
+      const generator = api.speak(sessionId.value, audioBlob, currentAbortController.signal, turnId)
 
       for await (const event of generator) {
         if (event.type === 'transcript') {
@@ -137,14 +142,15 @@ export function useDialogueEngine() {
       }
     } catch (err: any) {
       if (err.name === 'AbortError') return
-
-      // Determine which message to mark as error
+      const status: number | undefined = err instanceof DialogueApiError ? err.status : undefined
       if (studentMsg.status === 'loading') {
         studentMsg.status = 'error'
-        studentMsg.error = err.message || 'Request failed'
+        studentMsg.error = friendlyErrorMessage(err.message)
+        studentMsg.recovery = classifyError(err.message, status, 'student')
       } else if (aiMsg.status === 'loading') {
         aiMsg.status = 'error'
-        aiMsg.error = err.message || 'Request failed'
+        aiMsg.error = friendlyErrorMessage(err.message)
+        aiMsg.recovery = classifyError(err.message, status, 'ai')
       }
     } finally {
       currentAbortController = null
@@ -157,11 +163,11 @@ export function useDialogueEngine() {
     const msg = messages.value.find(m => m.id === messageId)
     if (!msg || msg.status !== 'error') return
 
-    if (msg.role === 'student' && msg.audioBlob) {
+    if (msg.role === 'student' && msg.audioBlob && msg.turnId) {
       // Remove the failed student message and any subsequent AI message
       const idx = messages.value.indexOf(msg)
       messages.value.splice(idx)
-      await sendStudentMessage(msg.audioBlob)
+      await sendStudentMessage(msg.audioBlob, msg.turnId)
     }
   }
 
@@ -172,7 +178,7 @@ export function useDialogueEngine() {
     // Find the student message before this AI message
     const idx = messages.value.indexOf(msg)
     const prevStudent = messages.value.slice(0, idx).reverse().find(m => m.role === 'student')
-    if (!prevStudent?.audioBlob || !sessionId.value) return
+    if (!prevStudent?.audioBlob || !sessionId.value || !prevStudent.turnId) return
 
     // Remove the failed AI message
     messages.value.splice(idx, 1)
@@ -184,13 +190,17 @@ export function useDialogueEngine() {
       content: '',
       timestamp: new Date(),
       status: 'loading',
+      turnId: prevStudent.turnId,
     })
     messages.value.push(aiMsg)
 
     currentAbortController = new AbortController()
 
     try {
-      const generator = api.speak(sessionId.value, prevStudent.audioBlob, currentAbortController.signal)
+      const generator = api.speak(
+        sessionId.value, prevStudent.audioBlob,
+        currentAbortController.signal, prevStudent.turnId,
+      )
 
       for await (const event of generator) {
         if (event.type === 'transcript') {
@@ -208,7 +218,9 @@ export function useDialogueEngine() {
     } catch (err: any) {
       if (err.name === 'AbortError') return
       aiMsg.status = 'error'
-      aiMsg.error = err.message || 'Request failed'
+      aiMsg.error = friendlyErrorMessage(err.message)
+      const status = err instanceof DialogueApiError ? err.status : undefined
+      aiMsg.recovery = classifyError(err.message, status, 'ai')
     } finally {
       currentAbortController = null
     }
@@ -410,11 +422,28 @@ export function useDialogueEngine() {
    * 流式失败时的 HTTP fallback:用完整 audioBlob 走旧 /speak 路径。
    * 会把 beginStudentStream 已 push 的占位消息回收(避免重复)。
    */
-  async function streamFallback(audioBlob: Blob, studentMsgId: string, aiMsgId: string) {
+  async function streamFallback(audioBlob: Blob, studentMsgId: string, aiMsgId: string, turnId: string) {
     // 移除占位消息
     messages.value = messages.value.filter(m => m.id !== studentMsgId && m.id !== aiMsgId)
     // 走旧流程
-    await sendStudentMessage(audioBlob)
+    await sendStudentMessage(audioBlob, turnId)
+  }
+
+  /**
+   * 丢弃当前轮次的所有消息(student + ai),用于"重录"按钮。
+   * 通过 turnId 精确定位同一轮的两条消息并一并移除。
+   */
+  function discardCurrentTurn(messageId: string) {
+    const msg = messages.value.find(m => m.id === messageId)
+    if (!msg) return
+    const turnId = msg.turnId
+    if (!turnId) {
+      // Defensive: messages without turnId (legacy / greeting) can't be turn-grouped.
+      messages.value = messages.value.filter(m => m.id !== messageId)
+      return
+    }
+    // Splice out both rows of the turn (student + ai) sharing this turnId.
+    messages.value = messages.value.filter(m => m.turnId !== turnId)
   }
 
   // ==================== Cleanup ====================
@@ -445,6 +474,7 @@ export function useDialogueEngine() {
     streamFallback,
     retryMessage,
     regenerateAiMessage,
+    discardCurrentTurn,
     getReport,
     completeSession,
     abort,