design.md 6.8 KB

Context

当前 DialogueChatView.vue 是一个 1041 行的单文件组件,所有对话逻辑(状态管理、录音模拟、评估生成、UI 渲染)全部内联在一个文件中,使用硬编码的 dialogueScript 数组驱动对话轮次。

现有架构问题:

  • 无真实录音能力,录音按钮仅切换 isRecording 布尔值
  • AI 回复来自本地脚本数组,无 LLM 调用
  • 评估数据由 generateMockEvaluation() 随机生成
  • 无异常处理(网络断开、请求超时、消息重发)
  • 无消息状态管理(无法区分消息是发送中、流式中还是失败)
  • UI 和业务逻辑完全耦合,难以测试和维护

技术约束:

  • 前端:Vue 3 + TypeScript + Composition API,优先使用浏览器原生 API
  • 后端:独立项目 cococlass-english-speaking-api(Python + FastAPI)
  • LLM 通过 One-Hub 统一网关调用(Chat Completions API)
  • ASR 和发音评估使用 Azure Speech

Goals / Non-Goals

Goals:

  • 将对话引擎从 UI 组件中解耦为独立的 composable + service 三层架构
  • 实现完整的消息状态机(loading / done / error)
  • 支持 LLM 流式输出(fetch streaming),逐 token 渲染
  • 实现异常处理:请求失败 → 显示重试按钮 → 重新发送
  • 封装录音采集(MediaRecorder)为独立 composable,含权限处理
  • 设计 API 抽象层,支持 mock adapter 和真实 API adapter 无缝切换
  • 后端实现对话编排服务,提供 session/speak/report 三个接口

Non-Goals:

  • 不重新设计 UI 外观——保留现有的视觉样式,仅重构逻辑层
  • 不涉及离线模式或本地缓存
  • 不做自动重试 / 指数退避(v1 先不做,反馈后迭代)
  • 不做服务端 TTS(v1 用前端 Web Speech API)

前端架构

Decision 1: 三层架构(Service → Composable → Component)

选择: llmService(通信层)→ useDialogueEngine(状态编排层)→ DialogueChatView(UI 层)

理由:

  • Service 层不依赖 Vue,可单元测试,也可在其他场景复用
  • Composable 层用 Vue 的 ref/reactive 管理状态
  • Component 层变为纯粹的展示层

Decision 2: 使用 fetch + ReadableStream

选择: fetch() + response.body.getReader() 实现流式读取

理由: EventSource 只支持 GET,fetch streaming 支持 POST + AbortController + 自定义 headers

Decision 3: 消息状态简化为 3 个

选择: loading | done | error,不细分内部阶段

理由:

  • 用户不关心是 ASR 阶段还是 LLM 阶段,只关心"在处理/好了/出错了"
  • 错误处理统一:出错 → 显示重试按钮 → 重新调 /speak
  • 第一版先简单,反馈后再迭代更精细的状态

Decision 4: 录音使用浏览器原生 MediaRecorder API

选择: 直接封装 MediaRecorder,不引入第三方录音库

理由: 现代浏览器兼容性已足够,避免额外依赖

Decision 5: API Adapter 对接后端 3 个接口

选择: DialogueAPI 接口对应后端的 session / speak / report

interface DialogueAPI {
  createSession(config: SessionConfig): Promise<SessionInfo>
  speak(sessionId: string, audioBlob: Blob, signal: AbortSignal): AsyncGenerator<SSEEvent>
  getReport(sessionId: string): Promise<Report>
}

实现: MockDialogueAPI(开发用)和 RealDialogueAPI(对接真实后端) 通过前端 prop mode: 'preview' | 'real' 切换

Decision 6: TTS 使用前端 Azure TTS

选择: AI 消息 done 后,前端整段文本调用 Azure TTS 合成语音并播放

理由:

  • 前端已通过 SSE 接收完整文本,直接调 Azure TTS 最简单
  • AI 回复限定 1-2 句(system prompt 约束),不需要分句 TTS
  • 不做服务端 TTS,后端不涉及语音合成

后端架构

项目:cococlass-english-speaking-api

独立编排服务,串联 Azure Speech 和 One-Hub,管理对话 session 状态。

前端
  │
  ▼
cococlass-english-speaking-api(编排层)
  ├──→ Azure Speech     ASR + 发音评估
  ├──→ One-Hub          LLM 流式对话(Chat Completions)
  ├──→ MySQL            session + 消息 + 评估结果
  └──→ S3               音频文件存储

技术栈

选择
框架 FastAPI + uvicorn
LLM Chat Completions via One-Hub(openai SDK)
ASR Azure Speech(纯转录模式)
发音评估 Azure Speech Pronunciation Assessment(后台)
SSE FastAPI 原生 EventSourceResponse + ServerSentEvent
数据库 MySQL + asyncmy + SQLAlchemy 2.0
存储 S3(boto3)
配置 pydantic-settings + python-dotenv

Decision 7: 不使用 Assistants API

理由: 口语对话是简单一问一答,不需要 Thread/Run 机制。对话历史要存自己 DB,Assistants 的 Thread 管理是重复功能。

Decision 8: 后端统一编排,前端只发一个请求

理由: 前端不应知道后端内部有几步(ASR/LLM/评估)。音频只上传一次,错误处理统一。

Decision 9: 对话中不展示评分

理由: 保证练习流畅度。发音评估后台静默执行,结果页才展示。

Decision 10: Protocol + 依赖注入

ASRProvider / LLMProvider / PronunciationAssessor / AudioStorage 四个 Protocol 接口,具体实现可替换可测试。

Decision 11: 不用 Redis

理由: 3 轮对话只有 3 次 DB 查询,不值得加缓存层。

API 设计

POST /api/speaking/dialogue/session   → JSON { sessionId, aiMessage }
POST /api/speaking/dialogue/speak     → SSE { transcript, token, done }
GET  /api/speaking/dialogue/report    → JSON { 对话历史 + 评分 + LLM 总结 }

数据模型

  • DialogueSession: id, user_id, topic, role_config, total_rounds, current_round, status, system_prompt, expires_at, summary, created_at, completed_at
  • DialogueMessage: id, session_id, round, role, content, audio_url, created_at
  • PronunciationEvaluation: id, message_id, session_id, round, status, accuracy/fluency/completeness/prosody scores, word_analysis, created_at, completed_at

Risks / Trade-offs

  • [MediaRecorder 格式兼容性] → Safari 不支持 webm,需 fallback 到 mp4/wav
  • [流式解析差异] → 在 adapter 层做格式解析,engine 层只关心 token string
  • [录音权限拒绝] → composable 暴露 permissionState,UI 显示引导提示
  • [内存泄漏] → 在 composable 的 onUnmounted 中统一清理录音 Blob
  • [后台评估未完成] → 前端轮询 GET /report(每 2 秒),30 秒超时后展示已有结果,缺失的标记为"评估不可用"。评估记录在 speak 主流程中创建(保证记录一定存在),后台任务只负责更新结果
  • [定时结束] → 后端通过 expires_at 判断,/speak 时检查是否超时。前端用 expires_at 显示倒计时,不再是纯前端控制