|
|
@@ -273,51 +273,39 @@ export function useDialogueEngine() {
|
|
|
|
|
|
// ==================== Streaming Speak (WebSocket) ====================
|
|
|
|
|
|
- /** 流式开始:立即 push 学生占位消息,返回 controller 供外部推 chunk / 结束 */
|
|
|
+ /** 流式开始:打开 WS,返回 { pushChunk, commit, abort };占位消息在 commit() 时才 push */
|
|
|
function beginStudentStream(opts: {
|
|
|
sampleRate: number
|
|
|
bits?: number
|
|
|
channels?: number
|
|
|
}) {
|
|
|
- if (!sessionId.value || isProcessing.value) {
|
|
|
- return null
|
|
|
- }
|
|
|
+ if (!sessionId.value || isProcessing.value) return null
|
|
|
|
|
|
- // 立即占位:录音按完成的那一刻 UI 就已经显示学生泡泡 + AI placeholder
|
|
|
- const studentMsg = reactive<PreviewChatMessage>({
|
|
|
- id: crypto.randomUUID(),
|
|
|
- role: 'student',
|
|
|
- content: '',
|
|
|
- timestamp: new Date(),
|
|
|
- status: 'loading',
|
|
|
- })
|
|
|
- messages.value.push(studentMsg)
|
|
|
+ const turnId = crypto.randomUUID()
|
|
|
|
|
|
- const aiMsg = reactive<PreviewChatMessage>({
|
|
|
- id: crypto.randomUUID(),
|
|
|
- role: 'ai',
|
|
|
- content: '',
|
|
|
- timestamp: new Date(),
|
|
|
- status: 'loading',
|
|
|
- })
|
|
|
- messages.value.push(aiMsg)
|
|
|
+ // No messages pushed yet. They are pushed in commit().
|
|
|
+ let studentMsg: PreviewChatMessage | null = null
|
|
|
+ let aiMsg: PreviewChatMessage | null = null
|
|
|
|
|
|
const wsUrl = buildWsUrl('/speak-stream')
|
|
|
const ws = new WebSocket(wsUrl)
|
|
|
ws.binaryType = 'arraybuffer'
|
|
|
|
|
|
let aborted = false
|
|
|
+ let committed = false
|
|
|
let chunkQueue: ArrayBuffer[] = []
|
|
|
let open = false
|
|
|
|
|
|
- const finalizeError = (msg: string) => {
|
|
|
- if (studentMsg.status === 'loading') {
|
|
|
+ const finalizeError = (raw: string) => {
|
|
|
+ const text = friendlyErrorMessage(raw)
|
|
|
+ if (studentMsg && studentMsg.status === 'loading') {
|
|
|
studentMsg.status = 'error'
|
|
|
- studentMsg.error = msg
|
|
|
- }
|
|
|
- else if (aiMsg.status === 'loading') {
|
|
|
+ studentMsg.error = text
|
|
|
+ studentMsg.recovery = classifyError(raw, undefined, 'student')
|
|
|
+ } else if (aiMsg && aiMsg.status === 'loading') {
|
|
|
aiMsg.status = 'error'
|
|
|
- aiMsg.error = msg
|
|
|
+ aiMsg.error = text
|
|
|
+ aiMsg.recovery = classifyError(raw, undefined, 'ai')
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -326,44 +314,47 @@ export function useDialogueEngine() {
|
|
|
ws.send(JSON.stringify({
|
|
|
type: 'start',
|
|
|
sessionId: sessionId.value,
|
|
|
+ turnId,
|
|
|
sampleRate: opts.sampleRate,
|
|
|
bits: opts.bits ?? 16,
|
|
|
channels: opts.channels ?? 1,
|
|
|
}))
|
|
|
- // flush 队列里攒的 chunk
|
|
|
for (const c of chunkQueue) ws.send(c)
|
|
|
chunkQueue = []
|
|
|
}
|
|
|
|
|
|
ws.onmessage = (e: MessageEvent) => {
|
|
|
+ if (!committed) return // tokens shouldn't arrive before commit; defensive
|
|
|
try {
|
|
|
const data = JSON.parse(e.data)
|
|
|
- if (data.type === 'transcript') {
|
|
|
+ if (data.type === 'transcript' && studentMsg) {
|
|
|
studentMsg.content = data.text
|
|
|
studentMsg.status = 'done'
|
|
|
}
|
|
|
- else if (data.type === 'token') {
|
|
|
+ else if (data.type === 'token' && aiMsg) {
|
|
|
aiMsg.content += data.content
|
|
|
}
|
|
|
else if (data.type === 'done') {
|
|
|
- aiMsg.status = 'done'
|
|
|
+ if (aiMsg) aiMsg.status = 'done'
|
|
|
+ else if (studentMsg && studentMsg.status === 'loading') studentMsg.status = 'done'
|
|
|
isComplete.value = !!data.isComplete
|
|
|
if (!data.isComplete) currentRound.value++
|
|
|
ws.close()
|
|
|
}
|
|
|
else if (data.type === 'error') {
|
|
|
- finalizeError(friendlyErrorMessage(data.message))
|
|
|
+ finalizeError(data.message)
|
|
|
ws.close()
|
|
|
}
|
|
|
} catch { /* ignore */ }
|
|
|
}
|
|
|
|
|
|
ws.onerror = () => {
|
|
|
- if (!aborted) finalizeError(friendlyErrorMessage('WebSocket error'))
|
|
|
+ if (!aborted && committed) finalizeError('WebSocket error')
|
|
|
}
|
|
|
ws.onclose = () => {
|
|
|
- if (studentMsg.status === 'loading') finalizeError(friendlyErrorMessage('Connection closed'))
|
|
|
- else if (aiMsg.status === 'loading') finalizeError(friendlyErrorMessage('Connection closed'))
|
|
|
+ if (!committed || aborted) return
|
|
|
+ if (studentMsg?.status === 'loading') finalizeError('Connection closed')
|
|
|
+ else if (aiMsg?.status === 'loading') finalizeError('Connection closed')
|
|
|
}
|
|
|
|
|
|
const pushChunk = (chunk: ArrayBuffer) => {
|
|
|
@@ -372,24 +363,47 @@ export function useDialogueEngine() {
|
|
|
else chunkQueue.push(chunk)
|
|
|
}
|
|
|
|
|
|
- const finish = () => {
|
|
|
- if (aborted) return
|
|
|
+ const commit = (blob: Blob) => {
|
|
|
+ if (committed || aborted) 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) {
|
|
|
ws.send(JSON.stringify({ type: 'stop' }))
|
|
|
} else {
|
|
|
- // 还没 open 就被要求结束 → 废话不说 直接 close
|
|
|
ws.close()
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- const abortStream = () => {
|
|
|
+ const abort = () => {
|
|
|
aborted = true
|
|
|
try { ws.close() } catch { /* ignore */ }
|
|
|
- finalizeError('Aborted')
|
|
|
+ // No messages were pushed (commit not called) → nothing to clean up.
|
|
|
}
|
|
|
|
|
|
- // 便于重试:保存关联的学生消息 id
|
|
|
- return { studentMsgId: studentMsg.id, aiMsgId: aiMsg.id, pushChunk, finish, abortStream }
|
|
|
+ return { turnId, pushChunk, commit, abort }
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
@@ -403,17 +417,6 @@ export function useDialogueEngine() {
|
|
|
await sendStudentMessage(audioBlob)
|
|
|
}
|
|
|
|
|
|
- /**
|
|
|
- * Attach the recorded audio blob to a student message that was
|
|
|
- * pushed by `beginStudentStream` (which doesn't know the final
|
|
|
- * blob until the user clicks 完成). Lets click-replay work in
|
|
|
- * the WebSocket path the same way it does for HTTP.
|
|
|
- */
|
|
|
- function attachStudentBlob(messageId: string, blob: Blob) {
|
|
|
- const msg = messages.value.find(m => m.id === messageId)
|
|
|
- if (msg && msg.role === 'student') msg.audioBlob = blob
|
|
|
- }
|
|
|
-
|
|
|
// ==================== Cleanup ====================
|
|
|
|
|
|
onUnmounted(() => {
|
|
|
@@ -445,7 +448,6 @@ export function useDialogueEngine() {
|
|
|
getReport,
|
|
|
completeSession,
|
|
|
abort,
|
|
|
- attachStudentBlob,
|
|
|
}
|
|
|
}
|
|
|
|