|
@@ -0,0 +1,339 @@
|
|
|
|
|
+# 音频真实时长 + DetailedReport 重播 设计文档
|
|
|
|
|
+
|
|
|
|
|
+**Date**: 2026-05-07
|
|
|
|
|
+**Status**: Draft
|
|
|
|
|
+**Touches**: `cococlass-english-speaking-api`(后端)+ `PPT`(前端)
|
|
|
|
|
+
|
|
|
|
|
+## 1. 问题陈述
|
|
|
|
|
+
|
|
|
|
|
+口语对话功能里,学生 / AI 语音泡泡的时长在多处呈现,但都是写死或假兜底:
|
|
|
|
|
+
|
|
|
|
|
+| 表面 | 当前实现 | 现状 |
|
|
|
|
|
+|---|---|---|
|
|
|
|
|
+| `DialogueChatView` 学生泡泡 | 模板写死 `0:04` | 假数据 |
|
|
|
|
|
+| `DialogueChatView` AI 泡泡 | 模板写死 `0:04` | 假数据 |
|
|
|
|
|
+| `DetailedReport` 学生卡片 | `audioDuration \|\| 3` 兜底 | 字段类型存在但后端不下发,永远是 `0:03` |
|
|
|
|
|
+| `DetailedReport` AI 卡片 | 同上 | 同上 |
|
|
|
|
|
+| `DetailedReport` 学生卡片 重播 | `<button @click.stop>` 无 handler | 装饰,无功能 |
|
|
|
|
|
+| `DetailedReport` AI 卡片 重播 | 同上 | 装饰,无功能 |
|
|
|
|
|
+
|
|
|
|
|
+根因:后端 `dialogue_message` 表只存 `audio_url`,从不计算/存储音频时长;前端 `DetailedReport` 也没接 `useAudioPlayer`。
|
|
|
|
|
+
|
|
|
|
|
+## 2. 设计目标
|
|
|
|
|
+
|
|
|
|
|
+- **学生消息**全部 surface 显示真实时长(实时泡泡 + 历史泡泡 + 报告卡片),**跨刷新可恢复**。
|
|
|
|
|
+- **AI 消息**不显示时长(无 audio_url 可存、无真值可拿)。
|
|
|
|
|
+- **DetailedReport 学生卡片可重播**(点 play 按钮 → 加载 audioUrl → 播放)。
|
|
|
|
|
+- **AI 卡片不接重播**(quota 不可控,文字内容已足够)。
|
|
|
|
|
+- 老数据 `audio_duration IS NULL` 时前端显示 `--:--`,不阻塞 UI。
|
|
|
|
|
+- **后端是 audioDuration 唯一来源**,避免双源数据漂移。
|
|
|
|
|
+
|
|
|
|
|
+## 3. 数据流总览
|
|
|
|
|
+
|
|
|
|
|
+| Surface | 时长来源 | 备注 |
|
|
|
|
|
+|---|---|---|
|
|
|
|
|
+| `DialogueChatView` 学生泡泡(实时刚录)| 后端 `/speak` 的 SSE `transcript` 事件回传 `audioDuration` | 后端 transcript+duration 一起到,voice-bar 出现时即有值,**无 UX 延迟**(见 §4) |
|
|
|
|
|
+| `DialogueChatView` 学生泡泡(刷新后历史)| 后端 `/sessions/latest` 下发的 `audioDuration` | 跨刷新可用 |
|
|
|
|
|
+| `DialogueChatView` AI 泡泡 | **不显示**(删 span) | 无 audio_url、TTS 即时合成 |
|
|
|
|
|
+| `DetailedReport` 学生卡片 | 后端 `/report` 下发的 `audioDuration` | 报告页只在会话结束后看 |
|
|
|
|
|
+| `DetailedReport` AI 卡片 | **不显示**(删 span) | 同上 |
|
|
|
|
|
+
|
|
|
|
|
+**真值产生**(后端):`/speak`(HTTP)和 `/speak-stream`(WebSocket)接到学生 WAV 时,从 RIFF header 解析出秒数,跟 `audio_url` 一并写入 `dialogue_message.audio_duration`。
|
|
|
|
|
+
|
|
|
|
|
+**老数据**:忽略(D)。已存在的会话 `audio_duration` 保持 NULL,前端显示 `--:--`。**不写回填脚本**——尚在开发阶段,老用户数据无回填价值。
|
|
|
|
|
+
|
|
|
|
|
+## 4. 单一数据源(消除双源风险)
|
|
|
|
|
+
|
|
|
|
|
+学生实时录音的 audioDuration **不在前端 recorder 计算**,由后端 `/speak` 的 `transcript` 事件回传。
|
|
|
|
|
+
|
|
|
|
|
+**为什么这条路 UX 无代价**:
|
|
|
|
|
+`DialogueChatView.vue:132` 学生 voice-bar 模板带条件 `v-if="message.content || message.status !== 'loading'"`——录完点 stop 后到 transcript 事件到达之间,voice-bar **本来就不渲染**(学生 typing-bubble 占位)。transcript 事件到达 → message.content 填入 → voice-bar 第一次渲染。把 audioDuration 一起塞进 transcript 事件,时长在 voice-bar 出现的同一帧就有值,视觉上等同"立即显示"。
|
|
|
|
|
+
|
|
|
|
|
+**为什么不用 recorder 端计算**:
|
|
|
|
|
+- 双源(recorder `totalSamples / audioContext.sampleRate` + 后端 WAV header)公式相同,但若 `encodeWAV` 写入的 sampleRate 与 `audioContext.sampleRate` 不一致(比如未来加 resampling),会出现"实时显示 X 秒、刷新后显示 Y 秒"的诡异 bug。
|
|
|
|
|
+- 单源后端:所有 surface 同一个值,无漂移可能。
|
|
|
|
|
+
|
|
|
|
|
+**`regenerate` 分支**:复用已存在的 student message,message.audioDuration 早已写入;`regenerate` 路径不重复 push student、不需要新值。无影响。
|
|
|
|
|
+
|
|
|
|
|
+## 5. 命名约定
|
|
|
|
|
+
|
|
|
|
|
+**全链路统一字段名 `audioDuration` / `audio_duration`,无单位后缀**——值的单位是"秒"(float)。
|
|
|
|
|
+
|
|
|
|
|
+- 后端列名 `audio_duration`(snake_case)
|
|
|
|
|
+- JSON 字段 `audioDuration`(camelCase)
|
|
|
|
|
+- 前端类型:
|
|
|
|
|
+ - `HistoricalDialogueMessage.audioDuration?: number | null`
|
|
|
|
|
+ - `PreviewChatMessage.audioDuration?: number`
|
|
|
|
|
+ - `BackendRound.audioDuration?: number | null`
|
|
|
|
|
+ - `SentenceEvaluation.audioDuration` 已存在(types/englishSpeaking.ts:147),保持
|
|
|
|
|
+
|
|
|
|
|
+理由:`SentenceEvaluation.audioDuration` 已是这个名字;统一命名避免一份数据三种叫法。**禁止用 `durationSec` / `audioDurationSec`**。
|
|
|
|
|
+
|
|
|
|
|
+## 6. 后端改动(`cococlass-english-speaking-api`)
|
|
|
|
|
+
|
|
|
|
|
+### 6.1 DB Migration
|
|
|
|
|
+`migrations/2026-05-07-add-dialogue-message-audio-duration.sql`:
|
|
|
|
|
+```sql
|
|
|
|
|
+USE speaking;
|
|
|
|
|
+
|
|
|
|
|
+ALTER TABLE dialogue_message
|
|
|
|
|
+ADD COLUMN audio_duration FLOAT NULL AFTER audio_url;
|
|
|
|
|
+```
|
|
|
|
|
+Nullable、无默认值——老数据保持 NULL。
|
|
|
|
|
+
|
|
|
|
|
+### 6.2 Model
|
|
|
|
|
+`app/models/dialogue.py:DialogueMessage` 加:
|
|
|
|
|
+```python
|
|
|
|
|
+audio_duration: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 6.3 WAV 时长解析(防御性工具函数)
|
|
|
|
|
+新建 `app/service/speaking/audio_utils.py`:
|
|
|
|
|
+```python
|
|
|
|
|
+import io
|
|
|
|
|
+import logging
|
|
|
|
|
+import struct
|
|
|
|
|
+import wave
|
|
|
|
|
+
|
|
|
|
|
+logger = logging.getLogger(__name__)
|
|
|
|
|
+
|
|
|
|
|
+def parse_wav_duration(audio_bytes: bytes) -> float | None:
|
|
|
|
|
+ """
|
|
|
|
|
+ Parse a WAV bytes blob and return its duration in seconds.
|
|
|
|
|
+
|
|
|
|
|
+ Returns None on any of:
|
|
|
|
|
+ - empty / too-short bytes
|
|
|
|
|
+ - non-RIFF / non-WAVE magic
|
|
|
|
|
+ - corrupt header / parse exception
|
|
|
|
|
+ - sampleRate <= 0
|
|
|
|
|
+
|
|
|
|
|
+ Defensive: NEVER raises. Duration is a non-essential UX field; the
|
|
|
|
|
+ callsite must not let a parse failure block audio_url persistence.
|
|
|
|
|
+ """
|
|
|
|
|
+ if len(audio_bytes) < 44:
|
|
|
|
|
+ return None
|
|
|
|
|
+ if audio_bytes[:4] != b"RIFF" or audio_bytes[8:12] != b"WAVE":
|
|
|
|
|
+ return None
|
|
|
|
|
+ try:
|
|
|
|
|
+ with wave.open(io.BytesIO(audio_bytes), "rb") as wf:
|
|
|
|
|
+ frames = wf.getnframes()
|
|
|
|
|
+ rate = wf.getframerate()
|
|
|
|
|
+ if rate <= 0 or frames < 0:
|
|
|
|
|
+ return None
|
|
|
|
|
+ return frames / rate
|
|
|
|
|
+ except (wave.Error, EOFError, struct.error, ValueError) as e:
|
|
|
|
|
+ logger.warning(f"parse_wav_duration failed: {e}")
|
|
|
|
|
+ return None
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**关键约束**:
|
|
|
|
|
+- 先验 RIFF/WAVE magic bytes(防止协议未来切到 webm/opus 时炸库)
|
|
|
|
|
+- 任何异常都吞掉、返回 None、记 warning 不记 error
|
|
|
|
|
+- 调用方写入失败不阻塞 audio_url 入库
|
|
|
|
|
+
|
|
|
|
|
+### 6.4 写入点(两处)
|
|
|
|
|
+**HTTP `/speak`**(`dialogue_service.py:527-535` 附近):
|
|
|
|
|
+```python
|
|
|
|
|
+audio_duration = parse_wav_duration(audio_bytes)
|
|
|
|
|
+student_msg = DialogueMessage(
|
|
|
|
|
+ ...
|
|
|
|
|
+ audio_url=audio_url,
|
|
|
|
|
+ audio_duration=audio_duration,
|
|
|
|
|
+ ...
|
|
|
|
|
+)
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**WebSocket `/speak-stream`**(`api/dialogue.py:545` 附近):累积完整段 WAV bytes 后同样调 `parse_wav_duration` 写入。具体行号待 PLAN 阶段定位。
|
|
|
|
|
+
|
|
|
|
|
+### 6.5 输出点(三处)
|
|
|
|
|
+
|
|
|
|
|
+**6.5.1 `/speak` SSE `transcript` 事件**(`dialogue_service.py:525` / `:499`)
|
|
|
|
|
+
|
|
|
|
|
+当前 yield:
|
|
|
|
|
+```python
|
|
|
|
|
+yield ("transcript", {"text": transcript, "round": current_round})
|
|
|
|
|
+```
|
|
|
|
|
+改为:
|
|
|
|
|
+```python
|
|
|
|
|
+yield ("transcript", {
|
|
|
|
|
+ "text": transcript,
|
|
|
|
|
+ "round": current_round,
|
|
|
|
|
+ "audioDuration": audio_duration, # 可能为 None
|
|
|
|
|
+})
|
|
|
|
|
+```
|
|
|
|
|
+两个 yield 点(partial hit `:499` 和 full miss `:525`)都要加;partial hit 时 `audio_duration` 从 `student_row.audio_duration` 取(已入库)。
|
|
|
|
|
+
|
|
|
|
|
+**6.5.2 `/sessions/latest`**(`dialogue_service.py:689` 附近):
|
|
|
|
|
+```python
|
|
|
|
|
+{
|
|
|
|
|
+ ...
|
|
|
|
|
+ "audioUrl": msg.audio_url,
|
|
|
|
|
+ "audioDuration": msg.audio_duration, # NEW
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**6.5.3 `/report`**(`dialogue_service.py:927` 附近):同样加 `"audioDuration": msg.audio_duration`。
|
|
|
|
|
+
|
|
|
|
|
+**6.5.4 WebSocket `/speak-stream` `transcript` 事件**:与 HTTP §6.5.1 协议对齐——WS 的 transcript 事件 payload 也加 `"audioDuration": audio_duration`,前端只写一个 transcript handler 即可。
|
|
|
|
|
+
|
|
|
|
|
+### 6.6 后端 unit test
|
|
|
|
|
+新建 `tests/test_audio_utils.py`,覆盖 5 种 case:
|
|
|
|
|
+1. 合法 WAV(mock 一段 1s 16kHz 单声道 silent WAV)→ duration ≈ 1.0
|
|
|
|
|
+2. 空 bytes → None
|
|
|
|
|
+3. 非 RIFF magic(如 `b"OPUS....."` 开头)→ None
|
|
|
|
|
+4. 截断 WAV(前 30 字节)→ None
|
|
|
|
|
+5. sampleRate=0 的恶意 WAV → None
|
|
|
|
|
+
|
|
|
|
|
+## 7. 前端改动(`PPT`)
|
|
|
|
|
+
|
|
|
|
|
+### 7.1 类型扩展(`src/types/englishSpeaking.ts`)
|
|
|
|
|
+```ts
|
|
|
|
|
+// HistoricalDialogueMessage 加:
|
|
|
|
|
+audioDuration?: number | null
|
|
|
|
|
+
|
|
|
|
|
+// PreviewChatMessage 加:
|
|
|
|
|
+audioDuration?: number
|
|
|
|
|
+
|
|
|
|
|
+// BackendRound (在 services/llmService.ts 内部) 加:
|
|
|
|
|
+audioDuration?: number | null
|
|
|
|
|
+```
|
|
|
|
|
+`SentenceEvaluation.audioDuration` 已存在,**不动**。
|
|
|
|
|
+
|
|
|
|
|
+### 7.2 SSE 事件类型扩展(`services/llmService.ts`)
|
|
|
|
|
+`SSEEvent` 的 `transcript` 分支加 `audioDuration` 字段:
|
|
|
|
|
+```ts
|
|
|
|
|
+| { type: 'transcript'; text: string; audioDuration?: number | null }
|
|
|
|
|
+```
|
|
|
|
|
+parser 把后端 JSON 的 `audioDuration` 字段透传出来。
|
|
|
|
|
+
|
|
|
|
|
+### 7.3 `useAudioRecorder.ts` —— **不动签名**
|
|
|
|
|
+`stopRecording()` 维持 `Promise<Blob>`。recorder 端不计算 audioDuration。
|
|
|
|
|
+
|
|
|
|
|
+### 7.4 `useAudioPlayer.ts` 扩 URL 模式
|
|
|
|
|
+```ts
|
|
|
|
|
+export type PlaySource =
|
|
|
|
|
+ | { kind: 'tts'; text: string }
|
|
|
|
|
+ | { kind: 'blob'; blob: Blob }
|
|
|
|
|
+ | { kind: 'url'; url: string } // NEW
|
|
|
|
|
+
|
|
|
|
|
+// play() 内部,url 分支:
|
|
|
|
|
+if (source.kind === 'url') {
|
|
|
|
|
+ const res = await fetch(source.url, { signal: ... })
|
|
|
|
|
+ if (!res.ok) throw new Error(`fetch audio failed: ${res.status}`)
|
|
|
|
|
+ blob = await res.blob()
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+共用现有 `currentAudio` / 错误处理 / stop 逻辑。可加可选缓存(`urlCache: Map<id, Blob>`)避免重复 fetch,但**首版不加**——audioUrl 通常是 CDN,第二次浏览器自带缓存命中。
|
|
|
|
|
+
|
|
|
|
|
+### 7.5 `useDialogueEngine.ts`
|
|
|
|
|
+- `sendStudentMessage` 签名**不变**。在**两个** push student 的位置(HTTP `:118-126` 和 WebSocket `:354-363`)后续的 transcript 事件 handler 各加一行写入:
|
|
|
|
|
+ - HTTP SSE handler(`:144` 附近的 `if (event.type === 'transcript')`):
|
|
|
|
|
+ ```ts
|
|
|
|
|
+ if (event.type === 'transcript') {
|
|
|
|
|
+ studentMsg.content = event.text
|
|
|
|
|
+ studentMsg.status = 'done'
|
|
|
|
|
+ if (event.audioDuration != null) studentMsg.audioDuration = event.audioDuration
|
|
|
|
|
+ ...
|
|
|
|
|
+ }
|
|
|
|
|
+ ```
|
|
|
|
|
+ - WebSocket onmessage handler(`:425` 附近的 `if (data.type === 'transcript')`):同样加 `if (data.audioDuration != null) studentMsg.audioDuration = data.audioDuration`。
|
|
|
|
|
+- `regenerate` 路径**不需要改**——它只 push AI,不重新 push student;prevStudent.audioDuration 早已写入并保留。
|
|
|
|
|
+- `toPreviewMessage()`(`:33-43`)把 `message.audioDuration` 映射进 `PreviewChatMessage.audioDuration`(覆盖刷新后历史消息):
|
|
|
|
|
+ ```ts
|
|
|
|
|
+ audioDuration: message.audioDuration ?? undefined,
|
|
|
|
|
+ ```
|
|
|
|
|
+
|
|
|
|
|
+### 7.6 `llmService.ts:adaptReport`(`:127-149`)
|
|
|
|
|
+```ts
|
|
|
|
|
+return {
|
|
|
|
|
+ ...
|
|
|
|
|
+ audioUrl: r.audioUrl ?? undefined,
|
|
|
|
|
+ audioDuration: r.audioDuration ?? undefined, // NEW
|
|
|
|
|
+ ...
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 7.7 `DialogueChatView.vue`
|
|
|
|
|
+- `handleStopRecording`(`:778-783`):**不动**(recorder 接口不变)
|
|
|
|
|
+- 模板学生泡泡(`:139-140`):
|
|
|
|
|
+ ```vue
|
|
|
|
|
+ <span class="voice-duration voice-duration-student">
|
|
|
|
|
+ {{ formatDuration(message.audioDuration) }}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ ```
|
|
|
|
|
+- 模板 AI 泡泡(`:98-101`):**整段删掉** `<span class="voice-duration voice-duration-ai">`。
|
|
|
|
|
+- `formatDuration(seconds: number | null | undefined): string`(在 `<script setup>` 内部新增):
|
|
|
|
|
+ ```ts
|
|
|
|
|
+ function formatDuration(seconds: number | null | undefined): string {
|
|
|
|
|
+ if (seconds == null || !Number.isFinite(seconds)) return '--:--'
|
|
|
|
|
+ const total = Math.round(seconds)
|
|
|
|
|
+ const mins = Math.floor(total / 60)
|
|
|
|
|
+ const secs = total % 60
|
|
|
|
|
+ return `${mins}:${String(secs).padStart(2, '0')}`
|
|
|
|
|
+ }
|
|
|
|
|
+ ```
|
|
|
|
|
+
|
|
|
|
|
+### 7.8 `DetailedReport.vue`
|
|
|
|
|
+- 引入 `useAudioPlayer` 实例(组件内部独立)
|
|
|
|
|
+- 学生卡片 `card-play-btn`(`:33`):
|
|
|
|
|
+ ```vue
|
|
|
|
|
+ <button
|
|
|
|
|
+ class="card-play-btn play-student"
|
|
|
|
|
+ :disabled="!sentence.audioUrl"
|
|
|
|
|
+ @click.stop="handleSentencePlay(sentence)"
|
|
|
|
|
+ >
|
|
|
|
|
+ ```
|
|
|
|
|
+ 其中:
|
|
|
|
|
+ ```ts
|
|
|
|
|
+ function handleSentencePlay(s: SentenceEvaluation) {
|
|
|
|
|
+ if (!s.audioUrl) return
|
|
|
|
|
+ player.play(s.id, { kind: 'url', url: s.audioUrl })
|
|
|
|
|
+ }
|
|
|
|
|
+ ```
|
|
|
|
|
+- AI 卡片:**整个 `<button class="card-play-btn">` 删掉**,只保留波形 + 文本。
|
|
|
|
|
+- 时长 span(`:48-50`):
|
|
|
|
|
+ - 学生:`{{ formatDuration(sentence.audioDuration) }}`,无值显示 `--:--`
|
|
|
|
|
+ - AI:删除整个 `<span class="card-duration">`
|
|
|
|
|
+- `formatDuration` 同 7.7 的实现,加 null 兜底。
|
|
|
|
|
+
|
|
|
|
|
+## 8. 风险 & 缓解
|
|
|
|
|
+
|
|
|
|
|
+| 风险 | 缓解 |
|
|
|
|
|
+|---|---|
|
|
|
|
|
+| WAV 解析失败 | RIFF magic + try/except 全包;返回 None 不阻塞入库;warning log |
|
|
|
|
|
+| 协议未来切 webm/opus | RIFF 校验首关命中失败即 None,不会异常 |
|
|
|
|
|
+| 前端老数据 `audioDuration` 是 null | `formatDuration` null 兜底返回 `--:--`;模板 `?.` 链不崩 |
|
|
|
|
|
+| Migration 顺序 | 后端必须先部署(标准 backward-compatible nullable add) |
|
|
|
|
|
+| TTS quota(AI 重播)| 范围内**完全不接 AI 重播**,DetailedReport AI 卡片纯文字 |
|
|
|
|
|
+| WS `/speak-stream` 路径漏改 | 设计明确两个写入点 + done 事件输出点都要改;执行时按 PLAN 任务清单逐个跟进 |
|
|
|
|
|
+| `/speak` partial hit 路径漏 audioDuration | partial hit 时从已入库的 `student_row.audio_duration` 取(spec §6.5.1 明确)|
|
|
|
|
|
+
|
|
|
|
|
+## 9. 验收标准
|
|
|
|
|
+
|
|
|
|
|
+1. 在 dev 环境录一条学生消息,泡泡显示真实时长(不是 0:04)。
|
|
|
|
|
+2. 录完不刷新继续录第二条 → 两条都显示各自真实时长。
|
|
|
|
|
+3. 不重启会话直接刷新页面,历史消息回填后泡泡时长仍正确显示。
|
|
|
|
|
+4. 跑完整对话进入 DetailedReport:学生卡片显示真时长 + 点 play 能听到学生录音;AI 卡片只显示文字,无时长、无按钮。
|
|
|
|
|
+5. 用浏览器打开**升级前**创建的旧会话历史,所有消息时长显示 `--:--`,无报错。
|
|
|
|
|
+6. 后端 `parse_wav_duration` unit test 全过(5 种 case)。
|
|
|
|
|
+7. 测一次 `/speak` partial hit(重发同一条)→ 时长仍正确返回。
|
|
|
|
|
+8. WebSocket `/speak-stream` 路径:录一条走 WS(默认 transport 就是 WS),泡泡时长正确。
|
|
|
|
|
+
|
|
|
|
|
+## 10. 不在范围内
|
|
|
|
|
+
|
|
|
|
|
+- AI 消息时长(无 audio_url 可存、无真值,删除显示)
|
|
|
|
|
+- AI 重播(quota 不可控,DetailedReport AI 卡片不接)
|
|
|
|
|
+- 老数据回填(D 选择,开发阶段无价值)
|
|
|
|
|
+- AI bubble 在 DialogueChatView 的播放体验改造(已可工作,不动)
|
|
|
|
|
+- 自动化 E2E 测试(手动验证)
|
|
|
|
|
+- `useAudioPlayer.url` 模式的 blob 缓存(首版不加,依赖浏览器 CDN 缓存)
|
|
|
|
|
+
|
|
|
|
|
+## 11. 实施顺序
|
|
|
|
|
+
|
|
|
|
|
+1. **后端 migration**(独立 commit)
|
|
|
|
|
+2. **后端 model + audio_utils + 写入点(HTTP+WS)+ 输出点(SSE/sessions-latest/report)+ unit test**(一个 commit)
|
|
|
|
|
+3. **前端类型补字段**(services + types,独立 commit)
|
|
|
|
|
+4. **前端 useAudioPlayer URL 模式 + useDialogueEngine 写入 audioDuration + adaptReport**(一个 commit)
|
|
|
|
|
+5. **前端 DialogueChatView 模板**(commit)
|
|
|
|
|
+6. **前端 DetailedReport 模板 + 接 player**(commit)
|
|
|
|
|
+7. **手动验收 8 条**
|