- websocket.ts: 移除硬编码 localhost:8080,基于 window.location 动态构建 WS URL - vite.config.ts: 添加 /ws 和 /api 的 server.proxy 配置 - 同步更新 02-系统架构.md 和 03-接口文档.md 中的开发环境说明
255 lines
11 KiB
Markdown
255 lines
11 KiB
Markdown
# 系统架构
|
||
|
||
## 概述
|
||
|
||
三层架构:**前端做轻量预处理,后端做智能编排,云端 AI 服务按需调用**。在保证交互体验的同时控制成本。
|
||
|
||
## 三层架构
|
||
|
||
| 层级 | 职责 | 关键约束 |
|
||
|------|------|---------|
|
||
| **客户端(浏览器)** | 媒体采集、边缘预处理、UI 渲染 | 浏览器资源有限,模型需轻量 |
|
||
| **Go 网关** | 会话管理、AI 服务编排、流式管道 | 高并发、低延迟、状态管理 |
|
||
| **AI 服务** | LLM 推理、语音识别、语音合成 | 按量计费,需控制调用频率 |
|
||
|
||
> 为什么要单独加一层 Go 网关,而不是让前端直连 AI API?1)API Key 安全性;2)统一的速率限制和成本管控;3)多模型路由逻辑集中在一处便于维护。
|
||
|
||
## 技术栈
|
||
|
||
### 前端
|
||
|
||
| 技术 | 选型 | 选择理由 |
|
||
|------|------|---------|
|
||
| 框架 | React 18 + TypeScript | 组件化开发,类型安全,生态成熟 |
|
||
| 构建 | Vite | 开发热更新快,构建产物小 |
|
||
| 实时通信 | WebSocket(原生 API) + 自封装连接管理 | 浏览器原生支持,封装心跳/重连/消息分发 |
|
||
| 边缘推理 | ONNX Runtime Web | 浏览器端跑轻量模型(VAD、关键帧检测) |
|
||
| 语音检测 | @ricky0123/vad-web | 基于 WebRTC VAD,纯前端零延迟 |
|
||
| 媒体采集 | MediaDevices API | 浏览器原生摄像头/麦克风访问 |
|
||
|
||
### 后端
|
||
|
||
| 技术 | 选型 | 选择理由 |
|
||
|------|------|---------|
|
||
| 语言 | Go | 高并发 goroutine 模型,适合长连接管理 |
|
||
| HTTP 框架 | Gin | 高性能 HTTP 路由,中间件生态成熟 |
|
||
| WebSocket | gorilla/websocket | Go 生态最成熟的 WebSocket 库 |
|
||
| 会话存储 | Redis(规划中) / Memory(MVP 默认) | 高速 KV 存储,MVP 阶段使用进程内存,可通过配置切换到 Redis |
|
||
| 持久化存储 | PostgreSQL(规划中) | 对话历史、用量统计、用户偏好(MVP 阶段未实现) |
|
||
| 配置管理 | Viper + godotenv | 支持 YAML + .env + 环境变量覆盖,详见 `03-接口文档.md` 第六章 |
|
||
| 日志 | Zap | 高性能结构化日志 |
|
||
|
||
### AI 服务
|
||
|
||
| 能力 | 主选方案 | 备选方案 | 选型考量 |
|
||
|------|---------|---------|---------|
|
||
| 多模态 LLM | GPT-4o(默认) | 通义千问等 OpenAI 兼容模型 | 通过 OpenAI 兼容接口,可灵活切换 |
|
||
| 语音识别 STT | Deepgram(默认) | MiMo ASR(小米) | 支持多 provider 切换 |
|
||
| 语音合成 TTS | OpenAI TTS(默认) | MiMo TTS(小米) | 支持多 provider 切换 |
|
||
|
||
> 不必绑定单一厂商。Go 网关的 AI 服务层统一封装不同服务商的调用接口,通过配置切换 provider。
|
||
|
||
## 核心交互流程
|
||
|
||
一次完整的"用户提问 → AI 回答"流程:
|
||
|
||
```
|
||
Browser Go Gateway STT LLM TTS
|
||
| | | | |
|
||
|-- VAD 检测到语音结束 --->| | | |
|
||
| | | | |
|
||
|-- [音频+图像] -------->| | | |
|
||
| |--- 音频流 ------->| | |
|
||
| |<-- 流式文本 ------| | |
|
||
| | | | |
|
||
| |--- [图像+文本+上下文] -------->| |
|
||
| |<-- 流式回答文本 --------------| |
|
||
|<-- 推送回答文本 --------| | | |
|
||
| |--- 回答文本 ---------------------------->|
|
||
| |<-- 流式音频 --------------------------------|
|
||
|<-- 推送音频流 ----------| | | |
|
||
| | | | |
|
||
|-> 播放音频 + 渲染文字 | | | |
|
||
```
|
||
|
||
**关键优化**:LLM 文本流和 TTS 音频流是**并行推送**的——客户端先展示文字,同时开始播放语音,用户感知延迟大幅降低。
|
||
|
||
## 后端模块
|
||
|
||
| 模块 | 职责 | 关键实现 |
|
||
|------|------|---------|
|
||
| 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 核心接口(`internal/orchestrator/orchestrator.go`):
|
||
|
||
```go
|
||
// 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` 第三、四章。
|
||
|
||
## 前端组件
|
||
|
||
| 组件 | 职责 |
|
||
|------|------|
|
||
| CameraManager | 摄像头流采集 |
|
||
| MicManager | 麦克风音频采集 |
|
||
| EdgeProcessor | VAD + 关键帧检测(Canvas 像素比较) |
|
||
| WebSocketManager | WS 连接生命周期管理 |
|
||
| ChatPanel | 消息展示 |
|
||
| VideoPreview | 摄像头画面预览 |
|
||
| ConfigPanel | 右侧抽屉式配置面板(主题、TTS 开关、detail level、语言) |
|
||
| Toast | 轻量通知提示(3 秒自动消失) |
|
||
|
||
核心 Hook:`useVisionSession()` 封装一次完整的视觉对话会话(摄像头、VAD、WebSocket、消息状态)。
|
||
|
||
```typescript
|
||
function useVisionSession() {
|
||
const [messages, setMessages] = useState<Message[]>([]);
|
||
const wsRef = useWebSocket(`${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/ws`);
|
||
const videoRef = useRef<HTMLVideoElement>(null);
|
||
const { captureFrame } = useCamera(videoRef);
|
||
|
||
const { isSpeaking } = useVAD({
|
||
onSpeechEnd: async (audio) => {
|
||
const frame = captureFrame();
|
||
wsRef.current?.send(JSON.stringify({
|
||
type: "query",
|
||
image: frame.toDataURL("image/jpeg", 0.7),
|
||
audio: encodeAudio(audio)
|
||
}));
|
||
}
|
||
});
|
||
|
||
useEffect(() => {
|
||
wsRef.current?.on("message", (data) => {
|
||
const { text, audio } = JSON.parse(data);
|
||
setMessages(prev => [...prev, { role: "assistant", text }]);
|
||
if (audio) playAudio(audio);
|
||
});
|
||
}, []);
|
||
|
||
return { messages, videoRef, isSpeaking };
|
||
}
|
||
```
|
||
|
||
## 存储策略(分阶段)
|
||
|
||
| 阶段 | 存储方案 | 持久化内容 | 理由 |
|
||
|------|---------|-----------|------|
|
||
| MVP | Memory(进程内) | 无 | 快速验证核心功能,重启丢数据可接受。Redis 实现已就绪,可通过 `storage.driver` 配置切换 |
|
||
| 上线 | Redis + PostgreSQL | 对话历史、用户偏好、用量统计 | 用户需要查看历史,运营需要成本数据 |
|
||
| 规模化 | Redis + PG + 对象存储 | 图像帧、音频片段归档 | 大文件不适合存关系库 |
|
||
|
||
冷热分离:Redis 存"热数据"(当前对话上下文,微秒级读写),PostgreSQL 存"冷数据"(历史记录)。
|
||
|
||
### PostgreSQL 表设计
|
||
|
||
```sql
|
||
CREATE TABLE sessions (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
user_id UUID NOT NULL,
|
||
created_at TIMESTAMPTZ DEFAULT now(),
|
||
updated_at TIMESTAMPTZ DEFAULT now()
|
||
);
|
||
|
||
CREATE TABLE messages (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
session_id UUID REFERENCES sessions(id),
|
||
role VARCHAR(16) NOT NULL, -- "user" | "assistant"
|
||
content TEXT NOT NULL,
|
||
image_url TEXT,
|
||
tokens_used INTEGER DEFAULT 0,
|
||
created_at TIMESTAMPTZ DEFAULT now()
|
||
);
|
||
|
||
CREATE TABLE usage_daily (
|
||
user_id UUID NOT NULL,
|
||
date DATE NOT NULL,
|
||
llm_tokens BIGINT DEFAULT 0,
|
||
stt_seconds REAL DEFAULT 0,
|
||
tts_chars INTEGER DEFAULT 0,
|
||
estimated_cost NUMERIC(10,4) DEFAULT 0,
|
||
PRIMARY KEY (user_id, date)
|
||
);
|
||
```
|
||
|
||
## 部署架构
|
||
|
||
```
|
||
用户浏览器
|
||
↓
|
||
Nginx(同源反代 + 负载均衡)
|
||
├── / → 前端静态资源(CDN 或本地 dist)
|
||
├── /api/* → Go Gateway(REST API)
|
||
└── /ws → Go Gateway(WebSocket)
|
||
├── Gateway-1 ──→ Redis
|
||
├── Gateway-2 ──→ Redis
|
||
└── Gateway-N ──→ AI Services(外部 API)
|
||
```
|
||
|
||
**跨域策略**:Nginx 将前端和后端统一到同一域名下,浏览器无跨域问题。
|
||
|
||
### Nginx 配置
|
||
|
||
```nginx
|
||
server {
|
||
listen 80;
|
||
server_name camtalk.example.com;
|
||
|
||
# 前端静态资源
|
||
location / {
|
||
root /var/www/camtalk/dist;
|
||
try_files $uri $uri/ /index.html;
|
||
}
|
||
|
||
# REST API 反代
|
||
location /api/ {
|
||
proxy_pass http://127.0.0.1:8080;
|
||
proxy_set_header Host $host;
|
||
proxy_set_header X-Real-IP $remote_addr;
|
||
}
|
||
|
||
# WebSocket 反代
|
||
location /ws {
|
||
proxy_pass http://127.0.0.1:8080;
|
||
proxy_http_version 1.1;
|
||
proxy_set_header Upgrade $http_upgrade;
|
||
proxy_set_header Connection "upgrade";
|
||
proxy_set_header Host $host;
|
||
proxy_set_header X-Real-IP $remote_addr;
|
||
proxy_read_timeout 86400s; # 长连接超时 24h
|
||
proxy_send_timeout 86400s;
|
||
}
|
||
}
|
||
```
|
||
|
||
> WebSocket 是长连接,Nginx 必须配置 `Upgrade` 和 `Connection` 头。`proxy_read_timeout` 需要覆盖心跳间隔(客户端 30s ping),否则 Nginx 会主动断开空闲连接。
|
||
|
||
### 开发环境
|
||
|
||
开发时前端(Vite :5173)和后端(Gin :8080)不同端口。前端 WebSocket 地址基于 `window.location.host` 动态构建,通过 Vite `server.proxy` 转发到后端,无需硬编码端口。
|
||
|
||
`vite.config.ts` 中配置了 `/ws`(WebSocket)和 `/api`(REST)的代理,目标为 `http://localhost:8080`。
|