Просмотр исходного кода

chore: WIP checkpoint before english-speaking redesign

Capture exploratory implementations and older design doc before final
design rewrite. Contents will be revised against the new design
(B1 resume / B2 overwrite / B3 countdown / B4 page-switch /
 A1-A4 error handling / E1 no-AI-last-round / E2 rollback invariants).

- doc/EnglishSpeakingIntegration.md (old design, to be rewritten)
- composables/useAudioRecorder.ts + useDialogueEngine.ts (early impl)
- services/llmService.ts (early impl)
- public/pcm-recorder-worklet.js (audio worklet)
- DialogueChatView.vue / TopicDiscussionPreview.vue / types edits
- ThumbnailElement.vue: slide prop type fix (number -> Slide)
jimmylee 2 недель назад
Родитель
Сommit
ad34c29b66

+ 333 - 0
doc/EnglishSpeakingIntegration.md

@@ -0,0 +1,333 @@
+# 英文口语 - PPT 组件集成设计
+
+> 状态: WIP,讨论中
+> 日期: 2026-04-21
+> 相关系统:
+> - PPT (`/Users/buoy/Development/gitrepo/PPT`) - 幻灯片编辑器,内嵌于父页面 iframe
+> - pbl-teacher-table (`/Users/buoy/Development/gitrepo/pbl-teacher-table`) - 父页面,课程管理
+> - cococlass-english-speaking-api (`/Users/buoy/Development/gitrepo/cococlass-english-speaking-api`) - 口语后端
+
+---
+
+## 1. 背景
+
+英文口语功能(toolType=77)以 Vue 组件形式直接写在 PPT 项目里,不走 iframe 嵌入路线,这与 PPT 现有其他工具组件(选择题 toolType=45、问答 toolType=15、AI 工具 toolType=72 等)的架构模式不同。
+
+本文档梳理英文口语功能要与 PPT 内其他工具组件行为一致,需要补充和改造的内容。
+
+---
+
+## 2. 现状对比
+
+### 2.1 PPT 其他工具组件的标准流程
+
+**Rendering:**
+- 以 iframe 形式渲染,`src` 指向外部 URL(如 `https://pbl.cocorobo.cn/work#workPageNew`)
+- URL 里注入参数:`courseid`、`userid`、`stage`、`task`、`tool`
+
+**答题阶段:**
+- 学生在 iframe 内操作,答案**只在 iframe 内存中**
+- **没有**每题自动保存机制,纯 Vue data binding
+
+**提交作业(单次触发):**
+- 学生点"提交"按钮 → PPT `Student/index.vue` 遍历当前 slide 的所有 iframe
+- 对每个 iframe 调 `iframe.contentWindow.submitWork(slideIndex)` 拿到答题 JSON
+- 调 `api.submitWork()` → `POST addCourseWorks_workPage`
+- 通过 Yjs WebSocket 广播 `{ type: 'homework_submitted', courseid, slideIndex, userid }`
+
+**`addCourseWorks_workPage` 存储结构:**
+```
+uid, cid, stage=0, task=slideIndex, tool=0
+atool = "45"(选择) / "15"(问答) / "72"(AI)
+type  = "8" / "3" / "20"
+content = encodeURIComponent(JSON.stringify(答题JSON))
+```
+
+- 选择题 `content`: `{ testJson: [{ teststitle, checkList, userAnswer }] }`
+- 问答 `content`: `{ answerQ, answer, fileList }`
+- AI 工具 `content`: S3 文件 URL(iframe 内 `exposed_outputs` 序列化后上传)
+
+**取回展示:**
+- `select_courseWorks_workPageData` 返回 `content` → 解析 JSON → 填回题目组件显示
+
+---
+
+### 2.2 英文口语的现状
+
+**Rendering:**
+- 以 Vue 组件 `TopicDiscussionPreview` 渲染(直接在 DOM,不是 iframe)
+- 由 `BaseFrameElement.vue` 根据 `toolType === 77` 判断后挂载
+
+**答题阶段:**
+- 学生和 AI 对话,每轮经过 `POST /api/speaking/dialogue/speak`
+- 口语后端**自动存储**每轮对话到 DB:
+  - `dialogue_message` (音频 URL + 转录文本)
+  - `pronunciation_evaluation` (评分,后台异步填充)
+  - `dialogue_session` (总体状态,当 `current_round > total_rounds` 或 `expires_at` 到期时自动 `status='completed'`)
+
+**提交作业:**
+- **完全缺失** - 对话结束后没有任何调用写入 `addCourseWorks_workPage`
+- `Student/index.vue` 的 submitWork 循环只遍历 iframe,找不到 toolType=77 的 Vue 组件
+
+**结果页:**
+- 调 `GET /report?sessionId=xxx` 轮询(最多 15 次,2s 间隔),等所有发音评估完成
+
+**父页面感知:**
+- pbl-teacher-table 收不到任何"学生完成口语"的信号
+- 不知道 atool=77 是什么、如何展示
+
+---
+
+## 3. 关键架构约束(已确认)
+
+- **对话流畅性优先**:`/speak` 请求必须尽快返回 AI 响应,评分不能阻塞对话
+- **评分异步**:发音评估在后端后台跑,通过 `pronunciation_evaluation.status: pending → completed` 追踪
+- **结果页才需要评分**:对话途中不展示评分,评分只在对话结束后的结果页显示
+- **每条对话包含三件套**:音频 URL + 转录文本 + 评分
+- **口语后端单一职责**:`cococlass-english-speaking-api` 只管口语部分,不承担 workPage 作业提交
+
+---
+
+## 4. 设计决策(已确认)
+
+### 4.1 数据所有权
+
+**口语后端是口语数据的 source of truth** - 音频、转录、评分、摘要都存在口语后端,不复制到 `addCourseWorks_workPage`。
+
+### 4.2 提交模型(模型 Z:双触发)
+
+**"完成" 和 "提交" 在口语场景里天然分开**,与其他工具不同:
+
+- 其他工具(选择题/问答):**提交 = 完成**,只有一个触发点 —— 学生点"提交"按钮
+- 英文口语:**完成**是系统事件(轮次/时间到),**提交**是向 teacher-table 报告的动作
+
+采用 **模型 Z(双触发)**:
+
+**自动路径(主流程)**:
+- 轮次用完 / 时间到 → `isComplete = true` → 自动调 `submitWork`
+
+**手动路径(fallback)**:
+- 学生主动放弃(点"结束对话"按钮 —— 是否要加待定)→ 显式结束 → 触发提交
+
+**提交 ≠ 评分完成** - 两者进一步解耦:
+
+- **提交**:学生对话结束 → 立即写入 `addCourseWorks_workPage`
+- **评分**:后台异步完成,通过 SSE 增量推送给结果页
+
+`addCourseWorks_workPage` 中 `atool=77` 的 `content` 字段存:
+
+```json
+{
+  "sessionId": "uuid-xxx",
+  "totalRounds": 5,
+  "completedRounds": 5,
+  "completedAt": "2026-04-21T10:00:00Z"
+}
+```
+
+> 注:是否追加评分摘要(overallScore 等)待讨论。
+
+### 4.3 避免前端轮询
+
+放弃对 `GET /report` 的轮询,改用 SSE 流式推送:
+
+- 新增 `GET /report/stream?sessionId=xxx`
+- 每个 `pronunciation_evaluation` 从 pending → completed 时推一条 `evaluation_ready` 事件
+- 全部完成 + 摘要生成完毕时推 `done` 事件
+- 结果页订阅该流,评分就绪即显示
+
+### 4.4 Teacher 侧查看详情
+
+- 教师侧拿到 `atool=77` 的 work 记录 → 解析 `content.sessionId`
+- 点击查看详情 → 直接调口语后端 `GET /report?sessionId=xxx` 拿完整数据渲染
+
+### 4.5 完成触发路径(含时间到期处理)
+
+**前置:两个独立的后端数据流**
+
+- **评分(`pronunciation_evaluation`)**:每次 `/speak` 结束时后端自动派发 background task,**不依赖会话是否结束**,自驱完成
+- **总结(`dialogue_session.summary`)**:首次调 `/report` 时,若"所有评分完成 + 无 summary"则调 LLM 生成,**依赖有人来查报告**
+
+**四种触发路径的处理**
+
+| 情形 | 谁触发 `isComplete` | 后端 `session.status` 谁负责置 'completed' |
+|---|---|---|
+| 轮次用完 | `/speak` done 事件(后端自检) | `/speak` 后端自检(现有逻辑) |
+| 时间到 + 学生正在说 | `/speak` done 事件(后端自检) | `/speak` 后端自检(现有逻辑) |
+| 时间到 + 学生沉默 | 前端 countdown 到 0 | `/report` 被调用时懒结束(新加逻辑) |
+| 关页面 | 无 | 无人管(数据废弃,不影响其他人) |
+| 学生主动放弃 | 点"结束对话"按钮(待定是否加) | `POST /session/{id}/end`(待定是否加) |
+
+**关键策略:`/report` 懒结束**
+
+后端 `GET /report` 处理开始时加一段:
+
+```python
+if session.expires_at and now > session.expires_at and session.status == 'active':
+    session.status = 'completed'
+    session.completed_at = now
+    # commit 后再继续原有报告生成逻辑
+```
+
+- 零新接口、零新基础设施
+- 时间到 + 沉默场景:前端 `isComplete=true` → 走结果页 → 订阅 SSE(必调 /report 或 /report/stream)→ 后端懒结束
+- 轮次/说话中场景:`/speak` 已经处理,无变化
+
+**关页面场景(可接受的弃置)**
+
+- 已派发的评分 background task 照跑照存 ✅
+- `session.status` 永远 'active'、`summary` 永远 null ⚠️
+- 但:`addCourseWorks_workPage` 无记录 → 教师侧看不到 → **没人会去调 `/report` → 没影响**
+- 后端 cron 清理 stuck 'active' sessions 是**可选的运维优化**,功能上不需要
+
+### 4.6 触发机制(模型 A:组件自提交 + provide/inject)
+
+**背景**:其他工具走 iframe,`Student/index.vue` 通过 `iframe.contentWindow.submitWork()` 主动拉答题 JSON;英文口语是直挂 Vue 组件,这条路径不通。
+
+**决策**:组件自己在 `isComplete` 变为 true 时提交,不让父层扫描。父层通过 provide 提供"广播+提交"能力,组件通过 inject 使用。
+
+**"一致性" 拆成三层看**
+
+| 层 | 是否需要一致 | 说明 |
+|---|---|---|
+| L1 后端契约 | **必须** | 同样调 `addCourseWorks_workPage`,同样走 Yjs `homework_submitted` 广播 —— 教师侧无感知差异 |
+| L2 学生 UX | 不必 | 选择题有"提交"按钮给"做完了"的反馈;口语的反馈是结果页(评分/总结),不需要再点一次按钮 |
+| L3 代码架构 | 不必 | `iframe.contentWindow.submitWork()` 是 iframe 模式的产物,Vue 组件没有 contentWindow,强行模仿反而别扭 |
+
+组件自提交只偏离 L3,对 L1/L2 无影响 —— 教师侧和 Yjs 协同逻辑完全复用既有机制。
+
+**接口约定**
+
+`Student/index.vue` 通过 `provide` 暴露:
+
+```ts
+provide('homeworkContext', {
+  courseid,
+  userid,
+  slideIndex,
+  submitAndBroadcast: async (atool: string, type: string, content: any) => {
+    await api.submitWork({ uid, cid, stage:'0', task:slideIndex, tool:'0', atool, type, content })
+    sendMessage({ type: 'homework_submitted', courseid, slideIndex, userid })
+  }
+})
+```
+
+`DialogueChatView.vue`(或 `TopicDiscussionPreview`)通过 `inject` 使用:
+
+```ts
+const ctx = inject<HomeworkContext | null>('homeworkContext', null)
+
+watch(isComplete, async (done) => {
+  if (!done) return
+  if (props.mode === 'preview') return  // 编辑器预览不提交
+  if (!ctx) return                       // 非 Student 场景(老师端预览/草稿)不提交
+  await ctx.submitAndBroadcast('77', '77', {
+    sessionId: sessionId.value,
+    totalRounds,
+    completedRounds: currentRound.value,
+    completedAt: new Date().toISOString(),
+  })
+})
+```
+
+**全局"提交作业"按钮在 toolType=77 场景**
+
+`Student/index.vue` 的 `handleHomeworkSubmit` 现在只遍历 iframe;当前 slide 全是 toolType=77(没有 iframe)时:
+- 不做任何网络请求
+- 弹 toast 提示:"口语对话结束后会自动提交,无需手动点击"
+
+混合场景(slide 同时有 iframe 工具 + 口语组件):iframe 部分走原流程,口语部分由组件自提交处理,互不干扰。
+
+---
+
+## 5. 改动清单
+
+### 5.1 口语后端 (cococlass-english-speaking-api)
+
+| 改动 | 位置 |
+|---|---|
+| 新增 SSE 接口 `GET /report/stream?sessionId=xxx` | `app/api/dialogue.py` |
+| 实现评估完成时推事件(内部事件总线或 DB 轮询触发) | `app/service/speaking/dialogue_service.py` |
+| `GET /report` 入口加懒结束逻辑:`expires_at < now AND status='active'` 时置 `completed` | `app/service/speaking/dialogue_service.py` lines 206-280 |
+| (可选)`POST /session/{id}/end` 用于学生主动放弃 | `app/api/dialogue.py` |
+
+### 5.2 PPT 前端
+
+| 改动 | 位置 |
+|---|---|
+| `Student/index.vue` 通过 `provide('homeworkContext', ...)` 暴露 `{ courseid, userid, slideIndex, submitAndBroadcast }` | `src/views/Student/index.vue` |
+| `Student/index.vue` 的 `handleHomeworkSubmit`:全为 toolType=77 时走 toast 提示分支,不阻断混合场景 | `src/views/Student/index.vue` (lines ~1749-1867) |
+| `TopicDiscussionPreview` / `DialogueChatView.vue` 通过 `inject('homeworkContext', null)` + `watch(isComplete)` 自提交 | `src/views/Editor/EnglishSpeaking/preview/*.vue` |
+| `DialogueChatView.vue` 的 `complete` 事件携带 `sessionId` | `src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue` |
+| `TopicDiscussionPreview` 结果页改用 SSE,移除轮询 | `src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue` |
+| `llmService.ts` 增加 `streamReport(sessionId): AsyncGenerator<SSEEvent>` | `src/views/Editor/EnglishSpeaking/services/llmService.ts` |
+
+### 5.3 pbl-teacher-table
+
+| 改动 | 位置 |
+|---|---|
+| `select_courseWorks_workPageData` 返回 atool=77 时解析 content | `src/components/pages/workPage/index.vue` 或新建口语查看组件 |
+| 新增"口语详情"视图,调口语 API `/report?sessionId=xxx` 渲染 | 新组件 |
+| 成绩列表添加 atool=77 类型展示(完成状态 + 简要信息) | 教师作业查看入口 |
+
+---
+
+## 6. 数据流图
+
+```
+┌─────────────────┐         ┌──────────────────────┐
+│  学生 (PPT)     │         │ pbl-teacher-table    │
+└────────┬────────┘         └──────────┬───────────┘
+         │                             │
+         │ 对话中 (每轮)                 │
+         │ POST /speak                  │
+         ├────────► 口语后端            │
+         │ (后端自动存 message + async eval)
+         │                             │
+         │ 对话结束                      │
+         │                             │
+         ├── submitWork(atool=77,      │
+         │    content={sessionId,...}) │
+         │    → addCourseWorks_workPage│
+         │                             │
+         ├── Yjs 广播 homework_submitted────► ✅ 看到"已完成"
+         │                             │
+         │                             │
+         │ 结果页                        │
+         │ GET /report/stream          │
+         │    (SSE 增量推评分)          │
+         ├────────► 口语后端            │
+         │                             │
+         │                             │  教师点详情
+         │                             │ GET /report?sessionId=xxx
+         │          口语后端 ◄──────────┤
+         │                             │
+```
+
+---
+
+## 7. 待讨论/待确认
+
+- [ ] 是否需要"结束对话"按钮(学生主动放弃的手动路径)?若需要,对应后端 `POST /session/{id}/end`
+- [ ] `addCourseWorks_workPage.content` 是否需要冗余存评分摘要(`overallScore` 等)?当前倾向:不存,保持单一数据源
+- [ ] 如果口语后端未来宕机,教师侧完全看不到任何成绩是否可接受?
+- [ ] SSE 报告流的事件结构详细定义(事件名、payload schema)
+- [ ] 编辑器预览模式(`mode='preview'`)是否走同一套流程?还是完全跳过提交?
+- [ ] 与 Yjs `homework_submitted` 广播的兼容性:教师侧已有机制接收,直接复用即可
+- [ ] teacher table 中 atool=77 的展示入口(沿用 workPage 组件 / 新建独立组件)
+- [ ] 错误处理:submit 失败、SSE 断连、评分 stuck 超过 N 秒等场景
+- [ ] `atool=77` 对应的 `type` 字段取值(延用"77"还是另起一档,需与后端/教师端对齐)
+- [ ] inject key 命名(字符串 `'homeworkContext'` vs Symbol vs Pinia store 封装)
+- [ ] 同一张幻灯片放多个口语组件的处理(理论上可能,但 UX 价值存疑)
+- [ ] 学生刷新页面后是否支持续接原 session(当前设计:不支持,每次新建 session)
+
+---
+
+## 8. 参考
+
+- `addCourseWorks_workPage` 提交逻辑:`pbl-teacher-table/src/components/pages/workPage/index.vue` lines 359-411
+- `submitWork` 循环(iframe 扫描):`PPT/src/views/Student/index.vue` lines 1749-1867
+- Yjs 广播模式:`PPT/src/views/Student/index.vue` lines 3054-3093
+- 口语后端 `/speak` 流程:`cococlass-english-speaking-api/app/service/speaking/dialogue_service.py` lines 95-204
+- 口语后端 `/report` 流程:`cococlass-english-speaking-api/app/service/speaking/dialogue_service.py` lines 206-280
+- 评分后台任务:`cococlass-english-speaking-api/app/service/speaking/dialogue_service.py` lines 321-352

+ 26 - 0
public/pcm-recorder-worklet.js

@@ -0,0 +1,26 @@
+/**
+ * AudioWorklet processor: 收集麦克风 PCM 采样数据
+ * 运行在音频线程,通过 MessagePort 将 Float32Array 发回主线程
+ */
+class PCMRecorderProcessor extends AudioWorkletProcessor {
+  constructor() {
+    super()
+    this._stopped = false
+    this.port.onmessage = (e) => {
+      if (e.data === 'stop') this._stopped = true
+    }
+  }
+
+  process(inputs) {
+    if (this._stopped) return false
+
+    const input = inputs[0]
+    if (input && input[0] && input[0].length > 0) {
+      // 取第一个声道(mono),拷贝一份发给主线程
+      this.port.postMessage(new Float32Array(input[0]))
+    }
+    return true
+  }
+}
+
+registerProcessor('pcm-recorder-processor', PCMRecorderProcessor)

+ 42 - 0
src/types/englishSpeaking.ts

@@ -194,12 +194,18 @@ export interface PreviewAIRole {
   isRecommended?: boolean
 }
 
+// 消息状态
+export type MessageStatus = 'loading' | 'done' | 'error'
+
 // 对话消息
 export interface PreviewChatMessage {
   id: string
   role: 'ai' | 'student'
   content: string
   timestamp: Date
+  status?: MessageStatus
+  error?: string
+  audioBlob?: Blob
   evaluation?: {
     dimensions: {
       accuracy: 'excellent' | 'good' | 'improve'
@@ -220,6 +226,42 @@ export interface PreviewChatMessage {
   }
 }
 
+// ==================== 对话引擎类型定义 ====================
+
+// SSE 事件类型
+export type SSEEvent =
+  | { type: 'transcript'; text: string }
+  | { type: 'token'; text: string }
+  | { type: 'done'; isComplete: boolean }
+
+// 对话会话配置
+export interface SessionConfig {
+  topic: string
+  roleId: string
+  totalRounds: number
+  vocabulary?: string[]
+  sentences?: string[]
+}
+
+// 对话会话信息
+export interface SessionInfo {
+  sessionId: string
+  aiMessage: string
+  expiresAt?: string
+}
+
+// 对话报告
+export interface DialogueReport {
+  evaluation: OverallEvaluation
+}
+
+// 对话 API 接口
+export interface DialogueAPI {
+  createSession(config: SessionConfig): Promise<SessionInfo>
+  speak(sessionId: string, audioBlob: Blob, signal: AbortSignal): AsyncGenerator<SSEEvent>
+  getReport(sessionId: string): Promise<DialogueReport>
+}
+
 // 徽章成就
 export interface BadgeAchievement {
   id: string

+ 214 - 0
src/views/Editor/EnglishSpeaking/composables/useAudioRecorder.ts

@@ -0,0 +1,214 @@
+import { ref, onUnmounted } from 'vue'
+
+const TARGET_SAMPLE_RATE = 16000
+
+export function useAudioRecorder() {
+  const isRecording = ref(false)
+  const permissionState = ref<PermissionState>('prompt')
+  const recordingDuration = ref(0)
+  const silenceDetected = ref(false)
+
+  let audioContext: AudioContext | null = null
+  let mediaStream: MediaStream | null = null
+  let workletNode: AudioWorkletNode | null = null
+  let pcmChunks: Float32Array[] = []
+  let durationTimer: ReturnType<typeof setInterval> | null = null
+  let silenceCheckTimer: ReturnType<typeof setInterval> | null = null
+  let analyser: AnalyserNode | null = null
+
+  // Check permission state
+  async function checkPermission() {
+    try {
+      const status = await navigator.permissions.query({ name: 'microphone' as PermissionName })
+      permissionState.value = status.state
+      status.onchange = () => { permissionState.value = status.state }
+    } catch {
+      // permissions API may not support microphone query in all browsers
+    }
+  }
+
+  // Silence detection using AnalyserNode
+  function startSilenceDetection(source: MediaStreamAudioSourceNode) {
+    analyser = audioContext!.createAnalyser()
+    analyser.fftSize = 512
+    source.connect(analyser)
+
+    const dataArray = new Uint8Array(analyser.frequencyBinCount)
+    let silentFrames = 0
+    const SILENCE_THRESHOLD = 10
+    const FRAMES_FOR_5S = Math.ceil(5000 / 200)
+
+    silenceCheckTimer = setInterval(() => {
+      if (!analyser) return
+      analyser.getByteFrequencyData(dataArray)
+      const average = dataArray.reduce((sum, v) => sum + v, 0) / dataArray.length
+
+      if (average < SILENCE_THRESHOLD) {
+        silentFrames++
+        if (silentFrames >= FRAMES_FOR_5S) {
+          silenceDetected.value = true
+        }
+      } else {
+        silentFrames = 0
+        silenceDetected.value = false
+      }
+    }, 200)
+  }
+
+  function stopSilenceDetection() {
+    silenceDetected.value = false
+    if (silenceCheckTimer) { clearInterval(silenceCheckTimer); silenceCheckTimer = null }
+    analyser = null
+  }
+
+  async function startRecording(): Promise<void> {
+    pcmChunks = []
+    silenceDetected.value = false
+
+    // 获取麦克风
+    try {
+      mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true })
+      permissionState.value = 'granted'
+    } catch (err: any) {
+      if (err.name === 'NotAllowedError') {
+        permissionState.value = 'denied'
+      }
+      throw err
+    }
+
+    // 创建 AudioContext(16kHz 采样率,直接在源头重采样)
+    audioContext = new AudioContext({ sampleRate: TARGET_SAMPLE_RATE })
+    const source = audioContext.createMediaStreamSource(mediaStream)
+
+    // 加载 AudioWorklet processor
+    await audioContext.audioWorklet.addModule('/pcm-recorder-worklet.js')
+    workletNode = new AudioWorkletNode(audioContext, 'pcm-recorder-processor')
+
+    // 收集 PCM 数据
+    workletNode.port.onmessage = (e: MessageEvent<Float32Array>) => {
+      pcmChunks.push(e.data)
+    }
+
+    source.connect(workletNode)
+    workletNode.connect(audioContext.destination) // 需要连接才能驱动处理
+
+    // 静音检测
+    startSilenceDetection(source)
+
+    isRecording.value = true
+    recordingDuration.value = 0
+    durationTimer = setInterval(() => { recordingDuration.value++ }, 1000)
+  }
+
+  async function stopRecording(): Promise<Blob> {
+    if (!workletNode || !audioContext) {
+      throw new Error('No active recording')
+    }
+
+    // 通知 worklet 停止
+    workletNode.port.postMessage('stop')
+    workletNode.disconnect()
+    workletNode = null
+
+    // 合并 PCM chunks 并编码为 WAV
+    const wavBlob = encodeWAV(pcmChunks, TARGET_SAMPLE_RATE)
+    pcmChunks = []
+
+    cleanup()
+    return wavBlob
+  }
+
+  function cleanup() {
+    isRecording.value = false
+    recordingDuration.value = 0
+
+    if (durationTimer) { clearInterval(durationTimer); durationTimer = null }
+    stopSilenceDetection()
+
+    if (audioContext) { audioContext.close().catch(() => {}); audioContext = null }
+    if (mediaStream) {
+      mediaStream.getTracks().forEach(t => t.stop())
+      mediaStream = null
+    }
+  }
+
+  checkPermission()
+
+  onUnmounted(() => {
+    cleanup()
+  })
+
+  return {
+    isRecording,
+    permissionState,
+    recordingDuration,
+    silenceDetected,
+    startRecording,
+    stopRecording,
+    cleanup,
+  }
+}
+
+// ==================== WAV 编码 ====================
+
+function encodeWAV(chunks: Float32Array[], sampleRate: number): Blob {
+  // 计算总长度
+  let totalLength = 0
+  for (const chunk of chunks) totalLength += chunk.length
+
+  // 合并为单个 Float32Array
+  const pcm = new Float32Array(totalLength)
+  let offset = 0
+  for (const chunk of chunks) {
+    pcm.set(chunk, offset)
+    offset += chunk.length
+  }
+
+  // Float32 → Int16
+  const int16 = new Int16Array(pcm.length)
+  for (let i = 0; i < pcm.length; i++) {
+    const s = Math.max(-1, Math.min(1, pcm[i]))
+    int16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF
+  }
+
+  // 写 WAV header + data
+  const numChannels = 1
+  const bitsPerSample = 16
+  const byteRate = sampleRate * numChannels * (bitsPerSample / 8)
+  const blockAlign = numChannels * (bitsPerSample / 8)
+  const dataSize = int16.length * (bitsPerSample / 8)
+
+  const buffer = new ArrayBuffer(44 + dataSize)
+  const view = new DataView(buffer)
+
+  // RIFF header
+  writeString(view, 0, 'RIFF')
+  view.setUint32(4, 36 + dataSize, true)
+  writeString(view, 8, 'WAVE')
+
+  // fmt chunk
+  writeString(view, 12, 'fmt ')
+  view.setUint32(16, 16, true)           // chunk size
+  view.setUint16(20, 1, true)            // PCM format
+  view.setUint16(22, numChannels, true)
+  view.setUint32(24, sampleRate, true)
+  view.setUint32(28, byteRate, true)
+  view.setUint16(32, blockAlign, true)
+  view.setUint16(34, bitsPerSample, true)
+
+  // data chunk
+  writeString(view, 36, 'data')
+  view.setUint32(40, dataSize, true)
+
+  // PCM data
+  const int16View = new Int16Array(buffer, 44)
+  int16View.set(int16)
+
+  return new Blob([buffer], { type: 'audio/wav' })
+}
+
+function writeString(view: DataView, offset: number, str: string) {
+  for (let i = 0; i < str.length; i++) {
+    view.setUint8(offset + i, str.charCodeAt(i))
+  }
+}

+ 281 - 0
src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts

@@ -0,0 +1,281 @@
+import { ref, computed, onUnmounted } from 'vue'
+import type { PreviewChatMessage, DialogueAPI, SessionConfig, DialogueReport } from '@/types/englishSpeaking'
+import { MockDialogueAPI, RealDialogueAPI } from '../services/llmService'
+
+export function useDialogueEngine(mode: 'preview' | 'real' = 'preview') {
+  const messages = ref<PreviewChatMessage[]>([])
+  const sessionId = ref<string | null>(null)
+  const expiresAt = ref<string | null>(null)
+  const currentRound = ref(1)
+  const isComplete = ref(false)
+  const countdownSeconds = ref<number | null>(null)
+
+  let api: DialogueAPI = mode === 'real' ? new RealDialogueAPI() : new MockDialogueAPI()
+  let currentAbortController: AbortController | null = null
+  let countdownTimer: ReturnType<typeof setInterval> | null = null
+  let ttsUtterance: SpeechSynthesisUtterance | null = null
+
+  const isProcessing = computed(() => messages.value.some(m => m.status === 'loading'))
+  const canRecord = computed(() => !isProcessing.value && !isComplete.value)
+
+  // ==================== Session ====================
+
+  async function initSession(config: SessionConfig) {
+    try {
+      const info = await api.createSession(config)
+      sessionId.value = info.sessionId
+      expiresAt.value = info.expiresAt || null
+
+      messages.value.push({
+        id: crypto.randomUUID(),
+        role: 'ai',
+        content: info.aiMessage,
+        timestamp: new Date(),
+        status: 'done',
+      })
+
+      if (info.expiresAt) startCountdown(info.expiresAt)
+      speakTTS(info.aiMessage)
+    } catch (err: any) {
+      console.error('Failed to init session:', err)
+    }
+  }
+
+  // ==================== Send Message ====================
+
+  async function sendStudentMessage(audioBlob: Blob) {
+    if (!sessionId.value || isProcessing.value) return
+
+    // Add student message (loading)
+    const studentMsg: PreviewChatMessage = {
+      id: crypto.randomUUID(),
+      role: 'student',
+      content: '',
+      timestamp: new Date(),
+      status: 'loading',
+      audioBlob,
+    }
+    messages.value.push(studentMsg)
+
+    // Add AI message placeholder
+    const aiMsg: PreviewChatMessage = {
+      id: crypto.randomUUID(),
+      role: 'ai',
+      content: '',
+      timestamp: new Date(),
+      status: 'loading',
+    }
+
+    currentAbortController = new AbortController()
+
+    try {
+      const generator = api.speak(sessionId.value, audioBlob, currentAbortController.signal)
+
+      for await (const event of generator) {
+        if (event.type === 'transcript') {
+          studentMsg.content = event.text
+          studentMsg.status = 'done'
+          // Now push AI message placeholder
+          messages.value.push(aiMsg)
+        } else if (event.type === 'token') {
+          aiMsg.content += event.text
+        } else if (event.type === 'done') {
+          aiMsg.status = 'done'
+          isComplete.value = event.isComplete
+
+          if (!event.isComplete) {
+            currentRound.value++
+          }
+
+          speakTTS(aiMsg.content)
+        }
+      }
+
+      // If student message never got transcript, mark done with fallback
+      if (studentMsg.status === 'loading') {
+        studentMsg.status = 'done'
+      }
+      if (aiMsg.status === 'loading') {
+        aiMsg.status = 'done'
+      }
+    } catch (err: any) {
+      if (err.name === 'AbortError') return
+
+      // Determine which message to mark as error
+      if (studentMsg.status === 'loading') {
+        studentMsg.status = 'error'
+        studentMsg.error = err.message || 'Request failed'
+      } else if (aiMsg.status === 'loading') {
+        aiMsg.status = 'error'
+        aiMsg.error = err.message || 'Request failed'
+      }
+    } finally {
+      currentAbortController = null
+    }
+  }
+
+  // ==================== Retry / Regenerate ====================
+
+  async function retryMessage(messageId: string) {
+    const msg = messages.value.find(m => m.id === messageId)
+    if (!msg || msg.status !== 'error') return
+
+    if (msg.role === 'student' && msg.audioBlob) {
+      // Remove the failed student message and any subsequent AI message
+      const idx = messages.value.indexOf(msg)
+      messages.value.splice(idx)
+      await sendStudentMessage(msg.audioBlob)
+    }
+  }
+
+  async function regenerateAiMessage(messageId: string) {
+    const msg = messages.value.find(m => m.id === messageId)
+    if (!msg || msg.role !== 'ai' || msg.status !== 'error') return
+
+    // Find the student message before this AI message
+    const idx = messages.value.indexOf(msg)
+    const prevStudent = messages.value.slice(0, idx).reverse().find(m => m.role === 'student')
+    if (!prevStudent?.audioBlob || !sessionId.value) return
+
+    // Remove the failed AI message
+    messages.value.splice(idx, 1)
+
+    // Re-add AI placeholder and stream
+    const aiMsg: PreviewChatMessage = {
+      id: crypto.randomUUID(),
+      role: 'ai',
+      content: '',
+      timestamp: new Date(),
+      status: 'loading',
+    }
+    messages.value.push(aiMsg)
+
+    currentAbortController = new AbortController()
+
+    try {
+      const generator = api.speak(sessionId.value, prevStudent.audioBlob, currentAbortController.signal)
+
+      for await (const event of generator) {
+        if (event.type === 'transcript') {
+          // Skip transcript on regenerate, student message already exists
+        } else if (event.type === 'token') {
+          aiMsg.content += event.text
+        } else if (event.type === 'done') {
+          aiMsg.status = 'done'
+          isComplete.value = event.isComplete
+          if (!event.isComplete) currentRound.value++
+          speakTTS(aiMsg.content)
+        }
+      }
+
+      if (aiMsg.status === 'loading') aiMsg.status = 'done'
+    } catch (err: any) {
+      if (err.name === 'AbortError') return
+      aiMsg.status = 'error'
+      aiMsg.error = err.message || 'Request failed'
+    } finally {
+      currentAbortController = null
+    }
+  }
+
+  // ==================== Report ====================
+
+  function getReport(): Promise<DialogueReport> {
+    if (!sessionId.value) return Promise.reject(new Error('No session'))
+
+    return new Promise((resolve, reject) => {
+      let attempts = 0
+      const maxAttempts = 15 // 30s / 2s
+
+      const poll = async () => {
+        attempts++
+        try {
+          const report = await api.getReport(sessionId.value!)
+          resolve(report)
+        } catch {
+          if (attempts >= maxAttempts) {
+            reject(new Error('Report timeout'))
+          } else {
+            setTimeout(poll, 2000)
+          }
+        }
+      }
+      poll()
+    })
+  }
+
+  // ==================== TTS ====================
+
+  function speakTTS(text: string) {
+    if (!text || typeof speechSynthesis === 'undefined') return
+
+    cancelTTS()
+    ttsUtterance = new SpeechSynthesisUtterance(text)
+    ttsUtterance.lang = 'en-US'
+    ttsUtterance.rate = 0.9
+    speechSynthesis.speak(ttsUtterance)
+  }
+
+  function cancelTTS() {
+    if (typeof speechSynthesis !== 'undefined') {
+      speechSynthesis.cancel()
+    }
+    ttsUtterance = null
+  }
+
+  // ==================== Countdown ====================
+
+  function startCountdown(expiresAtStr: string) {
+    stopCountdown()
+
+    const update = () => {
+      const remaining = Math.max(0, Math.floor((new Date(expiresAtStr).getTime() - Date.now()) / 1000))
+      countdownSeconds.value = remaining
+      if (remaining <= 0) {
+        stopCountdown()
+        isComplete.value = true
+      }
+    }
+
+    update()
+    countdownTimer = setInterval(update, 1000)
+  }
+
+  function stopCountdown() {
+    if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null }
+    countdownSeconds.value = null
+  }
+
+  // ==================== Abort ====================
+
+  function abort() {
+    currentAbortController?.abort()
+    currentAbortController = null
+  }
+
+  // ==================== Cleanup ====================
+
+  onUnmounted(() => {
+    abort()
+    cancelTTS()
+    stopCountdown()
+  })
+
+  return {
+    messages,
+    sessionId,
+    currentRound,
+    isComplete,
+    isProcessing,
+    canRecord,
+    countdownSeconds,
+
+    initSession,
+    sendStudentMessage,
+    retryMessage,
+    regenerateAiMessage,
+    getReport,
+    abort,
+    cancelTTS,
+  }
+}

+ 155 - 169
src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue

@@ -8,24 +8,32 @@
         <span class="online-dot"></span>
       </div>
       <div class="status-right">
-        <span v-if="isRecording" class="recording-duration">{{ recordingDuration }}s</span>
-        <span class="round-indicator">{{ currentRound }}/{{ totalRounds }}</span>
+        <span v-if="recorder.isRecording.value" class="recording-duration">{{ recorder.recordingDuration.value }}s</span>
+        <span v-if="engine.countdownSeconds.value != null" class="countdown">
+          {{ formatCountdown(engine.countdownSeconds.value) }}
+        </span>
+        <span class="round-indicator">{{ engine.currentRound.value }}/{{ totalRounds }}</span>
       </div>
     </div>
 
+    <!-- 麦克风权限引导 -->
+    <div v-if="recorder.permissionState.value === 'denied'" class="permission-banner">
+      <span class="permission-icon">🎤</span>
+      <span class="permission-text">麦克风权限已被拒绝,请在浏览器设置中开启后刷新页面</span>
+    </div>
+
     <!-- 对话消息区 -->
     <div ref="chatContainerRef" class="chat-messages">
       <div
-        v-for="message in messages"
+        v-for="message in engine.messages.value"
         :key="message.id"
         class="message-row"
         :class="{ 'message-student': message.role === 'student' }"
       >
         <div class="message-content" :class="{ 'student-content': message.role === 'student' }">
           <!-- 语音条 -->
-          <div class="voice-bar" :class="message.role === 'ai' ? 'voice-ai' : 'voice-student'">
+          <div v-if="message.content" class="voice-bar" :class="message.role === 'ai' ? 'voice-ai' : 'voice-student'">
             <button class="play-btn" :class="message.role === 'ai' ? 'play-ai' : 'play-student'">
-              <!-- VolumeIcon SVG -->
               <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
                 <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
                 <path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
@@ -43,8 +51,15 @@
             <span class="duration" :class="message.role === 'ai' ? 'duration-ai' : 'duration-student'">0:03</span>
           </div>
 
+          <!-- 流式输出中的 typing 动画 -->
+          <div v-if="message.status === 'loading' && message.role === 'ai'" class="typing-indicator">
+            <span class="typing-dot" style="animation-delay: 0ms" />
+            <span class="typing-dot" style="animation-delay: 150ms" />
+            <span class="typing-dot" style="animation-delay: 300ms" />
+          </div>
+
           <!-- 英文文本(带单词高亮) -->
-          <div class="text-bubble" :class="message.role === 'ai' ? 'text-ai' : 'text-student'">
+          <div v-if="message.content" class="text-bubble" :class="message.role === 'ai' ? 'text-ai' : 'text-student'">
             <template v-if="message.role === 'student' && message.evaluation?.wordAnalysis">
               <template v-for="(word, idx) in message.content.split(' ')" :key="idx">
                 <span
@@ -59,6 +74,21 @@
             <template v-else>{{ message.content }}</template>
           </div>
 
+          <!-- 错误状态 UI -->
+          <div v-if="message.status === 'error'" class="error-card">
+            <span class="error-text">{{ message.error || '发送失败' }}</span>
+            <button
+              v-if="message.role === 'student'"
+              class="retry-btn"
+              @click="engine.retryMessage(message.id)"
+            >重试</button>
+            <button
+              v-if="message.role === 'ai'"
+              class="retry-btn"
+              @click="engine.regenerateAiMessage(message.id)"
+            >重新生成</button>
+          </div>
+
           <!-- L1 即时反馈 -->
           <div v-if="message.role === 'student' && message.evaluation" class="feedback-card">
             <div class="feedback-l1">
@@ -82,7 +112,6 @@
                 </p>
                 <button class="detail-toggle" @click="toggleExpand(message.id)">
                   {{ expandedMessageId === message.id ? '收起' : '详情' }}
-                  <!-- ChevronDown SVG -->
                   <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" :class="{ 'chevron-up': expandedMessageId === message.id }">
                     <polyline points="6 9 12 15 18 9" />
                   </svg>
@@ -106,23 +135,14 @@
           </div>
         </div>
       </div>
-
-      <!-- AI 正在输入 -->
-      <div v-if="isWaiting" class="message-row">
-        <div class="typing-indicator">
-          <span class="typing-dot" style="animation-delay: 0ms" />
-          <span class="typing-dot" style="animation-delay: 150ms" />
-          <span class="typing-dot" style="animation-delay: 300ms" />
-        </div>
-      </div>
     </div>
 
     <!-- 录音时的沉默提示 -->
-    <div v-if="isRecording && silenceHint" class="silence-hint">
+    <div v-if="recorder.isRecording.value && recorder.silenceDetected.value" class="silence-hint">
       <div class="silence-hint-card">
         <p class="silence-hint-text">
           <span class="hint-icon">💡</span>
-          {{ silenceHint }}
+          Try saying something! Don't be shy.
         </p>
       </div>
     </div>
@@ -133,10 +153,9 @@
         <!-- 提示按钮 -->
         <button
           class="side-btn"
-          :disabled="isWaiting || isRecording"
+          :disabled="!engine.canRecord.value"
           @click="showSmartHint = true"
         >
-          <!-- LightbulbIcon SVG -->
           <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
             <path d="M9 18h6" /><path d="M10 22h4" />
             <path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14" />
@@ -148,11 +167,10 @@
         <div class="record-group">
           <button
             class="record-btn"
-            :class="{ recording: isRecording, waiting: isWaiting }"
-            :disabled="isWaiting"
+            :class="{ recording: recorder.isRecording.value, waiting: engine.isProcessing.value }"
+            :disabled="!engine.canRecord.value && !recorder.isRecording.value"
             @click="handleToggleRecording"
           >
-            <!-- MicIcon SVG -->
             <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
               <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
               <path d="M19 10v2a7 7 0 0 1-14 0v-2" /><line x1="12" y1="19" x2="12" y2="23" />
@@ -160,18 +178,17 @@
             </svg>
           </button>
           <div class="record-status">
-            <div v-if="isRecording" class="pulse-bars">
+            <div v-if="recorder.isRecording.value" class="pulse-bars">
               <div v-for="i in 4" :key="i" class="pulse-bar" :style="{ height: `${Math.sin((i) * 0.8) * 6 + 6}px`, animationDelay: `${i * 0.15}s` }" />
             </div>
-            <span class="record-label" :class="{ 'label-recording': isRecording, 'label-waiting': isWaiting }">
-              {{ isRecording ? '录音中' : isWaiting ? '等待中...' : '点击说话' }}
+            <span class="record-label" :class="{ 'label-recording': recorder.isRecording.value, 'label-waiting': engine.isProcessing.value }">
+              {{ recorder.isRecording.value ? '录音中' : engine.isProcessing.value ? '等待中...' : '点击说话' }}
             </span>
           </div>
         </div>
 
         <!-- 重录按钮 -->
-        <button class="side-btn" :disabled="isWaiting || isRecording">
-          <!-- RefreshIcon SVG -->
+        <button class="side-btn" :disabled="!engine.canRecord.value">
           <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
             <polyline points="23 4 23 10 17 10" /><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
           </svg>
@@ -198,14 +215,6 @@
           </button>
         </div>
 
-        <!-- 任务提示 -->
-        <div class="hint-section">
-          <div class="hint-section-label">任务提示</div>
-          <div class="hint-task-box">
-            <p>{{ aiName }} 刚刚问你最喜欢的动物是什么,你可以告诉他你喜欢的动物以及原因。</p>
-          </div>
-        </div>
-
         <!-- 句子提示 -->
         <div class="hint-section">
           <div class="hint-section-label">句子提示</div>
@@ -313,8 +322,10 @@
 </template>
 
 <script lang="ts" setup>
-import { ref, watch, onMounted, onUnmounted, nextTick } from 'vue'
+import { ref, watch, onMounted, nextTick } from 'vue'
 import type { PreviewChatMessage, BadgeAchievement } from '@/types/englishSpeaking'
+import { useDialogueEngine } from '../composables/useDialogueEngine'
+import { useAudioRecorder } from '../composables/useAudioRecorder'
 
 interface Props {
   topic?: string
@@ -322,6 +333,7 @@ interface Props {
   aiName?: string
   aiAvatar?: string
   totalRounds?: number
+  mode?: 'preview' | 'real'
 }
 
 const props = withDefaults(defineProps<Props>(), {
@@ -330,24 +342,18 @@ const props = withDefaults(defineProps<Props>(), {
   aiName: 'Tom',
   aiAvatar: '😊',
   totalRounds: 3,
+  mode: 'preview',
 })
 
 const emit = defineEmits<{
   complete: []
 }>()
 
-const messages = ref<PreviewChatMessage[]>([])
-const currentRound = ref(1)
-const isRecording = ref(false)
-const isWaiting = ref(false)
+const engine = useDialogueEngine(props.mode)
+const recorder = useAudioRecorder()
+
 const showSmartHint = ref(false)
 const chatContainerRef = ref<HTMLDivElement>()
-
-const silenceHint = ref<string | null>(null)
-const recordingDuration = ref(0)
-let silenceTimer: ReturnType<typeof setTimeout> | null = null
-let recordingTimer: ReturnType<typeof setInterval> | null = null
-
 const expandedMessageId = ref<string | null>(null)
 
 const phonemeDetailData = ref<{
@@ -368,13 +374,7 @@ const BADGE_CONFIG: Record<string, BadgeAchievement> = {
   perfect_round: { id: 'perfect_round', name: '完美一轮', nameEn: 'Perfect Round', icon: '⭐', description: '单轮四维度全优' },
 }
 
-// 提示数据
-const TRAVEL_HINTS = [
-  "You could say: I like pandas because they are cute!",
-  "You could say: My favorite animal is the elephant.",
-  "You could say: I went to the zoo last weekend.",
-]
-
+// 提示数据(后续可从配置/后端获取)
 const sentenceHints = [
   { en: 'I like pandas best because they are very cute.', zh: '我最喜欢大熊猫,因为它们非常可爱。', keyParts: ['I like', 'best', 'because'] },
   { en: "My favorite animal is the elephant. It's really smart!", zh: '我最喜欢的动物是大象。它真的很聪明!', keyParts: ['My favorite', 'is', 'really'] },
@@ -390,37 +390,55 @@ const vocabHints = [
   { word: 'endangered', phonetic: '/ɪnˈdeɪndʒərd/', meaning: '濒危的' },
 ]
 
-// 对话脚本
-const dialogueScript = [
-  { ai: "Hi! What's your favorite animal?", student: 'I like pandas. They are very cute!' },
-  { ai: 'Pandas are adorable! Have you seen them at the zoo?', student: 'Yes, I went to the zoo last month.' },
-  { ai: "That's great! What do pandas like to eat?", student: 'They like to eat bamboo.' },
-]
+// 初始化对话 session
+onMounted(() => {
+  engine.initSession({
+    topic: props.topic,
+    roleId: 'tom',
+    totalRounds: props.totalRounds,
+    vocabulary: props.keywords,
+  })
+})
+
+// 自动滚动
+watch(
+  () => engine.messages.value.map(m => m.content).join(''),
+  () => {
+    nextTick(() => {
+      if (chatContainerRef.value) {
+        chatContainerRef.value.scrollTop = chatContainerRef.value.scrollHeight
+      }
+    })
+  },
+)
+
+// 对话完成 → 通知父组件
+watch(
+  () => engine.isComplete.value,
+  (complete) => {
+    if (complete) emit('complete')
+  },
+)
 
-// 生成模拟评估数据
-function generateMockEvaluation(content: string): PreviewChatMessage['evaluation'] {
-  const dimensions = ['excellent', 'good', 'improve'] as const
-  const randomDim = () => dimensions[Math.floor(Math.random() * 2)]
-
-  const words = content.split(' ').filter(w => w.length > 3)
-  const wordAnalysis = words.slice(0, 3).map((word, i) => ({
-    word: word.replace(/[.,!?]/g, ''),
-    status: (i === 1 ? 'improvable' : 'correct') as 'correct' | 'improvable',
-    userPronunciation: i === 1 ? '/traˈvel/' : undefined,
-    standardPronunciation: i === 1 ? '/ˈtrævəl/' : undefined,
-    tip: i === 1 ? '重音在第一音节' : undefined,
-  }))
-
-  return {
-    dimensions: { accuracy: randomDim(), fluency: randomDim(), completeness: randomDim(), rhythm: randomDim() },
-    suggestion: 'Try: "I\'d like to..." instead of "I want to..."',
-    betterExpression: "I'd like to visit the zoo. It's one of the most famous places!",
-    suggestedWords: ['adorable', 'bamboo', 'habitat'],
-    wordAnalysis,
+// 录音切换
+async function handleToggleRecording() {
+  if (recorder.isRecording.value) {
+    try {
+      const audioBlob = await recorder.stopRecording()
+      await engine.sendStudentMessage(audioBlob)
+    } catch (err) {
+      console.error('Recording/send failed:', err)
+    }
+  } else {
+    try {
+      await recorder.startRecording()
+    } catch (err) {
+      console.error('Failed to start recording:', err)
+    }
   }
 }
 
-// 检查并触发徽章
+// 徽章检查
 function checkAndTriggerBadge(evaluation: PreviewChatMessage['evaluation']) {
   if (!evaluation) return
 
@@ -453,89 +471,23 @@ function checkAndTriggerBadge(evaluation: PreviewChatMessage['evaluation']) {
   }
 }
 
-// 录音计时与沉默检测
-watch(isRecording, (recording) => {
-  if (recording) {
-    recordingTimer = setInterval(() => { recordingDuration.value++ }, 1000)
-    silenceTimer = setTimeout(() => {
-      silenceHint.value = TRAVEL_HINTS[Math.floor(Math.random() * TRAVEL_HINTS.length)]
-      setTimeout(() => { silenceHint.value = null }, 3000)
-    }, 5000)
-  } else {
-    recordingDuration.value = 0
-    silenceHint.value = null
-    if (silenceTimer) { clearTimeout(silenceTimer); silenceTimer = null }
-    if (recordingTimer) { clearInterval(recordingTimer); recordingTimer = null }
-  }
-})
-
-// 初始化 AI 第一条消息
-onMounted(() => {
-  if (messages.value.length === 0) {
-    messages.value.push({
-      id: crypto.randomUUID(),
-      role: 'ai',
-      content: dialogueScript[0].ai,
-      timestamp: new Date(),
-    })
-  }
-})
-
-onUnmounted(() => {
-  if (silenceTimer) clearTimeout(silenceTimer)
-  if (recordingTimer) clearInterval(recordingTimer)
-})
-
-// 滚动到底部
-watch(() => messages.value.length, () => {
-  nextTick(() => {
-    if (chatContainerRef.value) {
-      chatContainerRef.value.scrollTop = chatContainerRef.value.scrollHeight
-    }
-  })
-})
-
-// 录音切换
-function handleToggleRecording() {
-  if (isWaiting.value) return
-
-  if (isRecording.value) {
-    isRecording.value = false
-    isWaiting.value = true
-
-    const scriptIndex = Math.min(currentRound.value - 1, dialogueScript.length - 1)
-    const studentResponse = dialogueScript[scriptIndex].student
-    const evaluation = generateMockEvaluation(studentResponse)
-
-    messages.value.push({
-      id: crypto.randomUUID(),
-      role: 'student',
-      content: studentResponse,
-      timestamp: new Date(),
-      evaluation,
-    })
-    checkAndTriggerBadge(evaluation)
+// Watch for new student messages with evaluations
+watch(
+  () => engine.messages.value.filter(m => m.role === 'student' && m.evaluation).length,
+  () => {
+    const studentMsgs = engine.messages.value.filter(m => m.role === 'student' && m.evaluation)
+    const last = studentMsgs[studentMsgs.length - 1]
+    if (last?.evaluation) checkAndTriggerBadge(last.evaluation)
+  },
+)
 
-    setTimeout(() => {
-      isWaiting.value = false
-      if (currentRound.value < props.totalRounds && scriptIndex + 1 < dialogueScript.length) {
-        messages.value.push({
-          id: crypto.randomUUID(),
-          role: 'ai',
-          content: dialogueScript[scriptIndex + 1].ai,
-          timestamp: new Date(),
-        })
-        currentRound.value++
-      } else {
-        emit('complete')
-      }
-    }, 1200)
-  } else {
-    isRecording.value = true
-  }
+// 工具函数
+function formatCountdown(seconds: number): string {
+  const m = Math.floor(seconds / 60)
+  const s = seconds % 60
+  return `${m}:${s.toString().padStart(2, '0')}`
 }
 
-// 工具函数
 function toggleExpand(id: string) {
   expandedMessageId.value = expandedMessageId.value === id ? null : id
 }
@@ -601,8 +553,23 @@ function showPhonemeDetail(analysis: NonNullable<NonNullable<PreviewChatMessage[
 .online-dot { width: 4px; height: 4px; background: #22c55e; border-radius: 50%; }
 .status-right { display: flex; align-items: center; gap: 8px; font-size: 10px; color: #9ca3af; }
 .recording-duration { color: #ef4444; font-weight: 500; }
+.countdown { color: #6b7280; font-variant-numeric: tabular-nums; }
 .round-indicator { color: #f97316; font-weight: 500; }
 
+// 权限引导
+.permission-banner {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 8px 12px;
+  background: #fef3c7;
+  border-bottom: 1px solid #fde68a;
+  font-size: 11px;
+  color: #92400e;
+}
+.permission-icon { font-size: 14px; }
+.permission-text { flex: 1; }
+
 // 消息区域
 .chat-messages {
   flex: 1;
@@ -667,6 +634,31 @@ function showPhonemeDetail(analysis: NonNullable<NonNullable<PreviewChatMessage[
   &:hover { background: #fef3c7; }
 }
 
+// 错误状态
+.error-card {
+  margin-top: 6px;
+  padding: 8px 12px;
+  background: #fef2f2;
+  border: 1px solid #fecaca;
+  border-radius: 8px;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+}
+.error-text { font-size: 11px; color: #dc2626; }
+.retry-btn {
+  padding: 4px 12px;
+  background: #fff;
+  border: 1px solid #fecaca;
+  border-radius: 999px;
+  font-size: 11px;
+  color: #dc2626;
+  cursor: pointer;
+  white-space: nowrap;
+  &:hover { background: #fef2f2; border-color: #f87171; }
+}
+
 // 即时反馈
 .feedback-card {
   margin-top: 8px;
@@ -763,7 +755,7 @@ function showPhonemeDetail(analysis: NonNullable<NonNullable<PreviewChatMessage[
   border-radius: 12px;
   border-top-left-radius: 4px;
   box-shadow: 0 1px 3px rgba(0,0,0,0.05);
-  display: flex;
+  display: inline-flex;
   align-items: center;
   gap: 4px;
 }
@@ -845,6 +837,7 @@ function showPhonemeDetail(analysis: NonNullable<NonNullable<PreviewChatMessage[
   &:hover { background: #ea580c; }
   &.recording { background: #ef4444; }
   &.waiting { background: #d1d5db; cursor: not-allowed; }
+  &:disabled { background: #d1d5db; cursor: not-allowed; }
 }
 
 .record-status { display: flex; align-items: center; gap: 6px; }
@@ -914,13 +907,6 @@ function showPhonemeDetail(analysis: NonNullable<NonNullable<PreviewChatMessage[
 // 提示弹窗内容
 .hint-section { margin-bottom: 16px; }
 .hint-section-label { font-size: 12px; color: #9ca3af; margin-bottom: 8px; }
-.hint-task-box {
-  padding: 12px;
-  background: #fff7ed;
-  border-radius: 12px;
-  border: 1px solid rgba(249,115,22,0.2);
-  p { font-size: 14px; color: #374151; line-height: 1.6; margin: 0; }
-}
 
 .hint-sentences { display: flex; flex-direction: column; gap: 8px; }
 .hint-sentence-card {

+ 9 - 0
src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue

@@ -44,6 +44,7 @@
         :ai-name="mockRole.name"
         :ai-avatar="mockRole.avatar"
         :total-rounds="totalRounds"
+        :mode="mode"
         @complete="handleDialogueComplete"
       />
     </StudentPreview>
@@ -89,6 +90,14 @@ import DialogueChatView from './DialogueChatView.vue'
 import OverallReport from './OverallReport.vue'
 import DetailedReport from './DetailedReport.vue'
 
+interface Props {
+  mode?: 'preview' | 'real'
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  mode: 'real',
+})
+
 const dialogueState = ref<PreviewDialogueState>('ready')
 const currentRound = ref(1)
 const totalRounds = 3

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

@@ -0,0 +1,167 @@
+import type { DialogueAPI, SSEEvent, SessionConfig, SessionInfo, DialogueReport } from '@/types/englishSpeaking'
+
+const API_BASE = 'http://localhost:8000/api/speaking/dialogue'
+
+// ==================== SSE 解析 ====================
+
+async function* parseSSEStream(reader: ReadableStreamDefaultReader<Uint8Array>): AsyncGenerator<SSEEvent> {
+  const decoder = new TextDecoder()
+  let buffer = ''
+
+  try {
+    while (true) {
+      const { done, value } = await reader.read()
+      if (done) break
+
+      buffer += decoder.decode(value, { stream: true })
+      const lines = buffer.split('\n')
+      buffer = lines.pop() || ''
+
+      let eventType = ''
+      let data = ''
+
+      for (const line of lines) {
+        if (line.startsWith('event:')) {
+          eventType = line.slice(6).trim()
+        } else if (line.startsWith('data:')) {
+          data = line.slice(5).trim()
+        } else if (line === '' && eventType && data) {
+          try {
+            const parsed = JSON.parse(data)
+            if (eventType === 'transcript') {
+              yield { type: 'transcript', text: parsed.text }
+            } else if (eventType === 'token') {
+              yield { type: 'token', text: parsed.content ?? parsed.text }
+            } else if (eventType === 'done') {
+              yield { type: 'done', isComplete: parsed.isComplete }
+            }
+          } catch {
+            // skip malformed JSON
+          }
+          eventType = ''
+          data = ''
+        }
+      }
+    }
+  } finally {
+    reader.releaseLock()
+  }
+}
+
+// ==================== Real API ====================
+
+export class RealDialogueAPI implements DialogueAPI {
+  async createSession(config: SessionConfig): Promise<SessionInfo> {
+    const res = await fetch(`${API_BASE}/session`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      credentials: 'include',
+      body: JSON.stringify({
+        topic: config.topic,
+        totalRounds: config.totalRounds,
+        roleId: config.roleId,
+      }),
+    })
+    if (!res.ok) throw new Error(`createSession failed: ${res.status}`)
+    return res.json()
+  }
+
+  async *speak(sessionId: string, audioBlob: Blob, signal: AbortSignal): AsyncGenerator<SSEEvent> {
+    const formData = new FormData()
+    formData.append('sessionId', sessionId)
+    formData.append('audio', audioBlob, 'recording.webm')
+
+    const res = await fetch(`${API_BASE}/speak`, {
+      method: 'POST',
+      credentials: 'include',
+      body: formData,
+      signal,
+    })
+    if (!res.ok) throw new Error(`speak failed: ${res.status}`)
+    if (!res.body) throw new Error('No response body')
+
+    yield* parseSSEStream(res.body.getReader())
+  }
+
+  async getReport(sessionId: string): Promise<DialogueReport> {
+    const res = await fetch(`${API_BASE}/report?sessionId=${encodeURIComponent(sessionId)}`, {
+      credentials: 'include',
+    })
+    if (!res.ok) throw new Error(`getReport failed: ${res.status}`)
+    return res.json()
+  }
+}
+
+// ==================== Mock API ====================
+
+const MOCK_AI_REPLIES = [
+  'Pandas are adorable! Have you seen them at the zoo?',
+  "That's great! What do pandas like to eat?",
+  "Bamboo is their favorite! You did a wonderful job talking about animals today!",
+]
+
+export class MockDialogueAPI implements DialogueAPI {
+  private roundIndex = 0
+
+  async createSession(_config: SessionConfig): Promise<SessionInfo> {
+    this.roundIndex = 0
+    return {
+      sessionId: 'mock-session-' + Date.now(),
+      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!',
+      'Yes, I went to the zoo last month.',
+      'They like to eat bamboo.',
+    ]
+
+    // Simulate transcript
+    await delay(300, signal)
+    yield { type: 'transcript', text: mockStudentTexts[this.roundIndex] || 'I think so.' }
+
+    // Simulate token streaming
+    const aiReply = MOCK_AI_REPLIES[this.roundIndex] || 'That is very interesting!'
+    const words = aiReply.split(' ')
+    for (const word of words) {
+      await delay(80, signal)
+      yield { type: 'token', text: word + ' ' }
+    }
+
+    this.roundIndex++
+    const isComplete = this.roundIndex >= MOCK_AI_REPLIES.length
+
+    await delay(100, signal)
+    yield { type: 'done', isComplete }
+  }
+
+  async getReport(_sessionId: string): Promise<DialogueReport> {
+    return {
+      evaluation: {
+        overallScore: 85,
+        scoreLevel: 'good',
+        percentile: 78,
+        dimensions: { fluency: 82, interaction: 88, vocabulary: 76, grammar: 90 },
+        aiComment: 'Great job! Your pronunciation was clear and your responses were relevant.',
+        highlights: ['Clear pronunciation', 'Good use of complete sentences', 'Natural flow'],
+        improvements: ['Try more adjectives', 'Practice linking words', 'Expand vocabulary'],
+        nextChallenge: { difficulty: 'Medium', unlockedTopic: 'My Dream Job' },
+        statistics: {
+          totalRounds: 3, averageScore: 83, highestScore: 92,
+          highestRound: 2, grammarErrors: 2, excellentExpressions: 4, totalDuration: 180,
+        },
+        sentenceEvaluations: [],
+      },
+    }
+  }
+}
+
+function delay(ms: number, signal?: AbortSignal): Promise<void> {
+  return new Promise((resolve, reject) => {
+    if (signal?.aborted) { reject(new DOMException('Aborted', 'AbortError')); return }
+    const timer = setTimeout(resolve, ms)
+    signal?.addEventListener('abort', () => { clearTimeout(timer); reject(new DOMException('Aborted', 'AbortError')) }, { once: true })
+  })
+}

+ 2 - 2
src/views/components/ThumbnailSlide/ThumbnailElement.vue

@@ -18,7 +18,7 @@
 
 <script lang="ts" setup>
 import { computed } from 'vue'
-import { ElementTypes, type PPTElement } from '@/types/slides'
+import { ElementTypes, type PPTElement, type Slide } from '@/types/slides'
 
 import BaseImageElement from '@/views/components/element/ImageElement/BaseImageElement.vue'
 import BaseTextElement from '@/views/components/element/TextElement/BaseTextElement.vue'
@@ -35,7 +35,7 @@ const props = defineProps<{
   elementInfo: PPTElement
   elementIndex: number
   scale: number
-  slide: number
+  slide: Slide
 }>()
 
 const currentElementComponent = computed<unknown>(() => {

+ 3 - 0
vite.config.ts

@@ -14,6 +14,9 @@ export default defineConfig({
   server: {
     host: '0.0.0.0',
     port: 5173,
+    headers: {
+      'Origin-Agent-Cluster': '?0',
+    },
     proxy: {
       '/api': {
         target: 'https://server.pptist.cn',