Files
CamTalk/docs/13-结束视频保留对话设计方案.md
cfy666 ae9a27c300 feat: 结束视频后保留对话,支持继续文字聊天
- useVisionSession 新增 stopVideo 回调(停媒体流,保持连接和消息)
- App.tsx 控制区从二态改为三态(initial/video/textOnly)
- 文字对话态显示「重新开始视频」和「结束会话」按钮
- 新增 CSS 样式(video-ended-hint、btn--outline)
- 新增 i18n key(stopVideo/endSession/resumeVideo/video.ended)
- 新增设计方案文档
2026-06-20 13:48:26 +08:00

289 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 结束视频后保留对话并支持继续文字聊天
> 创建日期2026-06-20
> 状态:草案
## 1. 背景与目标
### 1.1 现状问题
当前点击"结束对话"按钮会执行完整的 teardown 流程:
1. 停止 VAD、麦克风、摄像头
2. 断开 WebSocket 连接
3. **清空所有聊天消息**`setMessages([])`
4. **清空对话历史**`historyRef.current = []`
5. 重置统计数据
6. UI 切回初始界面(显示"开始视频通话"按钮)
**问题**:用户想结束视频通话后,保留聊天记录并继续通过文字输入对话,但当前实现会丢失所有对话内容。
### 1.2 目标
| 目标 | 说明 |
|------|------|
| 结束视频后保留对话 | 点击"结束视频"后,聊天记录保持不变 |
| 支持继续文字对话 | 视频结束后,用户可通过文字输入继续与 AI 对话 |
| 可恢复视频 | 视频结束后,用户可随时重新开启视频 |
| 完全结束可选 | 提供"结束会话"选项,彻底断开并清空 |
## 2. 状态设计
### 2.1 三态模型
引入三个会话状态,替代当前的二态(初始/通话)模型:
```
┌──────────┐ startSession() ┌──────────┐
│ initial │ ──────────────────→ │ video │
│ 初始态 │ │ 视频通话 │
└──────────┘ └──────────┘
↑ │
│ stopVideo()
│ │
│ ▼
│ ┌──────────┐
│ stopSession() │ textOnly │
└──────────────────────── │ 文字对话 │
└──────────┘
startSession()
┌──────────┐
│ video │
│ 视频通话 │
└──────────┘
```
### 2.2 各状态属性
| 状态 | WebSocket | 摄像头 | 麦克风 | VAD | 消息 | 文字输入 |
|------|-----------|--------|--------|-----|------|---------|
| `initial` | 断开 | 关闭 | 关闭 | 停止 | 空 | 可用(自动连接) |
| `video` | 连接 | 开启 | 开启 | 运行 | 有 | 可用 |
| `textOnly` | 连接 | 关闭 | 关闭 | 停止 | 保留 | 可用 |
### 2.3 派生状态
当前代码中 `isConnected` 是从 `connectionStatus === "connected"` 派生的布尔值。为支持三态,新增派生变量:
```ts
// 是否在会话中video 或 textOnly
const hasSession = isConnected || (connectionStatus === "disconnected" && messages.length > 0);
```
> **注意**`textOnly` 状态下 WebSocket 保持连接(`isConnected === true`),所以 `hasSession` 实际上主要靠 `isConnected` 判断。只有在 textOnly 状态下 WebSocket 异常断开时,`messages.length > 0` 才作为兜底。
## 3. 详细设计
### 3.1 `useVisionSession.ts` 改动
#### 3.1.1 新增 `stopVideo` 回调
只停止媒体流,保持 WebSocket 连接和消息:
```ts
/** 结束视频,保留聊天和连接 */
const stopVideo = useCallback(async () => {
// 1. 停止观察模式
stopObserving();
setMode("dialogue");
// 2. 停止媒体流
await stopVAD();
stopMic();
stopCamera();
// 3. 停止 TTS 播放
ttsPlayerRef.current?.stop();
setIsAudioPlaying(false);
// 4. 重置处理状态(但保留消息和历史)
setCurrentReply("");
setIsProcessing(false);
setIsCameraOn(false);
setIsMicOn(false);
// 注意:以下不执行
// - disconnect() → 保持 WebSocket 连接
// - setMessages([]) → 保留聊天记录
// - historyRef.current=[] → 保留对话历史
// - setStats(...) → 保留统计数据
}, [stopObserving, stopVAD, stopMic, stopCamera]);
```
#### 3.1.2 `stopSession` 保持不变
`stopSession` 仍然执行完全 teardown断开 + 清空),作为"结束会话"使用。
#### 3.1.3 `return` 新增导出
```ts
return {
// ...existing...
stopVideo, // 新增
// ...existing...
};
```
### 3.2 `App.tsx` 改动
#### 3.2.1 解构新增
```ts
const {
// ...existing...
stopVideo, // 新增
// ...existing...
} = useVisionSession(...)
```
#### 3.2.2 视频下方控制区改为三态
当前代码(二态):
```tsx
{!isConnected ? (
/* 初始态 */
) : (
/* 通话态 */
)}
```
改为三态:
```tsx
{!isConnected ? (
/* 初始态:开始按钮 + 设备选择 + 模式切换(不变) */
) : isCameraOn ? (
/* 视频通话态:摄像头/麦克风/识别/打断 + "结束视频" 按钮 + 模式切换 */
) : (
/* 文字对话态:
- "📹 视频已结束" 提示
- "📹 重新开始视频" 按钮
- "结束会话" 按钮
*/
)}
```
#### 3.2.3 按钮变化
**视频通话态**(原"结束对话"改为"结束视频"
```tsx
<button className="btn btn--danger" onClick={stopVideo}>
{tr("controls.stopVideo")}
</button>
```
**文字对话态**(新增):
```tsx
<div className="video-controls__text-only">
<div className="video-ended-hint">
<span>📹 {tr("video.ended")}</span>
<span className="video-ended-hint__sub">{tr("video.ended.hint")}</span>
</div>
<div className="video-controls__toolbar">
<button className="btn btn--primary" onClick={startSession}>
{tr("controls.resumeVideo")}
</button>
<button className="btn btn--danger btn--outline" onClick={stopSession}>
{tr("controls.endSession")}
</button>
</div>
</div>
```
#### 3.2.4 视频预览区
当前已有逻辑:`{!isConnected && !stream && <placeholder>}`。关闭摄像头后 `stream` 为 null自动显示占位符。**无需额外改动**。
但在 `textOnly` 状态下 `isConnected` 为 true所以需要额外判断
```tsx
{(!isConnected || !isCameraOn) && !stream && (
<div className="video-placeholder">
<span className="video-placeholder__icon">📷</span>
<span className="video-placeholder__text">{tr("video.cameraOff")}</span>
<span className="video-placeholder__hint">{tr("video.cameraOff.hint")}</span>
</div>
)}
```
#### 3.2.5 状态栏
`isConnected && stats.queryCount > 0` 改为在 textOnly 状态下也显示:
```tsx
{isConnected && stats.queryCount > 0 && (
<span className="chat-panel-header__stats">
{stats.queryCount} {tr("statusbar.recognitions")}
{stats.totalTokens > 0 && ` · ${stats.totalTokens.toLocaleString()} ${tr("statusbar.tokens")}`}
{` · ${formatTime(elapsed)}`}
</span>
)}
```
> `isConnected` 在 textOnly 状态下为 trueWebSocket 未断开),所以**无需改动**。
### 3.3 i18n 新增
| Key | zh-CN | en-US | ja-JP |
|-----|-------|-------|-------|
| `controls.stopVideo` | `结束视频` | `End Video` | `ビデオ終了` |
| `controls.endSession` | `结束会话` | `End Session` | `セッション終了` |
| `controls.resumeVideo` | `📹 重新开始视频` | `📹 Resume Video` | `📹 ビデオ再開` |
| `video.ended` | `视频已结束` | `Video Ended` | `ビデオ終了` |
| `video.ended.hint` | `您可以继续在下方输入文字对话` | `You can continue chatting below` | `下にテキストを入力して会話を続けることができます` |
### 3.4 CSS 样式
新增 `.video-controls__text-only``.video-ended-hint` 样式,复用现有 `.btn``.video-controls__toolbar` 样式。
## 4. 边界情况处理
### 4.1 textOnly 状态下 WebSocket 异常断开
`sendTextMessage` 已有自动重连逻辑:检测到未连接时,先加入 `pendingMessagesRef`,再调用 `connect()`。重连成功后自动 flush 待发队列。**无需改动**。
### 4.2 textOnly 状态下无摄像头画面
`sendTextMessage``captureFrame()` 在无摄像头时返回 null`dataUrlToBase64(null)` 返回空字符串。服务端 `PipelineInput.ImageData` 为空时History 节点跳过图像构建多模态消息。**无需改动**。
### 4.3 textOnly 状态下刷新页面
消息通过 `localStorage` 持久化(`camtalk:session:<uuid>`),刷新后从 `localStorage` 恢复。但 WebSocket 断开,`isConnected` 为 falseUI 显示初始态。用户可点击"开始视频通话"或直接输入文字。**无需改动**。
### 4.4 textOnly 状态下切换会话
`selectSession` 会先 persist 当前会话消息,然后加载目标会话消息。切换后 `isConnected` 取决于目标会话的 WebSocket 状态。**无需改动**。
### 4.5 textOnly 状态下 TTS 播放
`stopVideo` 已调用 `ttsPlayerRef.current?.stop()` 停止播放。后续文字对话中如果 AI 回复触发 TTSTTS 仍可正常播放WebSocket 连接保持)。**无需改动**。
## 5. 不改动的部分
| 模块 | 原因 |
|------|------|
| `useVisionSession.stopSession` | 保持完全 teardown 行为不变 |
| `sendTextMessage` | 已支持自动连接 + 无摄像头发送 |
| `ChatPanel` 组件 | 文字输入框始终显示,无需改动 |
| WebSocket Handler | 服务端无需感知客户端的 video/textOnly 状态 |
| Session Manager | 会话管理不受影响 |
| `useSessionList` | 会话列表管理不受影响 |
## 6. 验证清单
| 场景 | 预期结果 |
|------|---------|
| 视频通话中点击"结束视频" | 摄像头/麦克风关闭,消息保留,可继续打字 |
| 文字对话态输入文字发送 | AI 正常回复无图片TTS 正常播放 |
| 文字对话态点击"重新开始视频" | 摄像头/麦克风重新开启,恢复正常视频通话 |
| 文字对话态点击"结束会话" | 清空消息,断开连接,回到初始态 |
| 文字对话态刷新页面 | 消息从 localStorage 恢复,可继续打字 |
| 文字对话态切换到其他会话 | 当前会话消息保存,加载目标会话消息 |
| 文字对话态 WebSocket 异常断开 | 自动重连,重连后可继续发消息 |