Explorar el Código

refactor(speaking): split initSession into attachSession + generateGreeting + retryGreeting

- attachSession: sync method, only wires sessionId/expiresAt into engine refs
- generateGreeting: posts to /greeting, pushes loading AI placeholder, handles
  DialogueApiError status to flag 404/409 as unrecoverable
- retryGreeting: removes the failed AI message and re-runs generateGreeting
- greetingInflight ref guards concurrent requests
- onUnmounted aborts in-flight greeting fetch

Next task: wire DialogueChatView/TopicDiscussionPreview to the new API.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
jimmylee hace 2 semanas
padre
commit
a370f4ee98
Se han modificado 1 ficheros con 54 adiciones y 20 borrados
  1. 54 20
      src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts

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

@@ -1,6 +1,6 @@
 import { ref, reactive, computed, onUnmounted } from 'vue'
 import type { PreviewChatMessage, DialogueAPI, SessionConfig, DialogueReport } from '@/types/englishSpeaking'
-import { MockDialogueAPI, RealDialogueAPI } from '../services/llmService'
+import { createDialogueApi, DialogueApiError } from '../services/llmService'
 
 export function useDialogueEngine(mode: 'preview' | 'real' = 'preview') {
   const messages = ref<PreviewChatMessage[]>([])
@@ -10,7 +10,7 @@ export function useDialogueEngine(mode: 'preview' | 'real' = 'preview') {
   const isComplete = ref(false)
   const countdownSeconds = ref<number | null>(null)
 
-  let api: DialogueAPI = mode === 'real' ? new RealDialogueAPI() : new MockDialogueAPI()
+  const api: DialogueAPI = createDialogueApi(mode)
   let currentAbortController: AbortController | null = null
   let countdownTimer: ReturnType<typeof setInterval> | null = null
   let ttsUtterance: SpeechSynthesisUtterance | null = null
@@ -18,29 +18,59 @@ export function useDialogueEngine(mode: 'preview' | 'real' = 'preview') {
   const isProcessing = computed(() => messages.value.some(m => m.status === 'loading'))
   const canRecord = computed(() => !isProcessing.value && !isComplete.value)
 
-  // ==================== Session ====================
+  // ==================== Session Attach ====================
+
+  const greetingInflight = ref(false)
+  let greetingAbortController: AbortController | null = null
+
+  /** 仅附加已建好的 session 到 engine,不发任何网络请求。 */
+  function attachSession(info: { sessionId: string; expiresAt?: string | null }) {
+    sessionId.value = info.sessionId
+    expiresAt.value = info.expiresAt ?? null
+    if (info.expiresAt) startCountdown(info.expiresAt)
+  }
+
+  /** 触发开场白生成:push loading 占位 AI 消息 → 请求 /greeting → 成功填内容,失败标 error。 */
+  async function generateGreeting() {
+    if (!sessionId.value || greetingInflight.value) return
+    greetingInflight.value = true
+    greetingAbortController = new AbortController()
+
+    const aiMsg = reactive<PreviewChatMessage>({
+      id: crypto.randomUUID(),
+      role: 'ai',
+      content: '',
+      timestamp: new Date(),
+      status: 'loading',
+    })
+    messages.value.push(aiMsg)
 
-  async function initSession(config: SessionConfig) {
     try {
-      const info = await api.createSession(config)
-      sessionId.value = info.sessionId
-      expiresAt.value = info.expiresAt || null
-
-      messages.value.push({
-        id: crypto.randomUUID(),
-        role: 'ai',
-        content: info.aiMessage,
-        timestamp: new Date(),
-        status: 'done',
-      })
-
-      if (info.expiresAt) startCountdown(info.expiresAt)
-      speakTTS(info.aiMessage)
+      const { aiMessage } = await api.generateGreeting(sessionId.value, greetingAbortController.signal)
+      aiMsg.content = aiMessage
+      aiMsg.status = 'done'
+      speakTTS(aiMessage)
     } catch (err: any) {
-      console.error('Failed to init session:', err)
+      if (err?.name === 'AbortError') return  // 组件卸载:不改 UI
+      aiMsg.status = 'error'
+      aiMsg.error = friendlyErrorMessage(err?.message)
+      const status = err instanceof DialogueApiError ? err.status : undefined
+      aiMsg.unrecoverable = status === 404 || status === 409
+    } finally {
+      greetingInflight.value = false
+      greetingAbortController = null
     }
   }
 
+  /** 重试失败的开场白:移除 error 消息,再走一遍 generateGreeting(同 sessionId)。 */
+  async function retryGreeting() {
+    if (greetingInflight.value) return
+    const firstAi = messages.value.find(m => m.role === 'ai')
+    if (firstAi?.status !== 'error' || firstAi.unrecoverable) return
+    messages.value = messages.value.filter(m => m.id !== firstAi.id)
+    await generateGreeting()
+  }
+
   // ==================== Send Message ====================
 
   async function sendStudentMessage(audioBlob: Blob) {
@@ -390,6 +420,7 @@ export function useDialogueEngine(mode: 'preview' | 'real' = 'preview') {
 
   onUnmounted(() => {
     abort()
+    greetingAbortController?.abort()
     cancelTTS()
     stopCountdown()
   })
@@ -402,8 +433,11 @@ export function useDialogueEngine(mode: 'preview' | 'real' = 'preview') {
     isProcessing,
     canRecord,
     countdownSeconds,
+    greetingInflight,   // 新增:UI 可用来 disable 重试按钮
 
-    initSession,
+    attachSession,      // 替代 initSession
+    generateGreeting,
+    retryGreeting,
     sendStudentMessage,
     beginStudentStream,
     streamFallback,