|
@@ -46,25 +46,28 @@ export function useDialogueEngine() {
|
|
|
greetingInflight.value = true
|
|
greetingInflight.value = true
|
|
|
greetingAbortController = new AbortController()
|
|
greetingAbortController = new AbortController()
|
|
|
|
|
|
|
|
|
|
+ const greetingTurnId = crypto.randomUUID()
|
|
|
const aiMsg = reactive<PreviewChatMessage>({
|
|
const aiMsg = reactive<PreviewChatMessage>({
|
|
|
id: crypto.randomUUID(),
|
|
id: crypto.randomUUID(),
|
|
|
role: 'ai',
|
|
role: 'ai',
|
|
|
content: '',
|
|
content: '',
|
|
|
timestamp: new Date(),
|
|
timestamp: new Date(),
|
|
|
status: 'loading',
|
|
status: 'loading',
|
|
|
|
|
+ turnId: greetingTurnId,
|
|
|
})
|
|
})
|
|
|
messages.value.push(aiMsg)
|
|
messages.value.push(aiMsg)
|
|
|
|
|
|
|
|
try {
|
|
try {
|
|
|
- const { aiMessage } = await api.generateGreeting(sessionId.value, greetingAbortController.signal)
|
|
|
|
|
|
|
+ const { aiMessage } = await api.generateGreeting(sessionId.value, greetingTurnId, greetingAbortController.signal)
|
|
|
aiMsg.content = aiMessage
|
|
aiMsg.content = aiMessage
|
|
|
aiMsg.status = 'done'
|
|
aiMsg.status = 'done'
|
|
|
} catch (err: unknown) {
|
|
} catch (err: unknown) {
|
|
|
if (err instanceof Error && err.name === 'AbortError') return // 组件卸载:不改 UI
|
|
if (err instanceof Error && err.name === 'AbortError') return // 组件卸载:不改 UI
|
|
|
aiMsg.status = 'error'
|
|
aiMsg.status = 'error'
|
|
|
- aiMsg.error = friendlyErrorMessage(err instanceof Error ? err.message : undefined)
|
|
|
|
|
const status = err instanceof DialogueApiError ? err.status : 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 {
|
|
} finally {
|
|
|
greetingInflight.value = false
|
|
greetingInflight.value = false
|
|
|
greetingAbortController = null
|
|
greetingAbortController = null
|
|
@@ -75,14 +78,14 @@ export function useDialogueEngine() {
|
|
|
async function retryGreeting() {
|
|
async function retryGreeting() {
|
|
|
if (greetingInflight.value) return
|
|
if (greetingInflight.value) return
|
|
|
const firstAi = messages.value.find(m => m.role === 'ai')
|
|
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)
|
|
messages.value = messages.value.filter(m => m.id !== firstAi.id)
|
|
|
await generateGreeting()
|
|
await generateGreeting()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// ==================== Send Message ====================
|
|
// ==================== Send Message ====================
|
|
|
|
|
|
|
|
- async function sendStudentMessage(audioBlob: Blob) {
|
|
|
|
|
|
|
+ async function sendStudentMessage(audioBlob: Blob, turnId: string) {
|
|
|
if (!sessionId.value || isProcessing.value) return
|
|
if (!sessionId.value || isProcessing.value) return
|
|
|
|
|
|
|
|
// Add student message (loading)
|
|
// Add student message (loading)
|
|
@@ -93,6 +96,7 @@ export function useDialogueEngine() {
|
|
|
timestamp: new Date(),
|
|
timestamp: new Date(),
|
|
|
status: 'loading',
|
|
status: 'loading',
|
|
|
audioBlob,
|
|
audioBlob,
|
|
|
|
|
+ turnId,
|
|
|
})
|
|
})
|
|
|
messages.value.push(studentMsg)
|
|
messages.value.push(studentMsg)
|
|
|
|
|
|
|
@@ -103,12 +107,13 @@ export function useDialogueEngine() {
|
|
|
content: '',
|
|
content: '',
|
|
|
timestamp: new Date(),
|
|
timestamp: new Date(),
|
|
|
status: 'loading',
|
|
status: 'loading',
|
|
|
|
|
+ turnId,
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
currentAbortController = new AbortController()
|
|
currentAbortController = new AbortController()
|
|
|
|
|
|
|
|
try {
|
|
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) {
|
|
for await (const event of generator) {
|
|
|
if (event.type === 'transcript') {
|
|
if (event.type === 'transcript') {
|
|
@@ -137,14 +142,15 @@ export function useDialogueEngine() {
|
|
|
}
|
|
}
|
|
|
} catch (err: any) {
|
|
} catch (err: any) {
|
|
|
if (err.name === 'AbortError') return
|
|
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') {
|
|
if (studentMsg.status === 'loading') {
|
|
|
studentMsg.status = 'error'
|
|
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') {
|
|
} else if (aiMsg.status === 'loading') {
|
|
|
aiMsg.status = 'error'
|
|
aiMsg.status = 'error'
|
|
|
- aiMsg.error = err.message || 'Request failed'
|
|
|
|
|
|
|
+ aiMsg.error = friendlyErrorMessage(err.message)
|
|
|
|
|
+ aiMsg.recovery = classifyError(err.message, status, 'ai')
|
|
|
}
|
|
}
|
|
|
} finally {
|
|
} finally {
|
|
|
currentAbortController = null
|
|
currentAbortController = null
|
|
@@ -157,11 +163,11 @@ export function useDialogueEngine() {
|
|
|
const msg = messages.value.find(m => m.id === messageId)
|
|
const msg = messages.value.find(m => m.id === messageId)
|
|
|
if (!msg || msg.status !== 'error') return
|
|
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
|
|
// Remove the failed student message and any subsequent AI message
|
|
|
const idx = messages.value.indexOf(msg)
|
|
const idx = messages.value.indexOf(msg)
|
|
|
messages.value.splice(idx)
|
|
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
|
|
// Find the student message before this AI message
|
|
|
const idx = messages.value.indexOf(msg)
|
|
const idx = messages.value.indexOf(msg)
|
|
|
const prevStudent = messages.value.slice(0, idx).reverse().find(m => m.role === 'student')
|
|
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
|
|
// Remove the failed AI message
|
|
|
messages.value.splice(idx, 1)
|
|
messages.value.splice(idx, 1)
|
|
@@ -184,13 +190,17 @@ export function useDialogueEngine() {
|
|
|
content: '',
|
|
content: '',
|
|
|
timestamp: new Date(),
|
|
timestamp: new Date(),
|
|
|
status: 'loading',
|
|
status: 'loading',
|
|
|
|
|
+ turnId: prevStudent.turnId,
|
|
|
})
|
|
})
|
|
|
messages.value.push(aiMsg)
|
|
messages.value.push(aiMsg)
|
|
|
|
|
|
|
|
currentAbortController = new AbortController()
|
|
currentAbortController = new AbortController()
|
|
|
|
|
|
|
|
try {
|
|
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) {
|
|
for await (const event of generator) {
|
|
|
if (event.type === 'transcript') {
|
|
if (event.type === 'transcript') {
|
|
@@ -208,7 +218,9 @@ export function useDialogueEngine() {
|
|
|
} catch (err: any) {
|
|
} catch (err: any) {
|
|
|
if (err.name === 'AbortError') return
|
|
if (err.name === 'AbortError') return
|
|
|
aiMsg.status = 'error'
|
|
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 {
|
|
} finally {
|
|
|
currentAbortController = null
|
|
currentAbortController = null
|
|
|
}
|
|
}
|
|
@@ -410,11 +422,28 @@ export function useDialogueEngine() {
|
|
|
* 流式失败时的 HTTP fallback:用完整 audioBlob 走旧 /speak 路径。
|
|
* 流式失败时的 HTTP fallback:用完整 audioBlob 走旧 /speak 路径。
|
|
|
* 会把 beginStudentStream 已 push 的占位消息回收(避免重复)。
|
|
* 会把 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)
|
|
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 ====================
|
|
// ==================== Cleanup ====================
|
|
@@ -445,6 +474,7 @@ export function useDialogueEngine() {
|
|
|
streamFallback,
|
|
streamFallback,
|
|
|
retryMessage,
|
|
retryMessage,
|
|
|
regenerateAiMessage,
|
|
regenerateAiMessage,
|
|
|
|
|
+ discardCurrentTurn,
|
|
|
getReport,
|
|
getReport,
|
|
|
completeSession,
|
|
completeSession,
|
|
|
abort,
|
|
abort,
|