|
|
@@ -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,
|