Merge pull request 'fix: 修复 deploy 工作流缺少 Node.js 导致 checkout 失败的问题' #72

Merged
huanghaosheng merged 61 commits from develop into main 2026-06-14 14:37:27 +08:00
8 changed files with 171 additions and 170 deletions
Showing only changes of commit 6e0c67e1cb - Show all commits

View File

@@ -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-4oLLM、DeepgramSTT、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-4oLLM、DeepgramSTT、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/Redis30 分钟 TTL |
| AI Orchestrator | STT→LLM→TTS 流式并行管道编排 |
| AI Service Layer | AI 服务抽象层STT/LLM/TTS 多 provider |
| REST API | 健康检查、会话管理Gin 路由) |
| Models | 数据模型定义 |
| Model Router | 按请求选择 AI 模型(规划中) |
| Rate Limiter | 按用户的令牌桶速率限制(规划中) |
## 编码规范

View File

@@ -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":

View File

@@ -9,7 +9,7 @@
| 层级 | 职责 | 关键约束 |
|------|------|---------|
| **客户端(浏览器)** | 媒体采集、边缘预处理、UI 渲染 | 浏览器资源有限,模型需轻量 |
| **Go 网关** | 会话管理、模型路由、AI 服务编排 | 高并发、低延迟、状态管理 |
| **Go 网关** | 会话管理、AI 服务编排、流式管道 | 高并发、低延迟、状态管理 |
| **AI 服务** | LLM 推理、语音识别、语音合成 | 按量计费,需控制调用频率 |
> 为什么要单独加一层 Go 网关,而不是让前端直连 AI API1API 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(规划中) / MemoryMVP 默认) | 高速 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 + List30 分钟 TTL详见 `03-接口文档.md` 第五章) |
| Model Router | 根据请求类型选择 AI 模型 | 规则引擎 + 成本阈值 |
| AI Orchestrator | 编排多路 AI 调用(并行/串行) | context 取消 + 超时控制 |
| Rate Limiter | 防止单用户过度消耗 API 额度 | 令牌桶算法 |
| WebSocket Handler | 管理客户端连接生命周期,单播消息推送 | goroutine per connection |
| Session Manager | 维护用户会话状态、对话历史 | MemoryMVP 默认)/ 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 地址改为相对路径。

View File

@@ -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 | 浏览器 `<audio>` 原生支持OpenAI TTS 默认输出 |
| 编码 | `audio/mp3`MP3 | 浏览器 `<audio>` 原生支持OpenAI TTS 默认输出 |
| 采样率 | 24kHz | OpenAI TTS 默认 |
| 声道 | 单声道 | 语音不需要立体声 |
| 传输 | Base64 编码的 MP3 片段 | 每个 `tts_audio` 消息携带一个句子的音频 |
@@ -165,7 +165,7 @@ interface TTSAudioMessage {
1. **排队播放**:收到 `tts_audio` 时,将 Base64 解码为 Blob URL 并加入播放队列。第一片到达即开始播放,后续片段在 `onended` 回调中自动衔接。
2. **错误容错**:单个片段播放失败时跳过,继续播放队列中下一个,不中断整个回复。
3. **打断清理**:收到 `interrupt` 消息或用户触发打断时,清空播放队列并释放所有 Blob URL。
4. **类型锁定**`mime_type` 字段固定为 `"audio/mpeg"`,前端解码时直接使用,无需运行时判断。
4. **类型锁定**`mime_type` 字段固定为 `"audio/mp3"`,前端解码时直接使用,无需运行时判断。
```typescript
// 前端播放器伪代码
@@ -173,7 +173,7 @@ class AudioPlayer {
private queue: string[] = []; // Blob URL 队列
enqueue(base64: string) {
const url = decodeBase64Audio(base64, "audio/mpeg");
const url = decodeBase64Audio(base64, "audio/mp3");
this.queue.push(url);
if (this.queue.length === 1) this.playNext(); // 第一片到了就开始播
}
@@ -293,7 +293,7 @@ DELETE /api/sessions/{session_id}
## 三、AI 服务层接口
Go 网关内部与外部 AI 服务Deepgram STT、GPT-4o、OpenAI TTS的调用契约。前后端联调时,后端需实现这些接口。
Go 网关内部与外部 AI 服务(STT、LLM、TTS的调用契约。默认配置为 Deepgram STT、GPT-4o LLM、OpenAI TTS,但通过 OpenAI 兼容接口可灵活切换到其他服务商(如 MiMo ASR、通义千问等)。前后端联调时,后端需实现这些接口。
### STT 服务接口
@@ -370,7 +370,7 @@ user: [图片 + 用户语音文本]
```
- 超时10 秒,超时返回 `LLM_TIMEOUT` 错误
- 模型选择:默认 `gpt-4o`由 Model Router 按需切换
- 模型选择:默认 `gpt-4o`可通过配置切换到其他 OpenAI 兼容模型
### TTS 服务接口
@@ -644,7 +644,7 @@ backend/config.dev.yaml # 开发环境go run 时使用)
backend/config.prod.yaml # 生产环境
```
Viper 加载顺序:先读 `config.yaml`,再根据 `APP_ENV` 环境变量尝试读 `config.{env}.yaml` 覆盖,最后所有环境变量自动覆盖对应字段。
Viper 加载顺序:先读 `config.yaml`,再根据 `APP_ENV` 环境变量尝试读 `config.{env}.yaml` 覆盖,最后所有环境变量自动覆盖对应字段。此外,代码还通过 `godotenv` 加载 `.env` 文件(优先级最低,仅用于本地开发环境)。
### Go 配置结构体
@@ -686,6 +686,7 @@ type AIConfig struct {
type STTConfig struct {
Provider string `mapstructure:"provider"` // "deepgram"
APIKey string `mapstructure:"api_key"`
Model string `mapstructure:"model"` // 默认 "nova-2"
Endpoint string `mapstructure:"endpoint"` // 默认 "wss://api.deepgram.com/v1/listen"
}
@@ -700,6 +701,7 @@ type LLMConfig struct {
type TTSConfig struct {
Provider string `mapstructure:"provider"` // "openai"
APIKey string `mapstructure:"api_key"`
Model string `mapstructure:"model"` // 默认 "tts-1"
Voice string `mapstructure:"voice"` // 默认 "alloy"
Speed float64 `mapstructure:"speed"` // 默认 1.0
Endpoint string `mapstructure:"endpoint"` // 默认 "https://api.openai.com/v1"
@@ -738,6 +740,7 @@ redis:
ai:
stt:
provider: deepgram
model: nova-2
endpoint: "wss://api.deepgram.com/v1/listen"
llm:
provider: openai
@@ -746,6 +749,7 @@ ai:
timeout: 10
tts:
provider: openai
model: tts-1
voice: alloy
speed: 1.0
endpoint: "https://api.openai.com/v1"
@@ -791,8 +795,9 @@ func Load() (*Config, error) {
// 1. 读默认配置文件
v.SetConfigName("config")
v.SetConfigType("yaml")
v.AddConfigPath("./config") // go run 时
v.AddConfigPath(".") // 二进制运行时
v.AddConfigPath(".")
v.AddConfigPath("./config")
v.AddConfigPath("./backend")
if err := v.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
return nil, fmt.Errorf("read config: %w", err)
@@ -800,7 +805,7 @@ func Load() (*Config, error) {
}
// 2. 按环境覆盖
env := os.Getenv("CAMTALK_APP_ENV")
env := os.Getenv("APP_ENV")
if env == "" {
env = "dev"
}
@@ -1030,7 +1035,7 @@ func NewApp(cfg *Config) *App {
## 十、连接管理
**心跳机制**:客户端每 30 秒发送 `ping`,服务端回复 `pong`。超过 60 秒无 `ping`,服务端判定连接断开并清理会话资源。
**心跳机制**:客户端每 30 秒发送应用层 `{type: "ping"}` 消息,服务端回复 `{type: "pong"}` 并刷新心跳计时器。超过 60 秒无 `ping`,服务端判定连接断开并清理会话资源。
**重连策略**(指数退避 + 抖动):
@@ -1049,18 +1054,10 @@ function reconnect(attempt: number) {
**生产环境**Nginx 将 `/`(前端)、`/api/*`REST`/ws`WebSocket统一反代到同一域名详见 `02-系统架构.md` 部署架构章节。
**开发环境**Vite 内置代理,前端 :5173 的 `/api``/ws` 请求代理到后端 :8080
**开发环境**前端 WebSocket 地址硬编码为 `ws://localhost:8080/ws`,直连后端,不经过 Vite 代理。REST API 同理直连 `http://localhost:8080`
```typescript
// frontend/vite.config.ts
server: {
proxy: {
"/api": "http://localhost:8080",
"/ws": { target: "ws://localhost:8080", ws: true },
},
},
```
> 当前开发模式为前后端直连,未使用 Vite proxy。如需使用 Vite 代理解决跨域,需在 `vite.config.ts` 中添加 `server.proxy` 配置,并将前端 WebSocket 地址改为相对路径。
**Go 后端 WebSocket CheckOrigin**:生产环境 Nginx 同源,`CheckOrigin` 可保持默认(拒绝跨域)。开发环境由 Vite proxy 转发,不存在跨域。因此后端无需配置 CORS 中间件,`CheckOrigin` 保持 gorilla/websocket 默认值即可
**Go 后端 WebSocket CheckOrigin**:生产环境 Nginx 同源,`CheckOrigin` 可保持默认(拒绝跨域)。开发环境前端直连后端,需确保 `CheckOrigin` 允许跨域或使用 Vite proxy 转发
> 如果未来需要支持第三方客户端直连(如移动端),再按需添加 CORS 中间件和 `CheckOrigin` 白名单。

View File

@@ -4,20 +4,58 @@
本文档记录项目中各项技术的**选型过程、替代方案对比和决策理由**。技术选型没有"绝对正确",只有"更适合"。
**定位**:持久化部分是拓展选型,不阻塞 MVPMVP 用 Redis 即可)。前端边缘处理部分是 MVP 阶段就需要确定的技术栈。
**定位**:持久化部分是拓展选型,不阻塞 MVPMVP 用内存存储即可)。前端边缘处理部分是 MVP 阶段就需要确定的技术栈。AI 服务栈STT/LLM/TTS已确定默认选型可通过配置灵活切换。
```
技术选型
├── 持久化层 → 数据库选型: PostgreSQL
├── AI 服务栈
│ ├── STT: Deepgram默认 / MiMo ASR
│ ├── LLM: GPT-4o默认 / 通义千问等 OpenAI 兼容模型
│ └── TTS: OpenAI TTS默认 / MiMo TTS
├── 持久化层 → 数据库选型: PostgreSQL规划中MVP 阶段使用内存存储)
└── 前端边缘处理层
├── 边缘推理: ONNX Runtime Web
├── 边缘推理: ONNX Runtime Web规划中MVP 使用 Canvas 像素比较)
├── 语音检测: @ricky0123/vad-web
└── 媒体采集: MediaDevices API
```
---
## 一、持久化层选型
## 一、AI 服务栈选型
### STT语音识别
| 方案 | 延迟 | 成本 | 特点 |
|------|------|------|------|
| **Deepgram**(默认) | <500ms | 按分钟计费 | 流式识别延迟极低WebSocket 接口 |
| **MiMo ASR**(小米) | ~1s | 按量计费 | 国产替代,兼容 OpenAI chat/completions 格式HTTP 非流式 |
| Whisper API | 1-3s | 按分钟计费 | 准确率高,支持多语言 |
| FunASR | <500ms | 自部署免费 | 阿里开源,中文优化 |
当前默认使用 Deepgram nova-2可通过 `ai.stt.provider` 配置切换到 MiMo ASR。
### LLM多模态大模型
| 方案 | 成本 | 特点 |
|------|------|------|
| **GPT-4o**(默认) | $2.5/1M tokens | 视觉理解能力强API 成熟,流式推理 |
| 通义千问 qwen3-vl-plus | 按量计费 | 阿里云,通过 OpenAI 兼容接口调用 |
| Claude Sonnet | $3/1M tokens | Anthropic长上下文能力强 |
代码通过 OpenAI 兼容接口调用,可灵活切换到任何兼容服务商。配置 `ai.llm.provider``ai.llm.model``ai.llm.endpoint` 即可。
### TTS语音合成
| 方案 | 成本 | 特点 |
|------|------|------|
| **OpenAI TTS**(默认) | $15/1M 字符 | 音质自然,支持流式,默认模型 tts-1语音 alloy |
| MiMo TTS小米 | 按量计费 | 国产替代,通过配置切换 |
当前默认使用 OpenAI TTStts-1, alloy可通过 `ai.tts.provider` 配置切换。
---
## 二、持久化层选型规划中MVP 阶段使用内存存储)
### 数据特征分析

View File

@@ -28,7 +28,10 @@ const vad = await MicVAD.new({
sendToSTT(audio);
},
positiveSpeechThreshold: 0.5, // 检测灵敏度
minSpeechDuration: 250 // 最短语音时长 ms
negativeSpeechThreshold: 0.35, // 结束灵敏度
minSpeechMs: 250, // 最短语音时长 ms
redemptionMs: 300, // 语音结束确认时间 ms
preSpeechPadMs: 300, // 语音前填充 ms
});
vad.start();
@@ -39,34 +42,26 @@ vad.start();
| 方案 | 延迟 | 成本 | 特点 |
|------|------|------|------|
| Whisper API | 1-3s | 按分钟计费 | 准确率高,支持多语言 |
| **Deepgram** | <500ms | 按分钟计费 | 流式识别,延迟极低 |
| **Deepgram**(默认) | <500ms | 按分钟计费 | 流式识别,延迟极低 |
| **MiMo ASR**(小米) | ~1s | 按量计费 | 国产替代,兼容 OpenAI 格式HTTP 非流式 |
| 浏览器原生 | ~1s | 免费 | 中文效果一般 |
| FunASR | <500ms | 自部署免费 | 阿里开源,中文优化 |
流式 STT 是低延迟的关键——不必等用户说完,边说边识别:
当前实现为**一次性语音识别**(非流式):前端 VAD 检测到用户说完后,将完整音频片段发送到后端,后端调用 `stt.Recognize()` 一次性返回识别结果。流式 STT 为未来优化方向。
```typescript
// Deepgram 流式识别示例
const ws = new WebSocket("wss://api.deepgram.com/v1/listen", {
headers: { Authorization: `Token ${API_KEY}` }
});
ws.onmessage = (event) => {
const { transcript, is_final } = JSON.parse(event.data).channel.alternatives[0];
if (is_final) {
onFinalTranscript(transcript); // 一句完整语音,送入 LLM
}
};
```
音频编码格式:前端 `audio.ts` 将 Float32Array 转为 Int16 PCM16kHz, pcm_s16le再编码为 Base64。
## 环节三TTS文字转语音
流式 TTS检测 LLM 输出中的句子边界,每检测到一句就立即送入 TTS 合成并播放,不必等全部生成完。
句子切分规则:按中文标点(`。!?`)、英文标点(`. ! ?`)和换行符切分。
当前实现参数Voice `"alloy"`、Speed `1.0`、OutputFmt `"mp3"`、SampleRate `24000`
方案选择:
- **OpenAI TTS**:音质好,延迟中等,按字符计费
- **Edge TTS**:微软免费方案,音质不错,延迟略高
- **Fish Speech / CosyVoice**:开源方案,支持声音克隆,可自部署
- **OpenAI TTS**(默认):音质好,延迟中等,按字符计费,模型 tts-1
- **MiMo TTS**(小米):国产替代,通过配置切换
- **Edge TTS**(规划中):微软免费方案,音质不错,延迟略高
## 延迟优化要点

View File

@@ -15,18 +15,30 @@
| 事件驱动采样 | 用户主动触发(如拍照按钮) | 精确提问场景 |
| **混合策略** | 低频定时 + 高频事件触发 | **通用推荐方案** |
关键帧检测核心逻辑:
关键帧检测核心逻辑TypeScript 实现,`EdgeProcessor/index.tsx`
```python
import numpy as np
```typescript
// 降低分辨率到 160x120 做检测,兼顾速度与精度
const DETECT_WIDTH = 160;
const DETECT_HEIGHT = 120;
def is_keyframe(prev_frame, curr_frame, threshold=30):
"""通过帧间像素差异判断是否为关键帧"""
diff = np.mean(np.abs(prev_frame.astype(int) - curr_frame.astype(int)))
return diff > threshold
function calcSimilarity(prev: ImageData, curr: ImageData): number {
const pixelCount = prev.width * prev.height;
let diffSum = 0;
// 只比较 RGB 三通道,跳过 Alpha
for (let i = 0; i < prev.data.length; i += 4) {
diffSum += Math.abs(prev.data[i] - curr.data[i])
+ Math.abs(prev.data[i+1] - curr.data[i+1])
+ Math.abs(prev.data[i+2] - curr.data[i+2]);
}
const avgDiff = diffSum / (pixelCount * 3);
return 1 - avgDiff / 255; // 相似度1 = 完全相同0 = 完全不同
}
```
> 实际开发中,先降低分辨率(如 320x240做关键帧检测再对命中帧保留原始分辨率送入 LLM兼顾速度与精度。
阈值说明:
- 对话模式:`similarity > 0.9` 时跳过(视为重复帧)
- 观察模式:`similarity < 0.85` 时触发变化回调
## 图像编码与多模态输入

View File

@@ -27,15 +27,12 @@
| 本地预筛选 | 中 | 高 | 用轻量模型判断"是否值得问 LLM" |
```typescript
// 混合策略:定时低频 + 事件高频
const NORMAL_INTERVAL = 5000; // 正常 5 秒一帧
// 混合策略:定时低频 + 事件高频sampling.ts
const IDLE_INTERVAL = 5000; // 空闲 5 秒一帧
const ACTIVE_INTERVAL = 1000; // 用户说话时 1 秒一帧
let isUserSpeaking = false;
setInterval(() => {
captureAndSend(isUserSpeaking ? "low" : "high");
}, isUserSpeaking ? ACTIVE_INTERVAL : NORMAL_INTERVAL);
// SamplingController 根据 VAD 状态切换采样间隔
// detail_level 通过 session config 静态配置,不随说话状态动态变化
```
## 策略二:端云协同——把计算推到边缘
@@ -43,11 +40,11 @@ setInterval(() => {
不是所有计算都需要上云。可前置到客户端的计算:
- **VAD 语音检测**:浏览器端完成,减少无效音频上传(节省 ~70% 带宽)
- **人脸/物体检测**:用 ONNX Runtime 跑轻量模型(如 YOLOv8-nano ~6MB推理 ~30ms只在检测到新物体时触发 LLM
- **重复画面过滤**:计算帧间相似度,相似度 > 90% 直接跳过
- **敏感内容过滤**NSFW 检测前置,避免无效 API 调用
- **人脸/物体检测**(规划中):用 ONNX Runtime 跑轻量模型(如 YOLOv8-nano ~6MB推理 ~30ms只在检测到新物体时触发 LLM。当前 MVP 使用 Canvas 像素比较做关键帧检测
- **重复画面过滤**:计算帧间相似度,对话模式 similarity > 0.9 跳过,观察模式 similarity < 0.85 触发
- **敏感内容过滤**(规划中)NSFW 检测前置,避免无效 API 调用
## 策略三:模型分级——用对模型做对事
## 策略三:模型分级——用对模型做对事(规划中)
不是每个问题都需要最贵的模型:
@@ -58,20 +55,10 @@ setInterval(() => {
└── 代码/推理 → o1 ($15/1M tokens)
```
```typescript
async function routeQuery(image: string, question: string) {
const complexity = await classifyComplexity(question);
const modelMap = {
simple: "gpt-4o-mini", // "这是什么?"
moderate: "gpt-4o", // "分析这张图"
complex: "o1" // "推理/规划"
};
return callLLM(modelMap[complexity], image, question);
}
```
> 当前 MVP 阶段使用单一模型(默认 GPT-4o模型分级路由为未来优化方向。通过配置 `ai.llm.model` 可手动切换模型。
## 策略四:缓存与复用
## 策略四:缓存与复用(规划中)
- **语义缓存**:相似问题直接返回缓存结果(如反复问"这是什么"
- **上下文复用**:连续对话中,未变化的图像不必重复发送
- **Prompt 压缩**:精简 system prompt减少每轮的固定 token 开销
- **语义缓存**(规划中):相似问题直接返回缓存结果(如反复问"这是什么"
- **上下文复用**:连续对话中,未变化的图像不必重复发送(已通过重复画面过滤实现)
- **对话历史裁剪**:前端按 `MAX_HISTORY_ROUNDS = 10` 裁剪,后端按 `defaultHistorySize = 20` 裁剪,限制每轮的固定 token 开销