diff --git a/CLAUDE.md b/CLAUDE.md index f66681a..8a8a26a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,24 +12,23 @@ CamTalk 是一款多模态实时 AI 视觉对话助手。用户通过摄像头 三层系统: -1. **浏览器客户端**(React 18 + TypeScript, Vite)—— 媒体采集、边缘预处理(VAD 通过 `@ricky0123/vad-web`、关键帧检测通过 ONNX Runtime Web)、UI 渲染。核心 Hook:`useVisionSession()` -2. **Go 网关**(gorilla/websocket, Redis, Viper, Zap)—— WebSocket 服务器、会话管理、模型路由、AI 编排、速率限制。每个 WebSocket 连接一个 goroutine。 -3. **云端 AI 服务** —— GPT-4o(LLM)、Deepgram(STT)、OpenAI TTS。仅通过 Go 网关访问,浏览器不直连。 +1. **浏览器客户端**(React 18 + TypeScript, Vite)—— 媒体采集、边缘预处理(VAD 通过 `@ricky0123/vad-web`、关键帧检测通过 Canvas 像素比较)、UI 渲染。核心 Hook:`useVisionSession()` +2. **Go 网关**(Gin, gorilla/websocket, Viper, Zap)—— WebSocket 服务器、会话管理、AI 编排。每个 WebSocket 连接一个 goroutine。 +3. **云端 AI 服务** —— 通过 OpenAI 兼容接口可灵活切换。默认:GPT-4o(LLM)、Deepgram(STT)、OpenAI TTS。仅通过 Go 网关访问,浏览器不直连。 **关键模式**:LLM 文本流和 TTS 音频流并行推送给客户端,以最小化感知延迟。 -**存储**:冷热分离 —— Redis 存实时会话状态,PostgreSQL 存对话历史和用量统计(MVP 后引入)。Repository 接口模式(`HistoryRepository`、`UsageRepository`),MVP 用内存实现。 +**存储**:MVP 阶段使用进程内存(`MemoryManager`),Redis 实现已就绪可通过配置切换,PostgreSQL 为规划中。Repository 接口模式(`HistoryRepository`、`UsageRepository`),MVP 用内存实现。 ## 技术栈 | 层级 | 技术 | |------|------| -| 前端 | React 18, TypeScript, Vite, ONNX Runtime Web, @ricky0123/vad-web | -| 后端 | Go, gorilla/websocket, Redis, Viper, Zap | -| LLM | GPT-4o(主), Claude Sonnet(备) | -| STT | Deepgram(主), FunASR(自部署备选) | -| TTS | OpenAI TTS(主), Edge TTS(免费替代) | -| 模型路由 | GPT-4o-mini 用于轻量分类 | +| 前端 | React 18, TypeScript, Vite, @ricky0123/vad-web | +| 后端 | Go, Gin, gorilla/websocket, Viper, Zap | +| LLM | GPT-4o(默认,通过 OpenAI 兼容接口可切换) | +| STT | Deepgram(默认) / MiMo ASR | +| TTS | OpenAI TTS(默认) / MiMo TTS | ## 构建与运行命令 @@ -50,7 +49,7 @@ go test -run TestName ./path # 运行单个测试 go vet ./... # 静态分析 ``` -基础设施:Redis 为会话状态必需。PostgreSQL 为 MVP 可选(内存回退)。 +基础设施:MVP 使用进程内存管理会话状态。Redis 已实现可通过配置切换,PostgreSQL 为规划中。 ## WebSocket 协议 @@ -80,7 +79,7 @@ go vet ./... # 静态分析 |------|------| | `CameraManager` | 摄像头流采集 | | `MicManager` | 麦克风音频采集 | -| `EdgeProcessor` | VAD + 关键帧检测(ONNX Runtime) | +| `EdgeProcessor` | VAD + 关键帧检测(Canvas 像素比较) | | `WebSocketManager` | WebSocket 连接生命周期管理 | | `ChatPanel` | 消息展示 | | `VideoPreview` | 摄像头画面预览 | @@ -89,11 +88,14 @@ go vet ./... # 静态分析 | 模块 | 职责 | |------|------| -| WebSocket Hub | 连接管理、广播/定向推送 | -| Session Manager | 会话状态、对话历史(Redis + TTL) | -| Model Router | 按请求选择 AI 模型(规则引擎 + 成本阈值) | -| AI Orchestrator | 并行/串行 AI 调用编排,context 超时控制 | -| Rate Limiter | 按用户的令牌桶速率限制 | +| WebSocket Handler | 连接管理、单播消息推送 | +| Session Manager | 会话状态、对话历史(Memory/Redis,30 分钟 TTL) | +| AI Orchestrator | STT→LLM→TTS 流式并行管道编排 | +| AI Service Layer | AI 服务抽象层(STT/LLM/TTS 多 provider) | +| REST API | 健康检查、会话管理(Gin 路由) | +| Models | 数据模型定义 | +| Model Router | 按请求选择 AI 模型(规划中) | +| Rate Limiter | 按用户的令牌桶速率限制(规划中) | ## 编码规范 diff --git a/backend/internal/ws/handler.go b/backend/internal/ws/handler.go index eb84af9..4e17bc8 100644 --- a/backend/internal/ws/handler.go +++ b/backend/internal/ws/handler.go @@ -159,6 +159,7 @@ func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orche switch envelope.Type { case "ping": + lastPong = time.Now() // 刷新心跳计时器 _ = client.SendJSON(models.WsPong{Type: "pong"}) case "query": diff --git a/docs/02-系统架构.md b/docs/02-系统架构.md index 41f2a33..e4de9d1 100644 --- a/docs/02-系统架构.md +++ b/docs/02-系统架构.md @@ -9,7 +9,7 @@ | 层级 | 职责 | 关键约束 | |------|------|---------| | **客户端(浏览器)** | 媒体采集、边缘预处理、UI 渲染 | 浏览器资源有限,模型需轻量 | -| **Go 网关** | 会话管理、模型路由、AI 服务编排 | 高并发、低延迟、状态管理 | +| **Go 网关** | 会话管理、AI 服务编排、流式管道 | 高并发、低延迟、状态管理 | | **AI 服务** | LLM 推理、语音识别、语音合成 | 按量计费,需控制调用频率 | > 为什么要单独加一层 Go 网关,而不是让前端直连 AI API?1)API Key 安全性;2)统一的速率限制和成本管控;3)多模型路由逻辑集中在一处便于维护。 @@ -22,7 +22,7 @@ |------|------|---------| | 框架 | React 18 + TypeScript | 组件化开发,类型安全,生态成熟 | | 构建 | Vite | 开发热更新快,构建产物小 | -| 实时通信 | WebSocket(原生 API) | 浏览器原生支持,无需额外依赖 | +| 实时通信 | WebSocket(原生 API) + 自封装连接管理 | 浏览器原生支持,封装心跳/重连/消息分发 | | 边缘推理 | ONNX Runtime Web | 浏览器端跑轻量模型(VAD、关键帧检测) | | 语音检测 | @ricky0123/vad-web | 基于 WebRTC VAD,纯前端零延迟 | | 媒体采集 | MediaDevices API | 浏览器原生摄像头/麦克风访问 | @@ -32,22 +32,22 @@ | 技术 | 选型 | 选择理由 | |------|------|---------| | 语言 | Go | 高并发 goroutine 模型,适合长连接管理 | +| HTTP 框架 | Gin | 高性能 HTTP 路由,中间件生态成熟 | | WebSocket | gorilla/websocket | Go 生态最成熟的 WebSocket 库 | -| 会话存储 | Redis | 高速 KV 存储,适合会话状态和上下文缓存 | -| 持久化存储 | PostgreSQL | 对话历史、用量统计、用户偏好(MVP 阶段可选) | -| 配置管理 | Viper | 支持 YAML + 环境变量覆盖,详见 `03-接口文档.md` 第六章 | +| 会话存储 | Redis(规划中) / Memory(MVP 默认) | 高速 KV 存储,MVP 阶段使用进程内存,可通过配置切换到 Redis | +| 持久化存储 | PostgreSQL(规划中) | 对话历史、用量统计、用户偏好(MVP 阶段未实现) | +| 配置管理 | Viper + godotenv | 支持 YAML + .env + 环境变量覆盖,详见 `03-接口文档.md` 第六章 | | 日志 | Zap | 高性能结构化日志 | ### AI 服务 | 能力 | 主选方案 | 备选方案 | 选型考量 | |------|---------|---------|---------| -| 多模态 LLM | GPT-4o | Claude Sonnet | 视觉理解能力强,API 成熟 | -| 语音识别 STT | Deepgram | FunASR 自部署 | 流式识别延迟低(<500ms) | -| 语音合成 TTS | OpenAI TTS | Edge TTS(免费) | 音质自然,支持流式 | -| 轻量分类 | GPT-4o-mini | Haiku | 模型路由时的复杂度判断 | +| 多模态 LLM | GPT-4o(默认) | 通义千问等 OpenAI 兼容模型 | 通过 OpenAI 兼容接口,可灵活切换 | +| 语音识别 STT | Deepgram(默认) | MiMo ASR(小米) | 支持多 provider 切换 | +| 语音合成 TTS | OpenAI TTS(默认) | MiMo TTS(小米) | 支持多 provider 切换 | -> 不必绑定单一厂商。Go 网关的模型路由层统一封装不同 AI 服务的调用接口,按场景动态切换。 +> 不必绑定单一厂商。Go 网关的 AI 服务层统一封装不同服务商的调用接口,通过配置切换 provider。 ## 核心交互流程 @@ -78,52 +78,35 @@ Browser Go Gateway STT LLM TTS | 模块 | 职责 | 关键实现 | |------|------|---------| -| WebSocket Hub | 管理所有客户端连接,广播/定向推送 | goroutine per connection | -| Session Manager | 维护用户会话状态、对话历史 | Redis Hash + List,30 分钟 TTL(详见 `03-接口文档.md` 第五章) | -| Model Router | 根据请求类型选择 AI 模型 | 规则引擎 + 成本阈值 | -| AI Orchestrator | 编排多路 AI 调用(并行/串行) | context 取消 + 超时控制 | -| Rate Limiter | 防止单用户过度消耗 API 额度 | 令牌桶算法 | +| WebSocket Handler | 管理客户端连接生命周期,单播消息推送 | goroutine per connection | +| Session Manager | 维护用户会话状态、对话历史 | Memory(MVP 默认)/ Redis(可切换),30 分钟 TTL(详见 `03-接口文档.md` 第五章) | +| AI Orchestrator | 编排 STT→LLM→TTS 流式并行管道 | context 取消 + 超时控制 + 句子切分 | +| AI Service Layer | AI 服务抽象层(STT/LLM/TTS) | 多 provider 支持(Deepgram/MiMo/OpenAI 等) | +| REST API | 健康检查、会话管理端点 | Gin 路由 | +| Error Handler | 统一错误码定义与发送 | 错误码枚举 | +| Logger | 日志初始化封装 | Zap 结构化日志 | +| Models | 数据模型定义 | WebSocket 消息、会话、配置等 | +| Model Router | 根据请求类型选择 AI 模型(规划中) | 规则引擎 + 成本阈值 | +| Rate Limiter | 防止单用户过度消耗 API 额度(规划中) | 令牌桶算法 | -AI Orchestrator 核心代码(句子级流式并行): +AI Orchestrator 核心接口(`internal/orchestrator/orchestrator.go`): ```go -func (o *Orchestrator) ProcessQuery(ctx context.Context, client MessageSender, req *QueryRequest) { - ctx, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - - // Step 1: STT — 识别用户语音(串行) - text, err := o.stt.Recognize(ctx, req.Audio, STTOptions{...}) - if err != nil { - client.SendError(req.RequestID, "STT_ERROR", err.Error()) - return - } - client.SendSTTResult(req.RequestID, text, true) - - // Step 2: LLM 流式输出 + 句子切分(并行) - llmStream, _ := o.llm.ChatStream(ctx, LLMRequest{Image: req.Image, Text: text, ...}) - sentenceCh := make(chan string, 4) - go func() { - defer close(sentenceCh) - var buf strings.Builder - for chunk := range llmStream { - client.SendLLMChunk(req.RequestID, chunk.Delta) // 逐 token 推送文字 - buf.WriteString(chunk.Delta) - if isSentenceEnd(chunk.Delta) { // 按 。!?\n 切分 - sentenceCh <- buf.String() - buf.Reset() - } - } - if buf.Len() > 0 { sentenceCh <- buf.String() } - }() - - // Step 3: TTS 并行消费句子流 - ttsStream, _ := o.tts.SynthesizeStream(ctx, sentenceCh, TTSOptions{...}) - for chunk := range ttsStream { - client.SendTTSAudio(req.RequestID, chunk.Audio, chunk.IsLast) - } +// Orchestrator AI 编排器接口。 +type Orchestrator interface { + ProcessQuery(ctx context.Context, sessionID string, req models.WsQuery, + history []models.Message, sender Sender) error } ``` +Pipeline 实现(`internal/orchestrator/pipeline.go`)流程: +1. Base64 解码音频/图片 +2. 调用 `stt.Recognize()` → 发送 `stt_result` +3. 调用 `llm.ChatStream()` 获取流式输出,goroutine 消费 token → 发送 `llm_chunk` + 句子切分 +4. 另一 goroutine 从句子 channel 读取 → 调用 `tts.SynthesizeStream()` → 发送 `tts_audio` +5. 流结束 → 发送 `llm_done` +6. TTS 失败静默跳过,STT/LLM 失败发送对应 error 消息 + > **关键优化**:LLM 文本流和 TTS 音频流**并行推送**——客户端先逐 token 展示文字,同时 TTS 逐句子合成并推送音频,用户感知延迟大幅降低。详细的 AI 服务层接口和编排策略见 `03-接口文档.md` 第三、四章。 ## 前端组件 @@ -132,10 +115,12 @@ func (o *Orchestrator) ProcessQuery(ctx context.Context, client MessageSender, r |------|------| | CameraManager | 摄像头流采集 | | MicManager | 麦克风音频采集 | -| EdgeProcessor | VAD + 关键帧检测(ONNX Runtime) | +| EdgeProcessor | VAD + 关键帧检测(Canvas 像素比较) | | WebSocketManager | WS 连接生命周期管理 | | ChatPanel | 消息展示 | | VideoPreview | 摄像头画面预览 | +| ConfigPanel | 右侧抽屉式配置面板(主题、TTS 开关、detail level、语言) | +| Toast | 轻量通知提示(3 秒自动消失) | 核心 Hook:`useVisionSession()` 封装一次完整的视觉对话会话(摄像头、VAD、WebSocket、消息状态)。 @@ -173,7 +158,7 @@ function useVisionSession() { | 阶段 | 存储方案 | 持久化内容 | 理由 | |------|---------|-----------|------| -| MVP | Redis only | 无 | 快速验证核心功能,重启丢数据可接受 | +| MVP | Memory(进程内) | 无 | 快速验证核心功能,重启丢数据可接受。Redis 实现已就绪,可通过 `storage.driver` 配置切换 | | 上线 | Redis + PostgreSQL | 对话历史、用户偏好、用量统计 | 用户需要查看历史,运营需要成本数据 | | 规模化 | Redis + PG + 对象存储 | 图像帧、音频片段归档 | 大文件不适合存关系库 | @@ -262,24 +247,8 @@ server { > WebSocket 是长连接,Nginx 必须配置 `Upgrade` 和 `Connection` 头。`proxy_read_timeout` 需要覆盖心跳间隔(客户端 30s ping),否则 Nginx 会主动断开空闲连接。 -### 开发环境(Vite proxy) +### 开发环境 -开发时前端(Vite :5173)和后端(Gin :8080)不同端口,用 Vite 内置代理解决跨域: +开发时前端(Vite :5173)和后端(Gin :8080)不同端口。当前实现中前端 WebSocket 地址硬编码为 `ws://localhost:8080/ws`,直连后端,不经过 Vite 代理。 -```typescript -// frontend/vite.config.ts -export default defineConfig({ - plugins: [react()], - server: { - proxy: { - "/api": "http://localhost:8080", - "/ws": { - target: "ws://localhost:8080", - ws: true, - }, - }, - }, -}); -``` - -前端代码中 WebSocket 地址改为相对路径 `ws://localhost:5173/ws`,Vite 自动代理到后端。部署时 Nginx 同理,前端无需区分开发/生产地址。 +> 如需使用 Vite 代理解决跨域,可在 `vite.config.ts` 中添加 `server.proxy` 配置,并将前端 WebSocket 地址改为相对路径。 diff --git a/docs/03-接口文档.md b/docs/03-接口文档.md index f1b7e1c..152ac89 100644 --- a/docs/03-接口文档.md +++ b/docs/03-接口文档.md @@ -73,7 +73,7 @@ interface ConfigMessage { ```typescript interface InterruptMessage { type: "interrupt"; - request_id?: string; // 可选,指定打断哪次请求 + request_id?: string; // 可选,当前实现不使用此字段,服务端始终取消当前活跃请求 } ``` @@ -143,7 +143,7 @@ interface TTSAudioMessage { type: "tts_audio"; request_id: string; audio: string; // Base64 编码的音频片段 - mime_type: string; // "audio/mpeg" + mime_type: string; // "audio/mp3" is_last: boolean; // 是否为最后一片 } ``` @@ -152,7 +152,7 @@ interface TTSAudioMessage { | 属性 | 值 | 说明 | |------|------|------| -| 编码 | `audio/mpeg`(MP3) | 浏览器 `