608 lines
15 KiB
Markdown
608 lines
15 KiB
Markdown
|
|
---
|
|||
|
|
tags: [API, WebSocket, 接口设计, MVP, Go, TypeScript, 扩展性]
|
|||
|
|
create time: 2026-06-12 15:41
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
# 接口文档
|
|||
|
|
|
|||
|
|
## 概述
|
|||
|
|
|
|||
|
|
本文档定义 AI 视觉对话助手的**前后端通信接口**。以 MVP 为核心目标:用 WebSocket 承载实时对话,用最少的 REST 端点支撑基础运维。**暂不实现持久化**,但通过 Repository 接口模式为后续扩展(对话历史、用量统计)预留干净的接入点。
|
|||
|
|
|
|||
|
|
> [!info] 设计原则
|
|||
|
|
> - **WebSocket 为主**:实时对话是核心场景,所有对话数据走 WebSocket
|
|||
|
|
> - **REST 为辅**:仅用于健康检查、会话管理等低频操作
|
|||
|
|
> - **接口先行**:先定义契约,再填充实现——前后端可并行开发
|
|||
|
|
|
|||
|
|
## 正文
|
|||
|
|
|
|||
|
|
### 接口全景
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
graph LR
|
|||
|
|
subgraph Client["浏览器"]
|
|||
|
|
WS_C["WebSocket Client"]
|
|||
|
|
HTTP_C["HTTP Client"]
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
subgraph Server["Go Gateway :8080"]
|
|||
|
|
WS_EP["/ws"]
|
|||
|
|
HEALTH_EP["/api/health"]
|
|||
|
|
SESSION_EP["/api/sessions"]
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
WS_C <-->|"实时对话"| WS_EP
|
|||
|
|
HTTP_C -->|"GET"| HEALTH_EP
|
|||
|
|
HTTP_C <-->|"POST / DELETE"| SESSION_EP
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 一、WebSocket 协议
|
|||
|
|
|
|||
|
|
连接地址:`ws://localhost:8080/ws`
|
|||
|
|
|
|||
|
|
#### 1.1 连接生命周期
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
sequenceDiagram
|
|||
|
|
participant C as Client
|
|||
|
|
participant S as Server
|
|||
|
|
|
|||
|
|
C->>S: WebSocket Upgrade 请求
|
|||
|
|
S-->>C: 101 Switching Protocols
|
|||
|
|
S-->>C: {"type":"connected","session_id":"..."}
|
|||
|
|
Note over C,S: 连接建立,进入对话
|
|||
|
|
|
|||
|
|
C->>S: {"type":"query",...}
|
|||
|
|
S-->>C: {"type":"stt_result",...}
|
|||
|
|
S-->>C: {"type":"llm_chunk",...}
|
|||
|
|
S-->>C: {"type":"llm_chunk",...}
|
|||
|
|
S-->>C: {"type":"llm_done",...}
|
|||
|
|
S-->>C: {"type":"tts_audio",...}
|
|||
|
|
|
|||
|
|
C->>S: {"type":"query",...}
|
|||
|
|
Note over C,S: 持续对话...
|
|||
|
|
|
|||
|
|
C->>S: {"type":"ping"}
|
|||
|
|
S-->>C: {"type":"pong"}
|
|||
|
|
|
|||
|
|
C->>S: WebSocket Close
|
|||
|
|
S-->>C: WebSocket Close Ack
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 1.2 消息格式约定
|
|||
|
|
|
|||
|
|
所有 WebSocket 消息均为 **JSON 文本帧**,统一结构:
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// 通用消息信封
|
|||
|
|
interface WsMessage {
|
|||
|
|
type: string; // 消息类型,必填
|
|||
|
|
request_id?: string; // 可选,用于请求-响应关联
|
|||
|
|
timestamp?: number; // 可选,毫秒时间戳
|
|||
|
|
[key: string]: any; // 类型特定字段
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 1.3 客户端 → 服务端消息
|
|||
|
|
|
|||
|
|
##### `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"
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> [!question] 思考
|
|||
|
|
> 为什么图像和音频放在同一条消息里?因为 VAD 检测到用户说完话时,需要同时捕获"此刻的画面"和"说的话",拆成两条消息会增加时序同步的复杂度。
|
|||
|
|
|
|||
|
|
##### `config` —— 更新会话配置
|
|||
|
|
|
|||
|
|
运行时调整 AI 行为参数,无需重建连接:
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
interface ConfigMessage {
|
|||
|
|
type: "config";
|
|||
|
|
payload: {
|
|||
|
|
tts_enabled?: boolean; // 是否开启语音合成,默认 true
|
|||
|
|
detail_level?: "low" | "high"; // 图像精度,默认 "low"
|
|||
|
|
language?: string; // 交互语言,默认 "zh-CN"
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
##### `interrupt` —— 打断当前回复
|
|||
|
|
|
|||
|
|
用户在 AI 回复过程中再次说话,打断正在进行的 LLM/TTS 流:
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
interface InterruptMessage {
|
|||
|
|
type: "interrupt";
|
|||
|
|
request_id?: string; // 可选,指定打断哪次请求
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
##### `ping` —— 心跳保活
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
interface PingMessage {
|
|||
|
|
type: "ping";
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 1.4 服务端 → 客户端消息
|
|||
|
|
|
|||
|
|
##### `connected` —— 连接建立确认
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
interface ConnectedMessage {
|
|||
|
|
type: "connected";
|
|||
|
|
session_id: string; // 服务端生成的会话 ID
|
|||
|
|
server_version: string; // 服务端版本号,如 "0.1.0"
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
##### `stt_result` —— 语音识别结果
|
|||
|
|
|
|||
|
|
LLM 推理前,先返回 STT 识别出的文本,让用户看到"我听到了什么":
|
|||
|
|
|
|||
|
|
```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; // 输入 token 数
|
|||
|
|
completion: number; // 输出 token 数
|
|||
|
|
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";
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 1.5 消息流时序总览
|
|||
|
|
|
|||
|
|
一次完整交互的消息流转:
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
sequenceDiagram
|
|||
|
|
participant C as Client
|
|||
|
|
participant S as Server
|
|||
|
|
|
|||
|
|
Note over C: VAD 检测到语音结束
|
|||
|
|
C->>S: query {image, audio}
|
|||
|
|
S-->>C: stt_result {text, is_final: true}
|
|||
|
|
|
|||
|
|
loop LLM 流式输出
|
|||
|
|
S-->>C: llm_chunk {delta: "这"}
|
|||
|
|
S-->>C: llm_chunk {delta: "是一"}
|
|||
|
|
S-->>C: llm_chunk {delta: "朵花..."}
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
S-->>C: llm_done {full_text, tokens_used, latency_ms}
|
|||
|
|
|
|||
|
|
loop TTS 音频流
|
|||
|
|
S-->>C: tts_audio {audio, is_last: false}
|
|||
|
|
S-->>C: tts_audio {audio, is_last: true}
|
|||
|
|
end
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 二、REST API
|
|||
|
|
|
|||
|
|
MVP 阶段仅暴露最少量的 HTTP 端点:
|
|||
|
|
|
|||
|
|
#### 2.1 健康检查
|
|||
|
|
|
|||
|
|
```http
|
|||
|
|
GET /api/health
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
响应:
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{
|
|||
|
|
"status": "ok",
|
|||
|
|
"version": "0.1.0",
|
|||
|
|
"uptime_seconds": 3600,
|
|||
|
|
"active_sessions": 42
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 2.2 创建会话(可选)
|
|||
|
|
|
|||
|
|
MVP 阶段 WebSocket 连接即自动创建会话,此端点为**预留扩展**:
|
|||
|
|
|
|||
|
|
```http
|
|||
|
|
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"
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 2.3 销毁会话
|
|||
|
|
|
|||
|
|
```http
|
|||
|
|
DELETE /api/sessions/{session_id}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
响应:`204 No Content`
|
|||
|
|
|
|||
|
|
#### 2.4 预留端点(暂不实现)
|
|||
|
|
|
|||
|
|
> [!info] 后续扩展
|
|||
|
|
> 引入持久化后,按需添加以下端点:
|
|||
|
|
|
|||
|
|
| 端点 | 方法 | 用途 | MVP 状态 |
|
|||
|
|
|------|------|------|---------|
|
|||
|
|
| `/api/sessions/{id}/messages` | GET | 查询对话历史 | 预留,暂不实现 |
|
|||
|
|
| `/api/usage` | GET | 查询用量统计 | 预留,暂不实现 |
|
|||
|
|
| `/api/users/{id}/preferences` | GET/PUT | 用户偏好管理 | 预留,暂不实现 |
|
|||
|
|
|
|||
|
|
### 三、数据模型
|
|||
|
|
|
|||
|
|
#### 3.1 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"`
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```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)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type UsageRecord struct {
|
|||
|
|
SessionID string `json:"session_id"`
|
|||
|
|
LLMTokens int `json:"llm_tokens"`
|
|||
|
|
STTSeconds float64 `json:"stt_seconds"`
|
|||
|
|
TTSChars int `json:"tts_chars"`
|
|||
|
|
EstimatedCost float64 `json:"estimated_cost"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type UsageDaily struct {
|
|||
|
|
Date string `json:"date"`
|
|||
|
|
LLMTokens int `json:"llm_tokens"`
|
|||
|
|
EstimatedCost float64 `json:"estimated_cost"`
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 3.2 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; // 仅 assistant 消息
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---- WebSocket 消息联合类型 ----
|
|||
|
|
|
|||
|
|
type ServerMessage =
|
|||
|
|
| ConnectedMessage
|
|||
|
|
| STTResultMessage
|
|||
|
|
| LLMChunkMessage
|
|||
|
|
| LLMDoneMessage
|
|||
|
|
| TTSAudioMessage
|
|||
|
|
| ErrorMessage
|
|||
|
|
| PongMessage;
|
|||
|
|
|
|||
|
|
type ClientMessage =
|
|||
|
|
| QueryMessage
|
|||
|
|
| ConfigMessage
|
|||
|
|
| InterruptMessage
|
|||
|
|
| PingMessage;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 四、扩展接口设计
|
|||
|
|
|
|||
|
|
通过**接口(Interface)模式**隔离存储层,MVP 用内存实现,后续替换为数据库实现——业务逻辑层零改动。
|
|||
|
|
|
|||
|
|
#### 4.1 Repository 接口
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
graph TD
|
|||
|
|
subgraph Biz["业务逻辑层(不变)"]
|
|||
|
|
ORCH["AI Orchestrator"]
|
|||
|
|
SM["Session Manager"]
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
subgraph Repo["存储接口层"]
|
|||
|
|
HR["HistoryRepository"]
|
|||
|
|
UR["UsageRepository"]
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
subgraph Impl_MVP["MVP 实现"]
|
|||
|
|
MEM_H["InMemoryHistory"]
|
|||
|
|
MEM_U["InMemoryUsage"]
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
subgraph Impl_Future["后续实现"]
|
|||
|
|
PG_H["PgHistory"]
|
|||
|
|
PG_U["PgUsage"]
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
ORCH --> HR
|
|||
|
|
ORCH --> UR
|
|||
|
|
SM --> HR
|
|||
|
|
HR --> MEM_H
|
|||
|
|
UR --> MEM_U
|
|||
|
|
HR -.->|"替换"| PG_H
|
|||
|
|
UR -.->|"替换"| PG_U
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 4.2 MVP 内存实现
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// InMemoryHistory —— MVP 阶段的对话历史实现
|
|||
|
|
// 数据存在内存 map 中,连接断开即丢
|
|||
|
|
type InMemoryHistory struct {
|
|||
|
|
mu sync.RWMutex
|
|||
|
|
sessions map[string][]Message // sessionID -> messages
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 4.3 后续替换为 PostgreSQL
|
|||
|
|
|
|||
|
|
引入持久化时,只需新增一个实现,无需修改业务逻辑:
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// PgHistory —— PostgreSQL 实现(后续扩展)
|
|||
|
|
type PgHistory struct {
|
|||
|
|
pool *pgxpool.Pool
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (p *PgHistory) SaveMessage(ctx context.Context, sessionID string, msg Message) error {
|
|||
|
|
_, err := p.pool.Exec(ctx,
|
|||
|
|
`INSERT INTO messages (session_id, role, content, image_url, tokens_used)
|
|||
|
|
VALUES ($1, $2, $3, $4, $5)`,
|
|||
|
|
sessionID, msg.Role, msg.Content, msg.ImageURL, msg.TokensUsed,
|
|||
|
|
)
|
|||
|
|
return err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (p *PgHistory) GetMessages(ctx context.Context, sessionID string, limit int) ([]Message, error) {
|
|||
|
|
rows, _ := p.pool.Query(ctx,
|
|||
|
|
`SELECT role, content, image_url, tokens_used
|
|||
|
|
FROM messages WHERE session_id = $1
|
|||
|
|
ORDER BY created_at DESC LIMIT $2`,
|
|||
|
|
sessionID, limit,
|
|||
|
|
)
|
|||
|
|
defer rows.Close()
|
|||
|
|
// ... scan and return
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> [!question] 思考
|
|||
|
|
> 这就是**依赖倒置原则**——业务层依赖接口(`HistoryRepository`),不依赖具体实现。MVP 阶段注入 `InMemoryHistory`,上线时一行代码换成 `PgHistory`,其余逻辑完全不动。
|
|||
|
|
|
|||
|
|
#### 4.4 注入点示例
|
|||
|
|
|
|||
|
|
在应用启动时根据配置选择实现:
|
|||
|
|
|
|||
|
|
```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),
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 五、错误码定义
|
|||
|
|
|
|||
|
|
| 错误码 | 含义 | 客户端处理建议 |
|
|||
|
|
|--------|------|--------------|
|
|||
|
|
| `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` | 服务端内部错误 | 提示用户重试 |
|
|||
|
|
|
|||
|
|
> [!tip] 错误处理原则
|
|||
|
|
> 客户端收到 `error` 消息后,应根据错误码分别处理:可恢复的(如 `RATE_LIMITED`)自动重试;不可恢复的(如 `IMAGE_TOO_LARGE`)提示用户调整;服务端异常(如 `INTERNAL_ERROR`)记录日志并提示重试。
|
|||
|
|
|
|||
|
|
### 六、连接管理
|
|||
|
|
|
|||
|
|
#### 心跳机制
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
sequenceDiagram
|
|||
|
|
participant C as Client
|
|||
|
|
participant S as Server
|
|||
|
|
|
|||
|
|
loop 每 30 秒
|
|||
|
|
C->>S: ping
|
|||
|
|
S-->>C: pong
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
Note over S: 超过 60 秒无 ping
|
|||
|
|
S->>S: 判定连接断开
|
|||
|
|
S->>S: 清理会话资源
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 重连策略
|
|||
|
|
|
|||
|
|
客户端断线后按**指数退避**重连:
|
|||
|
|
|
|||
|
|
```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
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 关联笔记
|
|||
|
|
|
|||
|
|
- [[项目架构与技术栈]]
|
|||
|
|
- [[技术选型]]
|
|||
|
|
- [[项目架构与技术栈/技术名词解释]]
|