# WebSocket 协议

WebSocket 端点提供双向实时语音对话能力，支持音频流输入输出和文本消息交互。

## 连接地址

```
ws://<host>/ws?assistant_id=<assistant_id>
```

- `assistant_id` 为必填 query 参数，用于从数据库加载该助手的运行时配置。

## 传输规则

- **文本帧**：JSON 格式控制消息
- **二进制帧**：PCM 音频数据（`pcm_s16le`, 16kHz, 单声道）
- 帧长度必须是 640 字节的整数倍（20ms 音频 = 640 bytes）

---

## 消息流程

```
Client -> session.start
Server <- session.started
Server <- (optional) config.resolved
Client -> (binary pcm frames...)
Server <- input.speech_started / transcript.delta / transcript.final
Server <- assistant.response.delta / assistant.response.final
Server <- output.audio.start
Server <- (binary pcm frames...)
Server <- output.audio.end
Client -> output.audio.played (optional)
Client -> session.stop
Server <- session.stopped
```

---

## 客户端 -> 服务端消息

`session.start`

客户端连接后发送的第一个消息，用于启动对话会话。

```json
{
  "type": "session.start",
  "audio": {
    "encoding": "pcm_s16le",
    "sample_rate_hz": 16000,
    "channels": 1
  },
  "metadata": {
    "channel": "web",
    "source": "web_debug",
    "history": {
      "userId": 1
    },
    "overrides": {
      "systemPrompt": "你是简洁助手",
      "greeting": "你好，我能帮你什么？",
      "output": {
        "mode": "audio"
      }
    },
    "dynamicVariables": {
      "customer_name": "Alice",
      "plan_tier": "Pro"
    }
  }
}
```

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `type` | string | 是 | 固定为 `"session.start"` |
| `audio` | object | 否 | 音频格式描述 |
| `audio.encoding` | string | 否 | 固定为 `"pcm_s16le"` |
| `audio.sample_rate_hz` | number | 否 | 固定为 `16000` |
| `audio.channels` | number | 否 | 固定为 `1` |
| `metadata` | object | 否 | 运行时配置 |

**metadata 支持的字段**：
- `channel` - 渠道标识
- `source` - 来源标识
- `history.userId` - 历史记录用户 ID
- `overrides` - 可覆盖字段（仅限安全白名单）
- `dynamicVariables` - 动态变量（支持 `{{variable}}` 占位符）

**`metadata.overrides` 白名单字段**：
- `systemPrompt`
- `greeting`
- `firstTurnMode`
- `generatedOpenerEnabled`
- `output`
- `bargeIn`
- `knowledgeBaseId`
- `knowledge`
- `tools`
- `openerAudio`

**限制**：
- `metadata.workflow` 会被忽略（不触发 workflow 事件）
- 禁止提交 `metadata.services`
- 禁止提交 `assistantId` / `appId` / `app_id` / `configVersionId` / `config_version_id`
- 禁止提交包含密钥语义的字段（如 `apiKey` / `token` / `secret` / `password` / `authorization`）

---

`input.text`

发送文本输入，跳过 ASR 识别，直接触发 LLM 回复。

```json
{
  "type": "input.text",
  "text": "你能做什么？"
}
```

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `type` | string | 是 | 固定为 `"input.text"` |
| `text` | string | 是 | 用户文本内容 |

---

`response.cancel`

请求中断当前回答。

```json
{
  "type": "response.cancel",
  "graceful": false
}
```

| 字段 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|
| `type` | string | 是 | - | 固定为 `"response.cancel"` |
| `graceful` | boolean | 否 | `false` | `false` 立即打断 |

---

`output.audio.played`

客户端回执音频已在本地播放完成（含本地 jitter buffer / 播放队列）。

```json
{
  "type": "output.audio.played",
  "tts_id": "tts_001",
  "response_id": "resp_001",
  "turn_id": "turn_001",
  "played_at_ms": 1730000018450,
  "played_ms": 2520
}
```

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `type` | string | 是 | 固定为 `"output.audio.played"` |
| `tts_id` | string | 是 | 已完成播放的 TTS 段 ID |
| `response_id` | string | 否 | 所属回复 ID（建议回传） |
| `turn_id` | string | 否 | 所属轮次 ID（建议回传） |
| `played_at_ms` | number | 否 | 客户端本地播放完成时间戳（毫秒） |
| `played_ms` | number | 否 | 本次播放耗时（毫秒） |

---

`tool_call.results`

回传客户端执行的工具结果。

```json
{
  "type": "tool_call.results",
  "results": [
    {
      "tool_call_id": "call_abc123",
      "name": "weather",
      "output": { "temp_c": 21, "condition": "sunny" },
      "status": { "code": 200, "message": "ok" }
    }
  ]
}
```

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `type` | string | 是 | 固定为 `"tool_call.results"` |
| `results` | array | 否 | 工具结果列表 |
| `results[].tool_call_id` | string | 是 | 工具调用 ID |
| `results[].name` | string | 是 | 工具名称 |
| `results[].output` | any | 否 | 工具输出 |
| `results[].status` | object | 是 | 执行状态 |
| `results[].status.code` | number | 是 | HTTP 状态码（200-299 表示成功） |
| `results[].status.message` | string | 是 | 状态描述 |

---

`session.stop`

结束对话会话。

```json
{
  "type": "session.stop",
  "reason": "client_disconnect"
}
```

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `type` | string | 是 | 固定为 `"session.stop"` |
| `reason` | string | 否 | 结束原因 |

---

`Binary Audio`

在 `session.started` 之后可持续发送二进制 PCM 音频。

- **格式**：`pcm_s16le`
- **采样率**：16000 Hz
- **声道**：1（单声道）
- **帧长**：20ms = 640 bytes

---

## 服务端 -> 客户端事件

### 事件包络

所有 JSON 事件都包含统一包络字段：

```json
{
  "type": "event.name",
  "timestamp": 1730000000000,
  "sessionId": "sess_xxx",
  "seq": 42,
  "source": "asr",
  "trackId": "audio_in",
  "data": {}
}
```

| 字段 | 类型 | 说明 |
|---|---|---|
| `type` | string | 事件类型 |
| `timestamp` | number | 事件时间戳（Unix 毫秒） |
| `sessionId` | string | 会话 ID |
| `seq` | number | 递增序号（用于重放/恢复） |
| `source` | string | 事件来源：`asr` / `llm` / `tts` / `tool` / `system` / `client` / `server` |
| `trackId` | string | 事件轨道：`audio_in` / `audio_out` / `control` |
| `data` | object | 业务数据（可选） |

**轨道 ID 说明**：

| trackId | 说明 | 相关事件 |
|---------|------|---------|
| `audio_in` | ASR/VAD 输入侧事件 | `input.*`, `transcript.*` |
| `audio_out` | 助手输出侧事件 | `assistant.*`, `output.audio.*`, `response.interrupted`, `metrics.ttfb` |
| `control` | 会话控制事件 | `session.*`, `error`, `heartbeat`, `(optional) config.resolved` |

---

### 会话控制类事件

#### `session.started`

会话启动成功，客户端收到此事件后可以开始发送音频。

```json
{
  "type": "session.started",
  "timestamp": 1730000000000,
  "sessionId": "ea34e1ca-b417-4a57-b03e-f752cb82e97d",
  "seq": 1,
  "trackId": "control",
  "tracks": {
    "audio_in": "audio_in",
    "audio_out": "audio_out",
    "control": "control"
  },
  "audio": {
    "encoding": "pcm_s16le",
    "sample_rate_hz": 16000,
    "channels": 1
  }
}
```

| 字段 | 类型 | 说明 |
|---|---|---|
| `sessionId` | string | 会话唯一标识符 |
| `trackId` | string | 固定为 `"control"` |
| `tracks` | object | 可用轨道列表 |
| `tracks.audio_in` | string | 输入轨道 ID |
| `tracks.audio_out` | string | 输出轨道 ID |
| `tracks.control` | string | 控制轨道 ID |
| `audio` | object | 音频格式配置 |
| `audio.encoding` | string | 编码格式 |
| `audio.sample_rate_hz` | number | 采样率 |
| `audio.channels` | number | 声道数 |

---

#### `config.resolved`

服务端返回的**公开配置快照**。
默认不发送（SaaS 公网模式建议关闭）；仅在 `WS_EMIT_CONFIG_RESOLVED=true` 时发送。

```json
{
  "type": "config.resolved",
  "timestamp": 1730000000001,
  "sessionId": "ea34e1ca-b417-4a57-b03e-f752cb82e97d",
  "seq": 2,
  "trackId": "control",
  "config": {
    "channel": "web_debug",
    "output": {
      "mode": "audio"
    },
    "tools": {
      "enabled": true,
      "count": 2
    },
    "tracks": {
      "audio_in": "audio_in",
      "audio_out": "audio_out",
      "control": "control"
    }
  }
}
```

| 字段 | 类型 | 说明 |
|---|---|---|
| `trackId` | string | 固定为 `"control"` |
| `config` | object | SaaS 安全的公开配置快照 |
| `config.channel` | string | 回显 `session.start.metadata.channel`（如提供） |
| `config.output` | object | 输出配置 |
| `config.output.mode` | string | 输出模式：`"audio"` / `"text"` |
| `config.tools.enabled` | boolean | 是否启用工具能力 |
| `config.tools.count` | number | 可用工具数量（不暴露工具清单） |
| `config.tracks` | object | 可用轨道列表 |

**不会返回以下内部字段**：
- `assistantId` / `appId` / `configVersionId`
- `services`（provider/model/baseUrl 等）
- 系统提示词原文及其它内部编排细节

---

#### `heartbeat`

保活心跳事件，默认每 50 秒发送一次。

```json
{
  "type": "heartbeat",
  "timestamp": 1730000050000,
  "sessionId": "ea34e1ca-b417-4a57-b03e-f752cb82e97d",
  "seq": 10
}
```

| 字段 | 类型 | 说明 |
|---|---|---|
| `timestamp` | number | 心跳时间戳 |

---

#### `session.stopped`

会话结束确认。

```json
{
  "type": "session.stopped",
  "timestamp": 1730000100000,
  "sessionId": "ea34e1ca-b417-4a57-b03e-f752cb82e97d",
  "seq": 50,
  "reason": "client_requested"
}
```

| 字段 | 类型 | 说明 |
|---|---|---|
| `reason` | string | 结束原因：`"client_requested"` / `"timeout"` / `"error"` |

---

### ASR 识别事件

#### `input.speech_started`

检测到语音开始（VAD）。

```json
{
  "type": "input.speech_started",
  "timestamp": 1730000010000,
  "sessionId": "ea34e1ca-b417-4a57-b03e-f752cb82e97d",
  "seq": 5,
  "source": "asr",
  "trackId": "audio_in",
  "probability": 0.95
}
```

| 字段 | 类型 | 说明 |
|---|---|---|
| `trackId` | string | 固定为 `"audio_in"` |
| `probability` | number | 语音检测置信度（0-1） |

---

#### `input.speech_stopped`

检测到语音结束（VAD）。

```json
{
  "type": "input.speech_stopped",
  "timestamp": 1730000012000,
  "sessionId": "ea34e1ca-b417-4a57-b03e-f752cb82e97d",
  "seq": 8,
  "source": "asr",
  "trackId": "audio_in",
  "probability": 0.92
}
```

| 字段 | 类型 | 说明 |
|---|---|---|
| `trackId` | string | 固定为 `"audio_in"` |
| `probability` | number | 静音检测置信度（0-1） |

---

#### `transcript.delta`

ASR 增量识别文本（实时转写）。

```json
{
  "type": "transcript.delta",
  "timestamp": 1730000011000,
  "sessionId": "ea34e1ca-b417-4a57-b03e-f752cb82e97d",
  "seq": 6,
  "source": "asr",
  "trackId": "audio_in",
  "text": "你好",
  "data": {
    "text": "你好",
    "turn_id": "turn_001",
    "utterance_id": "utt_001"
  }
}
```

| 字段 | 类型 | 说明 |
|---|---|---|
| `trackId` | string | 固定为 `"audio_in"` |
| `text` | string | 增量识别文本 |
| `data.text` | string | 增量识别文本（同 `text`） |
| `data.turn_id` | string | 当前对话轮次 ID |
| `data.utterance_id` | string | 当前语句 ID |

**节流说明**：服务端默认每 300ms 合并一次 delta 事件。

---

#### `transcript.final`

ASR 最终识别文本（语句结束）。

```json
{
  "type": "transcript.final",
  "timestamp": 1730000012500,
  "sessionId": "ea34e1ca-b417-4a57-b03e-f752cb82e97d",
  "seq": 9,
  "source": "asr",
  "trackId": "audio_in",
  "text": "你好，请问今天天气怎么样",
  "data": {
    "text": "你好，请问今天天气怎么样",
    "turn_id": "turn_001",
    "utterance_id": "utt_001"
  }
}
```

| 字段 | 类型 | 说明 |
|---|---|---|
| `trackId` | string | 固定为 `"audio_in"` |
| `text` | string | 最终识别文本 |
| `data.text` | string | 最终识别文本（同 `text`） |
| `data.turn_id` | string | 当前对话轮次 ID |
| `data.utterance_id` | string | 当前语句 ID |

---

### LLM/TTS 输出事件

#### `assistant.response.delta`

助手增量文本输出（流式生成）。

```json
{
  "type": "assistant.response.delta",
  "timestamp": 1730000013000,
  "sessionId": "ea34e1ca-b417-4a57-b03e-f752cb82e97d",
  "seq": 12,
  "source": "llm",
  "trackId": "audio_out",
  "text": "今天天气",
  "data": {
    "text": "今天天气",
    "turn_id": "turn_001",
    "response_id": "resp_001"
  }
}
```

| 字段 | 类型 | 说明 |
|---|---|---|
| `trackId` | string | 固定为 `"audio_out"` |
| `source` | string | 固定为 `"llm"` |
| `text` | string | 增量文本内容 |
| `data.text` | string | 增量文本内容（同 `text`） |
| `data.turn_id` | string | 当前对话轮次 ID |
| `data.response_id` | string | 当前回复 ID |

**节流说明**：服务端默认每 80ms 合并一次 delta 事件。

---

#### `assistant.response.final`

助手完整文本输出（回复结束）。

```json
{
  "type": "assistant.response.final",
  "timestamp": 1730000015000,
  "sessionId": "ea34e1ca-b417-4a57-b03e-f752cb82e97d",
  "seq": 18,
  "source": "llm",
  "trackId": "audio_out",
  "text": "今天天气晴朗，气温25度，适合外出。",
  "data": {
    "text": "今天天气晴朗，气温25度，适合外出。",
    "turn_id": "turn_001",
    "response_id": "resp_001"
  }
}
```

| 字段 | 类型 | 说明 |
|---|---|---|
| `trackId` | string | 固定为 `"audio_out"` |
| `source` | string | 固定为 `"llm"` |
| `text` | string | 完整回复文本 |
| `data.text` | string | 完整回复文本（同 `text`） |
| `data.turn_id` | string | 当前对话轮次 ID |
| `data.response_id` | string | 当前回复 ID |

---

#### `assistant.tool_call`

工具调用通知，通知客户端 LLM 请求调用工具。

```json
{
  "type": "assistant.tool_call",
  "timestamp": 1730000014000,
  "sessionId": "ea34e1ca-b417-4a57-b03e-f752cb82e97d",
  "seq": 14,
  "source": "llm",
  "trackId": "audio_out",
  "tool_call_id": "call_abc123",
  "tool_name": "weather",
  "arguments": {
    "city": "北京"
  },
  "executor": "server",
  "timeout_ms": 30000,
  "data": {
    "tool_call": {
      "id": "call_abc123",
      "name": "weather",
      "arguments": "{\"city\":\"北京\"}"
    },
    "turn_id": "turn_001"
  }
}
```

| 字段 | 类型 | 说明 |
|---|---|---|
| `trackId` | string | 固定为 `"audio_out"` |
| `source` | string | 固定为 `"llm"` |
| `tool_call_id` | string | 工具调用唯一 ID |
| `tool_name` | string | 工具名称 |
| `arguments` | object | 工具参数（已解析的 JSON） |
| `executor` | string | 执行方：`"server"` 服务端执行 / `"client"` 客户端执行 |
| `timeout_ms` | number | 超时时间（毫秒） |
| `data.tool_call` | object | 原始工具调用信息 |
| `data.tool_call.id` | string | 工具调用 ID |
| `data.tool_call.name` | string | 工具名称 |
| `data.tool_call.arguments` | string | 工具参数（JSON 字符串） |
| `data.turn_id` | string | 当前对话轮次 ID |

**注意**：当 `executor = "client"` 时，客户端需要执行工具并返回 `tool_call.results`。

---

#### `assistant.tool_result`

工具执行结果通知。

```json
{
  "type": "assistant.tool_result",
  "timestamp": 1730000014500,
  "sessionId": "ea34e1ca-b417-4a57-b03e-f752cb82e97d",
  "seq": 15,
  "source": "server",
  "trackId": "audio_out",
  "tool_call_id": "call_abc123",
  "tool_name": "weather",
  "tool_display_name": "天气查询",
  "ok": true,
  "error": null,
  "result": {
    "tool_call_id": "call_abc123",
    "name": "weather",
    "output": {
      "temperature": 25,
      "condition": "晴",
      "humidity": 40
    },
    "status": {
      "code": 200,
      "message": "ok"
    }
  }
}
```

| 字段 | 类型 | 说明 |
|---|---|---|
| `trackId` | string | 固定为 `"audio_out"` |
| `source` | string | 执行方：`"server"` / `"client"` |
| `tool_call_id` | string | 工具调用 ID |
| `tool_name` | string | 工具名称 |
| `tool_display_name` | string | 工具显示名称 |
| `ok` | boolean | 执行是否成功（状态码 200-299 为 true） |
| `error` | object \| null | 错误信息（`ok=false` 时存在） |
| `error.code` | number | 错误状态码 |
| `error.message` | string | 错误描述 |
| `error.retryable` | boolean | 是否可重试 |
| `result` | object | 原始执行结果 |
| `result.output` | any | 工具返回数据 |
| `result.status` | object | 执行状态 |
| `result.status.code` | number | HTTP 状态码 |
| `result.status.message` | string | 状态描述 |

---

#### `output.audio.start`

TTS 音频播放开始标记。

```json
{
  "type": "output.audio.start",
  "timestamp": 1730000015500,
  "sessionId": "ea34e1ca-b417-4a57-b03e-f752cb82e97d",
  "seq": 19,
  "source": "tts",
  "trackId": "audio_out",
  "data": {
    "tts_id": "tts_001",
    "turn_id": "turn_001"
  }
}
```

| 字段 | 类型 | 说明 |
|---|---|---|
| `trackId` | string | 固定为 `"audio_out"` |
| `source` | string | 固定为 `"tts"` |
| `data.tts_id` | string | TTS 播放段 ID |
| `data.turn_id` | string | 当前对话轮次 ID |

**说明**：此事件后服务端将发送二进制 PCM 音频帧。

---

#### `output.audio.end`

TTS 音频播放结束标记。

```json
{
  "type": "output.audio.end",
  "timestamp": 1730000018000,
  "sessionId": "ea34e1ca-b417-4a57-b03e-f752cb82e97d",
  "seq": 25,
  "source": "tts",
  "trackId": "audio_out",
  "data": {
    "tts_id": "tts_001",
    "turn_id": "turn_001"
  }
}
```

| 字段 | 类型 | 说明 |
|---|---|---|
| `trackId` | string | 固定为 `"audio_out"` |
| `source` | string | 固定为 `"tts"` |
| `data.tts_id` | string | TTS 播放段 ID |
| `data.turn_id` | string | 当前对话轮次 ID |

**说明**：`output.audio.end` 表示服务端已发送完成，不代表客户端扬声器已播完。若需要“真实播完”信号，客户端应发送 `output.audio.played`。

---

#### `response.interrupted`

回答被打断（用户插话）。

```json
{
  "type": "response.interrupted",
  "timestamp": 1730000016000,
  "sessionId": "ea34e1ca-b417-4a57-b03e-f752cb82e97d",
  "seq": 20,
  "source": "system",
  "trackId": "audio_out",
  "data": {
    "turn_id": "turn_001",
    "response_id": "resp_001"
  }
}
```

| 字段 | 类型 | 说明 |
|---|---|---|
| `trackId` | string | 固定为 `"audio_out"` |
| `data.turn_id` | string | 被打断的对话轮次 ID |
| `data.response_id` | string | 被打断的回复 ID |

---

#### `metrics.ttfb`

首包音频时延指标（Time To First Byte）。

```json
{
  "type": "metrics.ttfb",
  "timestamp": 1730000015600,
  "sessionId": "ea34e1ca-b417-4a57-b03e-f752cb82e97d",
  "seq": 21,
  "source": "system",
  "trackId": "audio_out",
  "latencyMs": 1520,
  "data": {
    "latencyMs": 1520,
    "turn_id": "turn_001"
  }
}
```

| 字段 | 类型 | 说明 |
|---|---|---|
| `trackId` | string | 固定为 `"audio_out"` |
| `latencyMs` | number | 首包音频时延（毫秒） |
| `data.latencyMs` | number | 首包音频时延（同 `latencyMs`） |
| `data.turn_id` | string | 当前对话轮次 ID |

**说明**：从用户输入结束到第一个音频包发送的时间。

---

### 错误事件

#### `error`

统一错误事件。

```json
{
  "type": "error",
  "timestamp": 1730000020000,
  "sessionId": "ea34e1ca-b417-4a57-b03e-f752cb82e97d",
  "seq": 30,
  "sender": "server",
  "code": "llm.timeout",
  "message": "LLM request timeout",
  "stage": "llm",
  "retryable": true,
  "trackId": "audio_out",
  "data": {
    "error": {
      "stage": "llm",
      "code": "llm.timeout",
      "message": "LLM request timeout",
      "retryable": true
    }
  }
}
```

| 字段 | 类型 | 说明 |
|---|---|---|
| `sender` | string | 错误来源：`"server"` / `"client"` |
| `code` | string | 错误码 |
| `message` | string | 错误描述 |
| `stage` | string | 错误阶段：`"protocol"` / `"asr"` / `"llm"` / `"tts"` / `"tool"` / `"audio"` |
| `retryable` | boolean | 是否可重试 |
| `trackId` | string | 错误关联的轨道 |
| `data.error` | object | 结构化错误信息 |
| `data.error.stage` | string | 错误阶段 |
| `data.error.code` | string | 错误码 |
| `data.error.message` | string | 错误描述 |
| `data.error.retryable` | boolean | 是否可重试 |

**trackId 约定**：
- `audio_in`：ASR/音频输入相关错误
- `audio_out`：LLM/TTS/工具相关错误
- `control`：协议/会话控制相关错误

---

## 关联 ID 说明

事件中的关联 ID 用于追踪对话流程：

| ID 类型 | 说明 | 生命周期 |
|---------|------|---------|
| `turn_id` | 对话轮次 ID | 一次用户-助手交互 |
| `utterance_id` | 语句 ID | 一次 ASR 最终识别结果 |
| `response_id` | 回复 ID | 一次助手回复生成 |
| `tool_call_id` | 工具调用 ID | 一次工具调用 |
| `tts_id` | TTS 播放段 ID | 一段语音合成播放 |

---

## 心跳与超时

- **心跳间隔**：默认 50 秒（`heartbeat_interval_sec`）
- **空闲超时**：默认 60 秒（`inactivity_timeout_sec`）
- 客户端应持续发送音频或轻量消息避免被判定闲置

## 事件节流

为保持客户端渲染和服务端负载稳定，v1 协议对部分事件进行节流：

| 事件 | 默认节流间隔 | 说明 |
|------|-------------|------|
| `transcript.delta` | 300ms | ASR 增量文本 |
| `assistant.response.delta` | 80ms | LLM 增量文本 |

## 错误处理

详细错误码请参考 [错误码](errors.md)。
