2026-06-12 17:08:20 +08:00
|
|
|
|
# 接口文档
|
|
|
|
|
|
|
|
|
|
|
|
## 概述
|
|
|
|
|
|
|
2026-06-19 15:31:52 +08:00
|
|
|
|
前后端通信接口定义。以 WebSocket 承载实时对话,REST 端点支撑基础运维。持久化通过 PostgreSQL 实现,MemoryManager 支持 Write-Through 模式。
|
2026-06-12 17:08:20 +08:00
|
|
|
|
|
|
|
|
|
|
**设计原则**:
|
|
|
|
|
|
- WebSocket 为主:所有对话数据走 WebSocket
|
2026-06-19 15:31:52 +08:00
|
|
|
|
- REST 为辅:仅用于健康检查、认证、对话管理等低频操作
|
2026-06-12 17:08:20 +08:00
|
|
|
|
- 接口先行:先定义契约,再填充实现——前后端可并行开发
|
|
|
|
|
|
|
|
|
|
|
|
## 接口全景
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
浏览器 Go Gateway :8080
|
2026-06-14 18:05:50 +08:00
|
|
|
|
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
|
2026-06-12 17:08:20 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 一、WebSocket 协议
|
|
|
|
|
|
|
2026-06-14 18:05:50 +08:00
|
|
|
|
连接地址:`ws://localhost:8080/ws?token=<access_token>&conversation_id=<uuid>`
|
|
|
|
|
|
|
|
|
|
|
|
| 参数 | 必填 | 说明 |
|
|
|
|
|
|
|------|------|------|
|
|
|
|
|
|
| `token` | 是 | JWT access_token,缺失或无效时返回 401 |
|
|
|
|
|
|
| `conversation_id` | 否 | 恢复已有对话;省略则创建新对话 |
|
|
|
|
|
|
|
2026-06-12 17:08:20 +08:00
|
|
|
|
### 消息格式约定
|
|
|
|
|
|
|
|
|
|
|
|
所有 WebSocket 消息均为 JSON 文本帧,统一结构:
|
|
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
|
interface WsMessage {
|
|
|
|
|
|
type: string; // 消息类型,必填
|
|
|
|
|
|
request_id?: string; // 可选,用于请求-响应关联
|
|
|
|
|
|
timestamp?: number; // 可选,毫秒时间戳
|
|
|
|
|
|
[key: string]: any; // 类型特定字段
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### 客户端 → 服务端消息
|
|
|
|
|
|
|
|
|
|
|
|
#### `query` — 发起一次视觉对话
|
|
|
|
|
|
|
2026-06-14 12:52:39 +08:00
|
|
|
|
用户说完话后,客户端同时发送当前图像帧和语音片段。也支持文本输入模式(手动输入文字时跳过语音识别):
|
2026-06-12 17:08:20 +08:00
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
|
interface QueryMessage {
|
|
|
|
|
|
type: "query";
|
|
|
|
|
|
request_id: string; // 客户端生成的 UUID
|
|
|
|
|
|
image: string; // Base64 编码的 JPEG 图像(不含 data: 前缀)
|
2026-06-14 12:52:39 +08:00
|
|
|
|
audio: string; // Base64 编码的音频片段(PCM 16kHz),文本输入时为空字符串
|
|
|
|
|
|
text?: string; // 用户手动输入的文本(有值时跳过 STT,直接使用此文本)
|
2026-06-12 17:08:20 +08:00
|
|
|
|
mime_type?: string; // 音频格式,默认 "audio/pcm"
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
> 为什么图像和音频放在同一条消息里?因为 VAD 检测到用户说完话时,需要同时捕获"此刻的画面"和"说的话",拆成两条消息会增加时序同步的复杂度。
|
2026-06-14 12:52:39 +08:00
|
|
|
|
>
|
|
|
|
|
|
> **文本输入模式**:当用户关闭麦克风后,可通过对话框手动输入文字。此时 `text` 字段携带用户输入,`audio` 为空字符串,服务端跳过 STT 直接使用 `text` 进行 LLM 推理。
|
2026-06-12 17:08:20 +08:00
|
|
|
|
|
|
|
|
|
|
#### `config` — 更新会话配置
|
|
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
|
interface ConfigMessage {
|
|
|
|
|
|
type: "config";
|
|
|
|
|
|
payload: {
|
|
|
|
|
|
tts_enabled?: boolean; // 是否开启语音合成,默认 true
|
|
|
|
|
|
detail_level?: "low" | "high"; // 图像精度,默认 "low"
|
|
|
|
|
|
language?: string; // 交互语言,默认 "zh-CN"
|
2026-06-19 15:31:52 +08:00
|
|
|
|
scenario?: string; // 场景模式:free_chat / interviewer / english_teacher / debate / interpreter
|
2026-06-12 17:08:20 +08:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
#### `interrupt` — 打断当前回复
|
|
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
|
interface InterruptMessage {
|
|
|
|
|
|
type: "interrupt";
|
2026-06-14 08:52:36 +08:00
|
|
|
|
request_id?: string; // 可选,当前实现不使用此字段,服务端始终取消当前活跃请求
|
2026-06-12 17:08:20 +08:00
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
#### `ping` — 心跳保活
|
|
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
|
interface PingMessage {
|
|
|
|
|
|
type: "ping";
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### 服务端 → 客户端消息
|
|
|
|
|
|
|
|
|
|
|
|
#### `connected` — 连接建立确认
|
|
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
|
interface ConnectedMessage {
|
|
|
|
|
|
type: "connected";
|
2026-06-14 18:05:50 +08:00
|
|
|
|
session_id: string; // 服务端生成的会话 ID
|
|
|
|
|
|
conversation_id: string; // 同 session_id,便于前端统一使用
|
|
|
|
|
|
server_version: string; // 服务端版本号,如 "0.1.0"
|
2026-06-12 17:08:20 +08:00
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
#### `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 编码的音频片段
|
2026-06-14 08:52:36 +08:00
|
|
|
|
mime_type: string; // "audio/mp3"
|
2026-06-14 13:54:49 +08:00
|
|
|
|
is_last: boolean; // 当前句子的音频是否完整(每句结束时为 true)
|
|
|
|
|
|
final: boolean; // 整轮 TTS 是否结束(所有句子合成完毕后为 true)
|
2026-06-12 17:08:20 +08:00
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-06-14 13:54:49 +08:00
|
|
|
|
**字段语义**:
|
|
|
|
|
|
|
|
|
|
|
|
- `is_last`: 每个句子合成完毕后为 `true`,前端收到此信号即可将该句子加入播放队列。每句 TTS 音频由一次独立的 API 调用生成,对应一个 `tts_audio` 消息。
|
|
|
|
|
|
- `final`: 所有句子合成完毕后为 `true`(此时 `audio` 为空字符串),用于前端判断本轮 TTS 已全部到齐。
|
|
|
|
|
|
|
2026-06-13 13:18:17 +08:00
|
|
|
|
**音频格式规范**(前端播放依赖此约定):
|
|
|
|
|
|
|
|
|
|
|
|
| 属性 | 值 | 说明 |
|
|
|
|
|
|
|------|------|------|
|
2026-06-19 15:31:52 +08:00
|
|
|
|
| 编码 | `audio/mp3`(MP3) | 浏览器 `<audio>` 原生支持 |
|
2026-06-13 13:18:17 +08:00
|
|
|
|
| 采样率 | 24kHz | OpenAI TTS 默认 |
|
|
|
|
|
|
| 声道 | 单声道 | 语音不需要立体声 |
|
|
|
|
|
|
| 传输 | Base64 编码的 MP3 片段 | 每个 `tts_audio` 消息携带一个句子的音频 |
|
|
|
|
|
|
| 切片粒度 | 按句子切分 | LLM 输出中按 `。!?\n` 等标点切分,每个句子独立合成 |
|
|
|
|
|
|
|
|
|
|
|
|
**流式播放时序**:`tts_audio` 消息按句子顺序到达,前端应按序排队播放,不要等全部到齐再播。
|
|
|
|
|
|
|
|
|
|
|
|
**前端播放实现要点**:
|
|
|
|
|
|
|
2026-06-14 13:54:49 +08:00
|
|
|
|
1. **排队播放**:收到 `is_last: true` 时,将该句子的音频片段拼接为 Blob URL 并加入播放队列。第一句到达即开始播放,后续句子在 `onended` 回调中自动衔接。
|
|
|
|
|
|
2. **错误容错**:单个句子播放失败时跳过,继续播放队列中下一个,不中断整个回复。
|
2026-06-13 13:18:17 +08:00
|
|
|
|
3. **打断清理**:收到 `interrupt` 消息或用户触发打断时,清空播放队列并释放所有 Blob URL。
|
2026-06-14 08:52:36 +08:00
|
|
|
|
4. **类型锁定**:`mime_type` 字段固定为 `"audio/mp3"`,前端解码时直接使用,无需运行时判断。
|
2026-06-13 13:18:17 +08:00
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
|
// 前端播放器伪代码
|
|
|
|
|
|
class AudioPlayer {
|
2026-06-14 13:54:49 +08:00
|
|
|
|
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);
|
2026-06-19 15:31:52 +08:00
|
|
|
|
if (this.queue.length === 1) this.playNext();
|
2026-06-14 13:54:49 +08:00
|
|
|
|
}
|
2026-06-13 13:18:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private playNext() {
|
|
|
|
|
|
if (this.queue.length === 0) return;
|
2026-06-14 13:54:49 +08:00
|
|
|
|
const audio = new Audio(this.queue.shift()!);
|
|
|
|
|
|
audio.onended = () => { URL.revokeObjectURL(audio.src); this.playNext(); };
|
|
|
|
|
|
audio.onerror = () => { URL.revokeObjectURL(audio.src); this.playNext(); };
|
2026-06-13 13:18:17 +08:00
|
|
|
|
audio.play();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-14 13:54:49 +08:00
|
|
|
|
clear() { this.queue.forEach(url => URL.revokeObjectURL(url)); this.queue = []; this.sentenceChunks = []; }
|
2026-06-13 13:18:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-06-12 17:08:20 +08:00
|
|
|
|
#### `error` — 错误通知
|
|
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
|
interface ErrorMessage {
|
|
|
|
|
|
type: "error";
|
|
|
|
|
|
request_id?: string;
|
|
|
|
|
|
code: string; // 错误码,见下方错误码表
|
|
|
|
|
|
message: string; // 人类可读的错误描述
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
#### `pong` — 心跳响应
|
|
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
|
interface PongMessage {
|
|
|
|
|
|
type: "pong";
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### 消息流时序
|
|
|
|
|
|
|
2026-06-14 12:52:39 +08:00
|
|
|
|
**语音模式**(麦克风开启):
|
2026-06-12 17:08:20 +08:00
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
Client Server
|
|
|
|
|
|
| |
|
|
|
|
|
|
|-- query {image, audio} ------>|
|
2026-06-14 12:52:39 +08:00
|
|
|
|
|<-- stt_result {text} ---------| (语音识别)
|
2026-06-12 17:08:20 +08:00
|
|
|
|
| |
|
|
|
|
|
|
|<-- llm_chunk {delta: "这"} ---| (LLM 流式输出)
|
|
|
|
|
|
|<-- llm_chunk {delta: "是一"} -|
|
|
|
|
|
|
|<-- llm_chunk {delta: "朵花"} -|
|
|
|
|
|
|
|<-- llm_done {full_text} ------|
|
|
|
|
|
|
| |
|
|
|
|
|
|
|<-- tts_audio {audio} ---------| (TTS 音频流)
|
2026-06-14 13:54:49 +08:00
|
|
|
|
|<-- tts_audio {is_last: true} -| (句子完成)
|
|
|
|
|
|
|<-- tts_audio {final: true} ---| (TTS 全部结束)
|
2026-06-12 17:08:20 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-06-14 12:52:39 +08:00
|
|
|
|
**文本输入模式**(麦克风关闭,手动输入文字):
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
Client Server
|
|
|
|
|
|
| |
|
|
|
|
|
|
|-- query {image, text} ------->| (跳过 STT)
|
|
|
|
|
|
|<-- stt_result {text} ---------| (回显用户文本)
|
|
|
|
|
|
| |
|
|
|
|
|
|
|<-- llm_chunk {delta: "好的"} -| (LLM 流式输出)
|
|
|
|
|
|
|<-- llm_chunk {delta: ",我"} -|
|
|
|
|
|
|
|<-- llm_done {full_text} ------|
|
|
|
|
|
|
| |
|
|
|
|
|
|
|<-- tts_audio {audio} ---------| (TTS 音频流)
|
2026-06-14 13:54:49 +08:00
|
|
|
|
|<-- tts_audio {is_last: true} -| (句子完成)
|
|
|
|
|
|
|<-- tts_audio {final: true} ---| (TTS 全部结束)
|
2026-06-14 12:52:39 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-06-12 17:08:20 +08:00
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 二、REST API
|
|
|
|
|
|
|
2026-06-14 18:05:50 +08:00
|
|
|
|
### 通用约定
|
|
|
|
|
|
|
|
|
|
|
|
#### 认证方式
|
|
|
|
|
|
|
2026-06-20 16:51:03 +08:00
|
|
|
|
采用 **JWT 双 token 轮转认证机制**。详细设计见 [鉴权体系设计](./12-鉴权体系设计.md)。
|
2026-06-12 17:08:20 +08:00
|
|
|
|
|
2026-06-20 16:51:03 +08:00
|
|
|
|
**Token 类型**:
|
|
|
|
|
|
- **access_token**:短期令牌(15 分钟),用于 API 认证和 WebSocket 连接
|
|
|
|
|
|
- **refresh_token**:长期令牌(7 天),用于刷新 access_token
|
|
|
|
|
|
|
|
|
|
|
|
**请求头格式**:
|
2026-06-12 17:08:20 +08:00
|
|
|
|
```
|
2026-06-14 18:05:50 +08:00
|
|
|
|
Authorization: Bearer <access_token>
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-06-20 16:51:03 +08:00
|
|
|
|
**认证流程**:
|
|
|
|
|
|
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
|
2026-06-14 18:05:50 +08:00
|
|
|
|
|
|
|
|
|
|
#### 错误响应格式
|
|
|
|
|
|
|
|
|
|
|
|
所有错误响应统一结构:
|
|
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
|
interface ApiError {
|
|
|
|
|
|
code: string; // 机器可读错误码
|
|
|
|
|
|
message: string; // 人类可读描述
|
|
|
|
|
|
}
|
2026-06-12 17:08:20 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-06-14 18:05:50 +08:00
|
|
|
|
示例:
|
2026-06-12 17:08:20 +08:00
|
|
|
|
|
|
|
|
|
|
```json
|
|
|
|
|
|
{
|
2026-06-14 18:05:50 +08:00
|
|
|
|
"code": "USERNAME_TAKEN",
|
|
|
|
|
|
"message": "username already taken"
|
2026-06-12 17:08:20 +08:00
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-06-14 18:05:50 +08:00
|
|
|
|
#### 输入校验规则
|
|
|
|
|
|
|
|
|
|
|
|
| 字段 | 规则 |
|
|
|
|
|
|
|------|------|
|
|
|
|
|
|
| `username` | 3-64 字符,仅允许字母、数字、下划线 |
|
|
|
|
|
|
| `password` | 8-72 字符 |
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
### 认证接口(`/api/auth`)
|
|
|
|
|
|
|
|
|
|
|
|
#### 注册
|
2026-06-12 17:08:20 +08:00
|
|
|
|
|
|
|
|
|
|
```
|
2026-06-14 18:05:50 +08:00
|
|
|
|
POST /api/auth/register
|
2026-06-12 17:08:20 +08:00
|
|
|
|
Content-Type: application/json
|
2026-06-14 18:05:50 +08:00
|
|
|
|
```
|
2026-06-12 17:08:20 +08:00
|
|
|
|
|
2026-06-14 18:05:50 +08:00
|
|
|
|
**请求体**:
|
|
|
|
|
|
|
|
|
|
|
|
```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; // JWT,15 分钟有效
|
|
|
|
|
|
refresh_token: string; // JWT,7 天有效
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
**错误响应**:
|
|
|
|
|
|
|
|
|
|
|
|
| 状态码 | 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` | 用户名或密码错误 |
|
|
|
|
|
|
|
2026-06-20 16:51:03 +08:00
|
|
|
|
#### 刷新 Token(Refresh Token Rotation)
|
2026-06-14 18:05:50 +08:00
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
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 轮转)。
|
|
|
|
|
|
|
2026-06-20 16:51:03 +08:00
|
|
|
|
**安全机制**:
|
|
|
|
|
|
- **Token 轮转**:每次 refresh 都会生成新的 token pair,旧 refresh_token 立即失效
|
|
|
|
|
|
- **复用检测**:如果检测到已删除的 refresh_token 被复用,立即吊销该用户的所有 refresh_token
|
|
|
|
|
|
- **强制重新登录**:吊销后,该用户所有设备都需要重新登录
|
|
|
|
|
|
|
2026-06-14 18:05:50 +08:00
|
|
|
|
**错误响应**:
|
|
|
|
|
|
|
|
|
|
|
|
| 状态码 | code | 场景 |
|
|
|
|
|
|
|--------|------|------|
|
|
|
|
|
|
| 401 | `INVALID_TOKEN` | refresh_token 无效或已过期 |
|
|
|
|
|
|
|
2026-06-20 16:51:03 +08:00
|
|
|
|
**前端集成示例**:
|
|
|
|
|
|
|
|
|
|
|
|
```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);
|
|
|
|
|
|
}
|
|
|
|
|
|
);
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-06-14 18:05:50 +08:00
|
|
|
|
#### 登出
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
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[];
|
2026-06-19 15:31:52 +08:00
|
|
|
|
total: number;
|
2026-06-14 18:05:50 +08:00
|
|
|
|
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;
|
|
|
|
|
|
};
|
2026-06-19 15:31:52 +08:00
|
|
|
|
created_at: string;
|
2026-06-14 18:05:50 +08:00
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
#### 获取对话详情
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
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 字符
|
2026-06-12 17:08:20 +08:00
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-06-14 18:05:50 +08:00
|
|
|
|
**成功响应** `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 |
|
2026-06-19 15:31:52 +08:00
|
|
|
|
| `before` | int64 | — | 游标分页:返回此 message_id 之前的消息(不含) |
|
2026-06-14 18:05:50 +08:00
|
|
|
|
|
|
|
|
|
|
**成功响应** `200 OK`:
|
|
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
|
interface MessagesResponse {
|
|
|
|
|
|
messages: StoredMessage[];
|
2026-06-19 15:31:52 +08:00
|
|
|
|
has_more: boolean;
|
2026-06-14 18:05:50 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface StoredMessage {
|
|
|
|
|
|
id: number; // 自增 ID,用于游标分页
|
|
|
|
|
|
role: "user" | "assistant";
|
|
|
|
|
|
content: string;
|
2026-06-19 15:31:52 +08:00
|
|
|
|
tokens_used: number;
|
2026-06-14 18:05:50 +08:00
|
|
|
|
created_at: string; // ISO 8601
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
2026-06-12 17:08:20 +08:00
|
|
|
|
|
2026-06-14 18:05:50 +08:00
|
|
|
|
**分页用法**:首次请求不带 `before`,获取最新消息。滚动到顶部时,取当前列表最小的 `id` 作为 `before` 参数请求更早的消息。
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
### 健康检查
|
2026-06-12 17:08:20 +08:00
|
|
|
|
|
|
|
|
|
|
```
|
2026-06-14 18:05:50 +08:00
|
|
|
|
GET /api/health
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
无需认证。
|
|
|
|
|
|
|
|
|
|
|
|
**成功响应** `200 OK`:
|
|
|
|
|
|
|
|
|
|
|
|
```json
|
|
|
|
|
|
{
|
|
|
|
|
|
"status": "ok",
|
|
|
|
|
|
"version": "0.1.0",
|
|
|
|
|
|
"uptime_seconds": 3600,
|
|
|
|
|
|
"active_sessions": 42
|
|
|
|
|
|
}
|
2026-06-12 17:08:20 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-06-14 18:05:50 +08:00
|
|
|
|
---
|
|
|
|
|
|
|
2026-06-19 15:31:52 +08:00
|
|
|
|
### 预留端点(待实现)
|
2026-06-12 17:08:20 +08:00
|
|
|
|
|
|
|
|
|
|
| 端点 | 方法 | 用途 |
|
|
|
|
|
|
|------|------|------|
|
|
|
|
|
|
| `/api/usage` | GET | 查询用量统计 |
|
|
|
|
|
|
| `/api/users/{id}/preferences` | GET/PUT | 用户偏好管理 |
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
2026-06-13 13:18:17 +08:00
|
|
|
|
## 三、AI 服务层接口
|
|
|
|
|
|
|
2026-06-19 15:31:52 +08:00
|
|
|
|
Go 网关内部与外部 AI 服务(STT、LLM、TTS)的调用契约。通过 OpenAI 兼容接口可灵活切换到其他服务商。
|
2026-06-13 13:18:17 +08:00
|
|
|
|
|
|
|
|
|
|
### STT 服务接口
|
|
|
|
|
|
|
|
|
|
|
|
语音识别:接收前端采集的音频,返回识别文本。
|
|
|
|
|
|
|
|
|
|
|
|
```go
|
2026-06-13 16:40:29 +08:00
|
|
|
|
// Service 语音识别服务契约。
|
|
|
|
|
|
type Service interface {
|
2026-06-13 13:18:17 +08:00
|
|
|
|
// Recognize 识别一段完整音频,返回最终文本。
|
2026-06-13 16:40:29 +08:00
|
|
|
|
Recognize(ctx context.Context, audio []byte, opts Options) (string, error)
|
2026-06-13 13:18:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-13 16:40:29 +08:00
|
|
|
|
// Options 语音识别参数。
|
|
|
|
|
|
type Options struct {
|
2026-06-13 13:18:17 +08:00
|
|
|
|
Encoding string // "pcm_s16le" — 前端 VAD 输出格式
|
|
|
|
|
|
SampleRate int // 16000 — 前端麦克风采样率
|
|
|
|
|
|
Language string // "zh-CN"
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-06-19 15:31:52 +08:00
|
|
|
|
**已实现 Provider**:
|
|
|
|
|
|
|
|
|
|
|
|
| Provider | 连接方式 | 说明 |
|
|
|
|
|
|
|----------|---------|------|
|
2026-06-20 13:24:53 +08:00
|
|
|
|
| MiMo ASR(默认) | HTTP POST OpenAI 兼容 `/chat/completions` | 国产替代,PCM 自动转 WAV,支持 zh/en/auto |
|
|
|
|
|
|
| Deepgram | WebSocket `wss://api.deepgram.com/v1/listen` | 流式识别,延迟极低,模型 nova-2 |
|
2026-06-13 13:18:17 +08:00
|
|
|
|
|
|
|
|
|
|
### LLM 服务接口
|
|
|
|
|
|
|
2026-06-20 13:24:53 +08:00
|
|
|
|
多模态推理通过 Eino 框架的 `eino-ext/components/model/openai` ChatModel 组件实现,替代了原有的手动 `llm.Service` 接口。
|
|
|
|
|
|
|
|
|
|
|
|
**Eino ChatModel 配置**:
|
2026-06-13 13:18:17 +08:00
|
|
|
|
|
|
|
|
|
|
```go
|
2026-06-20 13:24:53 +08:00
|
|
|
|
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,
|
|
|
|
|
|
})
|
|
|
|
|
|
```
|
2026-06-13 13:18:17 +08:00
|
|
|
|
|
2026-06-20 13:24:53 +08:00
|
|
|
|
**ChatModel 接口**(Eino 组件标准接口):
|
2026-06-13 13:18:17 +08:00
|
|
|
|
|
2026-06-20 13:24:53 +08:00
|
|
|
|
```go
|
|
|
|
|
|
type BaseChatModel interface {
|
|
|
|
|
|
Generate(ctx, []*schema.Message, ...Option) (*schema.Message, error)
|
|
|
|
|
|
Stream(ctx, []*schema.Message, ...Option) (*schema.StreamReader[*schema.Message], error)
|
2026-06-13 13:18:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-06-20 13:24:53 +08:00
|
|
|
|
CamTalk 使用 `Stream()` 模式,通过 Eino Graph 的 Stream 调用触发,token 级流式输出通过 Callback `OnEndWithStreamOutput` 推送到客户端。
|
|
|
|
|
|
|
2026-06-19 15:31:52 +08:00
|
|
|
|
**接入约定**:
|
2026-06-20 13:24:53 +08:00
|
|
|
|
- 端点:通过 `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 执行。
|
|
|
|
|
|
```
|
2026-06-13 13:18:17 +08:00
|
|
|
|
|
|
|
|
|
|
### TTS 服务接口
|
|
|
|
|
|
|
|
|
|
|
|
语音合成:接收文本流,输出音频 chunk 流。
|
|
|
|
|
|
|
|
|
|
|
|
```go
|
2026-06-13 16:40:29 +08:00
|
|
|
|
// Service 语音合成服务契约。
|
|
|
|
|
|
type Service interface {
|
2026-06-13 13:18:17 +08:00
|
|
|
|
// SynthesizeStream 流式合成。
|
|
|
|
|
|
// textStream 接收句子级文本(由 Orchestrator 的句子切分器产出),
|
|
|
|
|
|
// 返回的 channel 持续输出 MP3 音频 chunk。
|
2026-06-13 16:40:29 +08:00
|
|
|
|
SynthesizeStream(ctx context.Context, textStream <-chan string, opts Options) (<-chan Chunk, error)
|
2026-06-13 13:18:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-13 16:40:29 +08:00
|
|
|
|
// Options 合成参数。
|
|
|
|
|
|
type Options struct {
|
2026-06-19 15:31:52 +08:00
|
|
|
|
Voice string // 语音名称
|
2026-06-13 13:18:17 +08:00
|
|
|
|
Speed float64 // 1.0 为正常语速
|
2026-06-19 15:31:52 +08:00
|
|
|
|
OutputFmt string // "mp3"
|
2026-06-13 13:18:17 +08:00
|
|
|
|
SampleRate int // 24000
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-06-19 15:31:52 +08:00
|
|
|
|
**已实现 Provider**:
|
|
|
|
|
|
|
|
|
|
|
|
| Provider | 端点 | 说明 |
|
|
|
|
|
|
|----------|------|------|
|
2026-06-20 13:24:53 +08:00
|
|
|
|
| MiMo TTS(默认) | `POST /chat/completions` | 国产替代,base64 音频响应 |
|
|
|
|
|
|
| OpenAI TTS | `POST /audio/speech` | 逐句合成,返回 MP3 流 |
|
2026-06-13 13:18:17 +08:00
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 四、AI 编排器(Orchestrator)
|
|
|
|
|
|
|
2026-06-20 13:24:53 +08:00
|
|
|
|
### 编排架构:Eino Graph 声明式编排
|
2026-06-13 13:18:17 +08:00
|
|
|
|
|
2026-06-20 13:24:53 +08:00
|
|
|
|
AI 编排层基于 [CloudWeGo Eino](https://github.com/cloudwego/eino) 框架的 `compose.Graph` 实现,替代了原有的手写 goroutine 管道。Eino Graph 是一个声明式的有向无环图(DAG)编排器,支持类型安全的流式数据传递和 Callback AOP 机制。
|
|
|
|
|
|
|
|
|
|
|
|
**核心矛盾**:LLM 流式输出逐 token,TTS 需要完整句子才能合成。解法:**Eino TransformableLambda 句子切分 + Stream 模式管道**。
|
2026-06-13 13:18:17 +08:00
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
LLM 流式输出: "这" "是一" "朵红色" "的花。" "它看起" "来很美" "丽。"
|
|
|
|
|
|
↓
|
|
|
|
|
|
┌── 句子检测器(按 。!?\n 切分)──┐
|
|
|
|
|
|
↓ ↓
|
|
|
|
|
|
句子1: "这是一朵红色的花。" 句子2: "它看起来很美丽。"
|
|
|
|
|
|
↓ ↓
|
|
|
|
|
|
TTS 合成 TTS 合成
|
|
|
|
|
|
↓ ↓
|
|
|
|
|
|
音频 chunk → 推送前端 音频 chunk → 推送前端
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
**时序保证**:
|
2026-06-20 13:24:53 +08:00
|
|
|
|
- `llm_chunk` 通过 Callback `OnEndWithStreamOutput` 实时推送,一定先于对应句子的 `tts_audio` 到达客户端
|
2026-06-13 13:18:17 +08:00
|
|
|
|
- 用户先看到文字,紧接着听到语音(感知延迟 < 0.5 秒)
|
|
|
|
|
|
|
2026-06-20 13:24:53 +08:00
|
|
|
|
### 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`
|
|
|
|
|
|
|
2026-06-13 13:18:17 +08:00
|
|
|
|
### Orchestrator 接口
|
|
|
|
|
|
|
|
|
|
|
|
```go
|
2026-06-13 16:40:29 +08:00
|
|
|
|
type Orchestrator interface {
|
|
|
|
|
|
ProcessQuery(ctx context.Context, sessionID string, req models.WsQuery,
|
|
|
|
|
|
history []models.Message, sender Sender) error
|
2026-06-13 13:18:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-13 16:40:29 +08:00
|
|
|
|
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
|
2026-06-13 13:18:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-06-20 13:24:53 +08:00
|
|
|
|
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,实现跨节点数据共享。
|
2026-06-13 16:40:29 +08:00
|
|
|
|
|
2026-06-13 13:18:17 +08:00
|
|
|
|
### 并发控制
|
|
|
|
|
|
|
|
|
|
|
|
- 每个 `ProcessQuery` 调用在独立 goroutine 中运行
|
2026-06-20 13:24:53 +08:00
|
|
|
|
- `context.WithTimeout` 确保总超时
|
|
|
|
|
|
- `interrupt` 消息触发 `cancel()`,Eino Graph 内部所有流式节点中断
|
2026-06-13 13:18:17 +08:00
|
|
|
|
- 同一 session 内同时只允许一个活跃请求,新请求自动取消上一个
|
2026-06-20 13:24:53 +08:00
|
|
|
|
- `PipelineState` 使用 `sync.Mutex` 保护并发写入
|
2026-06-13 13:18:17 +08:00
|
|
|
|
|
|
|
|
|
|
### 错误处理与降级
|
|
|
|
|
|
|
|
|
|
|
|
| 故障点 | 处理策略 | 客户端表现 |
|
|
|
|
|
|
|--------|---------|-----------|
|
2026-06-20 13:24:53 +08:00
|
|
|
|
| STT 失败 | 发送 `STT_ERROR`,Graph 终止 | 回退到纯文本模式 |
|
|
|
|
|
|
| LLM 超时 | 发送 `LLM_TIMEOUT`,取消下游 | 提示用户重试 |
|
2026-06-13 13:18:17 +08:00
|
|
|
|
| LLM 部分输出后失败 | 已推送的 `llm_chunk` 保留,发送 `error` 通知中断 | 显示已收到的部分文字 |
|
|
|
|
|
|
| TTS 失败 | 静默跳过,`llm_done` 正常发送 | 只有文字回复,无语音 |
|
2026-06-20 13:24:53 +08:00
|
|
|
|
| interrupt 打断 | cancel context,Graph 内所有流中断 | 前端清空播放队列 |
|
2026-06-13 13:18:17 +08:00
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
2026-06-13 13:24:25 +08:00
|
|
|
|
## 五、Session Manager
|
|
|
|
|
|
|
|
|
|
|
|
WebSocket Handler 和 AI Orchestrator 之间的会话管理层。负责维护会话生命周期、对话上下文和配置状态。
|
|
|
|
|
|
|
2026-06-19 15:31:52 +08:00
|
|
|
|
### 数据结构
|
2026-06-13 13:24:25 +08:00
|
|
|
|
|
2026-06-19 15:31:52 +08:00
|
|
|
|
**Memory 存储**(默认):
|
2026-06-13 13:24:25 +08:00
|
|
|
|
|
|
|
|
|
|
```
|
2026-06-19 15:31:52 +08:00
|
|
|
|
MemoryManager
|
|
|
|
|
|
├── sessions: map[string]*sessionEntry
|
|
|
|
|
|
│ ├── session models.Session
|
|
|
|
|
|
│ ├── history []models.Message
|
|
|
|
|
|
│ ├── activeReqID string
|
|
|
|
|
|
│ └── lastActive time.Time
|
|
|
|
|
|
├── msgRepo: MessageRepository (Write-Through, 可选)
|
|
|
|
|
|
└── sessRepo: SessionRepository (Write-Through, 可选)
|
2026-06-13 13:24:25 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-06-19 15:31:52 +08:00
|
|
|
|
**Redis 存储**(可选):
|
2026-06-13 13:24:25 +08:00
|
|
|
|
|
|
|
|
|
|
```
|
2026-06-19 15:31:52 +08:00
|
|
|
|
session:{id}:meta → Hash (会话元数据)
|
|
|
|
|
|
session:{id}:history → List (对话历史)
|
|
|
|
|
|
user:{id}:sessions → Set (用户会话索引)
|
|
|
|
|
|
```
|
2026-06-13 13:24:25 +08:00
|
|
|
|
|
|
|
|
|
|
### TTL 策略
|
|
|
|
|
|
|
|
|
|
|
|
| 场景 | TTL | 说明 |
|
|
|
|
|
|
|------|-----|------|
|
|
|
|
|
|
| 创建时 | 30 分钟 | `EXPIRE` 设置 |
|
|
|
|
|
|
| 每次收到消息 | 重置 30 分钟 | `EXPIRE` 刷新 |
|
|
|
|
|
|
| WebSocket 断开 | 不主动删 | 等自然过期,支持重连恢复 |
|
2026-06-19 15:31:52 +08:00
|
|
|
|
| 超过 30 分钟无活动 | 自动过期 | 自动清理 |
|
|
|
|
|
|
| 显式销毁(REST API) | 立即删除 | |
|
2026-06-13 13:24:25 +08:00
|
|
|
|
|
|
|
|
|
|
### 接口定义
|
|
|
|
|
|
|
|
|
|
|
|
```go
|
2026-06-13 16:40:29 +08:00
|
|
|
|
type Manager interface {
|
2026-06-19 14:58:43 +08:00
|
|
|
|
// Create 创建新会话,关联 user_id,返回 session ID。
|
|
|
|
|
|
Create(ctx context.Context, userID string, config models.SessionConfig) (string, error)
|
2026-06-13 13:24:25 +08:00
|
|
|
|
|
|
|
|
|
|
// Get 获取会话(含 config)。不存在返回 ErrSessionNotFound。
|
|
|
|
|
|
Get(ctx context.Context, sessionID string) (*models.Session, error)
|
|
|
|
|
|
|
|
|
|
|
|
// UpdateConfig 更新会话配置(config 消息触发)。
|
|
|
|
|
|
UpdateConfig(ctx context.Context, sessionID string, patch models.SessionConfigPatch) error
|
|
|
|
|
|
|
2026-06-19 14:58:43 +08:00
|
|
|
|
// UpdateTitle 更新对话标题。
|
|
|
|
|
|
UpdateTitle(ctx context.Context, sessionID string, title string) error
|
|
|
|
|
|
|
|
|
|
|
|
// ListByUser 获取用户的对话列表(分页)。
|
|
|
|
|
|
ListByUser(ctx context.Context, userID string, page, size int) ([]ConversationSummary, int, error)
|
|
|
|
|
|
|
2026-06-13 13:24:25 +08:00
|
|
|
|
// GetHistory 获取最近 N 轮对话历史(供 Orchestrator 构建 LLM 上下文)。
|
|
|
|
|
|
GetHistory(ctx context.Context, sessionID string, limit int) ([]models.Message, error)
|
|
|
|
|
|
|
2026-06-19 14:58:43 +08:00
|
|
|
|
// AppendMessage 追加一条对话消息,同时刷新 TTL。首条 user 消息自动更新标题。
|
2026-06-13 13:24:25 +08:00
|
|
|
|
AppendMessage(ctx context.Context, sessionID string, msg models.Message) error
|
|
|
|
|
|
|
|
|
|
|
|
// SetActiveRequest 标记当前正在处理的请求 ID(interrupt 用)。
|
|
|
|
|
|
SetActiveRequest(ctx context.Context, sessionID string, requestID string) error
|
|
|
|
|
|
|
2026-06-13 16:40:29 +08:00
|
|
|
|
// GetActiveRequestID 获取当前活跃请求 ID。
|
|
|
|
|
|
GetActiveRequestID(ctx context.Context, sessionID string) (string, error)
|
|
|
|
|
|
|
2026-06-13 13:24:25 +08:00
|
|
|
|
// ClearActiveRequest 清除活跃请求标记(请求完成或中断后)。
|
|
|
|
|
|
ClearActiveRequest(ctx context.Context, sessionID string) error
|
|
|
|
|
|
|
|
|
|
|
|
// Touch 刷新 TTL(心跳时调用)。
|
|
|
|
|
|
Touch(ctx context.Context, sessionID string) error
|
|
|
|
|
|
|
2026-06-13 16:40:29 +08:00
|
|
|
|
// Destroy 显式销毁会话(REST API DELETE)。
|
2026-06-13 13:24:25 +08:00
|
|
|
|
Destroy(ctx context.Context, sessionID string) error
|
2026-06-13 16:40:29 +08:00
|
|
|
|
|
|
|
|
|
|
// ActiveCount 返回当前活跃会话数(健康检查用)。
|
|
|
|
|
|
ActiveCount() int
|
2026-06-13 13:24:25 +08:00
|
|
|
|
}
|
2026-06-19 14:58:43 +08:00
|
|
|
|
|
|
|
|
|
|
// 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"`
|
|
|
|
|
|
}
|
2026-06-13 13:24:25 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-06-19 15:31:52 +08:00
|
|
|
|
### Write-Through 机制
|
|
|
|
|
|
|
|
|
|
|
|
MemoryManager 支持注入 `MessageRepository` 和 `SessionRepository`,实现写穿持久化:
|
2026-06-13 13:24:25 +08:00
|
|
|
|
|
|
|
|
|
|
```go
|
2026-06-19 15:31:52 +08:00
|
|
|
|
opts := []session.Option{}
|
|
|
|
|
|
if msgRepo != nil {
|
|
|
|
|
|
opts = append(opts, session.WithMessageRepository(msgRepo))
|
2026-06-19 14:58:43 +08:00
|
|
|
|
}
|
2026-06-19 15:31:52 +08:00
|
|
|
|
if sessRepo != nil {
|
|
|
|
|
|
opts = append(opts, session.WithSessionRepository(sessRepo))
|
|
|
|
|
|
}
|
|
|
|
|
|
sessionMgr = session.NewMemoryManager(30*time.Minute, 20, opts...)
|
|
|
|
|
|
```
|
2026-06-19 14:58:43 +08:00
|
|
|
|
|
2026-06-19 15:31:52 +08:00
|
|
|
|
- `Create` 时同时写入 PG sessions 表
|
|
|
|
|
|
- `AppendMessage` 时同时写入 PG messages 表
|
|
|
|
|
|
- `Get` 时如果内存中不存在,尝试从 PG 恢复
|
2026-06-13 13:24:25 +08:00
|
|
|
|
|
2026-06-19 15:31:52 +08:00
|
|
|
|
### 自动标题生成
|
2026-06-13 13:24:25 +08:00
|
|
|
|
|
2026-06-19 15:31:52 +08:00
|
|
|
|
首条 user 消息时,如果 title 仍为 "新对话",自动更新为消息内容前 20 个字符。
|
2026-06-13 13:24:25 +08:00
|
|
|
|
|
2026-06-19 15:31:52 +08:00
|
|
|
|
---
|
2026-06-13 13:24:25 +08:00
|
|
|
|
|
2026-06-19 15:31:52 +08:00
|
|
|
|
## 六、存储层接口
|
2026-06-13 13:24:25 +08:00
|
|
|
|
|
2026-06-19 15:31:52 +08:00
|
|
|
|
通过 Repository 接口隔离存储层,内存和 PostgreSQL 均已实现。
|
2026-06-13 13:24:25 +08:00
|
|
|
|
|
2026-06-19 15:31:52 +08:00
|
|
|
|
### UserRepository
|
2026-06-13 13:24:25 +08:00
|
|
|
|
|
|
|
|
|
|
```go
|
2026-06-19 15:31:52 +08:00
|
|
|
|
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
|
2026-06-13 13:24:25 +08:00
|
|
|
|
}
|
2026-06-19 15:31:52 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### MessageRepository
|
2026-06-13 13:24:25 +08:00
|
|
|
|
|
2026-06-19 15:31:52 +08:00
|
|
|
|
```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)
|
2026-06-13 13:24:25 +08:00
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-06-19 15:31:52 +08:00
|
|
|
|
### SessionRepository
|
2026-06-19 14:58:43 +08:00
|
|
|
|
|
2026-06-19 15:31:52 +08:00
|
|
|
|
```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
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
2026-06-19 14:58:43 +08:00
|
|
|
|
|
2026-06-19 15:31:52 +08:00
|
|
|
|
### 依赖注入
|
2026-06-13 13:24:25 +08:00
|
|
|
|
|
|
|
|
|
|
```go
|
2026-06-20 13:24:53 +08:00
|
|
|
|
// 存储层初始化
|
|
|
|
|
|
if cfg.Storage.Persistence.Enabled {
|
|
|
|
|
|
pool, _ := store.NewPostgresPool(ctx, cfg.Storage.Persistence.DSN)
|
2026-06-19 15:31:52 +08:00
|
|
|
|
userRepo = store.NewPgUserRepository(pool)
|
|
|
|
|
|
msgRepo = store.NewPgMessageRepository(pool)
|
|
|
|
|
|
sessRepo = store.NewPgSessionRepository(pool)
|
2026-06-20 13:24:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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 {
|
2026-06-19 15:31:52 +08:00
|
|
|
|
sessionMgr = session.NewMemoryManager(30*time.Minute, 20,
|
|
|
|
|
|
session.WithMessageRepository(msgRepo),
|
|
|
|
|
|
session.WithSessionRepository(sessRepo),
|
|
|
|
|
|
)
|
2026-06-13 13:24:25 +08:00
|
|
|
|
} else {
|
2026-06-19 15:31:52 +08:00
|
|
|
|
sessionMgr = session.NewMemoryManager(30*time.Minute, 20)
|
2026-06-13 13:24:25 +08:00
|
|
|
|
}
|
2026-06-20 13:24:53 +08:00
|
|
|
|
|
|
|
|
|
|
// Eino Graph 初始化
|
|
|
|
|
|
pipelineGraph, _ := eino.NewPipelineGraph(ctx, cfg, sttService, ttsService, sessionMgr)
|
|
|
|
|
|
orchestrator := eino.NewEinoOrchestrator(pipelineGraph, sessionMgr, cfg.AI.LLM.Model)
|
2026-06-13 13:24:25 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
2026-06-19 15:31:52 +08:00
|
|
|
|
## 七、配置管理
|
2026-06-13 13:31:48 +08:00
|
|
|
|
|
|
|
|
|
|
使用 Viper 加载配置,支持 YAML 文件 + 环境变量覆盖。**环境变量优先级高于配置文件**。
|
|
|
|
|
|
|
|
|
|
|
|
### 配置文件位置
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
backend/config.yaml # 默认加载
|
2026-06-19 15:31:52 +08:00
|
|
|
|
backend/config.dev.yaml # 开发环境
|
2026-06-13 13:31:48 +08:00
|
|
|
|
backend/config.prod.yaml # 生产环境
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-06-19 15:31:52 +08:00
|
|
|
|
加载顺序:`config.yaml` → `config.{APP_ENV}.yaml` → 环境变量(前缀 `CAMTALK_`)。
|
2026-06-13 13:31:48 +08:00
|
|
|
|
|
2026-06-19 15:31:52 +08:00
|
|
|
|
### 配置结构体
|
2026-06-13 13:31:48 +08:00
|
|
|
|
|
|
|
|
|
|
```go
|
|
|
|
|
|
type Config struct {
|
|
|
|
|
|
App AppConfig `mapstructure:"app"`
|
|
|
|
|
|
Server ServerConfig `mapstructure:"server"`
|
2026-06-19 14:58:43 +08:00
|
|
|
|
Session SessionConfig `mapstructure:"session"`
|
2026-06-13 13:31:48 +08:00
|
|
|
|
Redis RedisConfig `mapstructure:"redis"`
|
|
|
|
|
|
AI AIConfig `mapstructure:"ai"`
|
|
|
|
|
|
Storage StorageConfig `mapstructure:"storage"`
|
2026-06-14 18:05:50 +08:00
|
|
|
|
Auth AuthConfig `mapstructure:"auth"`
|
2026-06-13 13:31:48 +08:00
|
|
|
|
Log LogConfig `mapstructure:"log"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type AppConfig struct {
|
|
|
|
|
|
Env string `mapstructure:"env"` // "dev" | "prod",默认 "dev"
|
|
|
|
|
|
Version string `mapstructure:"version"` // 由编译时注入
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type ServerConfig struct {
|
2026-06-19 14:58:43 +08:00
|
|
|
|
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 {
|
2026-06-19 15:31:52 +08:00
|
|
|
|
TTL int `mapstructure:"ttl"` // 分钟,默认 30
|
|
|
|
|
|
MaxHistory int `mapstructure:"max_history"` // 条数,默认 20
|
2026-06-13 13:31:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type RedisConfig struct {
|
|
|
|
|
|
Addr string `mapstructure:"addr"` // "localhost:6379"
|
2026-06-19 15:31:52 +08:00
|
|
|
|
Password string `mapstructure:"password"`
|
2026-06-13 13:31:48 +08:00
|
|
|
|
DB int `mapstructure:"db"` // 默认 0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type AIConfig struct {
|
2026-06-19 15:31:52 +08:00
|
|
|
|
STT STTConfig `mapstructure:"stt"`
|
|
|
|
|
|
LLM LLMConfig `mapstructure:"llm"`
|
|
|
|
|
|
TTS TTSConfig `mapstructure:"tts"`
|
2026-06-13 13:31:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type STTConfig struct {
|
2026-06-20 13:24:53 +08:00
|
|
|
|
Provider string `mapstructure:"provider"` // "mimo" | "deepgram"
|
2026-06-19 15:31:52 +08:00
|
|
|
|
APIKey string `mapstructure:"api_key"`
|
2026-06-20 13:24:53 +08:00
|
|
|
|
Model string `mapstructure:"model"` // 默认 "mimo-v2.5-asr"
|
2026-06-19 15:31:52 +08:00
|
|
|
|
Endpoint string `mapstructure:"endpoint"`
|
|
|
|
|
|
Timeout int `mapstructure:"timeout"` // 秒,默认 5
|
|
|
|
|
|
HTTPClientTimeout int `mapstructure:"http_client_timeout"` // 秒,默认 30
|
2026-06-13 13:31:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type LLMConfig struct {
|
2026-06-20 13:24:53 +08:00
|
|
|
|
Provider string `mapstructure:"provider"` // "dashscope" / "openai"
|
2026-06-19 15:31:52 +08:00
|
|
|
|
APIKey string `mapstructure:"api_key"`
|
2026-06-20 13:24:53 +08:00
|
|
|
|
Model string `mapstructure:"model"` // 默认 "qwen3-vl-plus"
|
2026-06-19 15:31:52 +08:00
|
|
|
|
Endpoint string `mapstructure:"endpoint"`
|
2026-06-20 13:24:53 +08:00
|
|
|
|
Timeout int `mapstructure:"timeout"` // 秒,默认 30
|
2026-06-19 15:31:52 +08:00
|
|
|
|
HTTPClientTimeout int `mapstructure:"http_client_timeout"` // 秒,默认 60
|
2026-06-13 13:31:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type TTSConfig struct {
|
2026-06-20 13:24:53 +08:00
|
|
|
|
Provider string `mapstructure:"provider"` // "mimo" | "openai"
|
2026-06-19 15:31:52 +08:00
|
|
|
|
APIKey string `mapstructure:"api_key"`
|
2026-06-20 13:24:53 +08:00
|
|
|
|
Model string `mapstructure:"model"` // 默认 "mimo-v2.5-tts"
|
2026-06-19 15:31:52 +08:00
|
|
|
|
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
|
2026-06-13 13:31:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type StorageConfig struct {
|
|
|
|
|
|
Driver string `mapstructure:"driver"` // "memory" | "postgres"
|
2026-06-19 15:31:52 +08:00
|
|
|
|
DSN string `mapstructure:"dsn"`
|
2026-06-13 13:31:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-14 18:05:50 +08:00
|
|
|
|
type AuthConfig struct {
|
|
|
|
|
|
JWTSecret string `mapstructure:"jwt_secret"` // 必须通过 CAMTALK_AUTH_JWT_SECRET 设置
|
|
|
|
|
|
AccessTTL int `mapstructure:"access_ttl"` // 分钟,默认 15
|
|
|
|
|
|
RefreshTTL int `mapstructure:"refresh_ttl"` // 分钟,默认 10080(7 天)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-13 13:31:48 +08:00
|
|
|
|
type LogConfig struct {
|
|
|
|
|
|
Level string `mapstructure:"level"` // "debug" | "info" | "warn" | "error",默认 "info"
|
2026-06-19 15:31:52 +08:00
|
|
|
|
Format string `mapstructure:"format"` // "json" | "console"
|
2026-06-13 13:31:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### 配置文件示例
|
|
|
|
|
|
|
|
|
|
|
|
```yaml
|
|
|
|
|
|
app:
|
|
|
|
|
|
env: dev
|
|
|
|
|
|
|
|
|
|
|
|
server:
|
|
|
|
|
|
host: "0.0.0.0"
|
|
|
|
|
|
port: 8080
|
|
|
|
|
|
read_timeout: 30
|
|
|
|
|
|
write_timeout: 30
|
2026-06-19 14:58:43 +08:00
|
|
|
|
heartbeat_interval: 30
|
|
|
|
|
|
heartbeat_timeout: 60
|
|
|
|
|
|
shutdown_timeout: 10
|
|
|
|
|
|
|
|
|
|
|
|
session:
|
|
|
|
|
|
ttl: 30
|
|
|
|
|
|
max_history: 20
|
2026-06-13 13:31:48 +08:00
|
|
|
|
|
|
|
|
|
|
redis:
|
|
|
|
|
|
addr: "localhost:6379"
|
|
|
|
|
|
password: ""
|
|
|
|
|
|
db: 0
|
|
|
|
|
|
|
|
|
|
|
|
ai:
|
|
|
|
|
|
stt:
|
2026-06-20 13:24:53 +08:00
|
|
|
|
provider: mimo
|
|
|
|
|
|
model: mimo-v2.5-asr
|
|
|
|
|
|
endpoint: "https://api.xiaomimimo.com/v1"
|
2026-06-19 14:58:43 +08:00
|
|
|
|
timeout: 5
|
|
|
|
|
|
http_client_timeout: 30
|
2026-06-13 13:31:48 +08:00
|
|
|
|
llm:
|
2026-06-20 13:24:53 +08:00
|
|
|
|
provider: dashscope
|
|
|
|
|
|
model: qwen3-vl-plus
|
|
|
|
|
|
endpoint: "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
|
|
|
|
|
timeout: 30
|
2026-06-19 14:58:43 +08:00
|
|
|
|
http_client_timeout: 60
|
2026-06-13 13:31:48 +08:00
|
|
|
|
tts:
|
2026-06-20 13:24:53 +08:00
|
|
|
|
provider: mimo
|
|
|
|
|
|
model: mimo-v2.5-tts
|
2026-06-19 14:58:43 +08:00
|
|
|
|
voice: mimo_default
|
2026-06-13 13:31:48 +08:00
|
|
|
|
speed: 1.0
|
2026-06-20 13:24:53 +08:00
|
|
|
|
endpoint: "https://token-plan-cn.xiaomimimo.com/v1"
|
2026-06-13 13:31:48 +08:00
|
|
|
|
timeout: 5
|
2026-06-19 14:58:43 +08:00
|
|
|
|
http_client_timeout: 30
|
|
|
|
|
|
output_format: mp3
|
|
|
|
|
|
sample_rate: 24000
|
2026-06-13 13:31:48 +08:00
|
|
|
|
|
|
|
|
|
|
storage:
|
2026-06-20 13:24:53 +08:00
|
|
|
|
redis:
|
|
|
|
|
|
enabled: true
|
|
|
|
|
|
persistence:
|
|
|
|
|
|
enabled: true
|
|
|
|
|
|
driver: postgres
|
2026-06-13 13:31:48 +08:00
|
|
|
|
|
2026-06-14 18:05:50 +08:00
|
|
|
|
auth:
|
2026-06-19 15:31:52 +08:00
|
|
|
|
access_ttl: 15
|
|
|
|
|
|
refresh_ttl: 10080
|
2026-06-14 18:05:50 +08:00
|
|
|
|
|
2026-06-13 13:31:48 +08:00
|
|
|
|
log:
|
|
|
|
|
|
level: info
|
|
|
|
|
|
format: console
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### 环境变量覆盖规则
|
|
|
|
|
|
|
2026-06-19 15:31:52 +08:00
|
|
|
|
前缀 `CAMTALK_` + 路径大写用 `_` 连接:
|
2026-06-13 13:31:48 +08:00
|
|
|
|
|
2026-06-19 15:31:52 +08:00
|
|
|
|
| 配置项 | 环境变量 |
|
|
|
|
|
|
|--------|---------|
|
|
|
|
|
|
| `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` |
|
2026-06-13 13:31:48 +08:00
|
|
|
|
|
2026-06-19 15:31:52 +08:00
|
|
|
|
> API Key 和密码**只通过环境变量注入**,不写入配置文件,避免泄露到版本控制。
|
2026-06-13 13:31:48 +08:00
|
|
|
|
|
2026-06-19 15:31:52 +08:00
|
|
|
|
### 启动命令
|
2026-06-13 13:31:48 +08:00
|
|
|
|
|
|
|
|
|
|
```bash
|
2026-06-19 15:31:52 +08:00
|
|
|
|
# 开发环境
|
2026-06-13 13:31:48 +08:00
|
|
|
|
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" \
|
2026-06-14 18:05:50 +08:00
|
|
|
|
CAMTALK_AUTH_JWT_SECRET="$(openssl rand -hex 32)" \
|
2026-06-13 13:31:48 +08:00
|
|
|
|
./bin/camtalk
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
2026-06-19 15:31:52 +08:00
|
|
|
|
## 八、数据模型
|
2026-06-12 17:08:20 +08:00
|
|
|
|
|
|
|
|
|
|
### Go 后端模型
|
|
|
|
|
|
|
|
|
|
|
|
```go
|
|
|
|
|
|
type Session struct {
|
2026-06-19 15:31:52 +08:00
|
|
|
|
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"`
|
2026-06-12 17:08:20 +08:00
|
|
|
|
Config SessionConfig `json:"config"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type SessionConfig struct {
|
|
|
|
|
|
TTSEnabled bool `json:"tts_enabled"`
|
|
|
|
|
|
DetailLevel string `json:"detail_level"` // "low" | "high"
|
|
|
|
|
|
Language string `json:"language"`
|
2026-06-19 14:58:43 +08:00
|
|
|
|
Scenario string `json:"scenario"` // "free_chat" | "interviewer" | "english_teacher" | "debate" | "interpreter"
|
2026-06-12 17:08:20 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type Message struct {
|
2026-06-13 16:40:29 +08:00
|
|
|
|
Role string `json:"role"` // "user" | "assistant"
|
|
|
|
|
|
Content string `json:"content"`
|
2026-06-12 17:08:20 +08:00
|
|
|
|
}
|
2026-06-14 18:05:50 +08:00
|
|
|
|
|
|
|
|
|
|
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"`
|
|
|
|
|
|
}
|
2026-06-12 17:08:20 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### TypeScript 前端模型
|
|
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
|
interface SessionConfig {
|
|
|
|
|
|
ttsEnabled: boolean;
|
|
|
|
|
|
detailLevel: "low" | "high";
|
|
|
|
|
|
language: string;
|
2026-06-19 15:31:52 +08:00
|
|
|
|
scenario: string;
|
2026-06-12 17:08:20 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface ChatMessage {
|
|
|
|
|
|
role: "user" | "assistant";
|
|
|
|
|
|
content: string;
|
|
|
|
|
|
imageUrl?: string;
|
|
|
|
|
|
timestamp: number;
|
|
|
|
|
|
tokensUsed?: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-14 18:05:50 +08:00
|
|
|
|
interface AuthTokens {
|
|
|
|
|
|
accessToken: string;
|
|
|
|
|
|
refreshToken: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface User {
|
|
|
|
|
|
id: string;
|
2026-06-19 15:31:52 +08:00
|
|
|
|
username: string;
|
2026-06-14 18:05:50 +08:00
|
|
|
|
created_at: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-12 17:08:20 +08:00
|
|
|
|
// WebSocket 消息联合类型
|
|
|
|
|
|
type ServerMessage =
|
|
|
|
|
|
| ConnectedMessage
|
|
|
|
|
|
| STTResultMessage
|
|
|
|
|
|
| LLMChunkMessage
|
|
|
|
|
|
| LLMDoneMessage
|
|
|
|
|
|
| TTSAudioMessage
|
|
|
|
|
|
| ErrorMessage
|
|
|
|
|
|
| PongMessage;
|
|
|
|
|
|
|
|
|
|
|
|
type ClientMessage =
|
|
|
|
|
|
| QueryMessage
|
|
|
|
|
|
| ConfigMessage
|
|
|
|
|
|
| InterruptMessage
|
|
|
|
|
|
| PingMessage;
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
2026-06-13 13:31:48 +08:00
|
|
|
|
## 九、错误码
|
2026-06-12 17:08:20 +08:00
|
|
|
|
|
2026-06-14 18:05:50 +08:00
|
|
|
|
| 错误码 | HTTP 状态码 | 含义 | 客户端处理建议 |
|
|
|
|
|
|
|--------|-----------|------|--------------|
|
|
|
|
|
|
| `INVALID_MESSAGE` | — | 消息格式不合法(WS) | 检查 JSON 结构,不重试 |
|
|
|
|
|
|
| `SESSION_NOT_FOUND` | 404 | 会话/对话不存在或已过期 | 重新建立连接或刷新列表 |
|
|
|
|
|
|
| `RATE_LIMITED` | 429 | 请求频率超限 | 延迟后重试,提示用户稍等 |
|
|
|
|
|
|
| `IMAGE_TOO_LARGE` | — | 图像超过 4MB 限制(WS) | 降低分辨率或压缩质量 |
|
|
|
|
|
|
| `AUDIO_TOO_SHORT` | — | 音频片段 < 250ms(WS) | 忽略,等待下次语音输入 |
|
|
|
|
|
|
| `LLM_TIMEOUT` | — | LLM 推理超时 >10s(WS) | 提示用户重试 |
|
2026-06-19 15:31:52 +08:00
|
|
|
|
| `LLM_ERROR` | — | LLM 服务异常(WS) | 提示用户重试 |
|
2026-06-14 18:05:50 +08:00
|
|
|
|
| `STT_ERROR` | — | 语音识别失败(WS) | 回退到纯文本输入模式 |
|
|
|
|
|
|
| `TTS_ERROR` | — | 语音合成失败(WS) | 静默回退到纯文本回复 |
|
|
|
|
|
|
| `INTERNAL_ERROR` | 500 | 服务端内部错误 | 提示用户重试 |
|
|
|
|
|
|
| `USERNAME_TAKEN` | 409 | 用户名已被注册 | 提示换一个用户名 |
|
|
|
|
|
|
| `INVALID_CREDENTIALS` | 401 | 用户名或密码错误 | 提示检查输入 |
|
|
|
|
|
|
| `INVALID_TOKEN` | 401 | JWT 无效或已过期 | 尝试 refresh,失败则重新登录 |
|
|
|
|
|
|
| `INVALID_INPUT` | 400 | 请求参数校验失败 | 检查字段规则后重试 |
|
2026-06-12 17:08:20 +08:00
|
|
|
|
|
2026-06-19 15:31:52 +08:00
|
|
|
|
---
|
|
|
|
|
|
|
2026-06-13 13:31:48 +08:00
|
|
|
|
## 十、连接管理
|
2026-06-12 17:08:20 +08:00
|
|
|
|
|
2026-06-14 08:52:36 +08:00
|
|
|
|
**心跳机制**:客户端每 30 秒发送应用层 `{type: "ping"}` 消息,服务端回复 `{type: "pong"}` 并刷新心跳计时器。超过 60 秒无 `ping`,服务端判定连接断开并清理会话资源。
|
2026-06-12 17:08:20 +08:00
|
|
|
|
|
|
|
|
|
|
**重连策略**(指数退避 + 抖动):
|
|
|
|
|
|
|
|
|
|
|
|
```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
|
|
|
|
|
|
```
|
2026-06-13 14:05:57 +08:00
|
|
|
|
|
|
|
|
|
|
### 跨域处理
|
|
|
|
|
|
|
|
|
|
|
|
采用 **Nginx 同源反代**方案,前后端统一到同一域名,浏览器层面不存在跨域问题。
|
|
|
|
|
|
|
2026-06-19 15:31:52 +08:00
|
|
|
|
**开发环境**:前端 WebSocket 地址基于 `window.location.host` 动态构建,通过 Vite `server.proxy` 转发到后端 `http://localhost:8080`。
|