Преглед изворни кода

feat: seed speaking chat from historical session

jimmylee пре 2 месеци
родитељ
комит
9da141e043

+ 29 - 1
src/types/englishSpeaking.ts

@@ -204,6 +204,7 @@ export interface PreviewChatMessage {
   turnId?: string                                     // set when message is created via begin/commit
   recovery?: 'retry' | 'rerecord' | 'restart'        // set on error; drives the button matrix
   audioBlob?: Blob
+  audioUrl?: string
   evaluation?: {
     dimensions: {
       accuracy: 'excellent' | 'good' | 'improve'
@@ -242,6 +243,8 @@ export interface SessionConfig {
   durationMinutes: number
   vocabulary?: string[]
   sentences?: string[]
+  configId?: string | null
+  userId?: string | null
 }
 
 // 对话会话信息 (createSession 返回)
@@ -257,12 +260,36 @@ export interface GreetingInfo {
   aiMessage: string
 }
 
+export interface HistoricalDialogueMessage {
+  id: string
+  round: number
+  role: 'ai' | 'student'
+  content: string
+  audioUrl?: string | null
+  clientTurnId?: string | null
+}
+
 // 用于启动 chat view 的最小 session 信息(父组件 createSession 后传给 DialogueChatView)
 // expiresAt 由后端基于 createSession 时传入的 durationMinutes 计算下发 —— 后端权威,
 // 关页面/刷新/换设备重进都接续同一个截止时刻,确保倒计时不会因任何原因暂停。
 export interface SessionStartInfo {
   sessionId: string
   expiresAt: string | null
+  currentRound?: number
+  messages?: HistoricalDialogueMessage[]
+}
+
+export interface LatestSessionInfo extends SessionStartInfo {
+  status: 'active' | 'completed' | 'abandoned'
+  totalRounds: number
+  currentRound: number
+  createdAt: string | null
+  completedAt: string | null
+  messages: HistoricalDialogueMessage[]
+}
+
+export interface LatestSessionResponse {
+  session: LatestSessionInfo | null
 }
 
 // 对话报告
@@ -290,6 +317,7 @@ export interface TaskHint {
 // 对话 API 接口
 export interface DialogueAPI {
   createSession(config: SessionConfig): Promise<SessionInfo>
+  getLatestSession(configId: string, userId: string): Promise<LatestSessionResponse>
   completeSession(sessionId: string): Promise<void>
   /** Throws DOMException('AbortError') on signal abort; throws DialogueApiError on non-OK HTTP. */
   generateGreeting(sessionId: string, turnId: string, signal?: AbortSignal): Promise<GreetingInfo>
@@ -308,4 +336,4 @@ export interface BadgeAchievement {
 }
 
 // 预览对话状态
-export type PreviewDialogueState = 'ready' | 'chatting' | 'completed'
+export type PreviewDialogueState = 'checking-history' | 'ready' | 'chatting' | 'completed'

+ 17 - 1
src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts

@@ -1,5 +1,5 @@
 import { ref, reactive, computed, onUnmounted } from 'vue'
-import type { PreviewChatMessage, DialogueAPI, DialogueReport } from '@/types/englishSpeaking'
+import type { PreviewChatMessage, DialogueAPI, DialogueReport, HistoricalDialogueMessage } from '@/types/englishSpeaking'
 import { createDialogueApi, DialogueApiError } from '../services/llmService'
 import { buildSpeakingWsUrl } from '../services/speakingApiConfig'
 
@@ -30,14 +30,30 @@ export function useDialogueEngine() {
    *  expiresAt 由后端在 createSession 时基于 durationMinutes 计算下发,前端只读 ——
    *  保证关页面/刷新/换设备重进都能接续同一个截止时刻,倒计时不暂停。
    */
+  function toPreviewMessage(message: HistoricalDialogueMessage): PreviewChatMessage {
+    return {
+      id: message.id,
+      role: message.role,
+      content: message.content,
+      timestamp: new Date(),
+      status: 'done',
+      turnId: message.clientTurnId ?? undefined,
+      audioUrl: message.audioUrl ?? undefined,
+    }
+  }
+
   function attachSession(info: {
     sessionId: string
     expiresAt?: string | null
     totalRounds: number
+    currentRound?: number
+    messages?: HistoricalDialogueMessage[]
   }) {
     sessionId.value = info.sessionId
     expiresAt.value = info.expiresAt ?? null
     totalRounds.value = info.totalRounds
+    currentRound.value = info.currentRound ?? 1
+    messages.value = (info.messages ?? []).map(toPreviewMessage)
     if (info.expiresAt) startCountdown(info.expiresAt)
   }
 

+ 10 - 1
src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue

@@ -973,6 +973,7 @@ watch(
 // 自动播放:AI 消息流式 done 后,合成并播一次。
 // 用 Set 去重防止 watcher 因为不相关重渲染重复触发。
 const autoPlayedIds = new Set<string>()
+const seededHistoricalMessageIds = new Set<string>()
 watch(
   () => engine.messages.value.map(m => `${m.id}:${m.status}`).join('|'),
   () => {
@@ -981,6 +982,7 @@ watch(
         m.role === 'ai' &&
         m.status === 'done' &&
         m.content &&
+        !seededHistoricalMessageIds.has(m.id) &&
         !autoPlayedIds.has(m.id)
       ) {
         autoPlayedIds.add(m.id)
@@ -1054,12 +1056,19 @@ watch(
 
 onMounted(() => {
   if (props.sessionInfo) {
+    const hasHistory = !!props.sessionInfo.messages?.length
+    seededHistoricalMessageIds.clear()
+    for (const message of props.sessionInfo.messages ?? []) {
+      seededHistoricalMessageIds.add(message.id)
+    }
     engine.attachSession({
       sessionId: props.sessionInfo.sessionId,
       expiresAt: props.sessionInfo.expiresAt,
       totalRounds: props.totalRounds,
+      currentRound: props.sessionInfo.currentRound,
+      messages: props.sessionInfo.messages,
     })
-    engine.generateGreeting()
+    if (!hasHistory) engine.generateGreeting()
   } else {
     console.warn('[DialogueChatView] mounted without sessionInfo; chat is inert. Parent must createSession before mounting.')
   }

+ 15 - 0
src/views/Editor/EnglishSpeaking/services/llmService.ts

@@ -1,5 +1,6 @@
 import type {
   DialogueAPI,
+  LatestSessionResponse,
   SSEEvent,
   SessionConfig,
   SessionInfo,
@@ -209,6 +210,8 @@ export class RealDialogueAPI implements DialogueAPI {
         totalRounds: config.totalRounds,
         durationMinutes: config.durationMinutes,
         roleId: config.roleId,
+        configId: config.configId ?? null,
+        userId: config.userId ?? null,
       }),
     })
     if (!res.ok) {
@@ -223,6 +226,18 @@ export class RealDialogueAPI implements DialogueAPI {
     }
   }
 
+  async getLatestSession(configId: string, userId: string): Promise<LatestSessionResponse> {
+    const params = new URLSearchParams({ configId, userId })
+    const res = await fetch(`${API_BASE}/sessions/latest?${params.toString()}`, {
+      method: 'GET',
+      credentials: 'include',
+    })
+    if (!res.ok) {
+      throw new DialogueApiError(`getLatestSession failed: ${res.status}`, res.status)
+    }
+    return res.json()
+  }
+
   async completeSession(sessionId: string): Promise<void> {
     const res = await fetch(`${API_BASE}/session/${encodeURIComponent(sessionId)}/complete`, {
       method: 'POST',