|
|
@@ -253,6 +253,139 @@ export function useDialogueEngine(mode: 'preview' | 'real' = 'preview') {
|
|
|
currentAbortController = null
|
|
|
}
|
|
|
|
|
|
+ // ==================== Streaming Speak (WebSocket) ====================
|
|
|
+
|
|
|
+ /** 流式开始:立即 push 学生占位消息,返回 controller 供外部推 chunk / 结束 */
|
|
|
+ function beginStudentStream(opts: {
|
|
|
+ sampleRate: number
|
|
|
+ bits?: number
|
|
|
+ channels?: number
|
|
|
+ }) {
|
|
|
+ if (!sessionId.value || isProcessing.value) {
|
|
|
+ return null
|
|
|
+ }
|
|
|
+
|
|
|
+ // 立即占位:录音按完成的那一刻 UI 就已经显示学生泡泡 + AI placeholder
|
|
|
+ const studentMsg: PreviewChatMessage = {
|
|
|
+ id: crypto.randomUUID(),
|
|
|
+ role: 'student',
|
|
|
+ content: '',
|
|
|
+ timestamp: new Date(),
|
|
|
+ status: 'loading',
|
|
|
+ }
|
|
|
+ messages.value.push(studentMsg)
|
|
|
+
|
|
|
+ const aiMsg: PreviewChatMessage = {
|
|
|
+ id: crypto.randomUUID(),
|
|
|
+ role: 'ai',
|
|
|
+ content: '',
|
|
|
+ timestamp: new Date(),
|
|
|
+ status: 'loading',
|
|
|
+ }
|
|
|
+ messages.value.push(aiMsg)
|
|
|
+
|
|
|
+ const wsUrl = buildWsUrl('/speak-stream')
|
|
|
+ const ws = new WebSocket(wsUrl)
|
|
|
+ ws.binaryType = 'arraybuffer'
|
|
|
+
|
|
|
+ let aborted = false
|
|
|
+ let chunkQueue: ArrayBuffer[] = []
|
|
|
+ let open = false
|
|
|
+
|
|
|
+ const finalizeError = (msg: string) => {
|
|
|
+ if (studentMsg.status === 'loading') {
|
|
|
+ studentMsg.status = 'error'
|
|
|
+ studentMsg.error = msg
|
|
|
+ }
|
|
|
+ else if (aiMsg.status === 'loading') {
|
|
|
+ aiMsg.status = 'error'
|
|
|
+ aiMsg.error = msg
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ ws.onopen = () => {
|
|
|
+ open = true
|
|
|
+ ws.send(JSON.stringify({
|
|
|
+ type: 'start',
|
|
|
+ sessionId: sessionId.value,
|
|
|
+ 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) => {
|
|
|
+ try {
|
|
|
+ const data = JSON.parse(e.data)
|
|
|
+ if (data.type === 'transcript') {
|
|
|
+ studentMsg.content = data.text
|
|
|
+ studentMsg.status = 'done'
|
|
|
+ }
|
|
|
+ else if (data.type === 'token') {
|
|
|
+ aiMsg.content += data.content
|
|
|
+ }
|
|
|
+ else if (data.type === 'done') {
|
|
|
+ aiMsg.status = 'done'
|
|
|
+ isComplete.value = !!data.isComplete
|
|
|
+ if (!data.isComplete) currentRound.value++
|
|
|
+ speakTTS(aiMsg.content)
|
|
|
+ ws.close()
|
|
|
+ }
|
|
|
+ else if (data.type === 'error') {
|
|
|
+ finalizeError(friendlyErrorMessage(data.message))
|
|
|
+ ws.close()
|
|
|
+ }
|
|
|
+ } catch { /* ignore */ }
|
|
|
+ }
|
|
|
+
|
|
|
+ ws.onerror = () => {
|
|
|
+ if (!aborted) finalizeError(friendlyErrorMessage('WebSocket error'))
|
|
|
+ }
|
|
|
+ ws.onclose = () => {
|
|
|
+ if (studentMsg.status === 'loading') finalizeError(friendlyErrorMessage('Connection closed'))
|
|
|
+ else if (aiMsg.status === 'loading') finalizeError(friendlyErrorMessage('Connection closed'))
|
|
|
+ }
|
|
|
+
|
|
|
+ const pushChunk = (chunk: ArrayBuffer) => {
|
|
|
+ if (aborted) return
|
|
|
+ if (open && ws.readyState === WebSocket.OPEN) ws.send(chunk)
|
|
|
+ else chunkQueue.push(chunk)
|
|
|
+ }
|
|
|
+
|
|
|
+ const finish = () => {
|
|
|
+ if (aborted) return
|
|
|
+ if (open && ws.readyState === WebSocket.OPEN) {
|
|
|
+ ws.send(JSON.stringify({ type: 'stop' }))
|
|
|
+ } else {
|
|
|
+ // 还没 open 就被要求结束 → 废话不说 直接 close
|
|
|
+ ws.close()
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const abortStream = () => {
|
|
|
+ aborted = true
|
|
|
+ try { ws.close() } catch { /* ignore */ }
|
|
|
+ finalizeError('Aborted')
|
|
|
+ }
|
|
|
+
|
|
|
+ // 便于重试:保存关联的学生消息 id
|
|
|
+ return { studentMsgId: studentMsg.id, aiMsgId: aiMsg.id, pushChunk, finish, abortStream }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 流式失败时的 HTTP fallback:用完整 audioBlob 走旧 /speak 路径。
|
|
|
+ * 会把 beginStudentStream 已 push 的占位消息回收(避免重复)。
|
|
|
+ */
|
|
|
+ async function streamFallback(audioBlob: Blob, studentMsgId: string, aiMsgId: string) {
|
|
|
+ // 移除占位消息
|
|
|
+ messages.value = messages.value.filter(m => m.id !== studentMsgId && m.id !== aiMsgId)
|
|
|
+ // 走旧流程
|
|
|
+ await sendStudentMessage(audioBlob)
|
|
|
+ }
|
|
|
+
|
|
|
// ==================== Cleanup ====================
|
|
|
|
|
|
onUnmounted(() => {
|
|
|
@@ -272,6 +405,8 @@ export function useDialogueEngine(mode: 'preview' | 'real' = 'preview') {
|
|
|
|
|
|
initSession,
|
|
|
sendStudentMessage,
|
|
|
+ beginStudentStream,
|
|
|
+ streamFallback,
|
|
|
retryMessage,
|
|
|
regenerateAiMessage,
|
|
|
getReport,
|
|
|
@@ -279,3 +414,30 @@ export function useDialogueEngine(mode: 'preview' | 'real' = 'preview') {
|
|
|
cancelTTS,
|
|
|
}
|
|
|
}
|
|
|
+
|
|
|
+// ==================== Helpers ====================
|
|
|
+
|
|
|
+/** 把 /api/speaking/dialogue/... 的 HTTP base URL 转成 ws/wss URL */
|
|
|
+function buildWsUrl(path: string): string {
|
|
|
+ const API_BASE = 'http://localhost:8000/api/speaking/dialogue'
|
|
|
+ const wsBase = API_BASE.replace(/^http/, 'ws')
|
|
|
+ return wsBase + path
|
|
|
+}
|
|
|
+
|
|
|
+/** 把后端英文错误转成面向用户的中文友好文案 */
|
|
|
+function friendlyErrorMessage(raw: string | undefined): string {
|
|
|
+ const map: Record<string, string> = {
|
|
|
+ 'No speech detected': '没听清,请再说一次',
|
|
|
+ 'Session not found': '会话已失效,请刷新页面重新开始',
|
|
|
+ 'Session is not active': '会话已结束',
|
|
|
+ 'sessionId required': '会话参数缺失',
|
|
|
+ 'First message must be start': '协议异常,请刷新重试',
|
|
|
+ 'Invalid start payload': '协议异常,请刷新重试',
|
|
|
+ 'Internal error': '服务暂时不可用,请稍后重试',
|
|
|
+ 'WebSocket error': '网络连接异常',
|
|
|
+ 'Connection closed': '连接中断,请重试',
|
|
|
+ 'Aborted': '已取消',
|
|
|
+ }
|
|
|
+ if (!raw) return '请求失败,请重试'
|
|
|
+ return map[raw] || raw
|
|
|
+}
|