diff --git a/docs/13-结束视频保留对话设计方案.md b/docs/13-结束视频保留对话设计方案.md
new file mode 100644
index 0000000..5d7fd88
--- /dev/null
+++ b/docs/13-结束视频保留对话设计方案.md
@@ -0,0 +1,288 @@
+# 结束视频后保留对话并支持继续文字聊天
+
+> 创建日期: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
+
+```
+
+**文字对话态**(新增):
+
+```tsx
+
+
+ 📹 {tr("video.ended")}
+ {tr("video.ended.hint")}
+
+
+
+
+
+
+```
+
+#### 3.2.4 视频预览区
+
+当前已有逻辑:`{!isConnected && !stream && }`。关闭摄像头后 `stream` 为 null,自动显示占位符。**无需额外改动**。
+
+但在 `textOnly` 状态下 `isConnected` 为 true,所以需要额外判断:
+
+```tsx
+{(!isConnected || !isCameraOn) && !stream && (
+
+ 📷
+ {tr("video.cameraOff")}
+ {tr("video.cameraOff.hint")}
+
+)}
+```
+
+#### 3.2.5 状态栏
+
+将 `isConnected && stats.queryCount > 0` 改为在 textOnly 状态下也显示:
+
+```tsx
+{isConnected && stats.queryCount > 0 && (
+
+ {stats.queryCount} {tr("statusbar.recognitions")}
+ {stats.totalTokens > 0 && ` · ${stats.totalTokens.toLocaleString()} ${tr("statusbar.tokens")}`}
+ {` · ${formatTime(elapsed)}`}
+
+)}
+```
+
+> `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:`),刷新后从 `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 异常断开 | 自动重连,重连后可继续发消息 |
diff --git a/frontend/src/App.css b/frontend/src/App.css
index e31c238..8bc7ae9 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -1503,6 +1503,48 @@ body {
color: white;
}
+/* ---- Text-only mode (video ended, chat preserved) ---- */
+
+.video-controls__text-only {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 10px;
+ width: 100%;
+}
+
+.video-ended-hint {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 2px;
+ padding: 4px 0;
+}
+
+.video-ended-hint__title {
+ font-size: 0.85rem;
+ font-weight: 600;
+ color: var(--color-text-muted);
+}
+
+.video-ended-hint__sub {
+ font-size: 0.7rem;
+ color: var(--color-text-muted);
+ opacity: 0.7;
+}
+
+.btn--outline {
+ background: transparent;
+ border: 1px solid var(--color-border);
+ color: var(--color-text-muted);
+}
+
+.btn--outline:hover {
+ background: var(--color-surface-2);
+ color: var(--color-text);
+ border-color: var(--color-surface-3);
+}
+
/* ---- Scene Cards (empty state) ---- */
.scene-cards {
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 389b569..d53daaa 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -80,6 +80,7 @@ function AppContent() {
isObserving,
startSession,
stopSession,
+ stopVideo,
interrupt,
isCameraOn,
isMicOn,
@@ -397,9 +398,9 @@ function AppContent() {
>
- ) : (
+ ) : isCameraOn ? (
<>
- {/* 通话态:核心控制工具栏 */}
+ {/* 视频通话态:核心控制工具栏 */}
{/* 通话态模式切换 */}
@@ -456,6 +457,24 @@ function AppContent() {
>
+ ) : (
+ <>
+ {/* 文字对话态:视频已结束 */}
+
+
+ 📹 {tr("video.ended")}
+ {tr("video.ended.hint")}
+
+
+
+ {tr("controls.resumeVideo")}
+
+
+ {tr("controls.endSession")}
+
+
+
+ >
)}
diff --git a/frontend/src/hooks/useVisionSession.ts b/frontend/src/hooks/useVisionSession.ts
index 6b474a1..36d0cef 100644
--- a/frontend/src/hooks/useVisionSession.ts
+++ b/frontend/src/hooks/useVisionSession.ts
@@ -363,6 +363,22 @@ export function useVisionSession(accessToken?: string | null) {
setIsMicOn(false);
}, [stopObserving, stopVAD, stopMic, stopCamera, disconnect]);
+ /** 结束视频,保留聊天和连接 */
+ const stopVideo = useCallback(async () => {
+ stopObserving();
+ setMode("dialogue");
+ await stopVAD();
+ stopMic();
+ stopCamera();
+ ttsPlayerRef.current?.stop();
+ setIsAudioPlaying(false);
+ setCurrentReply("");
+ setIsProcessing(false);
+ setIsCameraOn(false);
+ setIsMicOn(false);
+ // 不断开 WebSocket,不清空消息、历史、统计
+ }, [stopObserving, stopVAD, stopMic, stopCamera]);
+
/** 摄像头开关 */
const toggleCamera = useCallback(async () => {
if (isCameraOn) {
@@ -479,6 +495,7 @@ export function useVisionSession(accessToken?: string | null) {
toggleMode,
startSession,
stopSession,
+ stopVideo,
interrupt,
isCameraOn,
isMicOn,
diff --git a/frontend/src/lib/i18n/en-US.ts b/frontend/src/lib/i18n/en-US.ts
index 9d4087d..ed2e92c 100644
--- a/frontend/src/lib/i18n/en-US.ts
+++ b/frontend/src/lib/i18n/en-US.ts
@@ -51,6 +51,8 @@ export const enUS: TranslationMap = {
"video.placeholder": "Type in the chat panel to start",
"video.cameraOff": "Camera is off",
"video.cameraOff.hint": "You can type in the chat panel",
+ "video.ended": "Video Ended",
+ "video.ended.hint": "You can continue chatting below",
// Controls
"controls.connecting": "Connecting...",
@@ -64,6 +66,9 @@ export const enUS: TranslationMap = {
"controls.observation": "👁️ Observe",
"controls.interrupt": "⏹ Interrupt",
"controls.stop": "End Session",
+ "controls.stopVideo": "End Video",
+ "controls.endSession": "End Session",
+ "controls.resumeVideo": "📹 Resume Video",
// Chat panel
"chat.title": "Chat",
diff --git a/frontend/src/lib/i18n/ja-JP.ts b/frontend/src/lib/i18n/ja-JP.ts
index 574fdb3..5d6e1db 100644
--- a/frontend/src/lib/i18n/ja-JP.ts
+++ b/frontend/src/lib/i18n/ja-JP.ts
@@ -51,6 +51,8 @@ export const jaJP: TranslationMap = {
"video.placeholder": "右側のチャットに入力して開始",
"video.cameraOff": "カメラがオフです",
"video.cameraOff.hint": "右側のチャットでテキスト対話ができます",
+ "video.ended": "ビデオ終了",
+ "video.ended.hint": "下にテキストを入力して会話を続けることができます",
// Controls
"controls.connecting": "接続中...",
@@ -64,6 +66,9 @@ export const jaJP: TranslationMap = {
"controls.observation": "👁️ 観察モード",
"controls.interrupt": "⏹ 中断",
"controls.stop": "対話を終了",
+ "controls.stopVideo": "ビデオ終了",
+ "controls.endSession": "セッション終了",
+ "controls.resumeVideo": "📹 ビデオ再開",
// Chat panel
"chat.title": "チャット",
diff --git a/frontend/src/lib/i18n/zh-CN.ts b/frontend/src/lib/i18n/zh-CN.ts
index ca939ab..8d7d686 100644
--- a/frontend/src/lib/i18n/zh-CN.ts
+++ b/frontend/src/lib/i18n/zh-CN.ts
@@ -51,6 +51,8 @@ export const zhCN: TranslationMap = {
"video.placeholder": "在右侧聊天框输入即可开始对话",
"video.cameraOff": "摄像头未开启",
"video.cameraOff.hint": "可在右侧聊天框打字对话",
+ "video.ended": "视频已结束",
+ "video.ended.hint": "您可以继续在下方输入文字对话",
// Controls
"controls.connecting": "连接中...",
@@ -64,6 +66,9 @@ export const zhCN: TranslationMap = {
"controls.observation": "👁️ 观察模式",
"controls.interrupt": "⏹ 打断",
"controls.stop": "结束对话",
+ "controls.stopVideo": "结束视频",
+ "controls.endSession": "结束会话",
+ "controls.resumeVideo": "📹 重新开始视频",
// Chat panel
"chat.title": "对话",