|
|
@@ -113,7 +113,8 @@ export function useDialogueEngine() {
|
|
|
async function sendStudentMessage(audioBlob: Blob, turnId: string) {
|
|
|
if (!sessionId.value || isProcessing.value) return
|
|
|
|
|
|
- // Add student message (loading)
|
|
|
+ // 只 push student 占位。aiMsg 留到 transcript 事件到达再 push——避免 AI typing-bubble
|
|
|
+ // 抢在 student 转录文本前出现。学生 loading 状态下由 student 自己的 typing-bubble 占位。
|
|
|
const studentMsg = reactive<PreviewChatMessage>({
|
|
|
id: crypto.randomUUID(),
|
|
|
role: 'student',
|
|
|
@@ -125,7 +126,6 @@ export function useDialogueEngine() {
|
|
|
})
|
|
|
messages.value.push(studentMsg)
|
|
|
|
|
|
- // Add AI message placeholder
|
|
|
const aiMsg = reactive<PreviewChatMessage>({
|
|
|
id: crypto.randomUUID(),
|
|
|
role: 'ai',
|
|
|
@@ -144,8 +144,9 @@ export function useDialogueEngine() {
|
|
|
if (event.type === 'transcript') {
|
|
|
studentMsg.content = event.text
|
|
|
studentMsg.status = 'done'
|
|
|
- // Now push AI message placeholder
|
|
|
- messages.value.push(aiMsg)
|
|
|
+ if (!isFinalRound.value) {
|
|
|
+ messages.value.push(aiMsg)
|
|
|
+ }
|
|
|
} else if (event.type === 'token') {
|
|
|
aiMsg.content += event.text
|
|
|
} else if (event.type === 'done') {
|
|
|
@@ -155,6 +156,13 @@ export function useDialogueEngine() {
|
|
|
if (!event.isComplete) {
|
|
|
currentRound.value++
|
|
|
}
|
|
|
+ } else if (event.type === 'error') {
|
|
|
+ studentMsg.status = 'error'
|
|
|
+ studentMsg.error = friendlyErrorMessage(event.message)
|
|
|
+ studentMsg.recovery = classifyError(event.message, undefined, 'student')
|
|
|
+ const aiIdx = messages.value.indexOf(aiMsg)
|
|
|
+ if (aiIdx !== -1) messages.value.splice(aiIdx, 1)
|
|
|
+ return
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -330,15 +338,55 @@ export function useDialogueEngine() {
|
|
|
|
|
|
let aborted = false
|
|
|
let committed = false
|
|
|
+ let pendingCommitBlob: Blob | null = null
|
|
|
+ let openWaitTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
let chunkQueue: ArrayBuffer[] = []
|
|
|
let open = false
|
|
|
|
|
|
+ const clearOpenWaitTimer = () => {
|
|
|
+ if (openWaitTimer) {
|
|
|
+ clearTimeout(openWaitTimer)
|
|
|
+ openWaitTimer = null
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const pushPlaceholders = (blob: Blob) => {
|
|
|
+ studentMsg = reactive<PreviewChatMessage>({
|
|
|
+ id: crypto.randomUUID(),
|
|
|
+ role: 'student',
|
|
|
+ content: '',
|
|
|
+ timestamp: new Date(),
|
|
|
+ status: 'loading',
|
|
|
+ audioBlob: blob,
|
|
|
+ turnId,
|
|
|
+ })
|
|
|
+ messages.value.push(studentMsg)
|
|
|
+
|
|
|
+ // aiMsg 创建但暂不 push——等 transcript 事件到达后再插入聊天列表。
|
|
|
+ // 这样 student loading 阶段不会有 AI typing-bubble 抢先显示。
|
|
|
+ if (!isFinalRound.value) {
|
|
|
+ aiMsg = reactive<PreviewChatMessage>({
|
|
|
+ id: crypto.randomUUID(),
|
|
|
+ role: 'ai',
|
|
|
+ content: '',
|
|
|
+ timestamp: new Date(),
|
|
|
+ status: 'loading',
|
|
|
+ turnId,
|
|
|
+ })
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
const finalizeError = (raw: string) => {
|
|
|
const text = friendlyErrorMessage(raw)
|
|
|
if (studentMsg && studentMsg.status === 'loading') {
|
|
|
studentMsg.status = 'error'
|
|
|
studentMsg.error = text
|
|
|
studentMsg.recovery = classifyError(raw, undefined, 'student')
|
|
|
+ // 学生侧出错时把 ai 占位移除,避免孤立 typing-bubble。
|
|
|
+ if (aiMsg && aiMsg.status === 'loading') {
|
|
|
+ const idx = messages.value.indexOf(aiMsg)
|
|
|
+ if (idx !== -1) messages.value.splice(idx, 1)
|
|
|
+ }
|
|
|
} else if (aiMsg && aiMsg.status === 'loading') {
|
|
|
aiMsg.status = 'error'
|
|
|
aiMsg.error = text
|
|
|
@@ -347,7 +395,12 @@ export function useDialogueEngine() {
|
|
|
}
|
|
|
|
|
|
ws.onopen = () => {
|
|
|
+ if (aborted) {
|
|
|
+ try { ws.close() } catch { /* ignore */ }
|
|
|
+ return
|
|
|
+ }
|
|
|
open = true
|
|
|
+ clearOpenWaitTimer()
|
|
|
ws.send(JSON.stringify({
|
|
|
type: 'start',
|
|
|
sessionId: sessionId.value,
|
|
|
@@ -358,6 +411,12 @@ export function useDialogueEngine() {
|
|
|
}))
|
|
|
for (const c of chunkQueue) ws.send(c)
|
|
|
chunkQueue = []
|
|
|
+ if (pendingCommitBlob) {
|
|
|
+ const blob = pendingCommitBlob
|
|
|
+ pendingCommitBlob = null
|
|
|
+ pushPlaceholders(blob)
|
|
|
+ ws.send(JSON.stringify({ type: 'stop' }))
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
ws.onmessage = (e: MessageEvent) => {
|
|
|
@@ -367,6 +426,9 @@ export function useDialogueEngine() {
|
|
|
if (data.type === 'transcript' && studentMsg) {
|
|
|
studentMsg.content = data.text
|
|
|
studentMsg.status = 'done'
|
|
|
+ if (aiMsg && !messages.value.includes(aiMsg)) {
|
|
|
+ messages.value.push(aiMsg)
|
|
|
+ }
|
|
|
}
|
|
|
else if (data.type === 'token' && aiMsg) {
|
|
|
aiMsg.content += data.content
|
|
|
@@ -386,10 +448,31 @@ export function useDialogueEngine() {
|
|
|
}
|
|
|
|
|
|
ws.onerror = () => {
|
|
|
- if (!aborted && committed) finalizeError('WebSocket error')
|
|
|
+ if (aborted) return
|
|
|
+ if (pendingCommitBlob) {
|
|
|
+ // commit 已发但 ws 仍 CONNECTING → 直接出错给用户,不再自动降级 HTTP。
|
|
|
+ const blob = pendingCommitBlob
|
|
|
+ pendingCommitBlob = null
|
|
|
+ clearOpenWaitTimer()
|
|
|
+ pushPlaceholders(blob)
|
|
|
+ finalizeError('WebSocket error')
|
|
|
+ try { ws.close() } catch { /* ignore */ }
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (committed) finalizeError('WebSocket error')
|
|
|
+ // commit 之前断开:什么都不做,等用户按完成时由 commit 处理 readyState。
|
|
|
}
|
|
|
ws.onclose = () => {
|
|
|
- if (!committed || aborted) return
|
|
|
+ if (aborted) return
|
|
|
+ if (pendingCommitBlob) {
|
|
|
+ const blob = pendingCommitBlob
|
|
|
+ pendingCommitBlob = null
|
|
|
+ clearOpenWaitTimer()
|
|
|
+ pushPlaceholders(blob)
|
|
|
+ finalizeError('Connection closed')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (!committed) return
|
|
|
if (studentMsg?.status === 'loading') finalizeError('Connection closed')
|
|
|
else if (aiMsg?.status === 'loading') finalizeError('Connection closed')
|
|
|
}
|
|
|
@@ -401,41 +484,38 @@ export function useDialogueEngine() {
|
|
|
}
|
|
|
|
|
|
const commit = (blob: Blob) => {
|
|
|
- if (committed || aborted) return
|
|
|
+ if (committed) return
|
|
|
committed = true
|
|
|
|
|
|
- studentMsg = reactive<PreviewChatMessage>({
|
|
|
- id: crypto.randomUUID(),
|
|
|
- role: 'student',
|
|
|
- content: '',
|
|
|
- timestamp: new Date(),
|
|
|
- status: 'loading',
|
|
|
- audioBlob: blob,
|
|
|
- turnId,
|
|
|
- })
|
|
|
- messages.value.push(studentMsg)
|
|
|
-
|
|
|
- if (!isFinalRound.value) {
|
|
|
- aiMsg = reactive<PreviewChatMessage>({
|
|
|
- id: crypto.randomUUID(),
|
|
|
- role: 'ai',
|
|
|
- content: '',
|
|
|
- timestamp: new Date(),
|
|
|
- status: 'loading',
|
|
|
- turnId,
|
|
|
- })
|
|
|
- messages.value.push(aiMsg)
|
|
|
- }
|
|
|
-
|
|
|
if (open && ws.readyState === WebSocket.OPEN) {
|
|
|
+ pushPlaceholders(blob)
|
|
|
ws.send(JSON.stringify({ type: 'stop' }))
|
|
|
- } else {
|
|
|
- ws.close()
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if (ws.readyState === WebSocket.CONNECTING) {
|
|
|
+ // 连接中按完成:等 onopen 至多 2.5s。超时还没来 = 直接报错给用户重试。
|
|
|
+ pendingCommitBlob = blob
|
|
|
+ openWaitTimer = setTimeout(() => {
|
|
|
+ if (!pendingCommitBlob) return
|
|
|
+ const b = pendingCommitBlob
|
|
|
+ pendingCommitBlob = null
|
|
|
+ try { ws.close() } catch { /* ignore */ }
|
|
|
+ pushPlaceholders(b)
|
|
|
+ finalizeError('Connection closed')
|
|
|
+ }, 2500)
|
|
|
+ return
|
|
|
}
|
|
|
+
|
|
|
+ // CLOSING / CLOSED:commit 时 ws 已断开 → 直接报错。
|
|
|
+ pushPlaceholders(blob)
|
|
|
+ finalizeError('Connection closed')
|
|
|
}
|
|
|
|
|
|
const abort = () => {
|
|
|
aborted = true
|
|
|
+ pendingCommitBlob = null
|
|
|
+ clearOpenWaitTimer()
|
|
|
try { ws.close() } catch { /* ignore */ }
|
|
|
// No messages were pushed (commit not called) → nothing to clean up.
|
|
|
}
|
|
|
@@ -443,17 +523,6 @@ export function useDialogueEngine() {
|
|
|
return { turnId, pushChunk, commit, abort }
|
|
|
}
|
|
|
|
|
|
- /**
|
|
|
- * 流式失败时的 HTTP fallback:用完整 audioBlob 走旧 /speak 路径。
|
|
|
- * 会把 beginStudentStream 已 push 的占位消息回收(避免重复)。
|
|
|
- */
|
|
|
- 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, turnId)
|
|
|
- }
|
|
|
-
|
|
|
/**
|
|
|
* 丢弃当前轮次的所有消息(student + ai),用于"重录"按钮。
|
|
|
* 通过 turnId 精确定位同一轮的两条消息并一并移除。
|
|
|
@@ -496,7 +565,6 @@ export function useDialogueEngine() {
|
|
|
retryGreeting,
|
|
|
sendStudentMessage,
|
|
|
beginStudentStream,
|
|
|
- streamFallback,
|
|
|
retryMessage,
|
|
|
regenerateAiMessage,
|
|
|
discardCurrentTurn,
|