Selaa lähdekoodia

feat(speaking): wire view to new engine API; add starting/finalizing/recovery UI

Tasks 16-19 combined:
- isStarting/reportFetchInflight refs + state computed adds
  'starting' and 'finalizing' values
- handleStartRecording uses AbortController to cancel mic acquisition;
  handleCancelRecording is now a no-op on the message list (engine
  abort() doesn't push placeholders); handleFinishRecording calls
  streamCtl.commit(blob) instead of the removed attachStudentBlob/finish
- New state-starting + state-finalizing layers in state-stack
- Error card buttons render from message.recovery: 重试 + 重录 + 返回重开
- Removed leftover unrecoverable references and old retry helpers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
jimmylee 2 kuukautta sitten
vanhempi
commit
3f7302689d
1 muutettua tiedostoa jossa 143 lisäystä ja 45 poistoa
  1. 143 45
      src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue

+ 143 - 45
src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue

@@ -117,10 +117,11 @@
             <div v-if="message.status === 'error'" class="error-card">
               <span class="error-text">{{ message.error || '生成失败' }}</span>
               <button
+                v-if="hasRetryButton(message)"
                 class="retry-btn"
                 :disabled="engine.greetingInflight.value"
-                @click="retryAiMessage(message)"
-              >{{ message.unrecoverable ? '返回重开' : '重新生成' }}</button>
+                @click="handleRetry(message)"
+              >{{ retryButtonLabel(message) }}</button>
             </div>
           </div>
         </div>
@@ -201,7 +202,16 @@
           <!-- 学生错误 -->
           <div v-if="message.status === 'error'" class="error-card">
             <span class="error-text">{{ message.error || '发送失败' }}</span>
-            <button class="retry-btn" @click="engine.retryMessage(message.id)">重试</button>
+            <button
+              v-if="hasRetryButton(message)"
+              class="retry-btn"
+              @click="handleRetry(message)"
+            >{{ retryButtonLabel(message) }}</button>
+            <button
+              v-if="hasRerecordButton(message)"
+              class="rerecord-btn"
+              @click="handleRerecord(message)"
+            >重录</button>
           </div>
 
           <!-- L1 评分卡 -->
@@ -330,6 +340,34 @@
           </button>
         </div>
 
+        <!-- starting -->
+        <div class="state-layer state-starting" :style="stateStyle('starting')">
+          <div class="record-capsule">
+            <button class="cancel-btn" @click="handleCancelStarting">
+              <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
+                stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+                <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
+              </svg>
+              取消
+            </button>
+            <div class="record-meter">
+              <svg class="spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
+                stroke-width="2" stroke-linecap="round">
+                <path d="M21 12a9 9 0 1 1-6.219-8.56" />
+              </svg>
+              <span class="record-time">准备录音中...</span>
+            </div>
+            <button class="finish-btn" disabled>
+              <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
+                stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+                <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
+                <polyline points="22 4 12 14.01 9 11.01" />
+              </svg>
+              完成
+            </button>
+          </div>
+        </div>
+
         <!-- recording -->
         <div class="state-layer state-recording" :style="stateStyle('recording')">
           <div class="record-capsule">
@@ -390,7 +428,20 @@
             <span class="warn-icon">⚠️</span>
             <span class="warn-text">{{ lastErrorText }}</span>
           </div>
-          <button class="retry-pill" @click="handleRetry">重试</button>
+          <button
+            v-if="lastErroredMessage && hasRetryButton(lastErroredMessage)"
+            class="retry-pill"
+            @click="handleRetry(lastErroredMessage)"
+          >{{ retryButtonLabel(lastErroredMessage) }}</button>
+        </div>
+
+        <!-- finalizing -->
+        <div class="state-layer state-center" :style="stateStyle('finalizing')">
+          <svg class="spinner" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
+            stroke-width="2" stroke-linecap="round">
+            <path d="M21 12a9 9 0 1 1-6.219-8.56" />
+          </svg>
+          <span class="center-text">正在生成你的本次对话报告...</span>
         </div>
       </div>
     </div>
@@ -579,6 +630,10 @@ const silenceHintText = ref('')
 const showIdleHint = ref(false)
 let idleHintTimer: ReturnType<typeof setTimeout> | null = null
 
+const isStarting = ref(false)
+const reportFetchInflight = ref(false)
+let startAbortController: AbortController | null = null
+
 // 徽章
 const showBadge = ref<BadgeAchievement | null>(null)
 const consecutiveFluent = ref(0)
@@ -591,10 +646,13 @@ let badgeTimer: ReturnType<typeof setTimeout> | null = null
 
 const currentRound = computed(() => engine.currentRound.value)
 
-// 状态机:recording → stt → ai_thinking → idle / error / done
-const state = computed<'idle' | 'recording' | 'stt' | 'ai_thinking' | 'error' | 'done'>(() => {
+// 状态机:starting → recording → stt → ai_thinking → idle / error / finalizing / done
+const state = computed<
+  'idle' | 'starting' | 'recording' | 'stt' | 'ai_thinking' | 'finalizing' | 'error' | 'done'
+>(() => {
+  if (isStarting.value) return 'starting'
   if (recorder.isRecording.value) return 'recording'
-  if (engine.isComplete.value) return 'done'
+  if (engine.isComplete.value) return reportFetchInflight.value ? 'finalizing' : 'done'
 
   const msgs = engine.messages.value
   const last = msgs[msgs.length - 1]
@@ -669,14 +727,18 @@ function stateStyle(target: string) {
 // ─────────────────────────────────────────────
 
 // 当前流式会话控制器(开始录音时打开 WebSocket,结束时收尾)
-let streamCtl: ReturnType<typeof engine.beginStudentStream> | null = null
+let streamCtl: ReturnType<typeof engine.beginStudentStream> = null
 
 async function handleStartRecording() {
-  if (!engine.canRecord.value || recorder.isRecording.value) return
+  if (!engine.canRecord.value || recorder.isRecording.value || isStarting.value) return
   player.stop()
+
+  isStarting.value = true
+  startAbortController = new AbortController()
+
   try {
-    await recorder.startRecording()
-    // 启动流式会话(立即 push UI 占位消息 + 打开 WebSocket)
+    await recorder.startRecording(startAbortController.signal)
+    // Mic acquired — open the WS now (no placeholders pushed yet).
     streamCtl = engine.beginStudentStream({
       sampleRate: recorder.sampleRate.value,
       bits: 16,
@@ -685,16 +747,28 @@ async function handleStartRecording() {
     if (streamCtl) {
       recorder.onChunk.value = streamCtl.pushChunk
     }
-  } catch (err) {
-    console.error('Failed to start recording:', err)
+  } catch (err: any) {
+    if (err.name === 'AbortError') {
+      // User cancelled during getUserMedia. State naturally returns to idle.
+    } else {
+      console.error('Failed to start recording:', err)
+    }
+  } finally {
+    isStarting.value = false
+    startAbortController = null
   }
 }
 
+function handleCancelStarting() {
+  startAbortController?.abort()
+  // recorder.startRecording will reject with AbortError; no further cleanup needed.
+}
+
 function handleCancelRecording() {
   // 停止录音 + 中止流式会话(丢弃本次录音)
   recorder.onChunk.value = null
   if (streamCtl) {
-    streamCtl.abortStream()
+    streamCtl.abort()  // close WS; nothing on the message list to clean
     streamCtl = null
   }
   if (recorder.isRecording.value) {
@@ -711,47 +785,57 @@ async function handleFinishRecording() {
     const blob = await recorder.stopRecording()
     recorder.onChunk.value = null
     if (ctl) {
-      // 走 WebSocket 流式路径。WS 失败时,不自动降级 HTTP —— 让错误泡泡的"重试"按钮走人工路径。
-      // attachStudentBlob 必须在 ctl.finish() 之前:服务端 done 事件回来前,blob 已经挂到学生消息上,replay 才能用。
-      engine.attachStudentBlob(ctl.studentMsgId, blob)
-      ctl.finish()
+      ctl.commit(blob)  // pushes placeholders, attaches blob, sends 'stop'
     } else {
-      // 没启动流式(异常情况)→ 直接走旧 HTTP 路径
-      await engine.sendStudentMessage(blob)
+      // Fallback: direct HTTP path
+      await engine.sendStudentMessage(blob, crypto.randomUUID())
     }
   } catch (err) {
     console.error('Recording/send failed:', err)
   }
 }
 
-function handleRetry() {
-  const msgs = engine.messages.value
-  const last = msgs[msgs.length - 1]
-  if (!last) return
-  if (last.role === 'student') engine.retryMessage(last.id)
-  else engine.regenerateAiMessage(last.id)
-}
-
-function retryAiMessage(message: PreviewChatMessage) {
-  const idx = engine.messages.value.indexOf(message)
-  if (idx === -1) return  // message already removed (e.g., retryGreeting filtered it out)
-  // Invariant: only generateGreeting pushes an AI message before any student turn,
-  // so the first AI message is always the greeting.
-  const hasPrevStudent = engine.messages.value.slice(0, idx).some(m => m.role === 'student')
-
-  // 第一条 AI 消息(前面没有学生消息)= greeting
-  if (!hasPrevStudent) {
-    if (message.unrecoverable) {
-      emit('restart')
-      return
-    }
-    engine.retryGreeting()
+function hasRetryButton(m: PreviewChatMessage): boolean {
+  return m.recovery === 'retry' || m.recovery === 'restart'
+}
+function hasRerecordButton(m: PreviewChatMessage): boolean {
+  return m.role === 'student' && (m.recovery === 'retry' || m.recovery === 'rerecord')
+}
+function retryButtonLabel(m: PreviewChatMessage): string {
+  if (m.recovery === 'restart') return '返回重开'
+  return '重试'
+}
+async function handleRetry(m: PreviewChatMessage) {
+  if (m.recovery === 'restart') {
+    emit('restart')
     return
   }
-
-  // 非首条:沿用原 regenerate
-  engine.regenerateAiMessage(message.id)
+  if (m.role === 'student') {
+    await engine.retryMessage(m.id)
+  } else {
+    // greeting case: first AI message with no prior student
+    const idx = engine.messages.value.indexOf(m)
+    const isGreeting = !engine.messages.value
+      .slice(0, idx)
+      .some(x => x.role === 'student')
+    if (isGreeting) {
+      await engine.retryGreeting()
+    } else {
+      await engine.regenerateAiMessage(m.id)
+    }
+  }
 }
+function handleRerecord(m: PreviewChatMessage) {
+  engine.discardCurrentTurn(m.id)
+}
+
+const lastErroredMessage = computed<PreviewChatMessage | null>(() => {
+  const msgs = engine.messages.value
+  for (let i = msgs.length - 1; i >= 0; i--) {
+    if (msgs[i].status === 'error') return msgs[i]
+  }
+  return null
+})
 
 function toggleExpand(id: string) {
   expandedMessageId.value = expandedMessageId.value === id ? null : id
@@ -820,12 +904,15 @@ async function handleExitConfirm() {
 }
 
 async function fetchReportSafe(): Promise<DialogueReport | null> {
+  reportFetchInflight.value = true
   try {
     await engine.completeSession()
     return await engine.getReport()
   } catch (err) {
     console.warn('[speaking] getReport failed:', err)
     return null
+  } finally {
+    reportFetchInflight.value = false
   }
 }
 
@@ -1263,6 +1350,17 @@ onUnmounted(() => {
   cursor: pointer;
   &:hover { background: #fef2f2; border-color: #f87171; }
 }
+.rerecord-btn {
+  padding: 3px 10px;
+  background: transparent;
+  border: 1px solid #d1d5db;
+  border-radius: 999px;
+  font-size: 11px;
+  color: #6b7280;
+  cursor: pointer;
+  margin-left: 4px;
+  &:hover { background: #f9fafb; border-color: #9ca3af; color: #374151; }
+}
 
 // 评分卡
 .eval-card {