Files
CamTalk/docs/13-结束视频保留对话设计方案.md

289 lines
10 KiB
Markdown
Raw Normal View History

# 结束视频后保留对话并支持继续文字聊天
> 创建日期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 异常断开 | 自动重连,重连后可继续发消息 |