- 移除 Obsidian 特有语法(callout、wikilinks、YAML frontmatter) - 目录结构从 3 层嵌套扁平化为编号文件(01~09) - 新增 docs/README.md 文档索引与推荐阅读顺序 - 精简冗余解释,保留所有代码块和实现参考 - CLAUDE.md 全面中文化
434 lines
10 KiB
Markdown
434 lines
10 KiB
Markdown
# 接口文档
|
||
|
||
## 概述
|
||
|
||
前后端通信接口定义。以 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 文本帧,统一结构:
|
||
|
||
```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)
|
||
mime_type?: string; // 音频格式,默认 "audio/pcm"
|
||
}
|
||
```
|
||
|
||
> 为什么图像和音频放在同一条消息里?因为 VAD 检测到用户说完话时,需要同时捕获"此刻的画面"和"说的话",拆成两条消息会增加时序同步的复杂度。
|
||
|
||
#### `config` — 更新会话配置
|
||
|
||
```typescript
|
||
interface ConfigMessage {
|
||
type: "config";
|
||
payload: {
|
||
tts_enabled?: boolean; // 是否开启语音合成,默认 true
|
||
detail_level?: "low" | "high"; // 图像精度,默认 "low"
|
||
language?: string; // 交互语言,默认 "zh-CN"
|
||
};
|
||
}
|
||
```
|
||
|
||
#### `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
|
||
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" 或 "audio/pcm"
|
||
is_last: boolean; // 是否为最后一片
|
||
}
|
||
```
|
||
|
||
#### `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} -|
|
||
```
|
||
|
||
---
|
||
|
||
## 二、REST API
|
||
|
||
### 健康检查
|
||
|
||
```
|
||
GET /api/health
|
||
```
|
||
|
||
响应:
|
||
|
||
```json
|
||
{
|
||
"status": "ok",
|
||
"version": "0.1.0",
|
||
"uptime_seconds": 3600,
|
||
"active_sessions": 42
|
||
}
|
||
```
|
||
|
||
### 创建会话(可选,MVP 自动创建)
|
||
|
||
```
|
||
POST /api/sessions
|
||
Content-Type: application/json
|
||
|
||
{
|
||
"user_id": "optional-user-id",
|
||
"config": {
|
||
"tts_enabled": true,
|
||
"detail_level": "low",
|
||
"language": "zh-CN"
|
||
}
|
||
}
|
||
```
|
||
|
||
响应:
|
||
|
||
```json
|
||
{
|
||
"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 | 用户偏好管理 |
|
||
|
||
---
|
||
|
||
## 三、数据模型
|
||
|
||
### Go 后端模型
|
||
|
||
```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"`
|
||
ImageURL string `json:"image_url,omitempty"`
|
||
TokensUsed int `json:"tokens_used,omitempty"`
|
||
}
|
||
```
|
||
|
||
### TypeScript 前端模型
|
||
|
||
```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 用内存实现,后续替换为数据库——业务逻辑零改动。
|
||
|
||
```go
|
||
// 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 内存实现:
|
||
|
||
```go
|
||
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
|
||
}
|
||
```
|
||
|
||
注入点(应用启动时根据配置选择实现):
|
||
|
||
```go
|
||
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 秒发送 `ping`,服务端回复 `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
|
||
```
|