# 英文口语 - PPT 组件集成设计

> 状态：决策收敛，待实现
> 日期：2026-04-22
> 相关系统：
> - 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`) — 口语后端（FastAPI）
> - enspeak (`/Users/buoy/Development/gitrepo/enspeak`) — 原型项目，UI/交互参照基准

---

## 1. 背景

英文口语功能以 Vue 组件（`toolType === 77`，`atool='77'`）直挂在 PPT 项目里，不走 iframe 路径。这与 PPT 其他工具组件（选择题 45 / 问答 15 / AI 工具 72 / 截图 73）的 iframe 架构不同，因此提交、感知、展示各环节都需要在 Vue 组件体系下重新接上。

本文档固化与 pbl-teacher-table / cococlass-english-speaking-api 协同的设计契约。

---

## 2. 现状对比

### 2.1 PPT 其他工具组件的标准流程

- **渲染**：iframe，`src` 指向外部 URL，URL 注入 `courseid / userid / stage / task / tool`
- **答题**：学生在 iframe 内答题，答案只在 iframe 内存
- **提交**：学生点"提交" → `Student/index.vue` 遍历 slide 的 iframe，通过 `iframe.contentWindow.submitWork(slideIndex)` 拉答题 JSON
- **存储**：`POST addCourseWorks_workPage`（`stage=0 / task=slideIndex / tool=0 / atool='15'|'45'|'72'...`）
- **感知**：Yjs 广播 `{ type:'homework_submitted', courseid, slideIndex, userid }`，教师侧幂等消费（调 `getWork(true)` 刷新）
- **回显**：`select_courseWorks_workPageData` 返回 content → 解析 → 渲染

### 2.2 英文口语的差异

- **渲染**：Vue 组件 `TopicDiscussionPreview`，由 `BaseFrameElement.vue` 根据 `toolType === 77` 挂载（无 iframe，无 `contentWindow`）
- **答题**：多轮语音对话，每轮经 `POST /api/speaking/dialogue/speak`（SSE 流）
- **后端持久化**：`DialogueSession` / `DialogueMessage` / `PronunciationEvaluation`（音频 URL + 转录 + 评分）
- **结果**：`GET /api/speaking/dialogue/report?sessionId=xxx` 返回完整报告（含 LLM summary）
- **评分异步**：`asyncio.create_task` fire-and-forget，`status: pending → completed | failed`

---

## 3. 核心约束

1. **数据单一所有权**：音频 / 转录 / 评分 / summary 只存在口语后端。`addCourseWorks_workPage` 只存**指针**（sessionId + 进度摘要），不复制内容。
2. **对话流畅性优先**：`/speak` 不能等评分；评分后台跑，对话途中**不显示**评分。
3. **教师侧直接访问口语后端**：pbl-teacher-table 通过 sessionId 调 `/report`；CORS 由后端配置解决。
4. **与既有 homework_submitted 广播兼容**：口语的 workPage 写入后同样发 Yjs 广播，教师侧无需新增分支（`homework_submitted` 消费是幂等的，都走 `getWork(true)`）。
5. **UI 全盘还原 enspeak 原型**：所有可视元素（布局、配色、动效、状态机文案、消息气泡、录音按钮、倒计时、空/错/过渡态、结果页四维分、summary 展示等）**严格对齐** `/Users/buoy/Development/gitrepo/enspeak`。实现方式是**React TSX → Vue 3 移植**：保留行为与视觉 1:1，语法层面由 hooks→composables、JSX→template、useState→ref/reactive、useEffect→watch/watchEffect/onMounted；不得为"Vue 更自然"而改动交互。映射表见 §5.4。

---

## 4. 数据契约

### 4.1 `addCourseWorks_workPage` payload

```
uid     = userid
cid     = courseid
stage   = '0'
task    = String(slideIndex)
tool    = '0'
atool   = '77'
type    = '21'
content = JSON.stringify({
  sessionId:       "uuid-xxx",
  totalRounds:     3,
  completedRounds: 2,
  isComplete:      false,
  lastUpdatedAt:   "2026-04-22T10:15:30Z"
})
```

- **每轮对话结束都写一次**（支持 B1 resume：刷新后能判断 in-progress）
- **重做直接覆盖**（`addCourseWorks_workPage` 原生是 upsert 语义，无需历史数组）

### 4.2 后端接口（cococlass-english-speaking-api）

| 方法 | 路径 | 作用 |
|---|---|---|
| POST | `/api/speaking/dialogue/session` | 创建 session，返回 sessionId + AI 开场白 |
| POST | `/api/speaking/dialogue/speak` | 提交一轮录音，SSE 流式响应 |
| GET  | `/api/speaking/dialogue/report?sessionId=xxx` | 拉完整报告（含历史 + 评分 + summary） |

### 4.3 SSE 事件类型（`/speak`）

| event | data | 触发 |
|---|---|---|
| `transcript` | `{ text, round }` | ASR 完成后 |
| `token` | `{ content }` | LLM 流式输出每个 token（**最后一轮不发**） |
| `done` | `{ isComplete, nextRound }` | 本轮所有 DB 操作完成 |
| `error` | `{ message }` | 异常兜底（DB 已 rollback） |

### 4.4 /report 返回结构（取关键字段）

```ts
{
  sessionId: string
  topic: string
  status: 'evaluating' | 'ready'    // evaluating = 还有评分在跑
  rounds: Array<{
    round: number
    role: 'ai' | 'student'
    content: string
    audioUrl: string | null
    evaluation?: {                  // 仅 role='student' 且评分有结果
      status: 'pending' | 'completed' | 'failed'
      accuracyScore: number
      fluencyScore: number
      completenessScore: number
      prosodyScore: number
      wordAnalysis: unknown
    }
  }>
  summary: string | null            // 全部评分完成（含 failed）后生成
}
```

---

## 5. 组件边界与注入

### 5.1 分层

```
TopicDiscussionPreview.vue        ← runner context 注入层（provide SPEAKING_RUNNER_KEY）
  └─ DialogueChatView.vue         ← 纯 UI 状态机 + SSE 消费（inject）
```

- `DialogueChatView` 不直接调 `submitWork`、不广播 Yjs、不知道 PPT 外壳的存在
- `TopicDiscussionPreview`（或更外层 wrapper）负责把 PPT 运行时上下文（`courseId / userId / slideIndex / mode`）和"提交能力"注入下去

### 5.2 Inject key 与 context

```ts
// src/views/Editor/EnglishSpeaking/types.ts (新增)
export const SPEAKING_RUNNER_KEY = Symbol('english-speaking-runner')

export interface RoundSnapshot {
  sessionId: string
  round: number
  transcript: string
  audioUrl: string
  totalRounds: number
}

export interface SessionSnapshot {
  sessionId: string
  totalRounds: number
  completedRounds: number
  isComplete: boolean
}

export interface SpeakingRunnerContext {
  userId: string
  courseId: string
  slideIndex: number
  mode: 'preview' | 'student' | 'teacher'
  onRoundFinished: (snap: RoundSnapshot) => Promise<void>
  onSessionComplete: (snap: SessionSnapshot) => Promise<void>
  initialSessionId?: string       // B1 resume 时由 parent 读 workPage 后塞入
}
```

- `mode==='preview'`：编辑器预览，**不** submitWork，不调真后端（走 mock / 直通）
- `mode==='student'`：Student/index.vue 场景，`onRoundFinished` / `onSessionComplete` 里实际调 `submitWork` + Yjs 广播
- `mode==='teacher'`：教师只读回放，不新建 session

### 5.3 DialogueChatView Props & Emits

**Props**（参照 enspeak `DialogueChatView.tsx:200-214`）：

```ts
defineProps<{
  topic: string
  keywords?: string[]
  totalRounds?: number           // 默认 3
  timeLimitSeconds?: number      // 未传 = 无倒计时（正计时）；传 = 倒计时
  aiName?: string
  aiAvatar?: string
  showEnglishText?: boolean
  showChineseText?: boolean
  showTaskHint?: boolean
  showSilenceHint?: boolean
  mode: 'preview' | 'student' | 'teacher'
}>()
```

**Emits**：

```ts
defineEmits<{
  (e: 'session-start', sessionId: string): void
  (e: 'round-finished', payload: RoundSnapshot): void
  (e: 'complete', payload: SessionSnapshot): void
  (e: 'error', payload: { code: ErrorCode; message?: string }): void
}>()

type ErrorCode =
  | 'asr_failed'         // /speak error 事件
  | 'network_error'      // SSE 连接中断 / 请求失败
  | 'session_expired'    // 409 Session is not active
  | 'permission_denied'  // 麦克风权限拒绝
  | 'unknown'
```

- **无 "role / identity / personality / speakingStyle / speed" 字段**（产品明确不需要）
- **无"结束对话"按钮**（MVP 不允许学生主动放弃）

### 5.4 UI 还原基准（enspeak → Vue 映射）

> 全部 UI 从 enspeak 原型移植，**视觉与交互 1:1**。以下是源文件与目标 Vue 文件的映射。所有 UI 细节（文案、颜色、动效时长、气泡样式、按钮状态切换、空态、提示 modal、倒计时样式、结果页四维分卡片、summary 排版等）以 enspeak 为准，不自行发挥。

| 功能区域 | enspeak 源（React TSX） | PPT 目标（Vue） |
|---|---|---|
| 对话主界面（状态机 + 录音按钮 + 消息气泡 + 倒计时显示 + 过渡动画） | `enspeak/src/components/dialogue/DialogueChatView.tsx` (47KB，主干) | `src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue` |
| 话题预览（配置面板进入对话前的 landing） | `enspeak/src/components/dialogue/DialoguePreview.tsx` | `src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue`（入口部分） |
| 详细报告页（每轮录音 / transcript / 四维分 / word analysis） | `enspeak/src/components/dialogue/DetailedReport.tsx` (17KB) | `src/views/Editor/EnglishSpeaking/preview/DetailedReport.vue` |
| 总览报告页（四维分汇总 + summary + 总体评价） | `enspeak/src/components/dialogue/OverallReport.tsx` (8KB) | `src/views/Editor/EnglishSpeaking/preview/OverallReport.vue` |
| 结果入口（控制详细/总览切换） | `enspeak/src/components/dialogue/DialogueResult.tsx` | 合入 `TopicDiscussionPreview.vue` 的完成态分支或新建 `DialogueResult.vue` |
| 提示弹窗（沉默提示 / 帮助等） | `enspeak/src/components/dialogue/HintDialog.tsx` | `src/views/Editor/EnglishSpeaking/preview/HintDialog.vue` |
| 实时反馈条（录音中的声纹/能量指示） | `enspeak/src/components/dialogue/RealtimeFeedback.tsx` | `src/views/Editor/EnglishSpeaking/preview/RealtimeFeedback.vue` |
| 话题讨论父容器（上下文包装层） | `enspeak/src/components/preview/TopicDiscussionPreview.tsx` | `src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue`（包装部分） |

**移植规则（TSX → Vue）**：

| React 原语 | Vue 对应 |
|---|---|
| `useState` | `ref` / `reactive` |
| `useRef` | `ref`（DOM 引用） / `shallowRef`（持有对象引用如 MediaRecorder） |
| `useEffect(..., [deps])` | `watch(deps, cb)` / `watchEffect` / `onMounted` + `onBeforeUnmount` |
| `useMemo` | `computed` |
| `useCallback` | 普通函数即可（Vue 不需要引用稳定性） |
| Props with optional callbacks | `defineEmits` + 父组件 listener |
| 条件 render `{cond && <X/>}` | `v-if` / `v-show`（动画场景慎选 `v-show`） |
| `className={clsx(...)}` | `:class="[...]"` |
| Tailwind classes | 保留 class 名不动（Tailwind 在 PPT 项目若可用直接沿用；若不可用，按 class 名对照抽 CSS vars） |

**不移植项**（enspeak 独有，PPT 不需要）：

- 角色选择器 `RoleSelector.tsx`、话题选择器 `TopicSelector.tsx`（PPT 编辑器侧已有自己的配置面板）
- 自定义角色/话题 modal `CustomRoleModal.tsx` / `CustomTopicModal.tsx`（同上）
- `DialogueChat.tsx`（enspeak 的路由容器，PPT 不需要路由层）

**可能的对齐差异**（实现时标注在代码注释）：

- 字体：enspeak 可能用 Inter / 系统字体，PPT 沿用项目字体栈
- 尺寸：enspeak 是独立页面（全屏），PPT 是 slide 内嵌组件，需按 `elementInfo` 的 width/height 缩放；保持相对比例不变
- 主题色：enspeak 有自己的色板，如与 PPT 冲突以 PPT 为准（最小改动）

---

## 6. 会话生命周期

### 6.1 B1 — 刷新 / 返回时恢复（允许 resume）

挂载时，Parent wrapper 按 workPage 状态分三支：

```
读 workPage.content
├─ 无记录 / content 为空          → 新建 session (POST /session)
├─ isComplete = true              → 直接渲染报告 (GET /report)
└─ isComplete = false + sessionId → 恢复模式
      └─ GET /report 拿已有 rounds
      └─ DialogueChatView 初始化 history + 定位 currentRound
      └─ 用户点"开始录音"继续
```

**降级**：
- `/speak` 返回 409 Session is not active（后端超时/已 completed）→ 降级为"展示最终报告"或"新建一次"（看 `/report` 的 status）
- `/report` 返回 404（极少见）→ 直接新建

### 6.2 B2 — 重做（重新进入已完成页面）

- 点"重做" → 新建 v2，**即刻覆盖 workPage**（第 1 轮结束时写入 `{sessionId:v2, isComplete:false, completedRounds:1}`）
- **只保留最新一份**，不维护历史数组
- v1 原始数据仍在后端 `DialogueSession` 表（通过 uuid 可查），但前端**无恢复入口**

### 6.3 B3 — 时长限制（倒计时）

- 老师侧可配置 `timeLimitSeconds`（slide 属性，传递给 `/session` 的 `durationSeconds`），UI 对齐 enspeak `DialogueChatView.tsx:200 / 292-315`
- `timeLimitSeconds` 未设置 = 正计时（仅轮数限制）
- `timeLimitSeconds` 有值 = 倒计时
- **到期处理（α 方案）**：倒计时到 0 → **禁用录音按钮** → 若正在录音则**取消录音，不调 /speak** → 触发 `onSessionComplete` 走报告页

**不变量**：
1. 倒计时以后端 `expires_at` 为真实依据；前端显示是客户端时钟（允许偏差但不超过几秒）
2. 重新可见时如已过期，立即跳报告页
3. **不可能真正"暂停"倒计时**（`expires_at` 是绝对时间戳）

### 6.4 B4 — 对话中 PPT 翻页（组件不卸载）

`ScreenSlideList.vue:27` 使用 `v-show`（非 `v-if`），翻页后 DialogueChatView 仍挂载。行为（γ+ 方案）：

- 翻走 → 倒计时**继续跑**（反映真实 wall-clock）
- 翻走时若正在录音 → **取消 in-flight 录音**（禁止上传到 /speak）
- 翻回 → 重新同步后端状态：若已过期，跳报告页；未过期，可以继续下一轮，UI 显示"已消耗 XX 秒"

**硬规则**：
1. 不可见时必须取消 in-flight 录音
2. 倒计时以 `expires_at` 为准；前端过期检测基于剩余时间 ≤ 0

---

## 7. 错误态

### 7.1 A1 — ASR 失败

- 前端缓存 MediaRecorder Blob（录音结束后不释放）
- 收到 `error` 事件 → toast "识别失败" + 显示**「重试」按钮**
- 点重试 → 用同一份 blob 再 POST `/speak`（sessionId 相同，round 未前进）
- 2 次都失败 → 按钮变「重新录制」，清 blob 回录音态

**为什么幂等**：`/speak` 失败走 `rollback`（`dialogue_service.py:203`），`student_msg` / `evaluation` 都未落库；S3 key `{sessionId}/round_{round}.webm` 重传覆盖，无脏数据。

### 7.2 A2 — Azure 发音评分失败（后台）

**后端改动**（`_evaluate_pronunciation`）：加 1 次重试

```python
for attempt in range(2):
    try:
        result = await self.assessor.assess(...)
        # set completed fields, break
        break
    except Exception as e:
        if attempt == 0:
            logger.warning(f"Eval attempt 1 failed, retrying: {e}")
            await asyncio.sleep(1)
            continue
        evaluation.status = "failed"
        evaluation.error_message = str(e)
```

**结果页渲染规则**：

| status | UI |
|---|---|
| `completed` | 展示四项分数 |
| `failed` | 整个评分块**空**（不显示占位/错误文字） |
| `pending` | 继续轮询 |

**Summary**：后端 `_generate_summary` 仅把 `status == 'completed'` 的评分喂给 LLM（`dialogue_service.py:295`），failed 轮自动被跳过，无需额外改。

### 7.3 A3 — "结束录音"并发点击

前端状态机（不依赖后端幂等）：

```
idle → recording → [点击 finish] → submitting → [SSE done] → idle
                                 ↑ 期间所有点击无效
```

- `recording` → 收到 finish 点击：立即置 `submitting`，按钮禁用
- `submitting` 期间任何点击丢弃
- `done` 事件到达才切回 `idle` / 转 report

### 7.4 A4 — 网络中断

| 场景 | 处理 |
|---|---|
| `/session` 创建断网 | toast + 手动重试；未进入对话 UI |
| `/speak` SSE 流中断 | toast "网络异常，请刷新重试" → 用户手动刷新 → 走 B1 resume，从服务端真相重建 |
| `/report` 轮询断 | 指数退避自动重试（见 §8） |
| idle 时断网 | 无感，下次操作才发现 |

**不自动重试 `/speak`**：后端 commit 在 LLM stream 之后（`dialogue_service.py:185`），自动重试可能造成重复轮次。让刷新走 resume，状态由服务端真相决定。

---

## 8. 报告轮询策略

结束后调 `GET /report`，若 `status === 'evaluating'` 则进入轮询。

**退避序列**（总上限 ~30s）：

```
1s, 1s, 1s, 2s, 3s, 5s, 5s, 5s, 5s  (cumulative: 1,2,3,5,8,13,18,23,28s)
```

**UI 时间阈值文案**：

| 累计等待 | 文案 |
|---|---|
| 0-15s | "正在生成报告..." |
| 15-30s | "评分耗时较长，请稍候..." |
| 30-60s | "仍在处理，建议稍后刷新查看" |
| > 60s | toast "评分未能完成，请稍后刷新；音频和对话内容已保存" |

**最终失败 fallback**：显示当前已拿到的部分数据（pending 的评分块留空），不阻塞用户阅读历史对话。

---

## 9. 时序

### 9.1 E1 — 最后一轮特殊处理（后端改动）

**产品决策**：第 N 轮（N = totalRounds）以学生回应作为结束，**不触发 AI 回复**。

**后端改动**（`dialogue_service.py::speak`，约 line 160-175）：

```python
is_last_round = (current_round >= session.total_rounds)

# 评估记录、transcript yield、student_msg 写入保持不变

if not is_last_round:
    # ⑥ 构建 LLM prompt
    # ⑦ LLM 流式 (yield token)
    # ⑧ 写入 AI 消息
    ...

# ⑨ 推进轮次
session.current_round += 1
is_complete = session.current_round > session.total_rounds
...
```

**SSE 流形态对比**：

- 普通轮：`transcript → token* → done`
- 最后一轮：`transcript → done`（无 token）

**前端状态机**：监听 SSE 时，收到 `done` 前未收到任何 `token` → "最后一轮"路径：保留学生 transcript 气泡，不渲染"AI 正在说"，1.5s 过渡（显示"正在生成报告..."）→ 进入结果页 + 启动轮询。

**single-round session**（`total_rounds = 1`）：AI 开场白（`create_session` 时已生成）→ 学生说一轮 → 无 AI 回复 → 直接结束。自洽。

### 9.2 E2 — 回滚不变量

| 副作用 | 异常回滚行为 |
|---|---|
| S3 音频文件 | **不回滚**（可能遗留孤儿文件；重试同 key 覆盖，无害） |
| `student_msg` / `evaluation`（flushed 未 commit） | session 关闭自动丢弃 ✓ |
| `ai_msg` / round 递增 / session status | 未 commit 即异常，rollback 清理 ✓ |

**硬不变量**：每轮要么**完整成功**（全部写入 + commit），要么**完全没发生**（从 `/report` 看不到这一轮）。

**SSE 中断但后端已 commit 的情况**（网络断在 `yield done` 之前但 commit 已完成）：前端看"连接断"但真相"这轮已完成"。处理方式：刷新页面走 B1 resume（α），`/report` 返回真相即可重建正确 UI。不做 SSE 自动重连。

---

## 10. 后端改动清单（cococlass-english-speaking-api）

| 改动 | 位置 | 必须？ |
|---|---|---|
| `_evaluate_pronunciation` 加 1 次重试 | `app/service/speaking/dialogue_service.py:321-352` | ✅ 必须 |
| `speak` 最后一轮跳过 LLM + AI msg | `app/service/speaking/dialogue_service.py:160-175` | ✅ 必须 |
| CORS 允许 pbl-teacher-table 域名访问 `/report` | FastAPI middleware 配置 | ✅ 必须 |
| S3 ACL 确认 `public-read` | 已确认，保持现状 | — |

---

## 11. 前端改动清单（PPT）

| 改动 | 位置 |
|---|---|
| 定义 `SPEAKING_RUNNER_KEY` + `SpeakingRunnerContext` 类型 | `src/views/Editor/EnglishSpeaking/types.ts`（新） |
| `TopicDiscussionPreview.vue` 作为 runner context 注入层：读 workPage → 决定 B1 resume / 新建 / 查看；provide `SPEAKING_RUNNER_KEY` | `src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue` |
| `DialogueChatView.vue` 重写为纯 UI + SSE 消费：inject runner context；Props/Emits 见 §5.3；状态机 §7.3；倒计时 §6.3；B4 `isVisible` 处理 §6.4。**UI 严格对齐 enspeak**（见 §5.4 映射） | `src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue` |
| `Student/index.vue` 通过 `provide(SPEAKING_RUNNER_KEY, ...)` 暴露 runner context | `src/views/Student/index.vue` |
| `Student/index.vue::handleHomeworkSubmit` 处理全 toolType=77 slide：toast "对话结束后自动提交" | `src/views/Student/index.vue` (~line 1749-1867) |
| 详细报告页 / 总览报告页：按 §4.4 数据结构渲染 + 轮询逻辑 §8；**UI 严格对齐 enspeak**（见 §5.4 映射） | `src/views/Editor/EnglishSpeaking/preview/DetailedReport.vue` / `OverallReport.vue` |
| 提示弹窗、实时反馈条等辅助 UI 按 §5.4 映射移植 | `src/views/Editor/EnglishSpeaking/preview/HintDialog.vue` / `RealtimeFeedback.vue` |
| `types/englishSpeaking.ts`：加 `timeLimitSeconds?: number`；清理 role.identity / personality / speakingStyle / speed 等不需要的字段 | `src/types/englishSpeaking.ts` |

### 11.1 WIP 代码处置（开发起步前先做）

上一次提交（`2ce4e49 chore: WIP checkpoint before english-speaking redesign`）里保留了一批探索性代码，基于已被推翻的旧设计（老 inject key `'homeworkContext'`、老 role 字段、SSE `/report/stream` 等）。新会话开始实现前应做**显式处置**，避免误把 WIP 当成可用基础。

| 文件 | 状态 | 建议 |
|---|---|---|
| `src/types/englishSpeaking.ts` | 含已废弃字段（`Role.identity` / `Role.personality` / `PreviewAIRole.speakingStyle` / `PreviewAIRole.speed`） | 按 §11 表的"清理" + 加 `timeLimitSeconds` 项执行；读 §5.3 Props 对齐 |
| `src/views/Editor/EnglishSpeaking/composables/useAudioRecorder.ts` | 录音封装，基础设施类 | **通读 + 评估复用**：录音本身与设计无耦合，若实现干净可保留；若依赖老类型则重写 |
| `src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts` | 引用老类型 `PreviewChatMessage / SessionConfig / DialogueReport` + 老 `MockDialogueAPI/RealDialogueAPI` 抽象 | **重写**。新设计里对话引擎的状态由 `DialogueChatView` + `SpeakingRunnerContext` 承载，不需要独立 engine composable（或 composable 要按新 inject 模式重构） |
| `src/views/Editor/EnglishSpeaking/services/llmService.ts` | `parseSSEStream` 逻辑本身可能可复用；API_BASE 硬编码需改 env | **SSE 解析保留 / 其余重写**。新设计没有 `report/stream`，只有 `/session` + `/speak` + `/report` 三个 endpoint |
| `src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue` | 基于旧 Props（有 role 字段）+ 旧状态机 | **重写**（§11 主表已标明）。按 §5.4 从 enspeak TSX 1:1 移植 |
| `src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue` | 基于旧流程（无 B1 resume / 无 runner context） | **重写**。按 §5.1 / §6.1 承担 runner context 注入 + B1 分支路由 |
| `public/pcm-recorder-worklet.js` | Audio worklet，基础设施类 | 若 enspeak 没对应 worklet（enspeak 用浏览器原生 MediaRecorder），按 §5.4 移植时对齐选择；目前文件保留 |
| `vite.config.ts` 改动 | 应该是为了 worklet / 路径别名 | 先看 diff 决定是否保留 |

**推荐动作**：新会话开始时先 `git diff 500179d HEAD -- src/` 回顾 WIP 改动；把"直接重写"的文件先 `git checkout 500179d -- <path>` 恢复到 checkpoint 前状态（干净起点），再按 §11 主表从零实现。保留性工具（worklet / recorder）先不动，实现到对应环节再决定。

### 11.2 teacher-table 侧

| 改动 | 位置 |
|---|---|
| 识别 atool='77' 的 workPage content（含 sessionId） | `pbl-teacher-table/src/components/pages/workPage/index.vue` |
| "口语详情"视图：直接调 `GET /api/speaking/dialogue/report?sessionId=xxx` 渲染 | 新组件 |
| 复用现有 `homework_submitted` 消费（幂等的 `getWork(true)`）— 无需新增 | — |

---

## 12. 已知缺陷 / Known Limitations

1. **评分任务进程重启会丢失**：`asyncio.create_task(self._evaluate_pronunciation(...))` 是 fire-and-forget。若 uvicorn 进程在评分完成前重启 / 崩溃，那一轮评分会永远 `status='pending'`，`/report` 不会生成 summary。MVP 接受；未来应改为持久化任务队列（Celery / RQ / DB polling）。
2. **客户端时钟偏差**：倒计时显示以前端时钟为准。客户端时钟偏慢时，UI 可能显示"还剩 3s"但后端已过期；此时发起的 `/speak` 会得到 `done + isComplete:true`（录音被丢弃）。后端 `expires_at` 是唯一真相。
3. **workPage 只保留最新一份**：学生重做后原作业在前端不可见（后端表里仍有记录，通过 sessionId 可查）。老师看到的始终是"最近一次"。
4. **SSE 无自动重连**：网络中断只能刷新页面走 B1 resume。
5. **音频孤儿文件**：`/speak` 失败时 S3 文件可能已上传，不自动清理；同 key 会被重试覆盖，实际无害。

---

## 13. 参考

- `addCourseWorks_workPage` 提交模式：`pbl-teacher-table/src/components/pages/workPage/index.vue:327, 394-408`
- `submitWork` 包装 + aichat S3 快照模式：`PPT/src/views/Student/index.vue:1665-1849`
- `homework_submitted` Yjs 广播 + 幂等消费：`PPT/src/views/Student/index.vue:2196, 3116-3124`
- 口语后端 `/speak` 主流程：`cococlass-english-speaking-api/app/service/speaking/dialogue_service.py:95-204`
- 口语后端 `/report` 主流程：`cococlass-english-speaking-api/app/service/speaking/dialogue_service.py:206-280`
- 后台评分：`cococlass-english-speaking-api/app/service/speaking/dialogue_service.py:321-352`
- enspeak 倒计时模式：`enspeak/src/components/dialogue/DialogueChatView.tsx:200, 292-315`
- enspeak 结果页参考：`enspeak/src/components/dialogue/DialogueResult.tsx`
- PPT slide 渲染（v-show 行为）：`PPT/src/views/Screen/ScreenSlideList.vue:27`
- 轮询方案详解：`/Users/buoy/Development/gitrepo/interview/network/polling.md`

---

## 14. 本次未讨论 / 待实现阶段处理

- 老师侧"口语详情"视图：UI 按 §5.4 从 enspeak `DetailedReport.tsx` + `OverallReport.tsx` 移植（布局是否需要压缩为嵌入式等实现阶段决定）
- 编辑器 `mode='preview'` 下的 mock 数据/直通策略
- 单 slide 上多个口语组件的处理（理论上可能，产品价值存疑，MVP 暂不支持）
- `showEnglishText` / `showChineseText` / `showTaskHint` / `showSilenceHint` 几个展示开关的默认值由产品决定
- 生产部署的 `API_BASE` 配置（`llmService.ts:3` 当前是 `http://localhost:8000`，需改环境变量）
- B4 `isVisible` 的具体传递链路（ScreenSlide 已经把 `is-visible` 传给子组件，DialogueChatView 接收方式到实现阶段确认）
- Tailwind 在 PPT 项目的可用性（enspeak 使用 Tailwind；若 PPT 不开启，需把 class 转为 scoped CSS，保留样式值）
