Files
CamTalk/docs/02-接口文档.md
hhs 4ff8cec312 docs: 添加鉴权体系设计文档,更新认证相关文档
- 新增 12-鉴权体系设计.md,详细描述 JWT 双 token 轮转认证机制
- 更新架构设计文档,补充认证设计章节的安全机制和配置说明
- 更新接口文档,补充 Refresh Token Rotation 安全机制和前端集成示例
- 更新文档索引,添加新文档的推荐阅读顺序
2026-06-20 16:51:03 +08:00

1448 lines
44 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 接口文档
## 概述
前后端通信接口定义。以 WebSocket 承载实时对话REST 端点支撑基础运维。持久化通过 PostgreSQL 实现MemoryManager 支持 Write-Through 模式。
**设计原则**
- WebSocket 为主:所有对话数据走 WebSocket
- REST 为辅:仅用于健康检查、认证、对话管理等低频操作
- 接口先行:先定义契约,再填充实现——前后端可并行开发
## 接口全景
```
浏览器 Go Gateway :8080
WebSocket Client <--> /ws?token=<jwt> (实时对话,需 JWT 认证)
HTTP Client --> GET /api/health (健康检查)
HTTP Client <--> POST /api/auth/* (注册/登录/刷新/登出)
HTTP Client <--> GET/POST/PATCH/DELETE (对话 CRUD
/api/conversations/*
HTTP Client <--> GET /api/conversations/:id (历史消息)
/messages
```
---
## 一、WebSocket 协议
连接地址:`ws://localhost:8080/ws?token=<access_token>&conversation_id=<uuid>`
| 参数 | 必填 | 说明 |
|------|------|------|
| `token` | 是 | JWT access_token缺失或无效时返回 401 |
| `conversation_id` | 否 | 恢复已有对话;省略则创建新对话 |
### 消息格式约定
所有 WebSocket 消息均为 JSON 文本帧,统一结构:
```typescript
interface WsMessage {
type: string; // 消息类型,必填
request_id?: string; // 可选,用于请求-响应关联
timestamp?: number; // 可选,毫秒时间戳
[key: string]: any; // 类型特定字段
}
```
### 客户端 → 服务端消息
#### `query` — 发起一次视觉对话
用户说完话后,客户端同时发送当前图像帧和语音片段。也支持文本输入模式(手动输入文字时跳过语音识别):
```typescript
interface QueryMessage {
type: "query";
request_id: string; // 客户端生成的 UUID
image: string; // Base64 编码的 JPEG 图像(不含 data: 前缀)
audio: string; // Base64 编码的音频片段PCM 16kHz文本输入时为空字符串
text?: string; // 用户手动输入的文本(有值时跳过 STT直接使用此文本
mime_type?: string; // 音频格式,默认 "audio/pcm"
}
```
> 为什么图像和音频放在同一条消息里?因为 VAD 检测到用户说完话时,需要同时捕获"此刻的画面"和"说的话",拆成两条消息会增加时序同步的复杂度。
>
> **文本输入模式**:当用户关闭麦克风后,可通过对话框手动输入文字。此时 `text` 字段携带用户输入,`audio` 为空字符串,服务端跳过 STT 直接使用 `text` 进行 LLM 推理。
#### `config` — 更新会话配置
```typescript
interface ConfigMessage {
type: "config";
payload: {
tts_enabled?: boolean; // 是否开启语音合成,默认 true
detail_level?: "low" | "high"; // 图像精度,默认 "low"
language?: string; // 交互语言,默认 "zh-CN"
scenario?: string; // 场景模式free_chat / interviewer / english_teacher / debate / interpreter
};
}
```
#### `interrupt` — 打断当前回复
```typescript
interface InterruptMessage {
type: "interrupt";
request_id?: string; // 可选,当前实现不使用此字段,服务端始终取消当前活跃请求
}
```
#### `ping` — 心跳保活
```typescript
interface PingMessage {
type: "ping";
}
```
### 服务端 → 客户端消息
#### `connected` — 连接建立确认
```typescript
interface ConnectedMessage {
type: "connected";
session_id: string; // 服务端生成的会话 ID
conversation_id: string; // 同 session_id便于前端统一使用
server_version: string; // 服务端版本号,如 "0.1.0"
}
```
#### `stt_result` — 语音识别结果
```typescript
interface STTResultMessage {
type: "stt_result";
request_id: string;
text: string; // 识别出的用户语音文本
is_final: boolean; // 是否为最终结果
}
```
#### `llm_chunk` — LLM 流式输出片段
```typescript
interface LLMChunkMessage {
type: "llm_chunk";
request_id: string;
delta: string; // 本次增量文本
role: "assistant";
}
```
#### `llm_done` — LLM 输出完成
```typescript
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 音频流片段
```typescript
interface TTSAudioMessage {
type: "tts_audio";
request_id: string;
audio: string; // Base64 编码的音频片段
mime_type: string; // "audio/mp3"
is_last: boolean; // 当前句子的音频是否完整(每句结束时为 true
final: boolean; // 整轮 TTS 是否结束(所有句子合成完毕后为 true
}
```
**字段语义**
- `is_last`: 每个句子合成完毕后为 `true`,前端收到此信号即可将该句子加入播放队列。每句 TTS 音频由一次独立的 API 调用生成,对应一个 `tts_audio` 消息。
- `final`: 所有句子合成完毕后为 `true`(此时 `audio` 为空字符串),用于前端判断本轮 TTS 已全部到齐。
**音频格式规范**(前端播放依赖此约定):
| 属性 | 值 | 说明 |
|------|------|------|
| 编码 | `audio/mp3`MP3 | 浏览器 `<audio>` 原生支持 |
| 采样率 | 24kHz | OpenAI TTS 默认 |
| 声道 | 单声道 | 语音不需要立体声 |
| 传输 | Base64 编码的 MP3 片段 | 每个 `tts_audio` 消息携带一个句子的音频 |
| 切片粒度 | 按句子切分 | LLM 输出中按 `。!?\n` 等标点切分,每个句子独立合成 |
**流式播放时序**`tts_audio` 消息按句子顺序到达,前端应按序排队播放,不要等全部到齐再播。
**前端播放实现要点**
1. **排队播放**:收到 `is_last: true` 时,将该句子的音频片段拼接为 Blob URL 并加入播放队列。第一句到达即开始播放,后续句子在 `onended` 回调中自动衔接。
2. **错误容错**:单个句子播放失败时跳过,继续播放队列中下一个,不中断整个回复。
3. **打断清理**:收到 `interrupt` 消息或用户触发打断时,清空播放队列并释放所有 Blob URL。
4. **类型锁定**`mime_type` 字段固定为 `"audio/mp3"`,前端解码时直接使用,无需运行时判断。
```typescript
// 前端播放器伪代码
class AudioPlayer {
private sentenceChunks: string[] = []; // 当前句子的音频片段缓冲
private queue: string[] = []; // 已就绪的句子 Blob URL 队列
enqueue(base64: string, isLast: boolean) {
this.sentenceChunks.push(base64);
if (isLast) {
const url = decodeBase64Audio(this.sentenceChunks.join(""), "audio/mp3");
this.sentenceChunks = [];
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.shift()!);
audio.onended = () => { URL.revokeObjectURL(audio.src); this.playNext(); };
audio.onerror = () => { URL.revokeObjectURL(audio.src); this.playNext(); };
audio.play();
}
clear() { this.queue.forEach(url => URL.revokeObjectURL(url)); this.queue = []; this.sentenceChunks = []; }
}
```
#### `error` — 错误通知
```typescript
interface ErrorMessage {
type: "error";
request_id?: string;
code: string; // 错误码,见下方错误码表
message: string; // 人类可读的错误描述
}
```
#### `pong` — 心跳响应
```typescript
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} -| (句子完成)
|<-- tts_audio {final: true} ---| (TTS 全部结束)
```
**文本输入模式**(麦克风关闭,手动输入文字):
```
Client Server
| |
|-- query {image, text} ------->| (跳过 STT)
|<-- stt_result {text} ---------| (回显用户文本)
| |
|<-- llm_chunk {delta: "好的"} -| (LLM 流式输出)
|<-- llm_chunk {delta: ",我"} -|
|<-- llm_done {full_text} ------|
| |
|<-- tts_audio {audio} ---------| (TTS 音频流)
|<-- tts_audio {is_last: true} -| (句子完成)
|<-- tts_audio {final: true} ---| (TTS 全部结束)
```
---
## 二、REST API
### 通用约定
#### 认证方式
采用 **JWT 双 token 轮转认证机制**。详细设计见 [鉴权体系设计](./12-鉴权体系设计.md)。
**Token 类型**
- **access_token**短期令牌15 分钟),用于 API 认证和 WebSocket 连接
- **refresh_token**长期令牌7 天),用于刷新 access_token
**请求头格式**
```
Authorization: Bearer <access_token>
```
**认证流程**
1. 用户登录后获取 access_token + refresh_token
2. 请求受保护接口时携带 access_token
3. access_token 过期时,使用 refresh_token 刷新获取新的 token pair
4. refresh_token 采用轮转机制,每次刷新后旧 token 失效
**WebSocket 认证**
- 连接地址:`ws://host/ws?token=<access_token>&conversation_id=<uuid>`
- HTTP Upgrade 前校验 token
- 校验失败返回 401 Unauthorized
#### 错误响应格式
所有错误响应统一结构:
```typescript
interface ApiError {
code: string; // 机器可读错误码
message: string; // 人类可读描述
}
```
示例:
```json
{
"code": "USERNAME_TAKEN",
"message": "username already taken"
}
```
#### 输入校验规则
| 字段 | 规则 |
|------|------|
| `username` | 3-64 字符,仅允许字母、数字、下划线 |
| `password` | 8-72 字符 |
---
### 认证接口(`/api/auth`
#### 注册
```
POST /api/auth/register
Content-Type: application/json
```
**请求体**
```typescript
interface RegisterRequest {
username: string; // 3-64 字符
password: string; // 8-72 字符
}
```
**成功响应** `201 Created`
```typescript
interface AuthResponse {
user: {
id: string; // UUID
username: string;
created_at: string; // ISO 8601
};
access_token: string; // JWT15 分钟有效
refresh_token: string; // JWT7 天有效
}
```
**错误响应**
| 状态码 | code | 场景 |
|--------|------|------|
| 400 | `INVALID_INPUT` | 用户名/密码不符合校验规则 |
| 409 | `USERNAME_TAKEN` | 用户名已存在 |
#### 登录
```
POST /api/auth/login
Content-Type: application/json
```
**请求体**
```typescript
interface LoginRequest {
username: string;
password: string;
}
```
**成功响应** `200 OK`:同 `AuthResponse` 结构。
**错误响应**
| 状态码 | code | 场景 |
|--------|------|------|
| 400 | `INVALID_INPUT` | 请求参数缺失或格式错误 |
| 401 | `INVALID_CREDENTIALS` | 用户名或密码错误 |
#### 刷新 TokenRefresh Token Rotation
```
POST /api/auth/refresh
Content-Type: application/json
```
**请求体**
```typescript
interface RefreshRequest {
refresh_token: string; // 之前签发的 refresh_token
}
```
**成功响应** `200 OK`:同 `AuthResponse` 结构(返回新的 access_token + refresh_token旧 refresh_token 失效——Token 轮转)。
**安全机制**
- **Token 轮转**:每次 refresh 都会生成新的 token pair旧 refresh_token 立即失效
- **复用检测**:如果检测到已删除的 refresh_token 被复用,立即吊销该用户的所有 refresh_token
- **强制重新登录**:吊销后,该用户所有设备都需要重新登录
**错误响应**
| 状态码 | code | 场景 |
|--------|------|------|
| 401 | `INVALID_TOKEN` | refresh_token 无效或已过期 |
**前端集成示例**
```typescript
// axios 响应拦截器
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// 如果是 401 且不是 refresh 请求,尝试刷新 token
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
const refreshToken = getRefreshToken();
const response = await api.post('/api/auth/refresh', {
refresh_token: refreshToken,
});
const { access_token, refresh_token } = response.data;
setAccessToken(access_token);
setRefreshToken(refresh_token);
// 重试原始请求
originalRequest.headers.Authorization = `Bearer ${access_token}`;
return api(originalRequest);
} catch (refreshError) {
// 刷新失败,跳转登录页
clearTokens();
window.location.href = '/login';
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
```
#### 登出
```
POST /api/auth/logout
Content-Type: application/json
Authorization: Bearer <access_token>
```
**请求体**
```typescript
interface LogoutRequest {
refresh_token: string; // 要废弃的 refresh_token
}
```
**成功响应** `204 No Content`(无响应体)。
**错误响应**
| 状态码 | code | 场景 |
|--------|------|------|
| 401 | `INVALID_TOKEN` | access_token 无效或已过期 |
---
### 对话接口(`/api/conversations`
> 以下所有接口均需认证(`Authorization: Bearer <access_token>`),省略不重复标注。
#### 对话列表
```
GET /api/conversations?page=1&size=20
```
**查询参数**
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `page` | int | 1 | 页码,从 1 开始 |
| `size` | int | 20 | 每页条数,最大 50 |
**成功响应** `200 OK`
```typescript
interface ConversationListResponse {
conversations: ConversationSummary[];
total: number;
page: number;
size: number;
}
interface ConversationSummary {
id: string; // 对话 ID即 session_id
title: string; // 对话标题(首条消息前 20 字)
last_message: string; // 最后一条消息内容预览
message_count: number; // 消息总数
updated_at: string; // ISO 8601最后活跃时间
}
```
#### 创建对话
```
POST /api/conversations
Content-Type: application/json
```
**请求体**(可选,全部有默认值):
```typescript
interface CreateConversationRequest {
config?: {
tts_enabled?: boolean; // 默认 true
detail_level?: "low" | "high"; // 默认 "low"
language?: string; // 默认 "zh-CN"
};
}
```
**成功响应** `201 Created`
```typescript
interface ConversationDetail {
id: string;
title: string;
config: {
tts_enabled: boolean;
detail_level: "low" | "high";
language: string;
};
created_at: string;
}
```
#### 获取对话详情
```
GET /api/conversations/:id
```
**成功响应** `200 OK`:同 `ConversationDetail` 结构。
**错误响应**
| 状态码 | code | 场景 |
|--------|------|------|
| 404 | `SESSION_NOT_FOUND` | 对话不存在或不属于当前用户 |
#### 更新对话标题
```
PATCH /api/conversations/:id
Content-Type: application/json
```
**请求体**
```typescript
interface UpdateTitleRequest {
title: string; // 1-100 字符
}
```
**成功响应** `200 OK`
```json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"title": "新的自定义标题"
}
```
**错误响应**
| 状态码 | code | 场景 |
|--------|------|------|
| 400 | `INVALID_INPUT` | title 为空或超长 |
| 404 | `SESSION_NOT_FOUND` | 对话不存在或不属于当前用户 |
#### 删除对话
```
DELETE /api/conversations/:id
```
**成功响应** `204 No Content`(无响应体)。
#### 获取对话消息
```
GET /api/conversations/:id/messages?limit=50&before=<message_id>
```
**查询参数**
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `limit` | int | 50 | 返回条数,最大 100 |
| `before` | int64 | — | 游标分页:返回此 message_id 之前的消息(不含) |
**成功响应** `200 OK`
```typescript
interface MessagesResponse {
messages: StoredMessage[];
has_more: boolean;
}
interface StoredMessage {
id: number; // 自增 ID用于游标分页
role: "user" | "assistant";
content: string;
tokens_used: number;
created_at: string; // ISO 8601
}
```
**分页用法**:首次请求不带 `before`,获取最新消息。滚动到顶部时,取当前列表最小的 `id` 作为 `before` 参数请求更早的消息。
---
### 健康检查
```
GET /api/health
```
无需认证。
**成功响应** `200 OK`
```json
{
"status": "ok",
"version": "0.1.0",
"uptime_seconds": 3600,
"active_sessions": 42
}
```
---
### 预留端点(待实现)
| 端点 | 方法 | 用途 |
|------|------|------|
| `/api/usage` | GET | 查询用量统计 |
| `/api/users/{id}/preferences` | GET/PUT | 用户偏好管理 |
---
## 三、AI 服务层接口
Go 网关内部与外部 AI 服务STT、LLM、TTS的调用契约。通过 OpenAI 兼容接口可灵活切换到其他服务商。
### STT 服务接口
语音识别:接收前端采集的音频,返回识别文本。
```go
// 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"
}
```
**已实现 Provider**
| Provider | 连接方式 | 说明 |
|----------|---------|------|
| MiMo ASR默认 | HTTP POST OpenAI 兼容 `/chat/completions` | 国产替代PCM 自动转 WAV支持 zh/en/auto |
| Deepgram | WebSocket `wss://api.deepgram.com/v1/listen` | 流式识别,延迟极低,模型 nova-2 |
### LLM 服务接口
多模态推理通过 Eino 框架的 `eino-ext/components/model/openai` ChatModel 组件实现,替代了原有的手动 `llm.Service` 接口。
**Eino ChatModel 配置**
```go
chatModel, _ := openaiImpl.NewChatModel(ctx, &openaiImpl.ChatModelConfig{
APIKey: cfg.AI.LLM.APIKey,
Model: cfg.AI.LLM.Model, // 默认 "qwen3-vl-plus"
BaseURL: cfg.AI.LLM.Endpoint, // 默认 DashScope OpenAI 兼容接口
Timeout: time.Duration(cfg.AI.LLM.Timeout) * time.Second,
})
```
**ChatModel 接口**Eino 组件标准接口):
```go
type BaseChatModel interface {
Generate(ctx, []*schema.Message, ...Option) (*schema.Message, error)
Stream(ctx, []*schema.Message, ...Option) (*schema.StreamReader[*schema.Message], error)
}
```
CamTalk 使用 `Stream()` 模式,通过 Eino Graph 的 Stream 调用触发token 级流式输出通过 Callback `OnEndWithStreamOutput` 推送到客户端。
**接入约定**
- 端点:通过 `ai.llm.endpoint` 配置,支持任何 OpenAI 兼容接口
- 默认模型:`qwen3-vl-plus`DashScope通过 `ai.llm.model` 配置切换
- 图片传入History 节点构建 `schema.Message.UserInputMultiContent`,使用 `Base64Data` + `MIMEType` 格式
- 流式响应Eino 框架原生 `StreamReader` 支持
- 超时:通过 `ChatModelConfig.Timeout` 控制
- 系统提示词History 节点根据语言和场景scenario动态构建
**保留的类型定义**`ai/llm/llm.go`
```go
// Request / Chunk / TokenUsage 类型定义仍保留在 ai/llm 包中,
// 供 prompt.go 和 scenarios.go 使用。LLM 推理本身通过 eino-ext ChatModel 执行。
```
### TTS 服务接口
语音合成:接收文本流,输出音频 chunk 流。
```go
// 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 // 语音名称
Speed float64 // 1.0 为正常语速
OutputFmt string // "mp3"
SampleRate int // 24000
}
```
**已实现 Provider**
| Provider | 端点 | 说明 |
|----------|------|------|
| MiMo TTS默认 | `POST /chat/completions` | 国产替代base64 音频响应 |
| OpenAI TTS | `POST /audio/speech` | 逐句合成,返回 MP3 流 |
---
## 四、AI 编排器Orchestrator
### 编排架构Eino Graph 声明式编排
AI 编排层基于 [CloudWeGo Eino](https://github.com/cloudwego/eino) 框架的 `compose.Graph` 实现,替代了原有的手写 goroutine 管道。Eino Graph 是一个声明式的有向无环图DAG编排器支持类型安全的流式数据传递和 Callback AOP 机制。
**核心矛盾**LLM 流式输出逐 tokenTTS 需要完整句子才能合成。解法:**Eino TransformableLambda 句子切分 + Stream 模式管道**。
```
LLM 流式输出: "这" "是一" "朵红色" "的花。" "它看起" "来很美" "丽。"
┌── 句子检测器(按 。!?\n 切分)──┐
↓ ↓
句子1: "这是一朵红色的花。" 句子2: "它看起来很美丽。"
↓ ↓
TTS 合成 TTS 合成
↓ ↓
音频 chunk → 推送前端 音频 chunk → 推送前端
```
**时序保证**
- `llm_chunk` 通过 Callback `OnEndWithStreamOutput` 实时推送,一定先于对应句子的 `tts_audio` 到达客户端
- 用户先看到文字,紧接着听到语音(感知延迟 < 0.5 秒)
### Graph 拓扑
```
START → STT → History → ChatModel → Msg2Str → Splitter → TTS → Done → END
```
| 节点 | Lambda 类型 | 输入 → 输出 | 职责 |
|------|------------|------------|------|
| STT | InvokableLambda | `PipelineInput → STTOutput` | 语音识别(文本模式跳过),发送 `stt_result`,写入 State |
| History | InvokableLambda | `STTOutput → []*schema.Message` | 组装系统提示词 + 对话历史 + 多模态图像消息 |
| ChatModel | ChatModel原生 | `[]*schema.Message → StreamReader[*Message]` | Eino 原生 LLM 流式推理 |
| Msg2Str | TransformableLambda | `StreamReader[*Message] → StreamReader[string]` | 提取 LLM 输出文本 |
| Splitter | TransformableLambda | `StreamReader[string] → StreamReader[string]` | 按句子分隔符切分,逐句输出 |
| TTS | TransformableLambda | `StreamReader[string] → StreamReader[struct{}]` | 逐句调用 TTS 服务,推送 `tts_audio` |
| Done | InvokableLambda | `struct{} → PipelineOutput` | 发送 `llm_done`,返回最终输出 |
**依赖版本**
- `github.com/cloudwego/eino v0.9.9`
- `github.com/cloudwego/eino-ext/components/model/openai v0.1.13`
### Orchestrator 接口
```go
type Orchestrator interface {
ProcessQuery(ctx context.Context, sessionID string, req models.WsQuery,
history []models.Message, sender Sender) error
}
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
}
```
WS Handler 通过 `Orchestrator` 接口与编排层交互,不感知 Eino 实现细节。
### EinoOrchestrator 执行流程
`EinoOrchestrator` 实现 `Orchestrator` 接口,包装 Eino Graph
1. 设置活跃请求,获取会话配置
2. Base64 解码音频/图片
3. 构建 `PipelineInput`
4. 注入 context 值Sender、RequestID、SessionID、PipelineState、StartTime
5. 追加用户消息到历史
6. 调用 `graph.Runnable.Stream(ctx, input, callbacks)` — Stream 模式触发整条链路惰性执行
7. 消费 `StreamReader[PipelineOutput]` 直到 EOF
8. 追加助手消息到历史
### Callback 机制
LLM token 推送通过 Eino Callback 实现,而非在 Lambda 节点中硬编码:
```go
// 构建 typed callback handler
handler := callbacks.NewHandlerHelper().ChatModel(&modelCallbackHandler{}).Handler()
// 运行时传入(不在 Compile 时注册)
streamReader, err := runnable.Stream(ctx, input, compose.WithCallbacks(handler))
```
**OnEndWithStreamOutput** 回调:
- 接收 ChatModel 的 `StreamReader[*schema.Message]`
- 逐 chunk 推送 `llm_chunk` 到客户端
- 累积完整回复到 `PipelineState`
- 记录 token 用量
### State 机制
`PipelineState` 是 Graph 级别的线程安全状态,通过 `compose.WithGenLocalState` 注册:
```go
type PipelineState struct {
FullResponse strings.Builder // LLM 完整回复Callback 累积)
TranscribedText string // STT 识别文本
TokenUsage *TokenUsage // Token 用量
SessionID string
RequestID string
ImageData []byte
Scenario string
Language string
TTSEnabled bool
}
```
各节点通过 `stateFromCtx(ctx)` 读写 State实现跨节点数据共享。
### 并发控制
- 每个 `ProcessQuery` 调用在独立 goroutine 中运行
- `context.WithTimeout` 确保总超时
- `interrupt` 消息触发 `cancel()`Eino Graph 内部所有流式节点中断
- 同一 session 内同时只允许一个活跃请求,新请求自动取消上一个
- `PipelineState` 使用 `sync.Mutex` 保护并发写入
### 错误处理与降级
| 故障点 | 处理策略 | 客户端表现 |
|--------|---------|-----------|
| STT 失败 | 发送 `STT_ERROR`Graph 终止 | 回退到纯文本模式 |
| LLM 超时 | 发送 `LLM_TIMEOUT`,取消下游 | 提示用户重试 |
| LLM 部分输出后失败 | 已推送的 `llm_chunk` 保留,发送 `error` 通知中断 | 显示已收到的部分文字 |
| TTS 失败 | 静默跳过,`llm_done` 正常发送 | 只有文字回复,无语音 |
| interrupt 打断 | cancel contextGraph 内所有流中断 | 前端清空播放队列 |
---
## 五、Session Manager
WebSocket Handler 和 AI Orchestrator 之间的会话管理层。负责维护会话生命周期、对话上下文和配置状态。
### 数据结构
**Memory 存储**(默认):
```
MemoryManager
├── sessions: map[string]*sessionEntry
│ ├── session models.Session
│ ├── history []models.Message
│ ├── activeReqID string
│ └── lastActive time.Time
├── msgRepo: MessageRepository (Write-Through, 可选)
└── sessRepo: SessionRepository (Write-Through, 可选)
```
**Redis 存储**(可选):
```
session:{id}:meta → Hash (会话元数据)
session:{id}:history → List (对话历史)
user:{id}:sessions → Set (用户会话索引)
```
### TTL 策略
| 场景 | TTL | 说明 |
|------|-----|------|
| 创建时 | 30 分钟 | `EXPIRE` 设置 |
| 每次收到消息 | 重置 30 分钟 | `EXPIRE` 刷新 |
| WebSocket 断开 | 不主动删 | 等自然过期,支持重连恢复 |
| 超过 30 分钟无活动 | 自动过期 | 自动清理 |
| 显式销毁REST API | 立即删除 | |
### 接口定义
```go
type Manager interface {
// Create 创建新会话,关联 user_id返回 session ID。
Create(ctx context.Context, userID string, 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
// UpdateTitle 更新对话标题。
UpdateTitle(ctx context.Context, sessionID string, title string) error
// ListByUser 获取用户的对话列表(分页)。
ListByUser(ctx context.Context, userID string, page, size int) ([]ConversationSummary, int, error)
// GetHistory 获取最近 N 轮对话历史(供 Orchestrator 构建 LLM 上下文)。
GetHistory(ctx context.Context, sessionID string, limit int) ([]models.Message, error)
// AppendMessage 追加一条对话消息,同时刷新 TTL。首条 user 消息自动更新标题。
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
}
// ConversationSummary 对话列表项。
type ConversationSummary struct {
ID string `json:"id"`
Title string `json:"title"`
LastMessage string `json:"last_message"`
MessageCount int `json:"message_count"`
UpdatedAt time.Time `json:"updated_at"`
}
```
### Write-Through 机制
MemoryManager 支持注入 `MessageRepository``SessionRepository`,实现写穿持久化:
```go
opts := []session.Option{}
if msgRepo != nil {
opts = append(opts, session.WithMessageRepository(msgRepo))
}
if sessRepo != nil {
opts = append(opts, session.WithSessionRepository(sessRepo))
}
sessionMgr = session.NewMemoryManager(30*time.Minute, 20, opts...)
```
- `Create` 时同时写入 PG sessions 表
- `AppendMessage` 时同时写入 PG messages 表
- `Get` 时如果内存中不存在,尝试从 PG 恢复
### 自动标题生成
首条 user 消息时,如果 title 仍为 "新对话",自动更新为消息内容前 20 个字符。
---
## 六、存储层接口
通过 Repository 接口隔离存储层,内存和 PostgreSQL 均已实现。
### UserRepository
```go
type UserRepository interface {
Create(ctx context.Context, username, passwordHash string) (string, error)
FindByUsername(ctx context.Context, username string) (*User, error)
FindByID(ctx context.Context, id string) (*User, error)
SaveRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error
FindRefreshToken(ctx context.Context, tokenHash string) (string, error)
DeleteRefreshToken(ctx context.Context, tokenHash string) error
DeleteUserRefreshTokens(ctx context.Context, userID string) error
}
```
### MessageRepository
```go
type MessageRepository interface {
SaveMessage(ctx context.Context, sessionID string, msg models.Message, tokensUsed int) error
GetMessages(ctx context.Context, sessionID string, limit int, beforeID int64) ([]StoredMessage, error)
GetLastMessage(ctx context.Context, sessionID string) (*StoredMessage, error)
GetMessageCount(ctx context.Context, sessionID string) (int, error)
GetSessionMessageStats(ctx context.Context, sessionIDs []string) (map[string]int, error)
}
```
### SessionRepository
```go
type SessionRepository interface {
Save(ctx context.Context, session models.Session) error
FindByID(ctx context.Context, id string) (*models.Session, error)
FindByUser(ctx context.Context, userID string, page, size int) ([]models.Session, int, error)
UpdateTitle(ctx context.Context, id string, title string) error
UpdateConfig(ctx context.Context, id string, config models.SessionConfig) error
Touch(ctx context.Context, id string) error
Delete(ctx context.Context, id string) error
}
```
### 依赖注入
```go
// 存储层初始化
if cfg.Storage.Persistence.Enabled {
pool, _ := store.NewPostgresPool(ctx, cfg.Storage.Persistence.DSN)
userRepo = store.NewPgUserRepository(pool)
msgRepo = store.NewPgMessageRepository(pool)
sessRepo = store.NewPgSessionRepository(pool)
}
// Session Manager 初始化(支持三级存储自动降级)
if cfg.Storage.Redis.Enabled {
sessionMgr = session.NewTieredManager(30*time.Minute, 20, redisClient,
session.WithMessageRepository(msgRepo),
session.WithSessionRepository(sessRepo),
)
} else if cfg.Storage.Persistence.Enabled {
sessionMgr = session.NewMemoryManager(30*time.Minute, 20,
session.WithMessageRepository(msgRepo),
session.WithSessionRepository(sessRepo),
)
} else {
sessionMgr = session.NewMemoryManager(30*time.Minute, 20)
}
// Eino Graph 初始化
pipelineGraph, _ := eino.NewPipelineGraph(ctx, cfg, sttService, ttsService, sessionMgr)
orchestrator := eino.NewEinoOrchestrator(pipelineGraph, sessionMgr, cfg.AI.LLM.Model)
```
---
## 七、配置管理
使用 Viper 加载配置,支持 YAML 文件 + 环境变量覆盖。**环境变量优先级高于配置文件**。
### 配置文件位置
```
backend/config.yaml # 默认加载
backend/config.dev.yaml # 开发环境
backend/config.prod.yaml # 生产环境
```
加载顺序:`config.yaml``config.{APP_ENV}.yaml` → 环境变量(前缀 `CAMTALK_`)。
### 配置结构体
```go
type Config struct {
App AppConfig `mapstructure:"app"`
Server ServerConfig `mapstructure:"server"`
Session SessionConfig `mapstructure:"session"`
Redis RedisConfig `mapstructure:"redis"`
AI AIConfig `mapstructure:"ai"`
Storage StorageConfig `mapstructure:"storage"`
Auth AuthConfig `mapstructure:"auth"`
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
HeartbeatInterval int `mapstructure:"heartbeat_interval"` // 秒,默认 30
HeartbeatTimeout int `mapstructure:"heartbeat_timeout"` // 秒,默认 60
ShutdownTimeout int `mapstructure:"shutdown_timeout"` // 秒,默认 10
AllowedOrigins []string `mapstructure:"allowed_origins"` // 空表示允许所有
}
type SessionConfig struct {
TTL int `mapstructure:"ttl"` // 分钟,默认 30
MaxHistory int `mapstructure:"max_history"` // 条数,默认 20
}
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"` // "mimo" | "deepgram"
APIKey string `mapstructure:"api_key"`
Model string `mapstructure:"model"` // 默认 "mimo-v2.5-asr"
Endpoint string `mapstructure:"endpoint"`
Timeout int `mapstructure:"timeout"` // 秒,默认 5
HTTPClientTimeout int `mapstructure:"http_client_timeout"` // 秒,默认 30
}
type LLMConfig struct {
Provider string `mapstructure:"provider"` // "dashscope" / "openai"
APIKey string `mapstructure:"api_key"`
Model string `mapstructure:"model"` // 默认 "qwen3-vl-plus"
Endpoint string `mapstructure:"endpoint"`
Timeout int `mapstructure:"timeout"` // 秒,默认 30
HTTPClientTimeout int `mapstructure:"http_client_timeout"` // 秒,默认 60
}
type TTSConfig struct {
Provider string `mapstructure:"provider"` // "mimo" | "openai"
APIKey string `mapstructure:"api_key"`
Model string `mapstructure:"model"` // 默认 "mimo-v2.5-tts"
Voice string `mapstructure:"voice"` // 默认 "mimo_default"
Speed float64 `mapstructure:"speed"` // 默认 1.0
Endpoint string `mapstructure:"endpoint"`
Timeout int `mapstructure:"timeout"` // 秒,默认 5
HTTPClientTimeout int `mapstructure:"http_client_timeout"` // 秒,默认 30
OutputFormat string `mapstructure:"output_format"` // 默认 "mp3"
SampleRate int `mapstructure:"sample_rate"` // 默认 24000
}
type StorageConfig struct {
Driver string `mapstructure:"driver"` // "memory" | "postgres"
DSN string `mapstructure:"dsn"`
}
type AuthConfig struct {
JWTSecret string `mapstructure:"jwt_secret"` // 必须通过 CAMTALK_AUTH_JWT_SECRET 设置
AccessTTL int `mapstructure:"access_ttl"` // 分钟,默认 15
RefreshTTL int `mapstructure:"refresh_ttl"` // 分钟,默认 100807 天)
}
type LogConfig struct {
Level string `mapstructure:"level"` // "debug" | "info" | "warn" | "error",默认 "info"
Format string `mapstructure:"format"` // "json" | "console"
}
```
### 配置文件示例
```yaml
app:
env: dev
server:
host: "0.0.0.0"
port: 8080
read_timeout: 30
write_timeout: 30
heartbeat_interval: 30
heartbeat_timeout: 60
shutdown_timeout: 10
session:
ttl: 30
max_history: 20
redis:
addr: "localhost:6379"
password: ""
db: 0
ai:
stt:
provider: mimo
model: mimo-v2.5-asr
endpoint: "https://api.xiaomimimo.com/v1"
timeout: 5
http_client_timeout: 30
llm:
provider: dashscope
model: qwen3-vl-plus
endpoint: "https://dashscope.aliyuncs.com/compatible-mode/v1"
timeout: 30
http_client_timeout: 60
tts:
provider: mimo
model: mimo-v2.5-tts
voice: mimo_default
speed: 1.0
endpoint: "https://token-plan-cn.xiaomimimo.com/v1"
timeout: 5
http_client_timeout: 30
output_format: mp3
sample_rate: 24000
storage:
redis:
enabled: true
persistence:
enabled: true
driver: postgres
auth:
access_ttl: 15
refresh_ttl: 10080
log:
level: info
format: console
```
### 环境变量覆盖规则
前缀 `CAMTALK_` + 路径大写用 `_` 连接:
| 配置项 | 环境变量 |
|--------|---------|
| `server.port` | `CAMTALK_SERVER_PORT` |
| `redis.addr` | `CAMTALK_REDIS_ADDR` |
| `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` |
| `storage.driver` | `CAMTALK_STORAGE_DRIVER` |
| `storage.dsn` | `CAMTALK_STORAGE_DSN` |
| `auth.jwt_secret` | `CAMTALK_AUTH_JWT_SECRET` |
| `log.level` | `CAMTALK_LOG_LEVEL` |
> API Key 和密码**只通过环境变量注入**,不写入配置文件,避免泄露到版本控制。
### 启动命令
```bash
# 开发环境
CAMTALK_AI_LLM_API_KEY=sk-xxx \
CAMTALK_AI_STT_API_KEY=xxx \
go run ./cmd/server
# 生产环境
CAMTALK_APP_ENV=prod \
CAMTALK_STORAGE_DRIVER=postgres \
CAMTALK_STORAGE_DSN="postgres://user:pass@db:5432/camtalk?sslmode=disable" \
CAMTALK_AUTH_JWT_SECRET="$(openssl rand -hex 32)" \
./bin/camtalk
```
---
## 八、数据模型
### Go 后端模型
```go
type Session struct {
ID string `json:"session_id"`
UserID string `json:"user_id"`
Title string `json:"title"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Config SessionConfig `json:"config"`
}
type SessionConfig struct {
TTSEnabled bool `json:"tts_enabled"`
DetailLevel string `json:"detail_level"` // "low" | "high"
Language string `json:"language"`
Scenario string `json:"scenario"` // "free_chat" | "interviewer" | "english_teacher" | "debate" | "interpreter"
}
type Message struct {
Role string `json:"role"` // "user" | "assistant"
Content string `json:"content"`
}
type User struct {
ID string `json:"id"`
Username string `json:"username"`
PasswordHash string `json:"-"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type StoredMessage struct {
ID int64 `json:"id"`
SessionID string `json:"-"`
Role string `json:"role"`
Content string `json:"content"`
TokensUsed int `json:"tokens_used"`
CreatedAt time.Time `json:"created_at"`
}
```
### TypeScript 前端模型
```typescript
interface SessionConfig {
ttsEnabled: boolean;
detailLevel: "low" | "high";
language: string;
scenario: string;
}
interface ChatMessage {
role: "user" | "assistant";
content: string;
imageUrl?: string;
timestamp: number;
tokensUsed?: number;
}
interface AuthTokens {
accessToken: string;
refreshToken: string;
}
interface User {
id: string;
username: string;
created_at: string;
}
// WebSocket 消息联合类型
type ServerMessage =
| ConnectedMessage
| STTResultMessage
| LLMChunkMessage
| LLMDoneMessage
| TTSAudioMessage
| ErrorMessage
| PongMessage;
type ClientMessage =
| QueryMessage
| ConfigMessage
| InterruptMessage
| PingMessage;
```
---
## 九、错误码
| 错误码 | HTTP 状态码 | 含义 | 客户端处理建议 |
|--------|-----------|------|--------------|
| `INVALID_MESSAGE` | — | 消息格式不合法WS | 检查 JSON 结构,不重试 |
| `SESSION_NOT_FOUND` | 404 | 会话/对话不存在或已过期 | 重新建立连接或刷新列表 |
| `RATE_LIMITED` | 429 | 请求频率超限 | 延迟后重试,提示用户稍等 |
| `IMAGE_TOO_LARGE` | — | 图像超过 4MB 限制WS | 降低分辨率或压缩质量 |
| `AUDIO_TOO_SHORT` | — | 音频片段 < 250msWS | 忽略,等待下次语音输入 |
| `LLM_TIMEOUT` | — | LLM 推理超时 >10sWS | 提示用户重试 |
| `LLM_ERROR` | — | LLM 服务异常WS | 提示用户重试 |
| `STT_ERROR` | — | 语音识别失败WS | 回退到纯文本输入模式 |
| `TTS_ERROR` | — | 语音合成失败WS | 静默回退到纯文本回复 |
| `INTERNAL_ERROR` | 500 | 服务端内部错误 | 提示用户重试 |
| `USERNAME_TAKEN` | 409 | 用户名已被注册 | 提示换一个用户名 |
| `INVALID_CREDENTIALS` | 401 | 用户名或密码错误 | 提示检查输入 |
| `INVALID_TOKEN` | 401 | JWT 无效或已过期 | 尝试 refresh失败则重新登录 |
| `INVALID_INPUT` | 400 | 请求参数校验失败 | 检查字段规则后重试 |
---
## 十、连接管理
**心跳机制**:客户端每 30 秒发送应用层 `{type: "ping"}` 消息,服务端回复 `{type: "pong"}` 并刷新心跳计时器。超过 60 秒无 `ping`,服务端判定连接断开并清理会话资源。
**重连策略**(指数退避 + 抖动):
```typescript
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 同源反代**方案,前后端统一到同一域名,浏览器层面不存在跨域问题。
**开发环境**:前端 WebSocket 地址基于 `window.location.host` 动态构建,通过 Vite `server.proxy` 转发到后端 `http://localhost:8080`