diff --git a/.env.example b/.env.example deleted file mode 100644 index 21ade5f..0000000 --- a/.env.example +++ /dev/null @@ -1,21 +0,0 @@ -# CamTalk 环境变量模板 -# 复制为 .env 并填入实际值:cp .env.example .env -# .env 已在 .gitignore 中,不会提交到版本控制 - -# ---- AI 服务 API Key ---- -CAMTALK_AI_LLM_API_KEY=sk-xxx -CAMTALK_AI_STT_API_KEY= -CAMTALK_AI_TTS_API_KEY= - -# ---- 可选覆盖(默认值见 config.yaml)---- -# CAMTALK_AI_LLM_MODEL=gpt-4o -# CAMTALK_AI_LLM_ENDPOINT=https://api.openai.com/v1 -# CAMTALK_AI_LLM_TIMEOUT=10 -# CAMTALK_AI_STT_ENDPOINT=https://api.xiaomimimo.com/v1 -# CAMTALK_AI_TTS_ENDPOINT=https://api.openai.com/v1 -# CAMTALK_AI_TTS_VOICE=alloy -# CAMTALK_AI_TTS_SPEED=1.0 -# CAMTALK_AI_TTS_TIMEOUT=5 - -# ---- 应用 ---- -# APP_ENV=dev diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index f910ceb..2e29362 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -2,7 +2,7 @@ name: Deploy on: push: - branches: [main] + branches: [v2] jobs: deploy: diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..c5b17b8 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,23 @@ +# 运行环境 +# dev / prod,决定加载 config.dev.yaml 或 config.prod.yaml(可选) +APP_ENV=dev + +# AI 服务 API Key +CAMTALK_AI_STT_API_KEY=sk-your-stt-key +CAMTALK_AI_LLM_API_KEY=sk-your-llm-key +CAMTALK_AI_TTS_API_KEY=sk-your-tts-key + +# JWT 认证 +CAMTALK_AUTH_JWT_SECRET=your-jwt-secret-here + +# PostgreSQL(storage.driver 为 postgres 时必填) +POSTGRES_USER=camtalk +POSTGRES_PASSWORD=your-postgres-password +CAMTALK_STORAGE_DSN=postgres://camtalk:your-postgres-password@postgres:5432/camtalk?sslmode=disable + +# 可选覆盖(默认值见 config.yaml) +# CAMTALK_SERVER_PORT=8080 +# CAMTALK_LOG_LEVEL=info +# CAMTALK_STORAGE_DRIVER=memory +# CAMTALK_REDIS_ADDR=localhost:6379 +# CAMTALK_REDIS_PASSWORD= diff --git a/backend/.gitignore b/backend/.gitignore index ac13b7b..8304260 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -3,6 +3,7 @@ bin/ # 环境配置 +.env config.dev.yaml config.prod.yaml diff --git a/backend/Dockerfile b/backend/Dockerfile index 7036eb6..dd93711 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -21,9 +21,8 @@ RUN apk add --no-cache ca-certificates tzdata WORKDIR /app -# 复制二进制和配置 +# 复制二进制(配置通过 docker-compose env_file 注入) COPY --from=builder /camtalk . -COPY config.yaml . EXPOSE 8080 diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 224f666..c266172 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -32,8 +32,8 @@ var Version string var startTime = time.Now() func main() { - // 加载配置 - cfg, err := config.Load() + // 加载配置(工作目录用于定位 .env 和 config.yaml) + cfg, err := config.Load(".") if err != nil { panic("failed to load config: " + err.Error()) } diff --git a/backend/config.yaml b/backend/config.yaml index 6e427d1..8480fc6 100644 --- a/backend/config.yaml +++ b/backend/config.yaml @@ -1,42 +1,60 @@ -# config.yaml — 默认配置 +# CamTalk 后端配置 + app: - env: dev + env: dev # dev / prod,可通过 APP_ENV 环境变量覆盖 server: host: "0.0.0.0" port: 8080 - read_timeout: 30 - write_timeout: 30 + read_timeout: 30 # 秒 + write_timeout: 30 # 秒 + shutdown_timeout: 10 # 优雅关闭超时(秒) + heartbeat_interval: 30 # 心跳检查间隔(秒) + heartbeat_timeout: 60 # 心跳超时断开(秒) + allowed_origins: [] # CORS 白名单,空=允许所有 + +session: + ttl: 30 # 会话过期时间(分钟) + max_history: 20 # 对话历史上限(条) + +ai: + stt: + provider: mimo # mimo / deepgram + model: mimo-v2.5-asr + endpoint: "https://api.xiaomimimo.com/v1" + timeout: 5 # STT 请求超时(秒) + http_client_timeout: 30 # HTTP 客户端超时(秒) + llm: + provider: dashscope # dashscope / openai + model: qwen3-vl-plus + endpoint: "https://dashscope.aliyuncs.com/compatible-mode/v1" + timeout: 30 # LLM 请求超时(秒) + http_client_timeout: 60 # HTTP 客户端超时(秒) + tts: + provider: mimo # mimo / openai + model: mimo-v2.5-tts + voice: mimo_default + speed: 1.0 + endpoint: "https://token-plan-cn.xiaomimimo.com/v1" + timeout: 5 # TTS 请求超时(秒) + http_client_timeout: 30 # HTTP 客户端超时(秒) + output_format: mp3 # 输出格式:mp3 / wav + sample_rate: 24000 # 输出采样率 + +storage: + driver: memory # memory / redis / postgres + # dsn 通过环境变量 CAMTALK_STORAGE_DSN 设置 redis: addr: "localhost:6379" password: "" db: 0 -ai: - stt: - provider: mimo - model: mimo-v2.5-asr - endpoint: "https://api.xiaomimimo.com/v1" - api_key: "sk-c3jhv58rr5djhxw398w2rrij5tfpnpdgxqq1bojagshzviah" - llm: - provider: dashscope - model: qwen3-vl-plus - endpoint: "https://dashscope.aliyuncs.com/compatible-mode/v1" - api_key: "sk-ws-H.REHELLY.C4s3.MEUCIQCRee37XWEKp2szaxVLFDtR1rxNNsf372zMvCR0Xl6UvQIgZgvhRTvaa1FmhbCQJgaHu4Jny29AQkn01-3hX9CWBOg" - timeout: 30 - tts: - provider: mimo - model: mimo-v2.5-tts - voice: mimo_default - speed: 1.0 - endpoint: "https://token-plan-cn.xiaomimimo.com/v1" - api_key: "tp-c9e7scwfx94qvqyhpnahnw8uaiya01za2qzvg4xe24rp3xiv" - timeout: 5 - -storage: - driver: memory +auth: + # jwt_secret 通过环境变量 CAMTALK_AUTH_JWT_SECRET 设置 + access_ttl: 15 # Access Token 过期时间(分钟) + refresh_ttl: 10080 # Refresh Token 过期时间(分钟),7 天 log: - level: info - format: console + level: info # debug / info / warn / error + format: console # console / json diff --git a/backend/go.mod b/backend/go.mod index d14bd93..755a60d 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -4,13 +4,16 @@ go 1.25.0 require ( github.com/gin-gonic/gin v1.10.0 + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/jackc/pgx/v5 v5.10.0 + github.com/joho/godotenv v1.5.1 github.com/redis/go-redis/v9 v9.20.1 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 go.uber.org/zap v1.28.0 + golang.org/x/crypto v0.23.0 ) require ( @@ -28,7 +31,6 @@ require ( github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/goccy/go-json v0.10.2 // indirect - github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect @@ -53,7 +55,6 @@ require ( go.uber.org/multierr v1.10.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/arch v0.8.0 // indirect - golang.org/x/crypto v0.23.0 // indirect golang.org/x/net v0.25.0 // indirect golang.org/x/sync v0.17.0 // indirect golang.org/x/sys v0.30.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 38b2a4b..18135ca 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -54,6 +54,8 @@ github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index b61ad4f..6a95bf2 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -2,9 +2,9 @@ package config import ( "fmt" - "os" - "strings" + "path/filepath" + "github.com/joho/godotenv" "github.com/spf13/viper" ) @@ -107,90 +107,120 @@ type AuthConfig struct { RefreshTTL int `mapstructure:"refresh_ttl"` // Refresh Token 过期时间(分钟),默认 10080(7天) } -// Load 加载配置。优先级:环境变量 > config.{env}.yaml > config.yaml。 -func Load() (*Config, error) { +// Load 加载配置。优先级:环境变量 > config.{env}.yaml > config.yaml > 默认值。 +// workDir 为项目根目录或 backend 目录,用于定位 .env 和 config.yaml。 +func Load(workDir string) (*Config, error) { + // 1. 加载 .env 文件(敏感信息) + envFile := filepath.Join(workDir, ".env") + _ = godotenv.Load(envFile) // 文件不存在也不报错 + v := viper.New() v.SetConfigName("config") v.SetConfigType("yaml") - v.AddConfigPath(".") - v.AddConfigPath("./config") - v.AddConfigPath("./backend") - v.AddConfigPath("..") // 兼容从 backend/cmd/ 启动 - v.AddConfigPath("../..") // 兼容从 backend/cmd/server/ 启动 + v.AddConfigPath(workDir) - // 默认值 - v.SetDefault("app.env", "dev") - v.SetDefault("app.version", "dev") - v.SetDefault("server.host", "0.0.0.0") - v.SetDefault("server.port", 8080) - v.SetDefault("server.read_timeout", 30) - v.SetDefault("server.write_timeout", 30) - v.SetDefault("server.heartbeat_interval", 30) - v.SetDefault("server.heartbeat_timeout", 60) - v.SetDefault("server.shutdown_timeout", 10) - v.SetDefault("session.ttl", 30) - v.SetDefault("session.max_history", 20) - v.SetDefault("redis.addr", "localhost:6379") - v.SetDefault("redis.db", 0) - v.SetDefault("ai.stt.provider", "deepgram") - v.SetDefault("ai.stt.model", "nova-2") - v.SetDefault("ai.stt.endpoint", "wss://api.deepgram.com/v1/listen") - v.SetDefault("ai.stt.timeout", 5) - v.SetDefault("ai.stt.http_client_timeout", 30) - v.SetDefault("ai.llm.provider", "openai") - v.SetDefault("ai.llm.model", "gpt-4o") - v.SetDefault("ai.llm.endpoint", "https://api.openai.com/v1") - v.SetDefault("ai.llm.timeout", 10) - v.SetDefault("ai.llm.http_client_timeout", 60) - v.SetDefault("ai.tts.provider", "openai") - v.SetDefault("ai.tts.model", "tts-1") - v.SetDefault("ai.tts.voice", "mimo_default") - v.SetDefault("ai.tts.speed", 1.0) - v.SetDefault("ai.tts.endpoint", "https://api.openai.com/v1") - v.SetDefault("ai.tts.timeout", 5) - v.SetDefault("ai.tts.http_client_timeout", 30) - v.SetDefault("ai.tts.output_format", "mp3") - v.SetDefault("ai.tts.sample_rate", 24000) - v.SetDefault("storage.driver", "memory") - v.SetDefault("storage.dsn", "") - v.SetDefault("log.level", "info") - v.SetDefault("log.format", "console") - v.SetDefault("auth.access_ttl", 15) - v.SetDefault("auth.refresh_ttl", 10080) + // 2. 设置默认值(与 config.yaml 保持一致,仅作为兜底) + setDefaults(v) - // 读取基础配置文件 - _ = v.ReadInConfig() // 文件不存在不报错 - - // 根据 APP_ENV 覆盖 - env := os.Getenv("APP_ENV") - if env == "" { - env = v.GetString("app.env") + // 3. 读取 config.yaml + if err := v.ReadInConfig(); err != nil { + return nil, fmt.Errorf("config: read config.yaml: %w", err) } + + // 4. 合并环境专属配置 config.{env}.yaml(可选) + env := v.GetString("app.env") if env != "" { v.SetConfigName("config." + env) - _ = v.MergeInConfig() + _ = v.MergeInConfig() // 文件不存在也不报错 } - // 环境变量覆盖 - v.SetEnvPrefix("CAMTALK") - v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) - v.AutomaticEnv() + // 5. 显式绑定敏感信息环境变量(不用 AutomaticEnv,避免隐式映射) + bindEnvVars(v) var cfg Config if err := v.Unmarshal(&cfg); err != nil { - return nil, fmt.Errorf("config unmarshal: %w", err) - } - - // 填充默认值 - if cfg.Server.Host == "" { - cfg.Server.Host = "0.0.0.0" - } - if cfg.Server.Port == 0 { - cfg.Server.Port = 8080 - } - if cfg.App.Env == "" { - cfg.App.Env = "dev" + return nil, fmt.Errorf("config: unmarshal: %w", err) } return &cfg, nil } + +// setDefaults 设置兜底默认值,与 config.yaml 保持一致。 +func setDefaults(v *viper.Viper) { + // app + v.SetDefault("app.env", "dev") + v.SetDefault("app.version", "dev") + + // server + v.SetDefault("server.host", "0.0.0.0") + v.SetDefault("server.port", 8080) + v.SetDefault("server.read_timeout", 30) + v.SetDefault("server.write_timeout", 30) + v.SetDefault("server.shutdown_timeout", 10) + v.SetDefault("server.heartbeat_interval", 30) + v.SetDefault("server.heartbeat_timeout", 60) + + // session + v.SetDefault("session.ttl", 30) + v.SetDefault("session.max_history", 20) + + // ai — 默认值与 config.yaml 一致(mimo/dashscope) + v.SetDefault("ai.stt.provider", "mimo") + v.SetDefault("ai.stt.model", "mimo-v2.5-asr") + v.SetDefault("ai.stt.endpoint", "https://api.xiaomimimo.com/v1") + v.SetDefault("ai.stt.timeout", 5) + v.SetDefault("ai.stt.http_client_timeout", 30) + + v.SetDefault("ai.llm.provider", "dashscope") + v.SetDefault("ai.llm.model", "qwen3-vl-plus") + v.SetDefault("ai.llm.endpoint", "https://dashscope.aliyuncs.com/compatible-mode/v1") + v.SetDefault("ai.llm.timeout", 30) + v.SetDefault("ai.llm.http_client_timeout", 60) + + v.SetDefault("ai.tts.provider", "mimo") + v.SetDefault("ai.tts.model", "mimo-v2.5-tts") + v.SetDefault("ai.tts.voice", "mimo_default") + v.SetDefault("ai.tts.speed", 1.0) + v.SetDefault("ai.tts.endpoint", "https://token-plan-cn.xiaomimimo.com/v1") + v.SetDefault("ai.tts.timeout", 5) + v.SetDefault("ai.tts.http_client_timeout", 30) + v.SetDefault("ai.tts.output_format", "mp3") + v.SetDefault("ai.tts.sample_rate", 24000) + + // storage + v.SetDefault("storage.driver", "memory") + + // redis + v.SetDefault("redis.addr", "localhost:6379") + v.SetDefault("redis.password", "") + v.SetDefault("redis.db", 0) + + // auth + v.SetDefault("auth.access_ttl", 15) + v.SetDefault("auth.refresh_ttl", 10080) + + // log + v.SetDefault("log.level", "info") + v.SetDefault("log.format", "console") +} + +// bindEnvVars 显式绑定敏感信息环境变量。 +// 只绑定不应出现在 config.yaml 中的敏感字段,非敏感配置通过 config.yaml 管理。 +func bindEnvVars(v *viper.Viper) { + // app.env 特殊处理:环境变量 APP_ENV 覆盖 config.yaml 中的 app.env + v.BindEnv("app.env", "APP_ENV") + + // AI API Key + v.BindEnv("ai.stt.api_key", "CAMTALK_AI_STT_API_KEY") + v.BindEnv("ai.llm.api_key", "CAMTALK_AI_LLM_API_KEY") + v.BindEnv("ai.tts.api_key", "CAMTALK_AI_TTS_API_KEY") + + // JWT + v.BindEnv("auth.jwt_secret", "CAMTALK_AUTH_JWT_SECRET") + + // 数据库 + v.BindEnv("storage.dsn", "CAMTALK_STORAGE_DSN") + + // Redis(密码可能包含特殊字符,通过环境变量设置更安全) + v.BindEnv("redis.password", "CAMTALK_REDIS_PASSWORD") +} diff --git a/deploy.sh b/deploy.sh index ebc22ea..fe8377e 100755 --- a/deploy.sh +++ b/deploy.sh @@ -4,30 +4,57 @@ set -euo pipefail PROJECT_DIR="$(cd "$(dirname "$0")" && pwd)" cd "$PROJECT_DIR" +# .env 固定路径(独立于项目目录,保证持久性) +ENV_FILE="/opt/camtalk/.env" + # 颜色输出 GREEN='\033[0;32m' NC='\033[0m' info() { echo -e "${GREEN}[INFO]${NC} $*"; } +# .env 检查:首次部署时从 .env.example 复制模板,提示用户填写 +check_env() { + local env_example="$PROJECT_DIR/backend/.env.example" + if [ ! -f "$ENV_FILE" ]; then + mkdir -p "$(dirname "$ENV_FILE")" + if [ -f "$env_example" ]; then + cp "$env_example" "$ENV_FILE" + info "未找到 $ENV_FILE,已从 .env.example 复制模板" + echo " 请编辑 $ENV_FILE 填入实际配置后重新运行本脚本" + exit 0 + else + echo "错误: $ENV_FILE 和 .env.example 均不存在,请手动创建" + exit 1 + fi + fi +} + +check_env + +# 所有 docker compose 命令统一使用 --env-file,用于解析 ${POSTGRES_USER} 等变量 +DC="docker compose --env-file $ENV_FILE" + cmd_build() { info "构建 Docker 镜像..." - # 启用 BuildKit 加速构建 - DOCKER_BUILDKIT=1 docker compose build --parallel + DOCKER_BUILDKIT=1 $DC build --parallel info "构建完成" } cmd_up() { info "启动服务..." - docker compose up -d + $DC up -d info "服务已启动" - info "前端: http://8.161.227.145:9000" - info "健康检查: http://8.161.227.145:9000/api/health" + PUBLIC_IP=$(curl -s --connect-timeout 3 https://ifconfig.me 2>/dev/null || \ + curl -s --connect-timeout 3 https://api.ipify.org 2>/dev/null || \ + echo "YOUR_SERVER_IP") + info "前端: http://$PUBLIC_IP:9000" + info "健康检查: http://$PUBLIC_IP:9000/api/health" } cmd_down() { info "停止服务..." - docker compose down + $DC down info "服务已停止" } @@ -38,11 +65,11 @@ cmd_restart() { } cmd_logs() { - docker compose logs -f "${@}" + $DC logs -f "${@}" } cmd_status() { - docker compose ps + $DC ps } usage() { @@ -58,6 +85,8 @@ CamTalk 部署脚本 restart 重启服务 logs 查看日志(可加服务名,如: $0 logs backend) status 查看服务状态 + +.env 路径: $ENV_FILE EOF } diff --git a/docker-compose.yml b/docker-compose.yml index fe4d766..50a0982 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,11 +17,11 @@ services: context: ./backend dockerfile: Dockerfile container_name: camtalk-backend + env_file: + - /opt/camtalk/.env environment: - - APP_ENV=production - - CAMTALK_STORAGE_DRIVER=postgres - - CAMTALK_STORAGE_DSN=postgres://camtalk:camtalk123@postgres:5432/camtalk?sslmode=disable - - CAMTALK_AUTH_JWT_SECRET=78uWBBAF8XEQEotKDlrnlnd4y8i4WN3E4zXmNmC8BYQ= + - CAMTALK_STORAGE_DRIVER=${CAMTALK_STORAGE_DRIVER:-postgres} + - CAMTALK_STORAGE_DSN=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/camtalk?sslmode=disable depends_on: postgres: condition: service_healthy @@ -30,12 +30,11 @@ services: restart: unless-stopped postgres: - # 轩辕镜像加速,避免 Docker Hub 拉取超时 image: docker.m.daocloud.io/library/postgres:15-alpine container_name: camtalk-postgres environment: - POSTGRES_USER: camtalk - POSTGRES_PASSWORD: camtalk123 + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: camtalk volumes: - pgdata:/var/lib/postgresql/data @@ -43,7 +42,7 @@ services: networks: - camtalk-net healthcheck: - test: ["CMD-SHELL", "pg_isready -U camtalk -d camtalk"] + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d camtalk"] interval: 5s timeout: 3s retries: 10 diff --git a/docs/01-架构设计.md b/docs/01-架构设计.md new file mode 100644 index 0000000..f770b43 --- /dev/null +++ b/docs/01-架构设计.md @@ -0,0 +1,389 @@ +# 架构设计 + +## 项目概述 + +CamTalk 是一款**多模态实时 AI 视觉对话助手**。用户通过摄像头和麦克风与 AI 交互,AI 理解视觉场景和语音输入后,以文字和语音形式给出自然回应。 + +核心挑战在于三个维度之间的张力: + +| 维度 | 关键问题 | +|------|---------| +| 视觉理解 | 如何准确理解摄像头画面中的人物、物体、场景? | +| 语音交互 | 如何让对话像真人交流一样自然、低延迟? | +| 成本控制 | 实时视频流 + LLM 推理,如何避免账单爆炸? | + +## 系统架构 + +三层架构:**前端做轻量预处理,后端做智能编排,云端 AI 服务按需调用**。 + +```mermaid +graph TB + subgraph Browser["浏览器客户端"] + UI["UI 渲染层
React 18 + TypeScript"] + Edge["边缘预处理层
VAD / 关键帧检测"] + Media["媒体采集层
Camera / Microphone"] + end + + subgraph Gateway["Go 网关"] + WS["WebSocket Handler
连接管理 / 消息分发"] + Session["Session Manager
会话状态 / 对话历史"] + Orch["AI Orchestrator
STT→LLM→TTS 流式并行"] + Auth["Auth 模块
JWT / bcrypt"] + REST["REST API
健康检查 / 对话管理"] + Store["Store 层
Repository 接口"] + end + + subgraph AI["云端 AI 服务"] + STT["STT
Deepgram / MiMo ASR"] + LLM["LLM
GPT-4o / 通义千问"] + TTS["TTS
OpenAI TTS / MiMo TTS"] + end + + subgraph Storage["存储层"] + Mem["Memory
进程内缓存"] + Redis["Redis
会话状态"] + PG["PostgreSQL
持久化存储"] + end + + Media --> Edge + Edge -->|"query (image+audio)"| WS + UI <-->|"WebSocket"| WS + WS --> Session + WS --> Orch + Orch --> STT + Orch --> LLM + Orch --> TTS + Session --> Store + Store --> Mem + Store --> Redis + Store --> PG + REST --> Session + WS --> Auth +``` + +> 为什么单独加一层 Go 网关,而不是让前端直连 AI API?1)API Key 安全性;2)统一的速率限制和成本管控;3)多模型路由逻辑集中在一处便于维护。 + +## 核心交互流程 + +一次完整的"用户提问 → AI 回答"流程: + +```mermaid +sequenceDiagram + participant B as 浏览器 + participant G as Go 网关 + participant S as STT + participant L as LLM + participant T as TTS + + B->>B: VAD 检测到语音结束 + B->>G: query {image, audio} + G->>S: 音频流 + S-->>G: 流式文本 + G-->>B: stt_result {text} + + G->>L: [图像 + 文本 + 上下文] + loop LLM 流式输出 + L-->>G: token delta + G-->>B: llm_chunk {delta} + end + G-->>B: llm_done {full_text, tokens} + + par LLM 输出的同时 + G->>G: 句子切分器检测到完整句子 + G->>T: 句子文本 + T-->>G: 音频 chunk + G-->>B: tts_audio {audio} + end + G-->>B: tts_audio {final: true} +``` + +**关键优化**:LLM 文本流和 TTS 音频流**并行推送**——客户端先逐 token 展示文字,同时 TTS 逐句子合成并推送音频,用户感知延迟大幅降低。 + +## 技术栈 + +### 前端 + +| 技术 | 选型 | 选择理由 | +|------|------|---------| +| 框架 | React 18 + TypeScript | 组件化开发,类型安全,生态成熟 | +| 构建 | Vite | 开发热更新快,构建产物小 | +| 实时通信 | WebSocket(原生 API) + 自封装连接管理 | 浏览器原生支持,封装心跳/重连/消息分发 | +| 语音检测 | @ricky0123/vad-web | 基于 WebRTC VAD,纯前端零延迟 | +| 媒体采集 | MediaDevices API | 浏览器原生摄像头/麦克风访问 | + +### 后端 + +| 技术 | 选型 | 选择理由 | +|------|------|---------| +| 语言 | Go | 高并发 goroutine 模型,适合长连接管理 | +| HTTP 框架 | Gin | 高性能 HTTP 路由,中间件生态成熟 | +| WebSocket | gorilla/websocket | Go 生态最成熟的 WebSocket 库 | +| 会话存储 | Memory(默认) / Redis | 进程内存零依赖,Redis 支持多实例部署 | +| 持久化存储 | PostgreSQL | 对话历史、用户数据、会话元数据 | +| 配置管理 | Viper + godotenv | 支持 YAML + .env + 环境变量覆盖 | +| 日志 | Zap | 高性能结构化日志 | + +### AI 服务 + +| 能力 | 默认方案 | 备选方案 | +|------|---------|---------| +| 多模态 LLM | GPT-4o | 通义千问等 OpenAI 兼容模型 | +| 语音识别 STT | Deepgram | MiMo ASR(小米) | +| 语音合成 TTS | OpenAI TTS | MiMo TTS(小米) | + +> Go 网关的 AI 服务层统一封装不同服务商的调用接口,通过配置切换 provider。 + +## 后端模块 + +```mermaid +graph LR + subgraph Entry["入口层"] + Main["main.go
依赖注入 / 启动"] + end + + subgraph Transport["传输层"] + WSH["WebSocket Handler
连接管理 / 认证"] + APH["REST API Handlers
Auth / Conversation / Health"] + end + + subgraph Business["业务层"] + SM["Session Manager
会话生命周期"] + ORCH["Orchestrator
STT→LLM→TTS 编排"] + AS["Auth Service
注册/登录/刷新/登出"] + end + + subgraph AI_Layer["AI 服务层"] + STT_S["STT Service
Deepgram / MiMo"] + LLM_S["LLM Service
OpenAI 兼容"] + TTS_S["TTS Service
OpenAI / MiMo"] + end + + subgraph Data["数据层"] + UR["UserRepository"] + MR["MessageRepository"] + SR["SessionRepository"] + end + + Main --> WSH + Main --> APH + Main --> SM + Main --> ORCH + Main --> AS + + WSH --> SM + WSH --> ORCH + APH --> SM + APH --> AS + ORCH --> STT_S + ORCH --> LLM_S + ORCH --> TTS_S + SM --> MR + SM --> SR + AS --> UR +``` + +| 模块 | 职责 | +|------|------| +| WebSocket Handler | 管理客户端连接生命周期,JWT 认证,conversation_id 恢复,单播消息推送 | +| Session Manager | 维护用户会话状态、对话历史。Memory(默认)/ Redis(可切换),30 分钟 TTL,Write-Through 到 PG | +| AI Orchestrator | 编排 STT→LLM→TTS 流式并行管道,context 取消 + 超时控制 + 句子切分 | +| AI Service Layer | AI 服务抽象层,多 provider 支持(Deepgram/MiMo/OpenAI 等) | +| Auth | 用户认证与授权。JWT (HS256) 双 token 轮转,bcrypt 密码哈希,Gin 中间件 | +| Store | 持久化存储层。UserRepository / MessageRepository / SessionRepository,内存 + PostgreSQL 双实现 | +| REST API | 健康检查、认证、对话管理端点 | +| Logger | Zap 结构化日志 | +| Models | 数据模型定义 | +| Migrations | 数据库版本化迁移,嵌入式 SQL 文件自动执行 | +| Model Router | 根据请求类型选择 AI 模型(待实现) | +| Rate Limiter | 令牌桶限流(待实现) | + +## 前端组件 + +| 组件 | 职责 | +|------|------| +| AuthPage | 登录/注册表单 | +| CameraManager | 摄像头流采集 | +| MicManager | 麦克风音频采集 | +| EdgeProcessor | VAD + 关键帧检测(Canvas 像素比较) | +| WebSocketManager | WS 连接生命周期管理 | +| ChatPanel | 消息展示、流式回复、文本输入、场景选择 | +| VideoPreview | 摄像头画面预览 | +| SessionSidebar | 左侧抽屉式对话列表(搜索、重命名、删除) | +| ConfigPanel | 右侧抽屉式配置面板(主题、TTS 开关、detail level、语言、场景、账户) | +| Toast | 轻量通知提示(3 秒自动消失) | + +核心 Hook:`useVisionSession()` 封装一次完整的视觉对话会话(摄像头、VAD、WebSocket、消息状态、认证、场景模式)。 + +## 数据库设计 + +### ER 关系 + +```mermaid +erDiagram + users ||--o{ sessions : "1:N" + users ||--o{ refresh_tokens : "1:N" + sessions ||--o{ messages : "1:N" + + users { + uuid id PK + varchar username UK + varchar password_hash + timestamptz created_at + timestamptz updated_at + } + + sessions { + uuid id PK + uuid user_id FK + varchar title + jsonb config + timestamptz created_at + timestamptz updated_at + } + + messages { + bigserial id PK + uuid session_id FK + varchar role + text content + integer tokens_used + timestamptz created_at + } + + refresh_tokens { + bigserial id PK + uuid user_id FK + varchar token_hash UK + timestamptz expires_at + timestamptz created_at + } +``` + +### 表结构 + +```sql +-- 用户表 +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + username VARCHAR(64) NOT NULL UNIQUE, + password_hash VARCHAR(256) NOT NULL, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +-- 会话表 +CREATE TABLE sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title VARCHAR(128) DEFAULT '新对话', + config JSONB DEFAULT '{}', + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +-- 消息表 +CREATE TABLE messages ( + id BIGSERIAL PRIMARY KEY, + session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + role VARCHAR(16) NOT NULL, + content TEXT NOT NULL, + tokens_used INTEGER DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT now() +); + +-- 刷新令牌表 +CREATE TABLE refresh_tokens ( + id BIGSERIAL PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash VARCHAR(256) NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() +); +``` + +### 存储策略 + +| 场景 | 存储方案 | 说明 | +|------|---------|------| +| 默认 | Memory(进程内) | 零依赖,快速启动。MemoryManager 支持 Write-Through 到 PG | +| 持久化 | Memory + PostgreSQL | 通过 `storage.driver: postgres` 启用,MemoryManager 注入 PG Repository | +| 多实例 | Redis(独立) | 通过配置切换到 RedisManager,适合多实例部署 | + +冷热分离:Redis/Memory 存"热数据"(当前对话上下文,微秒级读写),PostgreSQL 存"冷数据"(历史记录)。MemoryManager 的 Write-Through 机制确保每次 AppendMessage 同时写入 PG,重启后可从 PG 恢复会话。 + +## 认证设计 + +```mermaid +sequenceDiagram + participant C as 客户端 + participant G as Go 网关 + participant DB as PostgreSQL + + Note over C,DB: 注册流程 + C->>G: POST /api/auth/register {username, password} + G->>G: bcrypt hash 密码 + G->>DB: INSERT users + G->>G: 生成 access_token + refresh_token + G->>DB: 存 SHA256(refresh_token) + G-->>C: {user, access_token, refresh_token} + + Note over C,DB: 登录流程 + C->>G: POST /api/auth/login {username, password} + G->>DB: 查 users by username + G->>G: bcrypt.CompareHashAndPassword + G->>G: 生成 token pair + G->>DB: 存 SHA256(refresh_token) + G-->>C: {user, access_token, refresh_token} + + Note over C,DB: Token 刷新(轮转) + C->>G: POST /api/auth/refresh {refresh_token} + G->>G: 校验签名和过期 + G->>DB: 验证 hash 存在 + G->>DB: 撤销旧 refresh_token + G->>G: 生成新 token pair + G->>DB: 存新 refresh_token hash + G-->>C: {access_token, refresh_token} +``` + +**Token 策略**:access_token 15 分钟有效,refresh_token 7 天有效。refresh 时旧 token 失效(轮转),防止重放攻击。 + +**WebSocket 认证**:连接地址 `ws://host/ws?token=&conversation_id=`。HTTP Upgrade 前校验 token,失败返回 401。 + +## 部署架构 + +```mermaid +graph TB + User["用户浏览器"] --> Nginx + + subgraph Nginx["Nginx 反向代理"] + Static["/ → 前端静态资源"] + API["/api/* → Go Gateway"] + WS_Proxy["/ws → Go Gateway"] + end + + subgraph Gateway_Pool["Go Gateway 实例"] + G1["Gateway-1"] + G2["Gateway-2"] + GN["Gateway-N"] + end + + Nginx --> G1 + Nginx --> G2 + Nginx --> GN + + G1 --> Redis + G2 --> Redis + GN --> Redis + + G1 --> PG_DB["PostgreSQL"] + G2 --> PG_DB + GN --> PG_DB + + G1 --> AI_Services["AI Services(外部 API)"] + G2 --> AI_Services + GN --> AI_Services +``` + +**跨域策略**:Nginx 将前端(`/`)、REST API(`/api/*`)、WebSocket(`/ws`)统一反代到同一域名,浏览器无跨域问题。 + +**开发环境**:前端 Vite :5173 通过 `server.proxy` 转发 `/ws` 和 `/api` 到后端 :8080,无需硬编码端口。 diff --git a/docs/01-项目概述.md b/docs/01-项目概述.md deleted file mode 100644 index 8a5a2c9..0000000 --- a/docs/01-项目概述.md +++ /dev/null @@ -1,28 +0,0 @@ -# 项目概述 - -## 概述 - -开发一款**多模态实时对话应用**——通过摄像头与麦克风捕获用户的视觉场景与语音输入,由 AI 理解并给出自然、流畅的回应。 - -核心挑战在于三个维度之间的张力: - -| 维度 | 关键问题 | 详见 | -|------|---------|------| -| 视觉理解 | 如何准确理解摄像头画面中的人物、物体、场景? | `07-视觉理解.md` | -| 语音交互 | 如何让对话像真人交流一样自然、低延迟? | `06-语音交互.md` | -| 成本控制 | 实时视频流 + LLM 推理,如何避免账单爆炸? | `08-成本控制.md` | - -> 提升视觉精度意味着更高分辨率和更频繁的采样,但这会直接推高带宽和推理成本。架构设计需要在三者之间做好取舍。 - -## 项目目标 - -1. **用户故事规划**:明确"AI 能看、能听、能说"需要覆盖哪些场景 → `05-用户故事.md` -2. **成本控制策略**:从架构设计层面融入运营成本意识 → `08-成本控制.md` - -## 交付物 - -- 可运行的应用程序(摄像头 + 麦克风 → AI 回应) -- 设计文档,覆盖: - - 计划实现 vs 最终实现的用户故事 - - 成本控制技巧的构思 vs 实际采用的方案 - - 项目架构设计与技术选型 diff --git a/docs/03-接口文档.md b/docs/02-接口文档.md similarity index 60% rename from docs/03-接口文档.md rename to docs/02-接口文档.md index 3f7d28f..071a75e 100644 --- a/docs/03-接口文档.md +++ b/docs/02-接口文档.md @@ -2,11 +2,11 @@ ## 概述 -前后端通信接口定义。以 WebSocket 承载实时对话,REST 端点支撑基础运维。**暂不实现持久化**,但通过 Repository 接口模式为后续扩展预留接入点。 +前后端通信接口定义。以 WebSocket 承载实时对话,REST 端点支撑基础运维。持久化通过 PostgreSQL 实现,MemoryManager 支持 Write-Through 模式。 **设计原则**: - WebSocket 为主:所有对话数据走 WebSocket -- REST 为辅:仅用于健康检查、会话管理等低频操作 +- REST 为辅:仅用于健康检查、认证、对话管理等低频操作 - 接口先行:先定义契约,再填充实现——前后端可并行开发 ## 接口全景 @@ -20,7 +20,6 @@ /api/conversations/* HTTP Client <--> GET /api/conversations/:id (历史消息) /messages - HTTP Client ~~> POST/DELETE /api/sessions (已废弃,保留兼容) ``` --- @@ -34,8 +33,6 @@ | `token` | 是 | JWT access_token,缺失或无效时返回 401 | | `conversation_id` | 否 | 恢复已有对话;省略则创建新对话 | -> 详见"REST API → WebSocket 认证变更"章节。 - ### 消息格式约定 所有 WebSocket 消息均为 JSON 文本帧,统一结构: @@ -79,6 +76,7 @@ interface ConfigMessage { tts_enabled?: boolean; // 是否开启语音合成,默认 true detail_level?: "low" | "high"; // 图像精度,默认 "low" language?: string; // 交互语言,默认 "zh-CN" + scenario?: string; // 场景模式:free_chat / interviewer / english_teacher / debate / interpreter }; } ``` @@ -174,7 +172,7 @@ interface TTSAudioMessage { | 属性 | 值 | 说明 | |------|------|------| -| 编码 | `audio/mp3`(MP3) | 浏览器 `