Files
CamTalk/docs/03-接口文档.md
hhs 6e0c67e1cb docs: 文档与代码一致性检查与修复
- 修复心跳 Bug:应用层 ping 不更新 lastPong,60 秒后连接会被错误断开
- 03-接口文档:audio/mpeg→audio/mp3、STTConfig/TTSConfig 补充 Model 字段、
  APP_ENV 环境变量名修正、配置搜索路径补充、.env 加载说明、Vite proxy 说明修正
- 02-系统架构:补充 ConfigPanel/Toast 组件、Model Router/Rate Limiter 标注规划中、
  补充 Gin 框架、MVP 存储改为 Memory、AI 服务 provider 更新、Orchestrator 伪代码对齐
- 04-技术选型:新增 AI 服务栈选型章节(STT/LLM/TTS)、PostgreSQL 标注规划中
- 06-语音交互:VAD 参数名修正、STT 改为一次性识别描述、音频编码格式补充
- 07-视觉理解:关键帧检测代码改为 TypeScript、分辨率修正、阈值逻辑统一
- 08-成本控制:变量名修正、未实现功能标注规划中、对话历史裁剪策略补充
- CLAUDE.md:同步更新技术栈、模块结构、存储策略描述
2026-06-14 08:52:36 +08:00

32 KiB
Raw Blame History

接口文档

概述

前后端通信接口定义。以 WebSocket 承载实时对话REST 端点支撑基础运维。暂不实现持久化,但通过 Repository 接口模式为后续扩展预留接入点。

设计原则

  • WebSocket 为主:所有对话数据走 WebSocket
  • REST 为辅:仅用于健康检查、会话管理等低频操作
  • 接口先行:先定义契约,再填充实现——前后端可并行开发

接口全景

浏览器                    Go Gateway :8080
  WebSocket Client  <-->  /ws           (实时对话)
  HTTP Client       -->   GET /api/health
  HTTP Client       <-->  POST/DELETE /api/sessions

一、WebSocket 协议

连接地址:ws://localhost:8080/ws

消息格式约定

所有 WebSocket 消息均为 JSON 文本帧,统一结构:

interface WsMessage {
  type: string;        // 消息类型,必填
  request_id?: string; // 可选,用于请求-响应关联
  timestamp?: number;  // 可选,毫秒时间戳
  [key: string]: any;  // 类型特定字段
}

客户端 → 服务端消息

query — 发起一次视觉对话

用户说完话后,客户端同时发送当前图像帧和语音片段:

interface QueryMessage {
  type: "query";
  request_id: string;    // 客户端生成的 UUID
  image: string;         // Base64 编码的 JPEG 图像(不含 data: 前缀)
  audio: string;         // Base64 编码的音频片段PCM 16kHz
  mime_type?: string;    // 音频格式,默认 "audio/pcm"
}

为什么图像和音频放在同一条消息里?因为 VAD 检测到用户说完话时,需要同时捕获"此刻的画面"和"说的话",拆成两条消息会增加时序同步的复杂度。

config — 更新会话配置

interface ConfigMessage {
  type: "config";
  payload: {
    tts_enabled?: boolean;         // 是否开启语音合成,默认 true
    detail_level?: "low" | "high"; // 图像精度,默认 "low"
    language?: string;             // 交互语言,默认 "zh-CN"
  };
}

interrupt — 打断当前回复

interface InterruptMessage {
  type: "interrupt";
  request_id?: string; // 可选,当前实现不使用此字段,服务端始终取消当前活跃请求
}

ping — 心跳保活

interface PingMessage {
  type: "ping";
}

服务端 → 客户端消息

connected — 连接建立确认

interface ConnectedMessage {
  type: "connected";
  session_id: string;       // 服务端生成的会话 ID
  server_version: string;   // 服务端版本号,如 "0.1.0"
}

stt_result — 语音识别结果

interface STTResultMessage {
  type: "stt_result";
  request_id: string;
  text: string;        // 识别出的用户语音文本
  is_final: boolean;   // 是否为最终结果
}

llm_chunk — LLM 流式输出片段

interface LLMChunkMessage {
  type: "llm_chunk";
  request_id: string;
  delta: string;       // 本次增量文本
  role: "assistant";
}

llm_done — LLM 输出完成

interface LLMDoneMessage {
  type: "llm_done";
  request_id: string;
  full_text: string;     // 完整回复文本
  tokens_used: {
    prompt: number;
    completion: number;
    total: number;
  };
  model: string;         // 实际使用的模型名
  latency_ms: number;    // 端到端延迟(毫秒)
}

tts_audio — TTS 音频流片段

interface TTSAudioMessage {
  type: "tts_audio";
  request_id: string;
  audio: string;         // Base64 编码的音频片段
  mime_type: string;     // "audio/mp3"
  is_last: boolean;      // 是否为最后一片
}

音频格式规范(前端播放依赖此约定):

属性 说明
编码 audio/mp3MP3 浏览器 <audio> 原生支持OpenAI TTS 默认输出
采样率 24kHz OpenAI TTS 默认
声道 单声道 语音不需要立体声
传输 Base64 编码的 MP3 片段 每个 tts_audio 消息携带一个句子的音频
切片粒度 按句子切分 LLM 输出中按 。!?\n 等标点切分,每个句子独立合成

流式播放时序tts_audio 消息按句子顺序到达,前端应按序排队播放,不要等全部到齐再播。

前端播放实现要点

  1. 排队播放:收到 tts_audio 时,将 Base64 解码为 Blob URL 并加入播放队列。第一片到达即开始播放,后续片段在 onended 回调中自动衔接。
  2. 错误容错:单个片段播放失败时跳过,继续播放队列中下一个,不中断整个回复。
  3. 打断清理:收到 interrupt 消息或用户触发打断时,清空播放队列并释放所有 Blob URL。
  4. 类型锁定mime_type 字段固定为 "audio/mp3",前端解码时直接使用,无需运行时判断。
// 前端播放器伪代码
class AudioPlayer {
  private queue: string[] = [];  // Blob URL 队列

  enqueue(base64: string) {
    const url = decodeBase64Audio(base64, "audio/mp3");
    this.queue.push(url);
    if (this.queue.length === 1) this.playNext(); // 第一片到了就开始播
  }

  private playNext() {
    if (this.queue.length === 0) return;
    const audio = new Audio(this.queue[0]);
    audio.onended = () => { URL.revokeObjectURL(this.queue.shift()!); this.playNext(); };
    audio.onerror = () => { URL.revokeObjectURL(this.queue.shift()!); this.playNext(); };
    audio.play();
  }

  clear() { this.queue.forEach(url => URL.revokeObjectURL(url)); this.queue = []; }
}

error — 错误通知

interface ErrorMessage {
  type: "error";
  request_id?: string;
  code: string;          // 错误码,见下方错误码表
  message: string;       // 人类可读的错误描述
}

pong — 心跳响应

interface PongMessage {
  type: "pong";
}

消息流时序

一次完整交互:

Client                          Server
  |                               |
  |-- query {image, audio} ------>|
  |<-- stt_result {text} ---------|
  |                               |
  |<-- llm_chunk {delta: "这"} ---|  (LLM 流式输出)
  |<-- llm_chunk {delta: "是一"} -|
  |<-- llm_chunk {delta: "朵花"} -|
  |<-- llm_done {full_text} ------|
  |                               |
  |<-- tts_audio {audio} ---------|  (TTS 音频流)
  |<-- tts_audio {is_last: true} -|

二、REST API

健康检查

GET /api/health

响应:

{
  "status": "ok",
  "version": "0.1.0",
  "uptime_seconds": 3600,
  "active_sessions": 42
}

创建会话可选MVP 自动创建)

POST /api/sessions
Content-Type: application/json

{
  "config": {
    "tts_enabled": true,
    "detail_level": "low",
    "language": "zh-CN"
  }
}

响应:

{
  "session_id": "550e8400-e29b-41d4-a716-446655440000",
  "created_at": "2026-06-12T15:41:00Z"
}

销毁会话

DELETE /api/sessions/{session_id}

响应:204 No Content

预留端点(暂不实现)

端点 方法 用途
/api/sessions/{id}/messages GET 查询对话历史
/api/usage GET 查询用量统计
/api/users/{id}/preferences GET/PUT 用户偏好管理

三、AI 服务层接口

Go 网关内部与外部 AI 服务STT、LLM、TTS的调用契约。默认配置为 Deepgram STT、GPT-4o LLM、OpenAI TTS但通过 OpenAI 兼容接口可灵活切换到其他服务商(如 MiMo ASR、通义千问等。前后端联调时后端需实现这些接口。

STT 服务接口

语音识别:接收前端采集的音频,返回识别文本。

// Service 语音识别服务契约。
type Service interface {
    // Recognize 识别一段完整音频,返回最终文本。
    Recognize(ctx context.Context, audio []byte, opts Options) (string, error)
}

// Options 语音识别参数。
type Options struct {
    Encoding   string // "pcm_s16le" — 前端 VAD 输出格式
    SampleRate int    // 16000 — 前端麦克风采样率
    Language   string // "zh-CN"
}

Deepgram 接入约定

  • 连接方式WebSocket wss://api.deepgram.com/v1/listen
  • 音频格式PCM 16-bit signed little-endian16kHz 单声道(与前端 MicManager 输出一致)
  • 返回格式:channel.alternatives[0].transcriptis_final 字段标识最终结果
  • 超时:单次识别 5 秒超时

LLM 服务接口

多模态推理:接收图像 + 文本 + 对话历史,流式返回回复。

// Service 多模态大模型服务契约。
type Service interface {
    // ChatStream 流式推理,返回增量文本的 channel。
    // 调用方必须消费 channel 直到 Done=true否则需 cancel ctx 以释放连接。
    ChatStream(ctx context.Context, req Request) (<-chan Chunk, error)
}

// Request 推理请求。
type Request struct {
    Image    []byte            // JPEG 图片(已从 Base64 解码)
    Text     string            // 用户语音识别后的文本
    History  []models.Message  // 最近 N 轮对话历史
    Language string            // "zh-CN"
}

// Chunk 流式推理的一个增量片段。
type Chunk struct {
    Delta      string       // 增量文本
    Done       bool         // 是否结束
    TokensUsed *TokenUsage  // 仅 Done=true 时有值
    Model      string       // 实际使用的模型名
}

// TokenUsage 用量统计。
type TokenUsage struct {
    Prompt     int
    Completion int
    Total      int
}

OpenAI API 接入约定

  • 端点:POST https://api.openai.com/v1/chat/completions
  • 图片传入:image_url 字段使用 data:image/jpeg;base64,... 格式
  • 流式响应:stream: true,通过 SSE 逐 chunk 返回
  • Prompt 结构:
system: "你是一个视觉助手。用户通过摄像头看到一个场景,并用语音向你提问。
         请用简洁自然的中文回答。如果涉及视觉描述,先说'我看到...'。"
user: [图片 + 用户语音文本]
(重复 History 中的历史消息)
  • 超时10 秒,超时返回 LLM_TIMEOUT 错误
  • 模型选择:默认 gpt-4o,可通过配置切换到其他 OpenAI 兼容模型

TTS 服务接口

语音合成:接收文本流,输出音频 chunk 流。

// Service 语音合成服务契约。
type Service interface {
    // SynthesizeStream 流式合成。
    // textStream 接收句子级文本(由 Orchestrator 的句子切分器产出),
    // 返回的 channel 持续输出 MP3 音频 chunk。
    SynthesizeStream(ctx context.Context, textStream <-chan string, opts Options) (<-chan Chunk, error)
}

// Options 合成参数。
type Options struct {
    Voice      string  // "alloy" | "nova" | "shimmer" | ...
    Speed      float64 // 1.0 为正常语速
    OutputFmt  string  // "mp3" — 固定使用 MP3浏览器原生支持
    SampleRate int     // 24000
}

// Chunk 一个音频片段。
type Chunk struct {
    Audio  []byte // MP3 音频数据(未 Base64 编码,由发送层编码)
    IsLast bool   // 是否为最后一片
}

OpenAI TTS 接入约定

  • 端点:POST https://api.openai.com/v1/audio/speech
  • 模型:tts-1(低延迟优先)或 tts-1-hd(高音质)
  • 输出格式:mp324kHz
  • 流式:使用 response_format: "mp3" 并读取 response body 流
  • 超时:单个句子 5 秒超时

四、AI 编排器Orchestrator

编排策略:句子级流式并行

核心矛盾LLM 流式输出逐 tokenTTS 需要完整句子才能合成。解法:句子切分器 + 管道并行

LLM 流式输出:  "这" "是一" "朵红色" "的花。" "它看起" "来很美" "丽。"
                ↓
        ┌── 句子检测器(按 。!?\n 切分)──┐
        ↓                                 ↓
  句子1: "这是一朵红色的花。"          句子2: "它看起来很美丽。"
        ↓                                 ↓
     TTS 合成                          TTS 合成
        ↓                                 ↓
  音频 chunk → 推送前端             音频 chunk → 推送前端

时序保证

  • llm_chunk 消息一定先于对应句子的 tts_audio 到达客户端
  • 用户先看到文字,紧接着听到语音(感知延迟 < 0.5 秒)
  • 不必等 LLM 全部输出完才开始 TTS

Orchestrator 接口

// Orchestrator AI 编排器接口。
type Orchestrator interface {
    // ProcessQuery 处理一次完整的视觉对话请求。
    // 通过 sender 向前端实时推送 stt_result、llm_chunk、llm_done、tts_audio 消息。
    ProcessQuery(ctx context.Context, sessionID string, req models.WsQuery,
        history []models.Message, sender Sender) error
}

// Sender 抽象 WebSocket 消息推送能力,便于测试时 mock。
type Sender interface {
    SendSTTResult(result models.WsSTTResult) error
    SendLLMChunk(chunk models.WsLLMChunk) error
    SendLLMDone(done models.WsLLMDone) error
    SendTTSAudio(audio models.WsTTSAudio) error
    SendError(err models.WsError) 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 消息

并发控制

  • 每个 ProcessQuery 调用在独立 goroutine 中运行
  • context.WithTimeout 确保 10 秒总超时
  • interrupt 消息触发 cancel()LLM/TTS 流式全部中断
  • 同一 session 内同时只允许一个活跃请求,新请求自动取消上一个

错误处理与降级

故障点 处理策略 客户端表现
STT 失败 发送 STT_ERROR,终止本次请求 回退到纯文本模式
LLM 超时(>10s 发送 LLM_TIMEOUT,取消 TTS 提示用户重试
LLM 部分输出后失败 已推送的 llm_chunk 保留,发送 error 通知中断 显示已收到的部分文字
TTS 失败 静默跳过,llm_done 正常发送 只有文字回复,无语音
TTS 部分失败 已推送的音频保留,后续句子跳过 部分句子有语音
interrupt 打断 cancel context清空所有流 前端清空播放队列

五、Session Manager

WebSocket Handler 和 AI Orchestrator 之间的会话管理层。负责维护会话生命周期、对话上下文和配置状态。

Redis 数据结构

每个会话在 Redis 中占 2 个 key

session:{id}:meta    → Hash   (会话元数据)
session:{id}:history → List   (对话历史)

Hash — session:{id}:meta

field 类型 示例 说明
session_id string "550e8400-..." 主键冗余
config.tts_enabled string "true" Redis Hash 值均为 string
config.detail_level string "low"
config.language string "zh-CN"
created_at string "2026-06-13T10:00:00Z" RFC3339
last_active string "2026-06-13T10:05:30Z" 每次消息刷新
active_request_id string "uuid""" 当前处理中的请求 ID用于 interrupt

List — session:{id}:history

每个元素是一条 JSON 序列化的 Message

{"role":"user","content":"这是什么花?"}
{"role":"assistant","content":"这是一朵红色的玫瑰。"}
  • LPUSH 新消息到左头(最新在前)
  • LRANGE 0 {limit-1} 取最近 N 轮
  • LTRIM 0 {max-1} 限制总条数(默认保留最近 20 条 = 10 轮对话)

TTL 策略

场景 TTL 说明
创建时 30 分钟 EXPIRE 设置
每次收到消息 重置 30 分钟 EXPIRE 刷新
WebSocket 断开 不主动删 等自然过期,支持重连恢复
超过 30 分钟无活动 自动过期 Redis 自动清理 meta + history
显式销毁REST API 立即 DEL 两个 key 一起删

接口定义

// Manager 会话管理器接口。
// WebSocket Handler 通过此接口操作会话,不直接接触存储层。
type Manager interface {
    // Create 创建新会话,返回 session ID。
    Create(ctx context.Context, config models.SessionConfig) (string, error)

    // Get 获取会话(含 config。不存在返回 ErrSessionNotFound。
    Get(ctx context.Context, sessionID string) (*models.Session, error)

    // UpdateConfig 更新会话配置config 消息触发)。
    UpdateConfig(ctx context.Context, sessionID string, patch models.SessionConfigPatch) error

    // GetHistory 获取最近 N 轮对话历史(供 Orchestrator 构建 LLM 上下文)。
    GetHistory(ctx context.Context, sessionID string, limit int) ([]models.Message, error)

    // AppendMessage 追加一条对话消息,同时刷新 TTL。
    AppendMessage(ctx context.Context, sessionID string, msg models.Message) error

    // SetActiveRequest 标记当前正在处理的请求 IDinterrupt 用)。
    SetActiveRequest(ctx context.Context, sessionID string, requestID string) error

    // GetActiveRequestID 获取当前活跃请求 ID。
    GetActiveRequestID(ctx context.Context, sessionID string) (string, error)

    // ClearActiveRequest 清除活跃请求标记(请求完成或中断后)。
    ClearActiveRequest(ctx context.Context, sessionID string) error

    // Touch 刷新 TTL心跳时调用
    Touch(ctx context.Context, sessionID string) error

    // Destroy 显式销毁会话REST API DELETE
    Destroy(ctx context.Context, sessionID string) error

    // ActiveCount 返回当前活跃会话数(健康检查用)。
    ActiveCount() int
}

WebSocket Handler 集成

// query 分支
case "query":
    var msg models.WsQuery
    json.Unmarshal(message, &msg)

    sessionMgr.Touch(ctx, sessionID)                    // 刷新 TTL
    sessionMgr.SetActiveRequest(ctx, sessionID, msg.RequestID) // 标记活跃请求

    history, _ := sessionMgr.GetHistory(ctx, sessionID, 20)    // 获取对话上下文

    sender := &WSClient{client: client, requestID: msg.RequestID}
    go orch.ProcessQuery(ctx, sessionID, msg, history, sender) // 异步编排

// interrupt 分支
case "interrupt":
    reqID, _ := sessionMgr.GetActiveRequestID(ctx, sessionID)
    if reqID != "" {
        cancelFunc(reqID)                                    // 取消对应 context
        sessionMgr.ClearActiveRequest(ctx, sessionID)
    }

// 连接断开
// 不调用 Destroy让 session 自然过期(支持重连恢复)

MVP 内存实现

联调阶段无 Redis 时,用同一接口的内存实现:

type MemoryManager struct {
    mu          sync.RWMutex
    sessions    map[string]*sessionEntry
    ttl         time.Duration
    maxHistory  int
    stopCleaner chan struct{}
}

type sessionEntry struct {
    session     models.Session
    history     []models.Message
    activeReqID string
    lastActive  time.Time
}

注入时根据配置切换:

var sessionMgr session.Manager
if cfg.Redis.Addr != "" {
    sessionMgr = session.NewRedisManager(redisClient, 30*time.Minute, 20)
} else {
    sessionMgr = session.NewMemoryManager(30*time.Minute, 20)
}

六、配置管理

使用 Viper 加载配置,支持 YAML 文件 + 环境变量覆盖。环境变量优先级高于配置文件

配置文件位置

backend/config.yaml        # 默认加载
backend/config.dev.yaml    # 开发环境go run 时使用)
backend/config.prod.yaml   # 生产环境

Viper 加载顺序:先读 config.yaml,再根据 APP_ENV 环境变量尝试读 config.{env}.yaml 覆盖,最后所有环境变量自动覆盖对应字段。此外,代码还通过 godotenv 加载 .env 文件(优先级最低,仅用于本地开发环境)。

Go 配置结构体

// Config 应用配置。
type Config struct {
    App      AppConfig      `mapstructure:"app"`
    Server   ServerConfig   `mapstructure:"server"`
    Redis    RedisConfig    `mapstructure:"redis"`
    AI       AIConfig       `mapstructure:"ai"`
    Storage  StorageConfig  `mapstructure:"storage"`
    Log      LogConfig      `mapstructure:"log"`
}

type AppConfig struct {
    Env     string `mapstructure:"env"`     // "dev" | "prod",默认 "dev"
    Version string `mapstructure:"version"` // 由编译时注入
}

type ServerConfig struct {
    Host         string `mapstructure:"host"`          // 默认 "0.0.0.0"
    Port         int    `mapstructure:"port"`          // 默认 8080
    ReadTimeout  int    `mapstructure:"read_timeout"`  // 秒,默认 30
    WriteTimeout int    `mapstructure:"write_timeout"` // 秒,默认 30
}

type RedisConfig struct {
    Addr     string `mapstructure:"addr"`      // "localhost:6379"
    Password string `mapstructure:"password"`  // 无密码留空
    DB       int    `mapstructure:"db"`        // 默认 0
}

type AIConfig struct {
    STT  STTConfig  `mapstructure:"stt"`
    LLM  LLMConfig  `mapstructure:"llm"`
    TTS  TTSConfig  `mapstructure:"tts"`
}

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"
}

type LLMConfig struct {
    Provider string `mapstructure:"provider"` // "openai"
    APIKey   string `mapstructure:"api_key"`
    Model    string `mapstructure:"model"`    // 默认 "gpt-4o"
    Endpoint string `mapstructure:"endpoint"` // 默认 "https://api.openai.com/v1"
    Timeout  int    `mapstructure:"timeout"`  // 秒,默认 10
}

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"
    Timeout  int     `mapstructure:"timeout"`  // 秒,默认 5
}

type StorageConfig struct {
    Driver string `mapstructure:"driver"` // "memory" | "postgres"
    DSN    string `mapstructure:"dsn"`    // PostgreSQL 连接串driver=postgres 时必填
}

type LogConfig struct {
    Level  string `mapstructure:"level"`  // "debug" | "info" | "warn" | "error",默认 "info"
    Format string `mapstructure:"format"` // "json" | "console",生产用 json
}

配置文件示例

# config.yaml — 所有环境共享的默认值
app:
  env: dev

server:
  host: "0.0.0.0"
  port: 8080
  read_timeout: 30
  write_timeout: 30

redis:
  addr: "localhost:6379"
  password: ""
  db: 0

ai:
  stt:
    provider: deepgram
    model: nova-2
    endpoint: "wss://api.deepgram.com/v1/listen"
  llm:
    provider: openai
    model: gpt-4o
    endpoint: "https://api.openai.com/v1"
    timeout: 10
  tts:
    provider: openai
    model: tts-1
    voice: alloy
    speed: 1.0
    endpoint: "https://api.openai.com/v1"
    timeout: 5

storage:
  driver: memory

log:
  level: info
  format: console

环境变量覆盖规则

Viper 自动将配置项映射为环境变量,规则:前缀 CAMTALK_ + 路径大写用 _ 连接

配置项 环境变量 示例
server.port CAMTALK_SERVER_PORT 8080
redis.addr CAMTALK_REDIS_ADDR redis:6379
redis.password CAMTALK_REDIS_PASSWORD
ai.stt.api_key CAMTALK_AI_STT_API_KEY
ai.llm.api_key CAMTALK_AI_LLM_API_KEY
ai.tts.api_key CAMTALK_AI_TTS_API_KEY
ai.llm.model CAMTALK_AI_LLM_MODEL gpt-4o
storage.driver CAMTALK_STORAGE_DRIVER postgres
storage.dsn CAMTALK_STORAGE_DSN
app.env CAMTALK_APP_ENV prod
log.level CAMTALK_LOG_LEVEL warn
log.format CAMTALK_LOG_FORMAT json

API Key 和密码只通过环境变量注入,不写入配置文件,避免泄露到版本控制。

配置加载代码

// internal/config/config.go

func Load() (*Config, error) {
    v := viper.New()

    // 1. 读默认配置文件
    v.SetConfigName("config")
    v.SetConfigType("yaml")
    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)
        }
    }

    // 2. 按环境覆盖
    env := os.Getenv("APP_ENV")
    if env == "" {
        env = "dev"
    }
    v.SetConfigName("config." + env)
    v.MergeInConfig() // 忽略文件不存在

    // 3. 环境变量覆盖
    v.SetEnvPrefix("CAMTALK")
    v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
    v.AutomaticEnv()

    // 4. 解析
    var cfg Config
    if err := v.Unmarshal(&cfg); err != nil {
        return nil, fmt.Errorf("unmarshal config: %w", err)
    }
    return &cfg, nil
}

main.go 集成

func main() {
    cfg, err := config.Load()
    if err != nil {
        log.Fatalf("load config: %v", err)
    }

    r := gin.Default()
    // 使用 cfg.Server.Port 替代硬编码 :8080
    addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port)
    log.Printf("CamTalk gateway starting on %s (env=%s)", addr, cfg.App.Env)
    r.Run(addr)
}

启动方式

# 开发环境(默认 config.yamlAPI Key 通过环境变量注入)
CAMTALK_AI_LLM_API_KEY=sk-xxx \
CAMTALK_AI_STT_API_KEY=xxx \
go run ./cmd/server

# 生产环境
CAMTALK_APP_ENV=prod \
CAMTALK_REDIS_ADDR=redis:6379 \
CAMTALK_AI_LLM_API_KEY=sk-xxx \
CAMTALK_AI_STT_API_KEY=xxx \
CAMTALK_AI_TTS_API_KEY=xxx \
CAMTALK_STORAGE_DRIVER=postgres \
CAMTALK_STORAGE_DSN="postgres://user:pass@db:5432/camtalk?sslmode=disable" \
CAMTALK_LOG_LEVEL=warn \
CAMTALK_LOG_FORMAT=json \
./bin/camtalk

七、数据模型

Go 和 TypeScript 的数据模型定义见下方。AI 服务层的 Go 模型见上方"AI 服务层接口"章节。

Go 后端模型

// ---- 核心模型MVP 实现)----

type Session struct {
    ID        string    `json:"session_id"`
    CreatedAt time.Time `json:"created_at"`
    Config    SessionConfig `json:"config"`
}

type SessionConfig struct {
    TTSEnabled  bool   `json:"tts_enabled"`
    DetailLevel string `json:"detail_level"` // "low" | "high"
    Language    string `json:"language"`
}

type QueryRequest struct {
    RequestID string `json:"request_id"`
    Image     []byte `json:"-"`           // Base64 解码后
    Audio     []byte `json:"-"`           // Base64 解码后
    MimeType  string `json:"mime_type"`
}

type Message struct {
    Role    string `json:"role"`    // "user" | "assistant"
    Content string `json:"content"`
}

TypeScript 前端模型

interface Session {
  sessionId: string;
  createdAt: string;
  config: SessionConfig;
}

interface SessionConfig {
  ttsEnabled: boolean;
  detailLevel: "low" | "high";
  language: string;
}

interface ChatMessage {
  role: "user" | "assistant";
  content: string;
  imageUrl?: string;
  timestamp: number;
  tokensUsed?: number;
}

// WebSocket 消息联合类型
type ServerMessage =
  | ConnectedMessage
  | STTResultMessage
  | LLMChunkMessage
  | LLMDoneMessage
  | TTSAudioMessage
  | ErrorMessage
  | PongMessage;

type ClientMessage =
  | QueryMessage
  | ConfigMessage
  | InterruptMessage
  | PingMessage;

八、扩展接口设计

通过 Repository 接口隔离存储层MVP 用内存实现,后续替换为数据库——业务逻辑零改动。

// HistoryRepository — 对话历史存储契约
// MVP: 内存实现session 内有效,断开即丢)
// 后续: PostgreSQL 实现
type HistoryRepository interface {
    SaveMessage(ctx context.Context, sessionID string, msg Message) error
    GetMessages(ctx context.Context, sessionID string, limit int) ([]Message, error)
}

// UsageRepository — 用量统计存储契约
// MVP: 内存计数器
// 后续: PostgreSQL 按天聚合
type UsageRepository interface {
    RecordUsage(ctx context.Context, sessionID string, usage UsageRecord) error
    GetDailyUsage(ctx context.Context, userID string, days int) ([]UsageDaily, error)
}

MVP 内存实现:

type InMemoryHistory struct {
    mu       sync.RWMutex
    sessions map[string][]Message
}

func (h *InMemoryHistory) SaveMessage(ctx context.Context, sessionID string, msg Message) error {
    h.mu.Lock()
    defer h.mu.Unlock()
    h.sessions[sessionID] = append(h.sessions[sessionID], msg)
    return nil
}

func (h *InMemoryHistory) GetMessages(ctx context.Context, sessionID string, limit int) ([]Message, error) {
    h.mu.RLock()
    defer h.mu.RUnlock()
    msgs := h.sessions[sessionID]
    if limit > 0 && len(msgs) > limit {
        msgs = msgs[len(msgs)-limit:]
    }
    return msgs, nil
}

注入点(应用启动时根据配置选择实现):

func NewApp(cfg *Config) *App {
    var history HistoryRepository
    var usage UsageRepository

    switch cfg.Storage.Driver {
    case "postgres":
        pool, _ := pgxpool.New(ctx, cfg.Storage.DSN)
        history = &PgHistory{pool: pool}
        usage = &PgUsage{pool: pool}
    default: // "memory" — MVP 默认
        history = &InMemoryHistory{sessions: make(map[string][]Message)}
        usage = &InMemoryUsage{}
    }

    return &App{
        orchestrator: NewOrchestrator(cfg.AI, history, usage),
        sessionMgr:   NewSessionManager(cfg.Session, history),
    }
}

依赖倒置原则——业务层依赖接口不依赖具体实现。MVP 注入 InMemoryHistory,上线时一行代码换成 PgHistory


九、错误码

错误码 含义 客户端处理建议
INVALID_MESSAGE 消息格式不合法 检查 JSON 结构,不重试
SESSION_NOT_FOUND 会话不存在或已过期 重新建立 WebSocket 连接
RATE_LIMITED 请求频率超限 延迟后重试,提示用户稍等
IMAGE_TOO_LARGE 图像超过 4MB 限制 降低分辨率或压缩质量
AUDIO_TOO_SHORT 音频片段 < 250ms 忽略,等待下次语音输入
LLM_TIMEOUT LLM 推理超时(>10s 提示用户重试
LLM_ERROR LLM 服务异常 提示用户重试,服务端记录日志
STT_ERROR 语音识别失败 回退到纯文本输入模式
TTS_ERROR 语音合成失败 静默回退到纯文本回复
INTERNAL_ERROR 服务端内部错误 提示用户重试

十、连接管理

心跳机制:客户端每 30 秒发送应用层 {type: "ping"} 消息,服务端回复 {type: "pong"} 并刷新心跳计时器。超过 60 秒无 ping,服务端判定连接断开并清理会话资源。

重连策略(指数退避 + 抖动):

function reconnect(attempt: number) {
  const delay = Math.min(1000 * Math.pow(2, attempt), 30000); // 最大 30s
  const jitter = Math.random() * 1000;
  setTimeout(() => connect(), delay + jitter);
}
// attempt: 0 → 1s, 1 → 2s, 2 → 4s, 3 → 8s, ... 最大 30s

跨域处理

采用 Nginx 同源反代方案,前后端统一到同一域名,浏览器层面不存在跨域问题。

生产环境Nginx 将 /(前端)、/api/*REST/wsWebSocket统一反代到同一域名详见 02-系统架构.md 部署架构章节。

开发环境:前端 WebSocket 地址硬编码为 ws://localhost:8080/ws,直连后端,不经过 Vite 代理。REST API 同理直连 http://localhost:8080

当前开发模式为前后端直连,未使用 Vite proxy。如需使用 Vite 代理解决跨域,需在 vite.config.ts 中添加 server.proxy 配置,并将前端 WebSocket 地址改为相对路径。

Go 后端 WebSocket CheckOrigin:生产环境 Nginx 同源,CheckOrigin 可保持默认(拒绝跨域)。开发环境前端直连后端,需确保 CheckOrigin 允许跨域或使用 Vite proxy 转发。

如果未来需要支持第三方客户端直连(如移动端),再按需添加 CORS 中间件和 CheckOrigin 白名单。