diff --git a/docs/01-架构设计.md b/docs/01-架构设计.md index e81340d..02826e4 100644 --- a/docs/01-架构设计.md +++ b/docs/01-架构设计.md @@ -229,6 +229,41 @@ graph LR 核心 Hook:`useVisionSession()` 封装一次完整的视觉对话会话(摄像头、VAD、WebSocket、消息状态、认证、场景模式)。 +### 前端会话状态模型(三态) + +前端 UI 存在三个会话状态,由 `isConnected` 和 `isCameraOn` 联合决定: + +``` +┌──────────┐ startSession() ┌──────────┐ +│ initial │ ──────────────────→ │ video │ +│ 初始态 │ │ 视频通话 │ +└──────────┘ └──────────┘ + ↑ │ + │ stopSession() stopVideo() + │ │ + │ ▼ + │ ┌──────────┐ + └──────────────────────── │ textOnly │ + │ 文字对话 │ + └──────────┘ + │ + startSession() + │ + ▼ + ┌──────────┐ + │ video │ + └──────────┘ +``` + +| 状态 | 条件 | WebSocket | 摄像头 | 消息 | 文字输入 | +|------|------|-----------|--------|------|---------| +| `initial` | `!isConnected && messages.length === 0` | 断开 | 关闭 | 空 | 可用(自动连接) | +| `video` | `isConnected && isCameraOn` | 连接 | 开启 | 有 | 可用 | +| `textOnly` | `isConnected && !isCameraOn` | 连接 | 关闭 | 保留 | 可用 | + +- **`stopVideo()`**:停止摄像头/麦克风/VAD,保持 WebSocket 连接和消息历史,用户可继续文字对话 +- **`stopSession()`**:完全断开 WebSocket、清空消息、重置状态,回到初始态 + ## 数据库设计 ### ER 关系 diff --git a/docs/12-Eino重构实施记录.md b/docs/12-Eino重构实施记录.md deleted file mode 100644 index cafcdca..0000000 --- a/docs/12-Eino重构实施记录.md +++ /dev/null @@ -1,204 +0,0 @@ -# CamTalk Eino 重构实施记录 - -> 创建日期:2026-06-19 -> 状态:已完成 - -## 1. 重构背景 - -CamTalk 原 AI 编排层(`internal/orchestrator/pipeline.go`)使用手写 goroutine + WaitGroup + channel 实现 STT → LLM → TTS 流式管道,存在以下问题: - -1. **编排逻辑硬编码**:流程写死在 `ProcessQuery()` 中,扩展需重写 goroutine 调度 -2. **并发控制粗糙**:手动 `go func()` + `sync.WaitGroup`,缺乏结构化流式传递 -3. **无回调/AOP 机制**:日志、指标、追踪散落各处 -4. **配置耦合**:模型名、TTS 参数硬编码在 Pipeline 结构体 -5. **错误处理不一致**:TTS 错误被静默吞掉,缺乏统一模式 - -**重构目标**:使用 Eino Graph 替换手写 Pipeline,实现声明式编排、统一回调、按请求动态配置,保持 WebSocket 协议和 REST API 不变。 - -## 2. 整体架构变更 - -### 2.1 重构前 - -``` -WS Handler → Orchestrator.Pipeline.ProcessQuery() - ├→ goroutine: STT.Recognize() - ├→ goroutine: LLM.ChatStream() ──→ chan chunk ──→ Sender - └→ goroutine: Splitter → TTS.SynthesizeStream() ──→ chan audio ──→ Sender - WaitGroup.Wait() - Sender.SendLLMDone() -``` - -### 2.2 重构后 - -``` -WS Handler → EinoOrchestrator.ProcessQuery() - ├→ Graph.Stream(ctx, input) - │ ├→ STT Lambda ─→ History Lambda ─→ ChatModel ─→ Splitter ─→ TTS ─→ Done - │ │ (State 写入) (Callback (Transform) (Invoke) (Invoke) - │ │ 流式推送) - │ └→ 消费 StreamReader(触发整条链路惰性执行) - └→ 追加助手消息到历史 -``` - -### 2.3 关键设计决策 - -| 决策 | 选择 | 理由 | -|------|------|------| -| Graph 调用模式 | Stream | ChatModel 需要真正的 token 级流式输出 | -| LLM 组件 | eino-ext ChatModel | 原生 Eino 组件,直接对接 DashScope | -| 消息推送 | Callback(LLM)+ Sender(其他) | LLM token 流式推送需要 Callback | -| 值类型 vs 指针 | 值类型统一 | 避免框架类型转换不匹配 | -| 历史追加 | 适配器负责 | Done 节点只负责发送 llm_done | - -## 3. 分阶段实施 - -### Phase 1:基础设施(提交 `fd5c771`) - -**目标**:引入 Eino 依赖,创建基础类型和 Callback。 - -**任务清单**: - -| 任务 | 文件 | 说明 | -|------|------|------| -| 引入 Eino 依赖 | `go.mod` | `eino v0.9.9` + `eino-ext/components/model/openai v0.1.13` | -| 数据类型定义 | `eino/types.go` | `PipelineInput`、`PipelineOutput`、`STTOutput`、`TokenUsage` | -| State 定义 | `eino/state.go` | `PipelineState` 含 `sync.Mutex` 并发保护 | -| 消息推送 Callback | `eino/callback.go` | `BuildCallbackHandler()` 使用 `callbacks.NewHandlerHelper()` | - -**关键实现**: -- `PipelineState` 使用 `strings.Builder` + `sync.Mutex` 累积 LLM 完整回复 -- Callback 通过 `ModelCallbackHandler.OnEndWithStreamOutput` 逐 token 推送 `llm_chunk` -- Sender/RequestID/PipelineState 通过 `context.WithValue` 注入 - -**验证**:`go build ./cmd/server` ✓ - ---- - -### Phase 2:节点实现(提交 `fd5c771`) - -**目标**:实现 Graph 中的 5 个 Lambda 节点。 - -**任务清单**: - -| 任务 | 文件 | Lambda 类型 | 输入 → 输出 | -|------|------|------------|------------| -| STT Lambda | `eino/nodes_stt.go` | InvokableLambda | `PipelineInput → STTOutput` | -| 历史组装 Lambda | `eino/nodes_history.go` | InvokableLambda | `STTOutput → []*schema.Message` | -| 句子分割 Lambda | `eino/nodes_splitter.go` | TransformableLambda | `StreamReader[string] → StreamReader[[]string]` | -| TTS Lambda | `eino/nodes_tts.go` | InvokableLambda | `[]string → struct{}` | -| Done Lambda | `eino/nodes_done.go` | InvokableLambda | `struct{} → PipelineOutput` | - -**关键实现**: -- STT 节点将输入元数据写入 State,供下游节点读取 -- History 节点从 State 读取 SessionID/Scenario/ImageData,构建系统提示词 + 多模态消息 -- Splitter 使用 `TransformableLambda` 按句子分隔符切分,逐句输出给 TTS -- TTS 节点调用 `ttsService.SynthesizeStream()`,逐 chunk 推送 `tts_audio` -- Done 节点从 State 读取完整回复,发送 `llm_done` -- 所有 Lambda 使用值类型(非指针),返回 `*compose.Lambda` - -**验证**:`go build ./internal/eino/...` ✓ - ---- - -### Phase 3:Graph 构建与适配器(提交 `4b731b5`) - -**目标**:构建 Graph、实现适配器、切换 main.go。 - -**任务清单**: - -| 任务 | 文件 | 说明 | -|------|------|------| -| Graph 构建 | `eino/graph.go` | `NewPipelineGraph()` 组装 6 个节点 + 边 + 编译 | -| 适配器 | `eino/adapter.go` | `EinoOrchestrator` 实现 `orchestrator.Orchestrator` 接口 | -| main.go 切换 | `cmd/server/main.go` | 移除旧 LLM + orchestrator,替换为 Eino | - -**Graph 拓扑**: -``` -START → STT → History → ChatModel → Splitter → TTS → Done → END -``` - -**适配器职责**: -1. 解码 base64 音频/图片 -2. 获取会话配置 -3. 注入 Sender/RequestID/SessionID/StartTime/State 到 context -4. 追加用户消息到历史 -5. 调用 `graph.Stream(ctx, input, callbacks)` 触发惰性执行 -6. 消费 `StreamReader[PipelineOutput]` -7. 追加助手消息到历史 - -**关键实现**: -- eino-ext ChatModel 配置:`BaseURL` 对接 DashScope,`Timeout` 控制请求超时 -- Callback 在运行时通过 `compose.WithCallbacks()` 传入,不在编译时注册 -- 元数据(SessionID/Scenario 等)通过 State 跨节点传递,不通过 Graph 边传递 - -**变更文件**: -- 修改 `state.go`:新增 SessionID/RequestID/ImageData 等字段 -- 修改 `nodes_stt.go`:写入元数据到 State -- 修改 `nodes_history.go`:从 State 读取元数据(移除 HistoryInput 依赖) -- 修改 `nodes_done.go`:移除历史追加(由适配器负责) - -**验证**:`go build ./cmd/server` ✓,`go vet ./...` ✓ - ---- - -### Phase 4:清理与测试(提交 `4ffd845`) - -**目标**:删除旧代码,编写单元测试。 - -**删除的文件**: - -| 文件 | 说明 | -|------|------| -| `orchestrator/pipeline.go` | 旧 STT→LLM→TTS 手写 goroutine 管道(-547 行) | -| `orchestrator/splitter.go` | 旧句子切分器(-114 行) | -| `orchestrator/pipeline_test.go` | 旧 Pipeline 测试(-309 行) | -| `ai/llm/openai.go` | 旧 LLM OpenAI 实现(-548 行) | -| `ai/llm/openai_test.go` | 旧 LLM 测试(-143 行) | - -**保留的文件**: - -| 文件 | 保留原因 | -|------|---------| -| `orchestrator/orchestrator.go` | Orchestrator 接口(ws/handler 依赖) | -| `orchestrator/sender.go` | Sender 接口(eino/callback 依赖) | -| `ai/llm/llm.go` | Request/Chunk/TokenUsage 类型定义 | -| `ai/llm/prompt.go` | BuildSystemPrompt(eino/nodes_history 依赖) | -| `ai/llm/scenarios.go` | GetScenarioPrompt(eino/nodes_history 依赖) | - -**新增测试**:`eino/graph_test.go`(13 个测试) - -| 测试 | 覆盖内容 | -|------|---------| -| `TestDetectImageMimeType` | JPEG/PNG/GIF/WebP/未知格式检测 | -| `TestBuildPipelineInput` | 文本输入构建 | -| `TestBuildPipelineInput_WithAudioData` | 音频+图片输入构建 | -| `TestPipelineState_AppendAndGet` | State 文本追加和读取 | -| `TestPipelineState_ConcurrentAccess` | State 并发安全(100 goroutine) | -| `TestContextInjection` | Sender/RequestID/State 注入和提取 | -| `TestLatencyFromCtx` | 延迟计算 | -| `TestEinoOrchestrator_ImplementsInterface` | 接口实现检查 | -| `TestNew*Lambda_ReturnsNonNil` | 5 个 Lambda 构造函数非空检查 | - -**验证**:`go build ./...` ✓,`go vet ./...` ✓,`go test ./...` ✓ - -## 4. 代码变更统计 - -| 阶段 | 提交 | 新增 | 删除 | 净变化 | -|------|------|------|------|--------| -| Phase 1 + 2 | `fd5c771` | +946 | -24 | +922 | -| Phase 3 | `4b731b5` | +395 | -98 | +297 | -| Phase 4 | `4ffd845` | +235 | -1661 | -1426 | -| **合计** | | **+1576** | **-1783** | **-207** | - -重构后代码量净减少 207 行,同时获得了更好的可维护性、可测试性和可扩展性。 - -## 5. 遗留事项 - -| 事项 | 优先级 | 说明 | -|------|--------|------| -| eino-ext ChatModel DashScope 兼容性端到端验证 | 高 | 需要真实 API Key 验证流式输出和多模态 | -| LLM 超时控制 | 中 | eino-ext ChatModel 的 `Timeout` 配置需验证 | -| TTS 流式优化 | 中 | 当前 TTS 是 InvokableLambda,可改为 StreamableLambda | -| ReAct Agent 扩展 | 低 | 基于 Graph Branch 实现工具调用循环 | -| Model Router | 低 | 按场景/成本路由不同 LLM | -| 指标监控 | 低 | 通过 Callback 接入 Prometheus | diff --git a/docs/13-结束视频保留对话设计方案.md b/docs/13-结束视频保留对话设计方案.md deleted file mode 100644 index 5d7fd88..0000000 --- a/docs/13-结束视频保留对话设计方案.md +++ /dev/null @@ -1,288 +0,0 @@ -# 结束视频后保留对话并支持继续文字聊天 - -> 创建日期: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/docs/README.md b/docs/README.md index 4b35c10..a898a9a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,7 +17,7 @@ CamTalk 是一款多模态实时 AI 视觉对话助手。用户通过摄像头 | [09-技术名词解释](09-技术名词解释.md) | 前端/后端/AI 服务/Eino 框架技术名词简明解释 | | [10-Eino重构方案](10-Eino重构方案.md) | Eino Graph 替换手写 goroutine 管道的设计方案 | | [11-Eino框架技术文档](11-Eino框架技术文档.md) | Eino 框架在 CamTalk 中的使用指南(Graph、Lambda、Callback、State) | -| [12-Eino重构实施记录](12-Eino重构实施记录.md) | Eino 重构的四阶段实施记录、代码变更统计、测试覆盖 | + ## 推荐阅读顺序 @@ -28,3 +28,4 @@ CamTalk 是一款多模态实时 AI 视觉对话助手。用户通过摄像头 5. **05~07** — 各技术领域的详细设计 6. **09-技术名词解释** — 遇到不熟悉的名词时查阅 7. **10~12** — Eino 重构相关(方案、框架文档、实施记录) +