فهرست منبع

feat(speaking): add generateGreeting to DialogueAPI + DialogueApiError type

- SessionInfo no longer carries aiMessage (moved to separate GreetingInfo)
- PreviewChatMessage has new optional unrecoverable flag
- DialogueApiError carries HTTP status so callers can distinguish 404/409
- createDialogueApi factory for shared API instantiation

useDialogueEngine still references old SessionInfo shape — fixed in next task.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
jimmylee 2 هفته پیش
والد
کامیت
0eafd8afe7
2فایلهای تغییر یافته به همراه67 افزوده شده و 5 حذف شده
  1. 11 2
      src/types/englishSpeaking.ts
  2. 56 3
      src/views/Editor/EnglishSpeaking/services/llmService.ts

+ 11 - 2
src/types/englishSpeaking.ts

@@ -205,6 +205,8 @@ export interface PreviewChatMessage {
   timestamp: Date
   status?: MessageStatus
   error?: string
+  /** true 表示这个错误无法重试(如 session 404/409),UI 应提示用户重开 */
+  unrecoverable?: boolean
   audioBlob?: Blob
   evaluation?: {
     dimensions: {
@@ -243,11 +245,17 @@ export interface SessionConfig {
   sentences?: string[]
 }
 
-// 对话会话信息
+// 对话会话信息 (createSession 返回)
 export interface SessionInfo {
   sessionId: string
+  totalRounds: number
+  currentRound: number
+  expiresAt: string | null
+}
+
+// 开场白生成结果 (generateGreeting 返回)
+export interface GreetingInfo {
   aiMessage: string
-  expiresAt?: string
 }
 
 // 对话报告
@@ -258,6 +266,7 @@ export interface DialogueReport {
 // 对话 API 接口
 export interface DialogueAPI {
   createSession(config: SessionConfig): Promise<SessionInfo>
+  generateGreeting(sessionId: string, signal?: AbortSignal): Promise<GreetingInfo>
   speak(sessionId: string, audioBlob: Blob, signal: AbortSignal): AsyncGenerator<SSEEvent>
   getReport(sessionId: string): Promise<DialogueReport>
 }

+ 56 - 3
src/views/Editor/EnglishSpeaking/services/llmService.ts

@@ -1,7 +1,28 @@
-import type { DialogueAPI, SSEEvent, SessionConfig, SessionInfo, DialogueReport, SentenceEvaluation } from '@/types/englishSpeaking'
+import type {
+  DialogueAPI,
+  SSEEvent,
+  SessionConfig,
+  SessionInfo,
+  GreetingInfo,
+  DialogueReport,
+  SentenceEvaluation,
+} from '@/types/englishSpeaking'
 
 const API_BASE = 'http://localhost:8000/api/speaking/dialogue'
 
+export class DialogueApiError extends Error {
+  status: number
+  constructor(message: string, status: number) {
+    super(message)
+    this.status = status
+    this.name = 'DialogueApiError'
+  }
+}
+
+export function createDialogueApi(mode: 'preview' | 'real'): DialogueAPI {
+  return mode === 'real' ? new RealDialogueAPI() : new MockDialogueAPI()
+}
+
 // ==================== SSE 解析 ====================
 
 async function* parseSSEStream(reader: ReadableStreamDefaultReader<Uint8Array>): AsyncGenerator<SSEEvent> {
@@ -146,7 +167,31 @@ export class RealDialogueAPI implements DialogueAPI {
         roleId: config.roleId,
       }),
     })
-    if (!res.ok) throw new Error(`createSession failed: ${res.status}`)
+    if (!res.ok) {
+      throw new DialogueApiError(`createSession failed: ${res.status}`, res.status)
+    }
+    const body = await res.json()
+    return {
+      sessionId: body.sessionId,
+      totalRounds: body.totalRounds,
+      currentRound: body.currentRound,
+      expiresAt: body.expiresAt ?? null,
+    }
+  }
+
+  async generateGreeting(sessionId: string, signal?: AbortSignal): Promise<GreetingInfo> {
+    const res = await fetch(`${API_BASE}/session/${sessionId}/greeting`, {
+      method: 'POST',
+      credentials: 'include',
+      signal,
+    })
+    if (!res.ok) {
+      const text = await res.text().catch(() => '')
+      throw new DialogueApiError(
+        `greeting failed: ${res.status}${text ? ` (${text.slice(0, 100)})` : ''}`,
+        res.status,
+      )
+    }
     return res.json()
   }
 
@@ -192,10 +237,18 @@ export class MockDialogueAPI implements DialogueAPI {
     this.roundIndex = 0
     return {
       sessionId: 'mock-session-' + Date.now(),
-      aiMessage: "Hi! What's your favorite animal?",
+      totalRounds: _config.totalRounds,
+      currentRound: 1,
+      expiresAt: null,
     }
   }
 
+  async generateGreeting(_sessionId: string, _signal?: AbortSignal): Promise<GreetingInfo> {
+    // 模拟 300ms 延迟
+    await new Promise(r => setTimeout(r, 300))
+    return { aiMessage: "Hi! What's your favorite animal?" }
+  }
+
   async *speak(_sessionId: string, _audioBlob: Blob, signal: AbortSignal): AsyncGenerator<SSEEvent> {
     const mockStudentTexts = [
       'I like pandas. They are very cute!',