# WS v1 协议完整说明（中文）

本文档描述 `/ws` 端点的 WebSocket v1 协议，覆盖：
- 客户端输入（JSON 文本消息 + 二进制音频）；
- 服务端输出（JSON 事件 + 二进制音频）；
- 每个参数的类型、约束、含义与使用方式；
- 握手顺序、状态机、错误语义与实现细节。

实现对照来源：
- `models/ws_v1.py`
- `core/session.py`
- `core/duplex_pipeline.py`
- `app/main.py`

---

## 1. 传输与基础规则

- 连接地址：`ws://<host>/ws`
- 单连接双通道承载：
  - 文本帧：JSON 控制消息（严格校验 schema）
  - 二进制帧：原始 PCM 音频
- JSON 校验策略：
  - 所有已定义客户端消息都 `extra="forbid"`，即不允许未声明字段；
  - `hello.version` 固定必须是 `"v1"`；
  - 缺失 `type` 或未知 `type` 会返回协议错误。

---

## 2. 状态机与消息顺序

### 2.1 服务端状态

- `WAIT_HELLO`：等待 `hello`
- `WAIT_START`：已通过握手，等待 `session.start`
- `ACTIVE`：会话运行中，可收发文本/音频
- `STOPPED`：会话结束

### 2.2 正确顺序

1. 客户端发送 `hello`
2. 服务端返回 `hello.ack`
3. 客户端发送 `session.start`
4. 服务端返回 `session.started`
5. 客户端可持续发送：
  - 二进制音频
  - `input.text`（可选）
  - `response.cancel`（可选）
  - `tool_call.results`（可选）
6. 客户端发送 `session.stop` 或直接断开连接

顺序错误会返回 `error`，`code = "protocol.order"`。

---

## 3. 客户端 -> 服务端消息（输入）

## 3.1 `hello`

示例：

```json
{
  "type": "hello",
  "version": "v1",
  "auth": {
    "apiKey": "optional-api-key",
    "jwt": "optional-jwt"
  }
}
```

字段说明：

| 字段 | 类型 | 必填 | 约束 | 含义 | 使用说明 |
|---|---|---|---|---|---|
| `type` | string | 是 | 固定 `"hello"` | 消息类型 | 握手第一条消息 |
| `version` | string | 是 | 固定 `"v1"` | 协议版本 | 版本不匹配会 `protocol.version_unsupported` 并断开 |
| `auth` | object \| null | 否 | 仅允许 `apiKey`、`jwt` | 认证载荷 | 认证策略由服务端配置决定 |
| `auth.apiKey` | string \| null | 否 | 任意字符串 | API Key | 若服务端配置 `WS_API_KEY`，必须精确匹配 |
| `auth.jwt` | string \| null | 否 | 任意字符串 | JWT 字符串 | 当 `WS_REQUIRE_AUTH=true` 时可用于满足“有认证信息”条件 |

认证行为：
- 若设置了 `WS_API_KEY`：必须提供且匹配 `auth.apiKey`，否则 `auth.invalid_api_key` 并关闭连接。
- 若 `WS_REQUIRE_AUTH=true` 且未设置 `WS_API_KEY`：`auth.apiKey` 或 `auth.jwt` 至少一个非空，否则 `auth.required` 并关闭连接。

## 3.2 `session.start`

示例：

```json
{
  "type": "session.start",
  "audio": {
    "encoding": "pcm_s16le",
    "sample_rate_hz": 16000,
    "channels": 1
  },
  "metadata": {
    "appId": "assistant_123",
    "channel": "web",
    "configVersionId": "cfg_20260217_01",
    "client": "web-debug",
    "output": {
      "mode": "audio"
    },
    "systemPrompt": "你是简洁助手",
    "greeting": "你好，我能帮你什么？"
  }
}
```

字段说明：

| 字段 | 类型 | 必填 | 约束 | 含义 | 使用说明 |
|---|---|---|---|---|---|
| `type` | string | 是 | 固定 `"session.start"` | 启动会话 | 握手后第二阶段消息 |
| `audio` | object \| null | 否 | 仅支持固定值 | 音频格式描述 | 仅用于声明；MVP 实际只接受固定 PCM |
| `audio.encoding` | string | 否 | 固定 `"pcm_s16le"` | 编码格式 | 非该值会在模型校验层报错 |
| `audio.sample_rate_hz` | number | 否 | 固定 `16000` | 采样率 | 16kHz |
| `audio.channels` | number | 否 | 固定 `1` | 声道数 | 单声道 |
| `metadata` | object \| null | 否 | 任意对象（会被白名单过滤） | 运行时配置 | 用于 app/channel/提示词/输出模式等覆盖 |

`metadata` 白名单策略（关键）：
- 允许透传的标识字段（ID 类）：
  - `appId` / `app_id`
  - `channel`
  - `configVersionId` / `config_version_id`
- 允许透传的覆盖字段：
  - `firstTurnMode`
  - `greeting`
  - `generatedOpenerEnabled`
  - `systemPrompt`
  - `output`
  - `bargeIn`
  - `knowledge`
  - `knowledgeBaseId`
  - `history`
  - `userId`
  - `assistantId`
  - `source`
- 客户端传入 `metadata.services` 会被忽略（服务端会记录 warning），服务配置由后端/环境变量决定。

`output.mode` 用法：
- `"audio"`（默认语音输出）
- `"text"`（纯文本输出）
  - 纯文本模式下仍会收到 `assistant.response.delta/final`；
  - 不会收到 TTS 音频帧与 `output.audio.start/end`。

## 3.3 `input.text`

示例：

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

字段说明：

| 字段 | 类型 | 必填 | 约束 | 含义 | 使用说明 |
|---|---|---|---|---|---|
| `type` | string | 是 | 固定 `"input.text"` | 文本输入 | 跳过 ASR，直接触发 LLM 回答 |
| `text` | string | 是 | 非空字符串为佳 | 用户文本 | 用于文本聊天或调试 |

## 3.4 `response.cancel`

示例：

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

字段说明：

| 字段 | 类型 | 必填 | 默认值 | 含义 | 使用说明 |
|---|---|---|---|---|---|
| `type` | string | 是 | - | 固定 `"response.cancel"` | 请求中断当前回答 |
| `graceful` | boolean | 否 | `false` | 取消方式 | `false` 立即打断；`true` 当前实现主要用于记录日志，不强制中断 |

## 3.5 `tool_call.results`

仅在工具执行端为客户端时使用（`assistant.tool_call.executor == "client"`）。

示例：

```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 | 必须与 `assistant.tool_call.tool_call_id` 对应 |
| `results[].name` | string | 是 | 任意字符串 | 工具名 | 建议与请求一致 |
| `results[].output` | any | 否 | 任意 JSON | 工具输出 | 供模型后续组织回答 |
| `results[].status` | object | 是 | 包含 `code`、`message` | 执行状态 | 用于判定成功/失败 |
| `results[].status.code` | number | 是 | HTTP 风格状态码 | 状态码 | `200-299` 判定成功 |
| `results[].status.message` | string | 是 | 任意字符串 | 状态描述 | 例如 `"ok"` / `"timeout"` |

处理规则：
- 未请求过的 `tool_call_id` 会被忽略（防止伪造/串话）；
- 重复回传会被忽略；
- 超时未回传会由服务端合成超时结果（`504`）。

## 3.6 `session.stop`

示例：

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

字段说明：

| 字段 | 类型 | 必填 | 约束 | 含义 | 使用说明 |
|---|---|---|---|---|---|
| `type` | string | 是 | 固定 `"session.stop"` | 结束会话 | 正常结束推荐发送 |
| `reason` | string \| null | 否 | 任意字符串 | 结束原因 | 服务端会回传到 `session.stopped.reason` |

---

## 4. 二进制音频输入（客户端 -> 服务端）

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

固定格式（MVP）：
- 编码：`pcm_s16le`
- 采样率：`16000`
- 声道：`1`
- 帧长：20ms = `640 bytes`

分包规则：
- 单个 WebSocket 二进制消息可包含 1 帧或多帧；
- 长度必须是 `640` 的整数倍；
- 不是 `640` 倍数会触发 `audio.frame_size_mismatch`，该消息整包丢弃；
- 奇数字节长度会触发 `audio.invalid_pcm`。

---

## 5. 服务端 -> 客户端事件（输出）

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

## 5.1 统一包络（Envelope）

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

字段说明：

| 字段 | 类型 | 含义 | 使用说明 |
|---|---|---|---|
| `type` | string | 事件类型 | 见下方事件清单 |
| `timestamp` | number | 事件时间戳（毫秒） | 由 `ev()` 生成 |
| `sessionId` | string | 会话ID | 同一连接固定 |
| `seq` | number | 单会话递增序号 | 可用于重放、去重、排序 |
| `source` | string | 事件来源 | 常见：`asr`/`llm`/`tts`/`tool`/`system`/`client`/`server` |
| `trackId` | string | 事件轨道 | 常用：`audio_in`/`audio_out`/`control` |
| `data` | object | 结构化数据 | 顶层业务字段会镜像进 `data` 以兼容旧客户端 |

关联ID（在 `data` 内自动注入，存在时）：
- `turn_id`：一次用户-助手对话轮次
- `utterance_id`：一次用户语音话语
- `response_id`：一次助手生成响应
- `tool_call_id`：一次工具调用
- `tts_id`：一次 TTS 播放段

## 5.2 事件类型与参数

### 5.2.1 会话与控制类

1. `hello.ack`
- 关键字段：`version`
- 含义：握手成功，应紧接着发送 `session.start`

2. `session.started`
- 关键字段：
  - `trackId`
  - `tracks.audio_in`
  - `tracks.audio_out`
  - `tracks.control`
  - `audio`（回显客户端声明的音频元信息）
- 含义：会话进入 ACTIVE，可发音频/文本

3. `config.resolved`
- 关键字段：
  - `config.appId`
  - `config.channel`
  - `config.configVersionId`
  - `config.prompt.sha256`
  - `config.output`
  - `config.services`（去密钥后的有效服务配置）
  - `config.tools.allowlist`
  - `config.tracks`
- 含义：服务端最终生效配置快照，便于前端展示与排错

4. `heartbeat`
- 关键字段：无业务字段（仅 envelope）
- 含义：保活心跳
- 默认间隔：`heartbeat_interval_sec`（默认 50s）

5. `session.stopped`
- 关键字段：`reason`
- 含义：会话结束确认

6. `error`
- 关键字段：
  - `sender`
  - `code`
  - `message`
  - `stage`
  - `retryable`
  - `trackId`
  - `data.error`（结构化错误镜像）
- 含义：统一错误事件

### 5.2.2 识别与输入侧（ASR/VAD）

1. `input.speech_started`
- 字段：`probability`
- 含义：检测到语音开始

2. `input.speech_stopped`
- 字段：`probability`
- 含义：检测到语音结束

3. `transcript.delta`
- 字段：`text`
- 含义：ASR 增量识别文本（节流发送）

4. `transcript.final`
- 字段：`text`
- 含义：ASR 最终识别文本

### 5.2.3 输出侧（LLM/TTS/Tool）

1. `assistant.response.delta`
- 字段：`text`
- 含义：助手增量文本输出（节流发送）

2. `assistant.response.final`
- 字段：`text`
- 含义：助手完整文本输出

3. `assistant.tool_call`
- 字段：
  - `tool_call_id`
  - `tool_name`
  - `arguments`（对象）
  - `executor`（`client` 或 `server`）
  - `timeout_ms`
  - `tool_call`（完整工具调用对象）
- 含义：通知客户端发生工具调用（用于可视化或客户端执行）

4. `assistant.tool_result`
- 字段：
  - `source`（`client` 或 `server`）
  - `tool_call_id`
  - `tool_name`
  - `ok`（boolean）
  - `error`（失败时 `{code,message,retryable}`）
  - `result`（原始结果对象）
- 含义：工具调用结果回执

5. `output.audio.start`
- 含义：TTS 音频输出开始边界

6. `output.audio.end`
- 含义：TTS 音频输出结束边界

7. `response.interrupted`
- 含义：当前回答被打断（barge-in 或 cancel）

8. `metrics.ttfb`
- 字段：`latencyMs`
- 含义：首包音频时延（TTFB）

### 5.2.4 工作流扩展事件（可选）

若 `metadata.workflow` 生效，会额外出现：
- `workflow.started`
- `workflow.node.entered`
- `workflow.edge.taken`
- `workflow.tool.requested`
- `workflow.human_transfer`
- `workflow.ended`

这些事件用于外部可视化工作流状态，不影响基础语音会话协议。

---

## 6. 服务端二进制音频输出（服务端 -> 客户端）

- 音频为 PCM 二进制帧；
- 发送单位对齐到 `640 bytes`（不足会补零后发送）；
- 前端通常结合 `output.audio.start/end` 做播放边界控制；
- 收到 `response.interrupted` 后应丢弃队列中未播放完的旧音频。

---

## 7. 错误模型与常见错误码

统一结构（`error` 事件）：

```json
{
  "type": "error",
  "sender": "client",
  "code": "protocol.invalid_message",
  "message": "Invalid message: ...",
  "stage": "protocol",
  "retryable": false,
  "trackId": "control",
  "data": {
    "error": {
      "stage": "protocol",
      "code": "protocol.invalid_message",
      "message": "Invalid message: ...",
      "retryable": false
    }
  }
}
```

字段语义：
- `sender`：错误来源角色（如 `client` / `server` / `auth`）
- `code`：机器可读错误码
- `message`：人类可读描述
- `stage`：阶段（`protocol|audio|asr|llm|tts|tool`）
- `retryable`：是否建议重试
- `trackId`：错误归属轨道

常见错误码：
- `protocol.invalid_json`
- `protocol.invalid_message`
- `protocol.order`
- `protocol.version_unsupported`
- `protocol.unsupported`
- `auth.invalid_api_key`
- `auth.required`
- `audio.invalid_pcm`
- `audio.frame_size_mismatch`
- `audio.processing_failed`
- `server.internal`

---

## 8. 心跳与超时

服务端后台任务逻辑：
- 每隔约 5 秒检查一次连接；
- 超过 `inactivity_timeout_sec`（默认 60 秒）未收到任何客户端消息则关闭会话；
- 每隔 `heartbeat_interval_sec`（默认 50 秒）发送一次 `heartbeat`。

客户端建议：
- 持续上行音频或定期发送轻量文本消息，避免被判定闲置；
- 用 `heartbeat` + `seq` 检测连接活性和事件乱序。

---

## 9. 实战接入建议

1. 建连后立即发送 `hello`，收到 `hello.ack` 后再发 `session.start`。
2. 语音输入严格按 16k/16bit/mono，并保证每个 WS 二进制消息长度是 `640*n`。
3. UI 层把 `assistant.response.delta` 当作流式显示，把 `assistant.response.final` 当作收敛结果。
4. 播放器用 `output.audio.start/end` 管理一轮播报生命周期。
5. 工具调用场景下，若 `executor=client`，务必按 `tool_call_id` 回传 `tool_call.results`。
6. 出现 `error` 时优先按 `code` 分流处理，而不是仅看 `message`。

---

## 10. 最小完整时序示例

```text
Client -> hello
Server <- hello.ack
Client -> session.start
Server <- session.started
Server <- 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 -> session.stop
Server <- session.stopped
```

