- useVisionSession 新增 stopVideo 回调(停媒体流,保持连接和消息) - App.tsx 控制区从二态改为三态(initial/video/textOnly) - 文字对话态显示「重新开始视频」和「结束会话」按钮 - 新增 CSS 样式(video-ended-hint、btn--outline) - 新增 i18n key(stopVideo/endSession/resumeVideo/video.ended) - 新增设计方案文档
10 KiB
结束视频后保留对话并支持继续文字聊天
创建日期:2026-06-20 状态:草案
1. 背景与目标
1.1 现状问题
当前点击"结束对话"按钮会执行完整的 teardown 流程:
- 停止 VAD、麦克风、摄像头
- 断开 WebSocket 连接
- 清空所有聊天消息(
setMessages([])) - 清空对话历史(
historyRef.current = []) - 重置统计数据
- 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" 派生的布尔值。为支持三态,新增派生变量:
// 是否在会话中(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 连接和消息:
/** 结束视频,保留聊天和连接 */
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 新增导出
return {
// ...existing...
stopVideo, // 新增
// ...existing...
};
3.2 App.tsx 改动
3.2.1 解构新增
const {
// ...existing...
stopVideo, // 新增
// ...existing...
} = useVisionSession(...)
3.2.2 视频下方控制区改为三态
当前代码(二态):
{!isConnected ? (
/* 初始态 */
) : (
/* 通话态 */
)}
改为三态:
{!isConnected ? (
/* 初始态:开始按钮 + 设备选择 + 模式切换(不变) */
) : isCameraOn ? (
/* 视频通话态:摄像头/麦克风/识别/打断 + "结束视频" 按钮 + 模式切换 */
) : (
/* 文字对话态:
- "📹 视频已结束" 提示
- "📹 重新开始视频" 按钮
- "结束会话" 按钮
*/
)}
3.2.3 按钮变化
视频通话态(原"结束对话"改为"结束视频"):
<button className="btn btn--danger" onClick={stopVideo}>
{tr("controls.stopVideo")}
</button>
文字对话态(新增):
<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,所以需要额外判断:
{(!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 状态下也显示:
{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 状态下为 true(WebSocket 未断开),所以无需改动。
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 为 false,UI 显示初始态。用户可点击"开始视频通话"或直接输入文字。无需改动。
4.4 textOnly 状态下切换会话
selectSession 会先 persist 当前会话消息,然后加载目标会话消息。切换后 isConnected 取决于目标会话的 WebSocket 状态。无需改动。
4.5 textOnly 状态下 TTS 播放
stopVideo 已调用 ttsPlayerRef.current?.stop() 停止播放。后续文字对话中如果 AI 回复触发 TTS,TTS 仍可正常播放(WebSocket 连接保持)。无需改动。
5. 不改动的部分
| 模块 | 原因 |
|---|---|
useVisionSession.stopSession |
保持完全 teardown 行为不变 |
sendTextMessage |
已支持自动连接 + 无摄像头发送 |
ChatPanel 组件 |
文字输入框始终显示,无需改动 |
| WebSocket Handler | 服务端无需感知客户端的 video/textOnly 状态 |
| Session Manager | 会话管理不受影响 |
useSessionList |
会话列表管理不受影响 |
6. 验证清单
| 场景 | 预期结果 |
|---|---|
| 视频通话中点击"结束视频" | 摄像头/麦克风关闭,消息保留,可继续打字 |
| 文字对话态输入文字发送 | AI 正常回复(无图片),TTS 正常播放 |
| 文字对话态点击"重新开始视频" | 摄像头/麦克风重新开启,恢复正常视频通话 |
| 文字对话态点击"结束会话" | 清空消息,断开连接,回到初始态 |
| 文字对话态刷新页面 | 消息从 localStorage 恢复,可继续打字 |
| 文字对话态切换到其他会话 | 当前会话消息保存,加载目标会话消息 |
| 文字对话态 WebSocket 异常断开 | 自动重连,重连后可继续发消息 |