|
|
@@ -1,19 +1,20 @@
|
|
|
# 英文口语 - 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`) - 口语后端
|
|
|
+> 状态:决策收敛,待实现
|
|
|
+> 日期: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. 背景
|
|
|
|
|
|
-英文口语功能(toolType=77)以 Vue 组件形式直接写在 PPT 项目里,不走 iframe 嵌入路线,这与 PPT 现有其他工具组件(选择题 toolType=45、问答 toolType=15、AI 工具 toolType=72 等)的架构模式不同。
|
|
|
+英文口语功能以 Vue 组件(`toolType === 77`,`atool='77'`)直挂在 PPT 项目里,不走 iframe 路径。这与 PPT 其他工具组件(选择题 45 / 问答 15 / AI 工具 72 / 截图 73)的 iframe 架构不同,因此提交、感知、展示各环节都需要在 Vue 组件体系下重新接上。
|
|
|
|
|
|
-本文档梳理英文口语功能要与 PPT 内其他工具组件行为一致,需要补充和改造的内容。
|
|
|
+本文档固化与 pbl-teacher-table / cococlass-english-speaking-api 协同的设计契约。
|
|
|
|
|
|
---
|
|
|
|
|
|
@@ -21,313 +22,444 @@
|
|
|
|
|
|
### 2.1 PPT 其他工具组件的标准流程
|
|
|
|
|
|
-**Rendering:**
|
|
|
-- 以 iframe 形式渲染,`src` 指向外部 URL(如 `https://pbl.cocorobo.cn/work#workPageNew`)
|
|
|
-- URL 里注入参数:`courseid`、`userid`、`stage`、`task`、`tool`
|
|
|
+- **渲染**: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 → 解析 → 渲染
|
|
|
|
|
|
-**答题阶段:**
|
|
|
-- 学生在 iframe 内操作,答案**只在 iframe 内存中**
|
|
|
-- **没有**每题自动保存机制,纯 Vue data binding
|
|
|
+### 2.2 英文口语的差异
|
|
|
|
|
|
-**提交作业(单次触发):**
|
|
|
-- 学生点"提交"按钮 → 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 }`
|
|
|
+- **渲染**: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`
|
|
|
|
|
|
-**`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` 序列化后上传)
|
|
|
+## 3. 核心约束
|
|
|
|
|
|
-**取回展示:**
|
|
|
-- `select_courseWorks_workPageData` 返回 `content` → 解析 JSON → 填回题目组件显示
|
|
|
+1. **数据单一所有权**:音频 / 转录 / 评分 / summary 只存在口语后端。`addCourseWorks_workPage` 只存**指针**(sessionId + 进度摘要),不复制内容。
|
|
|
+2. **对话流畅性优先**:`/speak` 不能等评分;评分后台跑,对话途中**不显示**评分。
|
|
|
+3. **教师侧直接访问口语后端**:pbl-teacher-table 通过 sessionId 调 `/report`;CORS 由后端配置解决。
|
|
|
+4. **与既有 homework_submitted 广播兼容**:口语的 workPage 写入后同样发 Yjs 广播,教师侧无需新增分支(`homework_submitted` 消费是幂等的,都走 `getWork(true)`)。
|
|
|
|
|
|
---
|
|
|
|
|
|
-### 2.2 英文口语的现状
|
|
|
+## 4. 数据契约
|
|
|
|
|
|
-**Rendering:**
|
|
|
-- 以 Vue 组件 `TopicDiscussionPreview` 渲染(直接在 DOM,不是 iframe)
|
|
|
-- 由 `BaseFrameElement.vue` 根据 `toolType === 77` 判断后挂载
|
|
|
+### 4.1 `addCourseWorks_workPage` payload
|
|
|
|
|
|
-**答题阶段:**
|
|
|
-- 学生和 AI 对话,每轮经过 `POST /api/speaking/dialogue/speak`
|
|
|
-- 口语后端**自动存储**每轮对话到 DB:
|
|
|
- - `dialogue_message` (音频 URL + 转录文本)
|
|
|
- - `pronunciation_evaluation` (评分,后台异步填充)
|
|
|
- - `dialogue_session` (总体状态,当 `current_round > total_rounds` 或 `expires_at` 到期时自动 `status='completed'`)
|
|
|
+```
|
|
|
+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"
|
|
|
+})
|
|
|
+```
|
|
|
|
|
|
-**提交作业:**
|
|
|
-- **完全缺失** - 对话结束后没有任何调用写入 `addCourseWorks_workPage`
|
|
|
-- `Student/index.vue` 的 submitWork 循环只遍历 iframe,找不到 toolType=77 的 Vue 组件
|
|
|
+- **每轮对话结束都写一次**(支持 B1 resume:刷新后能判断 in-progress)
|
|
|
+- **重做直接覆盖**(`addCourseWorks_workPage` 原生是 upsert 语义,无需历史数组)
|
|
|
|
|
|
-**结果页:**
|
|
|
-- 调 `GET /report?sessionId=xxx` 轮询(最多 15 次,2s 间隔),等所有发音评估完成
|
|
|
+### 4.2 后端接口(cococlass-english-speaking-api)
|
|
|
|
|
|
-**父页面感知:**
|
|
|
-- pbl-teacher-table 收不到任何"学生完成口语"的信号
|
|
|
-- 不知道 atool=77 是什么、如何展示
|
|
|
+| 方法 | 路径 | 作用 |
|
|
|
+|---|---|---|
|
|
|
+| 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`)
|
|
|
|
|
|
-## 3. 关键架构约束(已确认)
|
|
|
+| event | data | 触发 |
|
|
|
+|---|---|---|
|
|
|
+| `transcript` | `{ text, round }` | ASR 完成后 |
|
|
|
+| `token` | `{ content }` | LLM 流式输出每个 token(**最后一轮不发**) |
|
|
|
+| `done` | `{ isComplete, nextRound }` | 本轮所有 DB 操作完成 |
|
|
|
+| `error` | `{ message }` | 异常兜底(DB 已 rollback) |
|
|
|
|
|
|
-- **对话流畅性优先**:`/speak` 请求必须尽快返回 AI 响应,评分不能阻塞对话
|
|
|
-- **评分异步**:发音评估在后端后台跑,通过 `pronunciation_evaluation.status: pending → completed` 追踪
|
|
|
-- **结果页才需要评分**:对话途中不展示评分,评分只在对话结束后的结果页显示
|
|
|
-- **每条对话包含三件套**:音频 URL + 转录文本 + 评分
|
|
|
-- **口语后端单一职责**:`cococlass-english-speaking-api` 只管口语部分,不承担 workPage 作业提交
|
|
|
+### 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)后生成
|
|
|
+}
|
|
|
+```
|
|
|
|
|
|
---
|
|
|
|
|
|
-## 4. 设计决策(已确认)
|
|
|
+## 5. 组件边界与注入
|
|
|
|
|
|
-### 4.1 数据所有权
|
|
|
+### 5.1 分层
|
|
|
|
|
|
-**口语后端是口语数据的 source of truth** - 音频、转录、评分、摘要都存在口语后端,不复制到 `addCourseWorks_workPage`。
|
|
|
+```
|
|
|
+TopicDiscussionPreview.vue ← runner context 注入层(provide SPEAKING_RUNNER_KEY)
|
|
|
+ └─ DialogueChatView.vue ← 纯 UI 状态机 + SSE 消费(inject)
|
|
|
+```
|
|
|
|
|
|
-### 4.2 提交模型(模型 Z:双触发)
|
|
|
+- `DialogueChatView` 不直接调 `submitWork`、不广播 Yjs、不知道 PPT 外壳的存在
|
|
|
+- `TopicDiscussionPreview`(或更外层 wrapper)负责把 PPT 运行时上下文(`courseId / userId / slideIndex / mode`)和"提交能力"注入下去
|
|
|
|
|
|
-**"完成" 和 "提交" 在口语场景里天然分开**,与其他工具不同:
|
|
|
+### 5.2 Inject key 与 context
|
|
|
|
|
|
-- 其他工具(选择题/问答):**提交 = 完成**,只有一个触发点 —— 学生点"提交"按钮
|
|
|
-- 英文口语:**完成**是系统事件(轮次/时间到),**提交**是向 teacher-table 报告的动作
|
|
|
+```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
|
|
|
+}
|
|
|
|
|
|
-采用 **模型 Z(双触发)**:
|
|
|
+export interface SessionSnapshot {
|
|
|
+ sessionId: string
|
|
|
+ totalRounds: number
|
|
|
+ completedRounds: number
|
|
|
+ isComplete: boolean
|
|
|
+}
|
|
|
|
|
|
-**自动路径(主流程)**:
|
|
|
-- 轮次用完 / 时间到 → `isComplete = true` → 自动调 `submitWork`
|
|
|
+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 后塞入
|
|
|
+}
|
|
|
+```
|
|
|
|
|
|
-**手动路径(fallback)**:
|
|
|
-- 学生主动放弃(点"结束对话"按钮 —— 是否要加待定)→ 显式结束 → 触发提交
|
|
|
+- `mode==='preview'`:编辑器预览,**不** submitWork,不调真后端(走 mock / 直通)
|
|
|
+- `mode==='student'`:Student/index.vue 场景,`onRoundFinished` / `onSessionComplete` 里实际调 `submitWork` + Yjs 广播
|
|
|
+- `mode==='teacher'`:教师只读回放,不新建 session
|
|
|
|
|
|
-**提交 ≠ 评分完成** - 两者进一步解耦:
|
|
|
+### 5.3 DialogueChatView Props & Emits
|
|
|
|
|
|
-- **提交**:学生对话结束 → 立即写入 `addCourseWorks_workPage`
|
|
|
-- **评分**:后台异步完成,通过 SSE 增量推送给结果页
|
|
|
+**Props**(参照 enspeak `DialogueChatView.tsx:200-214`):
|
|
|
|
|
|
-`addCourseWorks_workPage` 中 `atool=77` 的 `content` 字段存:
|
|
|
+```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'
|
|
|
+}>()
|
|
|
+```
|
|
|
|
|
|
-```json
|
|
|
-{
|
|
|
- "sessionId": "uuid-xxx",
|
|
|
- "totalRounds": 5,
|
|
|
- "completedRounds": 5,
|
|
|
- "completedAt": "2026-04-21T10:00:00Z"
|
|
|
-}
|
|
|
+**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'
|
|
|
```
|
|
|
|
|
|
-> 注:是否追加评分摘要(overallScore 等)待讨论。
|
|
|
+- **无 "role / identity / personality / speakingStyle / speed" 字段**(产品明确不需要)
|
|
|
+- **无"结束对话"按钮**(MVP 不允许学生主动放弃)
|
|
|
|
|
|
-### 4.3 避免前端轮询
|
|
|
+---
|
|
|
|
|
|
-放弃对 `GET /report` 的轮询,改用 SSE 流式推送:
|
|
|
+## 6. 会话生命周期
|
|
|
|
|
|
-- 新增 `GET /report/stream?sessionId=xxx`
|
|
|
-- 每个 `pronunciation_evaluation` 从 pending → completed 时推一条 `evaluation_ready` 事件
|
|
|
-- 全部完成 + 摘要生成完毕时推 `done` 事件
|
|
|
-- 结果页订阅该流,评分就绪即显示
|
|
|
+### 6.1 B1 — 刷新 / 返回时恢复(允许 resume)
|
|
|
|
|
|
-### 4.4 Teacher 侧查看详情
|
|
|
+挂载时,Parent wrapper 按 workPage 状态分三支:
|
|
|
|
|
|
-- 教师侧拿到 `atool=77` 的 work 记录 → 解析 `content.sessionId`
|
|
|
-- 点击查看详情 → 直接调口语后端 `GET /report?sessionId=xxx` 拿完整数据渲染
|
|
|
+```
|
|
|
+读 workPage.content
|
|
|
+├─ 无记录 / content 为空 → 新建 session (POST /session)
|
|
|
+├─ isComplete = true → 直接渲染报告 (GET /report)
|
|
|
+└─ isComplete = false + sessionId → 恢复模式
|
|
|
+ └─ GET /report 拿已有 rounds
|
|
|
+ └─ DialogueChatView 初始化 history + 定位 currentRound
|
|
|
+ └─ 用户点"开始录音"继续
|
|
|
+```
|
|
|
|
|
|
-### 4.5 完成触发路径(含时间到期处理)
|
|
|
+**降级**:
|
|
|
+- `/speak` 返回 409 Session is not active(后端超时/已 completed)→ 降级为"展示最终报告"或"新建一次"(看 `/report` 的 status)
|
|
|
+- `/report` 返回 404(极少见)→ 直接新建
|
|
|
|
|
|
-**前置:两个独立的后端数据流**
|
|
|
+### 6.2 B2 — 重做(重新进入已完成页面)
|
|
|
|
|
|
-- **评分(`pronunciation_evaluation`)**:每次 `/speak` 结束时后端自动派发 background task,**不依赖会话是否结束**,自驱完成
|
|
|
-- **总结(`dialogue_session.summary`)**:首次调 `/report` 时,若"所有评分完成 + 无 summary"则调 LLM 生成,**依赖有人来查报告**
|
|
|
+- 点"重做" → 新建 v2,**即刻覆盖 workPage**(第 1 轮结束时写入 `{sessionId:v2, isComplete:false, completedRounds:1}`)
|
|
|
+- **只保留最新一份**,不维护历史数组
|
|
|
+- v1 原始数据仍在后端 `DialogueSession` 表(通过 uuid 可查),但前端**无恢复入口**
|
|
|
|
|
|
-**四种触发路径的处理**
|
|
|
+### 6.3 B3 — 时长限制(倒计时)
|
|
|
|
|
|
-| 情形 | 谁触发 `isComplete` | 后端 `session.status` 谁负责置 'completed' |
|
|
|
-|---|---|---|
|
|
|
-| 轮次用完 | `/speak` done 事件(后端自检) | `/speak` 后端自检(现有逻辑) |
|
|
|
-| 时间到 + 学生正在说 | `/speak` done 事件(后端自检) | `/speak` 后端自检(现有逻辑) |
|
|
|
-| 时间到 + 学生沉默 | 前端 countdown 到 0 | `/report` 被调用时懒结束(新加逻辑) |
|
|
|
-| 关页面 | 无 | 无人管(数据废弃,不影响其他人) |
|
|
|
-| 学生主动放弃 | 点"结束对话"按钮(待定是否加) | `POST /session/{id}/end`(待定是否加) |
|
|
|
+- 老师侧可配置 `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 回录音态
|
|
|
|
|
|
-**关键策略:`/report` 懒结束**
|
|
|
+**为什么幂等**:`/speak` 失败走 `rollback`(`dialogue_service.py:203`),`student_msg` / `evaluation` 都未落库;S3 key `{sessionId}/round_{round}.webm` 重传覆盖,无脏数据。
|
|
|
|
|
|
-后端 `GET /report` 处理开始时加一段:
|
|
|
+### 7.2 A2 — Azure 发音评分失败(后台)
|
|
|
+
|
|
|
+**后端改动**(`_evaluate_pronunciation`):加 1 次重试
|
|
|
|
|
|
```python
|
|
|
-if session.expires_at and now > session.expires_at and session.status == 'active':
|
|
|
- session.status = 'completed'
|
|
|
- session.completed_at = now
|
|
|
- # commit 后再继续原有报告生成逻辑
|
|
|
+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)
|
|
|
```
|
|
|
|
|
|
-- 零新接口、零新基础设施
|
|
|
-- 时间到 + 沉默场景:前端 `isComplete=true` → 走结果页 → 订阅 SSE(必调 /report 或 /report/stream)→ 后端懒结束
|
|
|
-- 轮次/说话中场景:`/speak` 已经处理,无变化
|
|
|
+**结果页渲染规则**:
|
|
|
|
|
|
-**关页面场景(可接受的弃置)**
|
|
|
+| status | UI |
|
|
|
+|---|---|
|
|
|
+| `completed` | 展示四项分数 |
|
|
|
+| `failed` | 整个评分块**空**(不显示占位/错误文字) |
|
|
|
+| `pending` | 继续轮询 |
|
|
|
|
|
|
-- 已派发的评分 background task 照跑照存 ✅
|
|
|
-- `session.status` 永远 'active'、`summary` 永远 null ⚠️
|
|
|
-- 但:`addCourseWorks_workPage` 无记录 → 教师侧看不到 → **没人会去调 `/report` → 没影响**
|
|
|
-- 后端 cron 清理 stuck 'active' sessions 是**可选的运维优化**,功能上不需要
|
|
|
+**Summary**:后端 `_generate_summary` 仅把 `status == 'completed'` 的评分喂给 LLM(`dialogue_service.py:295`),failed 轮自动被跳过,无需额外改。
|
|
|
|
|
|
-### 4.6 触发机制(模型 A:组件自提交 + provide/inject)
|
|
|
+### 7.3 A3 — "结束录音"并发点击
|
|
|
|
|
|
-**背景**:其他工具走 iframe,`Student/index.vue` 通过 `iframe.contentWindow.submitWork()` 主动拉答题 JSON;英文口语是直挂 Vue 组件,这条路径不通。
|
|
|
+前端状态机(不依赖后端幂等):
|
|
|
|
|
|
-**决策**:组件自己在 `isComplete` 变为 true 时提交,不让父层扫描。父层通过 provide 提供"广播+提交"能力,组件通过 inject 使用。
|
|
|
+```
|
|
|
+idle → recording → [点击 finish] → submitting → [SSE done] → idle
|
|
|
+ ↑ 期间所有点击无效
|
|
|
+```
|
|
|
|
|
|
-**"一致性" 拆成三层看**
|
|
|
+- `recording` → 收到 finish 点击:立即置 `submitting`,按钮禁用
|
|
|
+- `submitting` 期间任何点击丢弃
|
|
|
+- `done` 事件到达才切回 `idle` / 转 report
|
|
|
|
|
|
-| 层 | 是否需要一致 | 说明 |
|
|
|
-|---|---|---|
|
|
|
-| L1 后端契约 | **必须** | 同样调 `addCourseWorks_workPage`,同样走 Yjs `homework_submitted` 广播 —— 教师侧无感知差异 |
|
|
|
-| L2 学生 UX | 不必 | 选择题有"提交"按钮给"做完了"的反馈;口语的反馈是结果页(评分/总结),不需要再点一次按钮 |
|
|
|
-| L3 代码架构 | 不必 | `iframe.contentWindow.submitWork()` 是 iframe 模式的产物,Vue 组件没有 contentWindow,强行模仿反而别扭 |
|
|
|
+### 7.4 A4 — 网络中断
|
|
|
|
|
|
-组件自提交只偏离 L3,对 L1/L2 无影响 —— 教师侧和 Yjs 协同逻辑完全复用既有机制。
|
|
|
+| 场景 | 处理 |
|
|
|
+|---|---|
|
|
|
+| `/session` 创建断网 | toast + 手动重试;未进入对话 UI |
|
|
|
+| `/speak` SSE 流中断 | toast "网络异常,请刷新重试" → 用户手动刷新 → 走 B1 resume,从服务端真相重建 |
|
|
|
+| `/report` 轮询断 | 指数退避自动重试(见 §8) |
|
|
|
+| idle 时断网 | 无感,下次操作才发现 |
|
|
|
|
|
|
-**接口约定**
|
|
|
+**不自动重试 `/speak`**:后端 commit 在 LLM stream 之后(`dialogue_service.py:185`),自动重试可能造成重复轮次。让刷新走 resume,状态由服务端真相决定。
|
|
|
|
|
|
-`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 })
|
|
|
- }
|
|
|
-})
|
|
|
-```
|
|
|
+## 8. 报告轮询策略
|
|
|
|
|
|
-`DialogueChatView.vue`(或 `TopicDiscussionPreview`)通过 `inject` 使用:
|
|
|
+结束后调 `GET /report`,若 `status === 'evaluating'` 则进入轮询。
|
|
|
|
|
|
-```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(),
|
|
|
- })
|
|
|
-})
|
|
|
+**退避序列**(总上限 ~30s):
|
|
|
+
|
|
|
+```
|
|
|
+1s, 1s, 1s, 2s, 3s, 5s, 5s, 5s, 5s (cumulative: 1,2,3,5,8,13,18,23,28s)
|
|
|
```
|
|
|
|
|
|
-**全局"提交作业"按钮在 toolType=77 场景**
|
|
|
+**UI 时间阈值文案**:
|
|
|
|
|
|
-`Student/index.vue` 的 `handleHomeworkSubmit` 现在只遍历 iframe;当前 slide 全是 toolType=77(没有 iframe)时:
|
|
|
-- 不做任何网络请求
|
|
|
-- 弹 toast 提示:"口语对话结束后会自动提交,无需手动点击"
|
|
|
+| 累计等待 | 文案 |
|
|
|
+|---|---|
|
|
|
+| 0-15s | "正在生成报告..." |
|
|
|
+| 15-30s | "评分耗时较长,请稍候..." |
|
|
|
+| 30-60s | "仍在处理,建议稍后刷新查看" |
|
|
|
+| > 60s | toast "评分未能完成,请稍后刷新;音频和对话内容已保存" |
|
|
|
|
|
|
-混合场景(slide 同时有 iframe 工具 + 口语组件):iframe 部分走原流程,口语部分由组件自提交处理,互不干扰。
|
|
|
+**最终失败 fallback**:显示当前已拿到的部分数据(pending 的评分块留空),不阻塞用户阅读历史对话。
|
|
|
|
|
|
---
|
|
|
|
|
|
-## 5. 改动清单
|
|
|
+## 9. 时序
|
|
|
|
|
|
-### 5.1 口语后端 (cococlass-english-speaking-api)
|
|
|
+### 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 — 回滚不变量
|
|
|
+
|
|
|
+| 副作用 | 异常回滚行为 |
|
|
|
|---|---|
|
|
|
-| 新增 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` |
|
|
|
+| 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 自动重连。
|
|
|
|
|
|
-### 5.2 PPT 前端
|
|
|
+---
|
|
|
+
|
|
|
+## 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)
|
|
|
|
|
|
| 改动 | 位置 |
|
|
|
|---|---|
|
|
|
-| `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` |
|
|
|
+| 定义 `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 | `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) |
|
|
|
+| 报告页(DetailedReport / OverallReport)按 §4.4 结构渲染;轮询逻辑 §8 | `src/views/Editor/EnglishSpeaking/preview/DetailedReport.vue` / `OverallReport.vue` |
|
|
|
+| `types/englishSpeaking.ts`:加 `timeLimitSeconds?: number`;清理 role.identity / personality / speakingStyle / speed 等不需要的字段 | `src/types/englishSpeaking.ts` |
|
|
|
+| 结果页 UI 还原 enspeak 原型(轮次列表 / 录音播放 / transcript / 四维分 / summary) | 实现阶段逐个从 `/Users/buoy/Development/gitrepo/enspeak/src/components/dialogue/DialogueResult.tsx` 对齐 |
|
|
|
|
|
|
-### 5.3 pbl-teacher-table
|
|
|
+### 11.1 teacher-table 侧
|
|
|
|
|
|
| 改动 | 位置 |
|
|
|
|---|---|
|
|
|
-| `select_courseWorks_workPageData` 返回 atool=77 时解析 content | `src/components/pages/workPage/index.vue` 或新建口语查看组件 |
|
|
|
-| 新增"口语详情"视图,调口语 API `/report?sessionId=xxx` 渲染 | 新组件 |
|
|
|
-| 成绩列表添加 atool=77 类型展示(完成状态 + 简要信息) | 教师作业查看入口 |
|
|
|
+| 识别 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)`)— 无需新增 | — |
|
|
|
|
|
|
---
|
|
|
|
|
|
-## 6. 数据流图
|
|
|
+## 12. 已知缺陷 / Known Limitations
|
|
|
|
|
|
-```
|
|
|
-┌─────────────────┐ ┌──────────────────────┐
|
|
|
-│ 学生 (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
|
|
|
- │ 口语后端 ◄──────────┤
|
|
|
- │ │
|
|
|
-```
|
|
|
+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 会被重试覆盖,实际无害。
|
|
|
|
|
|
---
|
|
|
|
|
|
-## 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)
|
|
|
+## 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`
|
|
|
|
|
|
---
|
|
|
|
|
|
-## 8. 参考
|
|
|
+## 14. 本次未讨论 / 待实现阶段处理
|
|
|
|
|
|
-- `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
|
|
|
+- 老师侧"口语详情"视图的具体 UI(直接复用 enspeak `DialogueResult.tsx` 还是重写)
|
|
|
+- 编辑器 `mode='preview'` 下的 mock 数据/直通策略
|
|
|
+- 单 slide 上多个口语组件的处理(理论上可能,产品价值存疑,MVP 暂不支持)
|
|
|
+- `showEnglishText` / `showChineseText` / `showTaskHint` / `showSilenceHint` 几个展示开关的默认值由产品决定
|
|
|
+- 生产部署的 `API_BASE` 配置(`llmService.ts:3` 当前是 `http://localhost:8000`,需改环境变量)
|
|
|
+- B4 `isVisible` 的具体传递链路(ScreenSlide 已经把 `is-visible` 传给子组件,DialogueChatView 接收方式到实现阶段确认)
|