状态:决策收敛,待实现 日期: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/交互参照基准
英文口语功能以 Vue 组件(toolType === 77,atool='77')直挂在 PPT 项目里,不走 iframe 路径。这与 PPT 其他工具组件(选择题 45 / 问答 15 / AI 工具 72 / 截图 73)的 iframe 架构不同,因此提交、感知、展示各环节都需要在 Vue 组件体系下重新接上。
本文档固化与 pbl-teacher-table / cococlass-english-speaking-api 协同的设计契约。
src 指向外部 URL,URL 注入 courseid / userid / stage / task / toolStudent/index.vue 遍历 slide 的 iframe,通过 iframe.contentWindow.submitWork(slideIndex) 拉答题 JSONPOST addCourseWorks_workPage(stage=0 / task=slideIndex / tool=0 / atool='15'|'45'|'72'...){ type:'homework_submitted', courseid, slideIndex, userid },教师侧幂等消费(调 getWork(true) 刷新)select_courseWorks_workPageData 返回 content → 解析 → 渲染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 | failedaddCourseWorks_workPage 只存指针(sessionId + 进度摘要),不复制内容。/speak 不能等评分;评分后台跑,对话途中不显示评分。/report;CORS 由后端配置解决。homework_submitted 消费是幂等的,都走 getWork(true))。/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。addCourseWorks_workPage payloaduid = 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 原生是 upsert 语义,无需历史数组)| 方法 | 路径 | 作用 |
|---|---|---|
| POST | /api/speaking/dialogue/session |
创建 session,返回 sessionId + AI 开场白 |
| POST | /api/speaking/dialogue/speak |
提交一轮录音,SSE 流式响应 |
| GET | /api/speaking/dialogue/report?sessionId=xxx |
拉完整报告(含历史 + 评分 + summary) |
/speak)| event | data | 触发 |
|---|---|---|
transcript |
{ text, round } |
ASR 完成后 |
token |
{ content } |
LLM 流式输出每个 token(最后一轮不发) |
done |
{ isComplete, nextRound } |
本轮所有 DB 操作完成 |
error |
{ message } |
异常兜底(DB 已 rollback) |
{
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)后生成
}
TopicDiscussionPreview.vue ← runner context 注入层(provide SPEAKING_RUNNER_KEY)
└─ DialogueChatView.vue ← 纯 UI 状态机 + SSE 消费(inject)
DialogueChatView 不直接调 submitWork、不广播 Yjs、不知道 PPT 外壳的存在TopicDiscussionPreview(或更外层 wrapper)负责把 PPT 运行时上下文(courseId / userId / slideIndex / mode)和"提交能力"注入下去// 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':教师只读回放,不新建 sessionProps(参照 enspeak DialogueChatView.tsx:200-214):
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:
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'
全部 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 编辑器侧已有自己的配置面板)CustomRoleModal.tsx / CustomTopicModal.tsx(同上)DialogueChat.tsx(enspeak 的路由容器,PPT 不需要路由层)可能的对齐差异(实现时标注在代码注释):
elementInfo 的 width/height 缩放;保持相对比例不变挂载时,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(极少见)→ 直接新建{sessionId:v2, isComplete:false, completedRounds:1})DialogueSession 表(通过 uuid 可查),但前端无恢复入口timeLimitSeconds(slide 属性,传递给 /session 的 durationSeconds),UI 对齐 enspeak DialogueChatView.tsx:200 / 292-315timeLimitSeconds 未设置 = 正计时(仅轮数限制)timeLimitSeconds 有值 = 倒计时onSessionComplete 走报告页不变量:
expires_at 为真实依据;前端显示是客户端时钟(允许偏差但不超过几秒)expires_at 是绝对时间戳)ScreenSlideList.vue:27 使用 v-show(非 v-if),翻页后 DialogueChatView 仍挂载。行为(γ+ 方案):
硬规则:
expires_at 为准;前端过期检测基于剩余时间 ≤ 0error 事件 → toast "识别失败" + 显示「重试」按钮/speak(sessionId 相同,round 未前进)为什么幂等:/speak 失败走 rollback(dialogue_service.py:203),student_msg / evaluation 都未落库;S3 key {sessionId}/round_{round}.webm 重传覆盖,无脏数据。
后端改动(_evaluate_pronunciation):加 1 次重试
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 轮自动被跳过,无需额外改。
前端状态机(不依赖后端幂等):
idle → recording → [点击 finish] → submitting → [SSE done] → idle
↑ 期间所有点击无效
recording → 收到 finish 点击:立即置 submitting,按钮禁用submitting 期间任何点击丢弃done 事件到达才切回 idle / 转 report| 场景 | 处理 |
|---|---|
/session 创建断网 |
toast + 手动重试;未进入对话 UI |
/speak SSE 流中断 |
toast "网络异常,请刷新重试" → 用户手动刷新 → 走 B1 resume,从服务端真相重建 |
/report 轮询断 |
指数退避自动重试(见 §8) |
| idle 时断网 | 无感,下次操作才发现 |
不自动重试 /speak:后端 commit 在 LLM stream 之后(dialogue_service.py:185),自动重试可能造成重复轮次。让刷新走 resume,状态由服务端真相决定。
结束后调 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 的评分块留空),不阻塞用户阅读历史对话。
产品决策:第 N 轮(N = totalRounds)以学生回应作为结束,不触发 AI 回复。
后端改动(dialogue_service.py::speak,约 line 160-175):
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* → donetranscript → done(无 token)前端状态机:监听 SSE 时,收到 done 前未收到任何 token → "最后一轮"路径:保留学生 transcript 气泡,不渲染"AI 正在说",1.5s 过渡(显示"正在生成报告...")→ 进入结果页 + 启动轮询。
single-round session(total_rounds = 1):AI 开场白(create_session 时已生成)→ 学生说一轮 → 无 AI 回复 → 直接结束。自洽。
| 副作用 | 异常回滚行为 |
|---|---|
| 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 自动重连。
| 改动 | 位置 | 必须? |
|---|---|---|
_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 |
已确认,保持现状 | — |
| 改动 | 位置 |
|---|---|
定义 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 |
上一次提交(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)先不动,实现到对应环节再决定。
| 改动 | 位置 |
|---|---|
| 识别 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))— 无需新增 |
— |
asyncio.create_task(self._evaluate_pronunciation(...)) 是 fire-and-forget。若 uvicorn 进程在评分完成前重启 / 崩溃,那一轮评分会永远 status='pending',/report 不会生成 summary。MVP 接受;未来应改为持久化任务队列(Celery / RQ / DB polling)。/speak 会得到 done + isComplete:true(录音被丢弃)。后端 expires_at 是唯一真相。/speak 失败时 S3 文件可能已上传,不自动清理;同 key 会被重试覆盖,实际无害。addCourseWorks_workPage 提交模式:pbl-teacher-table/src/components/pages/workPage/index.vue:327, 394-408submitWork 包装 + aichat S3 快照模式:PPT/src/views/Student/index.vue:1665-1849homework_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-280cococlass-english-speaking-api/app/service/speaking/dialogue_service.py:321-352enspeak/src/components/dialogue/DialogueChatView.tsx:200, 292-315enspeak/src/components/dialogue/DialogueResult.tsxPPT/src/views/Screen/ScreenSlideList.vue:27/Users/buoy/Development/gitrepo/interview/network/polling.mdDetailedReport.tsx + OverallReport.tsx 移植(布局是否需要压缩为嵌入式等实现阶段决定)mode='preview' 下的 mock 数据/直通策略showEnglishText / showChineseText / showTaskHint / showSilenceHint 几个展示开关的默认值由产品决定API_BASE 配置(llmService.ts:3 当前是 http://localhost:8000,需改环境变量)isVisible 的具体传递链路(ScreenSlide 已经把 is-visible 传给子组件,DialogueChatView 接收方式到实现阶段确认)