chore: checkpoint 第一版
ci / verify (push) Canceled after 0s

This commit is contained in:
Codex Local
2026-09-06 17:04:11 +08:00
parent a8cdec98d1
commit 0c2151ace9
659 changed files with 185855 additions and 554 deletions
+1 -1
View File
@@ -23,7 +23,7 @@
| 记忆修订与停用 | 当前记忆、正文或状态、原因、强ETag | 正文变化创建新版本并立即使旧索引失效;停用后不再召回;重新启用必须重新校验授权并重新索引;`PENDING_DELETE/SUPERSEDED/DELETED`均禁止修改 | 新记忆版本或状态 |
| 记忆永久删除 | 当前记忆、删除原因、强ETag | 事务内先进入`PENDING_DELETE`并清空活动向量,使新通话立即停止召回;该状态不可通过修改接口恢复;Outbox后台把索引标记删除、清除所有记忆版本正文并记录完成审计 | `DELETED`记忆和索引 |
| 新通话记忆检索 | 服务端已确定的客户组织、长者和通话目标 | SQL同时限制租户、长者、当前授权、活动记忆、当前版本、普通敏感级别、有效期和活动索引;最多5条 | 冻结到`context_snapshot.memory_versions`的版本引用和最小背景摘要 |
| 通话中记忆工具 | 供应商持有的单通话短期签名令牌、自然语言查询 | 令牌只绑定服务端既有通话和任务;租户、家庭和长者边界从数据库反查,模型不能传入或扩大;单条240字、总计800字、最多5条;Embedding失败返回空结果并标记降级,不中断通话 | `/internal/v1/tools/memory-search`、`memory_tool_call`和实际使用追踪 |
| 通话中记忆工具 | 供应商持有的单通话短期签名令牌、自然语言查询 | 令牌只绑定服务端既有通话和RTC任务或硬件智能体会话;租户、家庭和长者边界从数据库反查,模型不能传入或扩大;单条240字、总计800字、最多5条;Embedding失败返回空结果并标记降级,不中断通话 | `/internal/v1/tools/memory-search`、`memory_tool_call`和实际使用追踪 |
| 可选供应商原生记忆 | 当前人工确认记忆、有效授权、普通敏感级别、受控HTTP适配器 | 数据库始终是主数据;开启后用Outbox同步,修订先清理旧引用,停用、授权撤回、单条删除和主体删除均可靠删除并对账;关闭功能后通话继续走数据库检索 | `provider`索引的`PENDING/ACTIVE/FAILED/DELETED`状态 |
| 内容质量反馈 | 故事、事实或记忆的人工确认、修改、拒绝、停用及九类标准问题 | 不复制私密正文;按生成模型和提示词版本聚合候选数、审核动作和问题类型;跨租户统一拒绝 | 维护端“内容质量”与`content_review_signal` |
| 跨通话连续上下文 | 最近已完成通话的就绪摘要、当前授权快照、交流偏好 | 只有`story_extraction`和`personalized_memory`在新通话冻结快照中同时有效时,才按同租户同长者读取最近3次摘要;读取结果每条最多240字,供应商上下文按每条140字压缩且总计不超过1000字;撤回任一授权后不再注入 | 冻结到新通话`context_snapshot.sanitized_summary`的短期连续上下文 |
+6 -1
View File
@@ -1,7 +1,7 @@
# 设备 API 兼容性约定
> 当前设备接口主版本:`/device/v2`
> 当前接口契约:OpenAPI 0.73.0
> 当前接口契约:OpenAPI 0.77.0
> 适用对象:云端 API、终端固件、模拟终端和设备联调工具
## 1. 兼容目标
@@ -18,6 +18,10 @@
## 3. 必须升级到`/device/v3`的变更
当前`/device/v3/hardware-agent-credentials:provision`和`/device/v3/agent-sessions`承载火山硬件对话智能体控制面:前者由平台代设备动态注册且不下发Product Secret,后者在设备直连供应商音频前预取冻结的人设、记忆和授权配置,并回报会话事件与终稿。现有RTC/VoiceChat设备继续使用完整的`/device/v2`接口,平台不得将v3字段或状态语义反向强加给v2固件。
v3硬件会话必须在创建时冻结SDK版本、音频格式、可靠字幕、动态配置、凭证轮换和双路PCM录制能力,并以`session_updated`作为平台存活检查。`session_started`只有在设备证明本次`session.updated`已应用且Bot、传输、配置摘要匹配后才会激活。事件响应中的`action=stop_session`是强制控制动作:设备先断开供应商,再以新的单调序号回报结束或失败;`continue`才允许保持连接。低负载传输不具备供应商服务端状态回调,因此不得把设备事件和终稿上报能力声明为可选。硬件v3仍处于目标板卡接入期,本次新增必填能力字段不改变已经量产的v2契约。
以下任一变化都属于破坏性变更:删除或重命名字段、改变字段类型或业务含义、给既有请求增加必填字段、缩小已发布枚举导致旧值失效、改变签名算法或规范化规则、让旧固件收到无法安全忽略的新控制动作、改变既有状态机或重试/幂等语义。此类变更必须新增`/device/v3`,并保留v2迁移期和独立契约。
CI在拉取请求中以目标分支OpenAPI为基线执行破坏性变更检查。若确实需要破坏性变更,应先建立新主版本路径和迁移方案,不得通过修改或删除旧版契约绕过门禁。
@@ -42,6 +46,7 @@ CI在拉取请求中以目标分支OpenAPI为基线执行破坏性变更检查
| OpenAPI版本 | 设备兼容变更 | 兼容结论 |
|---|---|---|
| 0.77.0 | 新增v3平台代注册端点;Windows硬件智能体客户端不再接收Product Secret,既有v2接口不变 | v2完全兼容;硬件v3客户端升级 |
| 0.68.0 | 生产基础设施状态增加实际应用运行环境与身份服务模式证明;未修改设备端点、字段、状态机或终端行为 | v2完全兼容 |
| 0.67.0 | 发布候选快照增加生产基础设施强制门禁及主机部署模式、异地备份、外部告警、模拟服务状态;未修改设备端点、字段、状态机或终端行为 | v2完全兼容 |
| 0.66.0 | 发布候选技术验收快照增加主机健康当前性门禁;未修改设备端点、字段、状态机或终端行为 | v2完全兼容 |
+847
View File
@@ -0,0 +1,847 @@
# 设备相关接口完整说明
> 版本:2026-09-05
> 契约来源:`internal/httpapi/router.go`、实际处理器、`openapi/openapi.yaml`
> 生产 Base URL:`https://121.41.46.238`
> 适用对象:EchoEar/ESP32-S3 固件、Windows 模拟设备、用户 App/Web、平台运维端。
## 1. 文档范围
本文覆盖当前代码中全部直接或间接管理设备的接口:
| 接口面 | 方法数 | 鉴权 | 用途 |
|---|---:|---|---|
| 设备直连接口 `/device/v2`、`/device/v3` | 20 | 设备 HMAC | 启动、配置、命令、遥测、固件下载、会话、字幕、媒体、火山凭据 |
| 用户设备管理 `/api/v1/devices*` | 10 | OIDC Bearer | 绑定、设备列表、在线状态、影子、命令 |
| 运维设备管理 `/api/v1/ops/devices*` | 9 | OIDC Bearer + `X-Tenant-Id` | 登记、密钥轮换、绑定码、激活状态机 |
| 家庭端与运维端固件接口 | 31 | OIDC Bearer | 签名密钥、版本、上传、灰度、暂停、回滚、下载 |
| 设备诊断处置 | 2 | OIDC Bearer + `X-Tenant-Id` | 强制刷新配置、吊销设备 |
火山硬件智能体 WebSocket 不是平台 HTTP API,但设备聊天必须使用,故在第 10 节单独说明。
## 2. 通用约定
### 2.1 平台响应包络
成功:
```json
{
"request_id": "req-01J...",
"data": {}
}
```
失败:
```json
{
"request_id": "req-01J...",
"error": {
"code": "VALIDATION_FAILED",
"message": "请求参数不正确",
"retryable": false,
"field_errors": [{ "field": "hardware_id", "message": "不能为空" }],
"details": {}
}
}
```
### 2.2 ID、时间和分页
- `{id}`、`device_id`、`conversation_id`、`household_id`、`subject_id` 均为 UUID。
- 时间均为 RFC3339 UTC,例如 `2026-09-05T06:30:00Z`。
- `Idempotency-Key` 长度为 16—128 字符;业务重试保留原正文和原幂等键。
- `If-Match` 使用接口返回的强 ETag,例如 `"7"`。
- 密钥、Token、绑定码和下载地址不得写入普通日志。
### 2.3 设备 HMAC 请求头
所有 `/device/v2/*` 和 `/device/v3/*` 请求必须携带:
| Header | 输入说明 |
|---|---|
| `X-Device-Id` | 平台设备公开 UUID |
| `X-Device-Timestamp` | RFC3339 时间,允许时钟偏差默认 ±5 分钟 |
| `X-Device-Nonce` | 16—128 字符,一次请求只能使用一次 |
| `X-Device-Content-SHA256` | 原始请求正文 SHA-256 小写十六进制;空正文也计算 |
| `X-Device-Signature` | HMAC-SHA256 的 Base64URL 无填充编码 |
签名原文:
```text
HTTP_METHOD大写\n
完整REQUEST_URI(含查询字符串)\n
X-Device-Timestamp\n
X-Device-Nonce\n
X-Device-Content-SHA256
```
## 3. 设备启动、配置、命令和遥测
### 3.1 GET `/device/v2/bootstrap`
介绍:设备上电后报告固件和硬件信息,取得平台权威设备状态、绑定信息、配置版本和服务器时间。
输入:
| 位置 | 字段 | 必填 | 说明 |
|---|---|---:|---|
| Query | `firmware_version` | 是 | 当前固件版本,最长 80 |
| Query | `device_time` | 是 | RFC3339 设备时间 |
| Query | `board_rev` | 否 | 硬件版本 |
| Query | `rtc_sdk_version` | 否 | SDK 版本 |
| Query | `codec` | 否 | 编码,例如 `pcm_s16le` |
| Query | `sample_rate` | 否 | 采样率,例如 `16000` |
| Query | `network` | 否 | 网络类型,例如 `wifi` |
输出:`server_time`、`clock_skew_ms`、设备状态、最低版本、兼容性、`config_revision` 和绑定对象。
示例:
```http
GET /device/v2/bootstrap?firmware_version=1.0.0&board_rev=echoear-v1&codec=pcm_s16le&sample_rate=16000&device_time=2026-09-05T06%3A30%3A00Z&network=wifi
```
```json
{
"request_id": "req-1",
"data": {
"server_time": "2026-09-05T06:30:01Z",
"clock_skew_ms": 1000,
"device": {
"device_id": "10000000-0000-0000-0000-000000000512",
"status": "registered",
"model": "echoear",
"firmware_compatible": true,
"config_revision": 7,
"binding": { "bound": true, "household_id": "11111111-1111-1111-1111-111111111111", "subject_id": "22222222-2222-2222-2222-222222222222", "subject_name": "张大妈" }
}
}
}
```
### 3.2 GET `/device/v2/config`
介绍:读取云端期望配置和当前绑定。设备保存响应 `ETag`,后续用 `If-None-Match` 增量检查。
输入:无正文;可选 Header `If-None-Match: "7"`。
输出:HTTP `200` 返回 `device_id`、`revision`、`desired_config`、`reported_revision`、`binding`;配置未变化返回 `304` 且无正文。
```json
{
"request_id": "req-2",
"data": {
"device_id": "10000000-0000-0000-0000-000000000512",
"revision": 8,
"desired_config": { "voice_runtime": "volc_hardware_agent", "volume": { "speaker": 70 } },
"reported_revision": 7,
"binding": { "bound": true, "subject_id": "22222222-2222-2222-2222-222222222222" }
}
}
```
### 3.3 GET `/device/v2/commands`
介绍:拉取未过期的待执行命令,并增加投递次数。
输入:Query `limit`,范围 1—20,默认 10。
输出:`items[]`;每项包含 `id`、`type`、`payload`、`status`、`expires_at`、`delivery_attempts`、`created_at`。
```json
{
"request_id": "req-3",
"data": {
"items": [{
"id": "33333333-3333-3333-3333-333333333333",
"type": "apply_config",
"payload": { "revision": 8 },
"status": "DELIVERED",
"expires_at": "2026-09-05T07:30:00Z",
"delivery_attempts": 1,
"created_at": "2026-09-05T06:30:00Z"
}]
}
}
```
### 3.4 POST `/device/v2/commands/{id}/ack`
介绍:设备回报命令已接收、成功或失败。
输入:`status` 为 `ACKNOWLEDGED`、`SUCCEEDED`、`FAILED`;`result_summary` 只能包含有限诊断字段,不得含 Secret/Token。
```json
{
"status": "SUCCEEDED",
"result_summary": { "applied_revision": 8 }
}
```
输出:更新后的命令投递对象。
```json
{
"request_id": "req-4",
"data": { "id": "33333333-3333-3333-3333-333333333333", "type": "apply_config", "status": "SUCCEEDED", "result_summary": { "applied_revision": 8 } }
}
```
### 3.5 POST `/device/v2/telemetry:batch`
介绍:批量上报状态、脱敏错误或命令结果;按 `event_key` 和 `sequence_no` 去重,并返回平台动作。
输入:`events` 为 1—500 项;`event_type` 为 `status`、`error` 或 `command_result`,且只携带对应同名对象。
```json
{
"events": [{
"event_key": "boot.000008",
"sequence_no": 8,
"occurred_at": "2026-09-05T06:31:00Z",
"event_type": "status",
"status": {
"firmware_version": "1.0.0",
"reported_config_revision": 8,
"network": { "type": "wifi", "rssi": -51 },
"rtc": { "joined": false, "uplink": false, "downlink": false },
"recording_queue": { "pending_segments": 0, "failed_segments": 0 }
}
}]
}
```
输出:接收/重复数、最新期望配置版本和动作列表。
```json
{
"request_id": "req-5",
"data": { "accepted_count": 1, "duplicate_count": 0, "desired_config_revision": 8, "actions": [{ "type": "pull_commands" }] }
}
```
## 4. 火山硬件智能体平台控制面
### 4.1 POST `/device/v3/hardware-agent-credentials:provision`
介绍:平台使用 Product Secret 代设备调用火山动态注册,只向当前设备返回它自己的一机一密。允许状态:`BOUND`、`PROVISIONING`、`ACTIVE`。
输入:Header `Idempotency-Key`;正文只包含稳定 `hardware_id`。
```json
{ "hardware_id": "echoear-b43a451634a8" }
```
输出:`instance_id`、`product_key`、`device_name`、`hardware_id`、一次性敏感的 `device_secret`、版本和来源。响应必须 `Cache-Control: no-store`。
```json
{
"request_id": "req-6",
"data": {
"instance_id": "6a92945cc0c4689234aef656",
"product_key": "6a9568047dd4b23eacab9b1a",
"device_name": "echoear-100000000512",
"hardware_id": "echoear-b43a451634a8",
"device_secret": "<仅保存到设备安全区>",
"credential_ref": "cred-01J...",
"credential_version": 1,
"credential_source": "platform_dynamic_registration",
"issued_at": "2026-09-05T06:32:00Z"
}
}
```
### 4.2 POST `/device/v3/agent-sessions`
介绍:每次聊天前创建一次平台会话,冻结本次 Bot、授权、人设、记忆、隐私策略和会话证明。仅 `ACTIVE` 设备可调用。
输入:
```json
{
"device_time": "2026-09-05T06:35:00Z",
"monotonic_ms": 123456,
"capabilities": {
"hardware_agent": true,
"sdk_version": "1.1.0-echoear",
"transports": ["low_load"],
"input_audio_format": "pcm_s16le_16000_mono",
"output_audio_format": "pcm_s16le_16000_mono",
"server_event_callback": false,
"transcript_upload": true,
"dynamic_session_config": true,
"credential_rotation": true,
"near_pcm_recording": false,
"far_pcm_recording": false
},
"network": { "type": "wifi", "rssi": -52 },
"requested_goal": "open_conversation",
"config_revision": 8
}
```
输出:
```json
{
"request_id": "req-7",
"data": {
"conversation_id": "44444444-4444-4444-4444-444444444444",
"status": "SESSION_READY",
"runtime_type": "volc_hardware_agent",
"runtime": {
"provider": "volcengine",
"bot_id": "botM4gWQ9A9y",
"transport": "low_load",
"provider_session_ref": "provider-ref",
"session_nonce": "<仅内存保存>",
"config_digest": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"wait_for_session_update": true
},
"session_config": {
"dialog_id": "55555555-5555-5555-5555-555555555555",
"system_prompt": "<本次人设>",
"persona_tone": "亲切、自然",
"authorized_context": "<本次授权背景与记忆>",
"prompt_bundle_version": "minimal-v2"
},
"privacy": { "retention_mode": "transcript_only", "record_near": false, "record_far": false, "upload_allowed": false },
"session": { "engine_mode": "s2s_hybrid", "heartbeat_interval_sec": 15, "allocation_expires_at": "2026-09-05T06:40:00Z" }
}
}
```
### 4.3 POST `/device/v3/agent-sessions/{id}/events`
介绍:设备回流 `session_started`、`session_updated`、`session_ended`、`session_failed`。`sequence_no` 严格递增。
开始事件输入:
```json
{
"event_id": "evt-start-1",
"sequence_no": 1,
"event_type": "session_started",
"occurred_at": "2026-09-05T06:35:03Z",
"provider_session_ref": "provider-ref",
"session_nonce": "<创建会话返回值>",
"provider_connection_ref": "event_LUVjX_wvR",
"bot_id": "botM4gWQ9A9y",
"transport": "low_load",
"config_digest": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"dynamic_config_applied": true
}
```
输出:`status`、平台动作、是否重复和接收时间。
```json
{
"request_id": "req-8",
"data": { "conversation_id": "44444444-4444-4444-4444-444444444444", "status": "ACTIVE", "action": "continue", "duplicate": false, "accepted_at": "2026-09-05T06:35:03Z" }
}
```
`action=stop_session` 时必须立即停麦、断开火山,再上报 `session_ended` 或 `session_failed`。
### 4.4 POST `/device/v3/agent-sessions/{id}/turns:batch`
介绍:可靠上传最终字幕;禁止上传增量字幕。单批 1—500 条。
输入:
```json
{
"turns": [{
"turn_key": "user-final-1",
"sequence_no": 1,
"speaker": "subject",
"start_at": "2026-09-05T06:35:10Z",
"end_at": "2026-09-05T06:35:12Z",
"final_text": "你好。",
"interrupted": false
}]
}
```
输出:
```json
{ "request_id": "req-9", "data": { "conversation_id": "44444444-4444-4444-4444-444444444444", "accepted_count": 1, "duplicate_count": 0 } }
```
## 5. 旧 RTC 会话兼容接口
新 EchoEar 使用硬件智能体 v3,不应调用本节的创建/激活接口。本节仅供存量 RTC 设备。
### 5.1 POST `/device/v2/conversations`
介绍:分配 RTC 房间和短期 Token,不启动智能体。
输入:
```json
{
"device_time": "2026-09-05T06:40:00Z",
"monotonic_ms": 130000,
"capabilities": { "rtc": true, "audio_tracks": ["near", "far"], "upload_resume": true, "rtc_credential_renewal": true },
"network": { "type": "wifi", "rssi": -50 },
"requested_goal": "open_conversation"
}
```
输出:`conversation_id`、`ROOM_ISSUED`、RTC 房间/用户/Token、隐私策略和心跳配置。
```json
{
"request_id": "req-10",
"data": {
"conversation_id": "66666666-6666-6666-6666-666666666666",
"status": "ROOM_ISSUED",
"rtc": { "app_id": "app", "room_id": "room", "user_id": "device-user", "agent_user_id": "agent-user", "token": "<仅内存>", "expires_at": "2026-09-05T06:45:00Z" },
"privacy": { "retention_mode": "none", "record_near": false, "record_far": false, "upload_allowed": false },
"session": { "engine_mode": "cascade", "heartbeat_interval_sec": 10, "allocation_expires_at": "2026-09-05T06:45:00Z" }
}
}
```
### 5.2 POST `/device/v2/conversations/{id}/activate`
介绍:设备确认已成功进入指定 RTC 房间后,触发平台启动旧语音任务。
输入:
```json
{ "joined_at": "2026-09-05T06:40:02Z", "room_id": "room", "rtc_user_id": "device-user", "rtc_join_result": "success", "config_revision": 8, "connection_reference": "rtc-conn-1" }
```
输出:
```json
{ "request_id": "req-11", "data": { "conversation_id": "66666666-6666-6666-6666-666666666666", "status": "STARTING_AGENT", "accepted_at": "2026-09-05T06:40:02Z" } }
```
### 5.3 GET `/device/v2/conversations/{id}`
介绍:读取平台权威会话状态和设备下一步动作。
输入:Path `id`。
输出示例:
```json
{ "request_id": "req-12", "data": { "conversation_id": "66666666-6666-6666-6666-666666666666", "status": "ACTIVE", "action": "continue" } }
```
失败时可能返回 `status=FAILED`、`action=leave_room`、稳定脱敏的 `failure_code`。
### 5.4 POST `/device/v2/conversations/{id}/heartbeat`
介绍:刷新会话租约、设备在线状态和配置状态;返回停止、续签或配置动作。
输入:
```json
{
"sequence_no": 2,
"device_time": "2026-09-05T06:40:12Z",
"monotonic_ms": 142000,
"conversation_status": "ACTIVE",
"rtc": { "joined": true, "uplink": true, "downlink": true },
"network": { "type": "wifi", "rssi": -49 },
"firmware_version": "1.0.0",
"reported_config_revision": 8,
"recording_queue": { "pending_segments": 0, "failed_segments": 0 },
"command_results": []
}
```
输出:
```json
{ "request_id": "req-13", "data": { "lease_expires_at": "2026-09-05T06:40:42Z", "actions": [{ "type": "continue" }], "desired_config_revision": 8, "commands": [] } }
```
### 5.5 POST `/device/v2/conversations/{id}/stop`
介绍:设备挂断并可靠停止旧供应商任务;支持幂等重放。
输入:
```json
{ "reason": "user_hangup", "device_time": "2026-09-05T06:42:00Z", "last_heartbeat_sequence": 5, "rtc_left": true, "recording": { "near_segments": 0, "far_segments": 0 } }
```
输出:
```json
{ "request_id": "req-14", "data": { "conversation_id": "66666666-6666-6666-6666-666666666666", "status": "STOPPING", "accepted_at": "2026-09-05T06:42:00Z" } }
```
### 5.6 POST `/device/v2/conversations/{id}/turns:batch`
介绍:旧 RTC 回调缺少终稿时,由设备补传最终字幕。输入输出与 4.4 完全相同。
```json
{ "turns": [{ "turn_key": "agent-final-2", "sequence_no": 2, "speaker": "agent", "start_at": "2026-09-05T06:41:00Z", "end_at": "2026-09-05T06:41:02Z", "final_text": "您好。", "interrupted": false }] }
```
## 6. 设备录音上传接口
仅当本次 `privacy.upload_allowed=true` 且对应轨道获授权时使用。
### 6.1 POST `/device/v2/conversations/{id}/upload-batches`
介绍:声明待上传录音分片,平台返回每个对象的短期 PUT 地址;业务 API 不接收音频字节。
输入:
```json
{
"segments": [{
"track": "near",
"sequence_no": 0,
"codec": "wav_pcm_s16le",
"content_type": "audio/wav",
"duration_ms": 10000,
"expected_size": 320044,
"expected_digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}]
}
```
输出:批次状态和分片;创建响应中的分片包含 `upload_url`、`upload_until`。
```json
{
"request_id": "req-15",
"data": {
"id": "77777777-7777-7777-7777-777777777777",
"conversation_id": "66666666-6666-6666-6666-666666666666",
"status": "UPLOADING",
"segment_count": 1,
"expected_total_size": 320044,
"verified_count": 0,
"rejected_count": 0,
"verification_attempts": 0,
"expires_at": "2026-09-05T07:02:00Z",
"segments": [{ "id": "88888888-8888-8888-8888-888888888888", "track": "near", "sequence_no": 0, "status": "AUTHORIZED", "upload_url": "https://object.example/put?...", "upload_until": "2026-09-05T07:02:00Z" }]
}
}
```
### 6.2 POST `/device/v2/upload-batches/{id}/complete`
介绍:声明分片已上传,平台开始异步对象大小、摘要和容器校验。
输入:无正文;Path `id` 为上传批次 UUID。
输出:HTTP `202`,返回批次,`status` 通常为 `VERIFYING`。
```json
{ "request_id": "req-16", "data": { "id": "77777777-7777-7777-7777-777777777777", "status": "VERIFYING", "segment_count": 1, "verified_count": 0, "rejected_count": 0, "segments": [] } }
```
### 6.3 GET `/device/v2/upload-batches/{id}`
介绍:轮询后台校验结果。只有 `VERIFIED` 分片返回 `local_delete_after`。
输入:Path `id`。
输出示例:
```json
{ "request_id": "req-17", "data": { "id": "77777777-7777-7777-7777-777777777777", "status": "COMPLETED", "segment_count": 1, "verified_count": 1, "rejected_count": 0, "segments": [{ "id": "88888888-8888-8888-8888-888888888888", "status": "VERIFIED", "local_delete_after": "2026-09-05T07:10:00Z" }] } }
```
### 6.4 POST `/device/v2/media-segments/{id}/local-delete-ack`
介绍:到达平台允许删除时间后,设备删除本地密文并回报;不会删除云端对象。
输入:
```json
{ "status": "DELETED" }
```
失败示例:`{ "status": "FAILED", "error_code": "FLASH_DELETE_FAILED" }`。
输出:更新后的分片对象,包含 `local_delete_status` 和 `local_delete_reported_at`。
## 7. 设备固件下载接口
### GET `/device/v2/firmware-releases/{id}/download`
介绍:仅向当前灰度批次中的目标设备签发 5 分钟私有下载地址。
输入:Path `id` 为固件版本 UUID,无正文。
输出:
```json
{ "request_id": "req-18", "data": { "download_url": "https://object.example/firmware.bin?...", "download_until": "2026-09-05T07:05:00Z" } }
```
非目标设备或暂停/退役版本返回 `403 FIRMWARE_DOWNLOAD_DENIED`。
## 8. 用户 App/Web 设备管理接口
统一输入 Header:`Authorization: Bearer <OIDC access token>`。以下表中“示例输入/输出”均省略通用包络字段。
| 方法与路径 | 接口介绍 | 输入 | 输出 | 示例 |
|---|---|---|---|---|
| `POST /api/v1/device-claims` | 消费一次性绑定码 | Body:`claim_code`、`household_id`、`subject_id` | `binding_id`、`device_id`、归属、`bound_at` | 入:`{"claim_code":"CLAIM-...","household_id":"<uuid>","subject_id":"<uuid>"}`;出:`{"binding_id":"<uuid>","device_id":"<uuid>","bound_at":"2026-09-05T07:00:00Z"}` |
| `GET /api/v1/devices` | 列出当前账号可管理设备 | 无 | `items: ManagedDevice[]` | `{"items":[{"id":"<uuid>","model":"echoear","status":"registered","desired_revision":8}]}` |
| `GET /api/v1/devices/{id}` | 查询设备与绑定摘要 | Path:设备 UUID | `ManagedDevice` | `{"id":"<uuid>","binding_id":"<uuid>","subject_name":"张大妈","firmware_version":"1.0.0","version":3}` |
| `GET /api/v1/devices/{id}/binding-impact` | 解绑/转移前检查阻断项 | Query:`action=unbind\|transfer`;转移时 `target_subject_id` | 影响、`allowed`、`blockers`、当前 `binding_id` | `{"action":"transfer","allowed":true,"pending_command_count":0,"active_firmware_task_count":0}` |
| `POST /api/v1/devices/{id}/binding-change` | 执行解绑或转移 | Body:`action`、`expected_binding_id`、`reason`、`acknowledged=true`;转移时 `target_subject_id` | 旧/新绑定和变更时间 | 入:`{"action":"unbind","expected_binding_id":"<uuid>","reason":"用户解除绑定","acknowledged":true}`;出:`{"device_id":"<uuid>","action":"unbind","changed_at":"..."}` |
| `GET /api/v1/devices/{id}/presence` | 查询实时在线状态 | Path:设备 UUID | `online`、`status`、`last_seen_at`、`source` | `{"device_id":"<uuid>","online":true,"status":"ACTIVE","last_seen_at":"...","source":"redis"}` |
| `GET /api/v1/devices/{id}/twin` | 查询设备影子 | Path:设备 UUID | 期望/上报配置、版本、能力、同步状态;Header `ETag` | `{"device_id":"<uuid>","desired_revision":8,"desired_config":{"volume":{"speaker":70}},"sync_state":"applied"}` |
| `PATCH /api/v1/devices/{id}/twin` | 修改非敏感期望配置 | Header:`If-Match`;Body:`desired_config`,顶层仅 `volume/interaction/upload/rtc/policy_refs` | 更新后的 `DeviceTwin` 和新 `ETag` | `{"desired_config":{"volume":{"speaker":60}}}` |
| `GET /api/v1/devices/{id}/commands` | 查询最近 100 条命令 | Path:设备 UUID | `items: DeviceCommand[]` | `{"items":[{"id":"<uuid>","type":"reboot","status":"SUCCEEDED"}]}` |
| `POST /api/v1/devices/{id}/commands` | 下发可靠命令 | Header:`Idempotency-Key`;Body:`type`、`payload`、`expires_in_sec` | `DeviceCommand`,首次 `202`,重放 `200` | 入:`{"type":"reboot","payload":{"reason":"maintenance"},"expires_in_sec":300}`;出:`{"id":"<uuid>","status":"QUEUED","delivery_attempts":0}` |
`ManagedDevice` 主要输出字段:`id`、`binding_id`、`household_id`、`subject_id`、`subject_name`、`model`、`hardware_revision`、`status`、固件/配置版本、`last_reported_at`、`bound_at`、`registered_at`、`version`。
## 9. 平台运维设备管理接口
统一输入 Header:`Authorization: Bearer <operator token>`、`X-Tenant-Id: <tenant UUID>`。只有独立运维账号可调用。
| 方法与路径 | 接口介绍 | 输入 | 输出 | 示例 |
|---|---|---|---|---|
| `GET /api/v1/ops/devices` | 查询租户全部设备 | 无 | `items: OpsDevice[]`,不返回密钥 | `{"items":[{"id":"<uuid>","model":"echoear","activation_state":"ACTIVE","credential_status":"active","voice_runtime":"volc_hardware_agent"}]}` |
| `POST /api/v1/ops/devices` | 登记新设备并生成平台 HMAC 密钥 | Body:`model` 必填;可选硬件/最低版本/归属/运行时/初始激活状态 | `device`、只显示一次的 `credential`;可能同时返回 `claim` | 入:`{"model":"echoear","hardware_revision":"esp32-s3","voice_runtime":"volc_hardware_agent","activation_state":"FACTORY"}`;出:`{"device":{"id":"<uuid>"},"credential":{"device_id":"<uuid>","secret":"<64位hex>","key_version":1}}` |
| `GET /api/v1/ops/device-assignment-options` | 查询可分配家庭和长者 | 无 | `items[]` 含家庭/长者 ID 和名称 | `{"items":[{"household_id":"<uuid>","household_name":"张家","subject_id":"<uuid>","subject_name":"张大妈"}]}` |
| `PATCH /api/v1/ops/devices/{id}` | 更新设备型号、版本策略、状态、归属或运行时 | Header:`If-Match`;Body 至少一个可修改字段 | 更新后的 `OpsDevice` | `{"minimum_firmware_version":"1.1.0","status":"registered"}` |
| `DELETE /api/v1/ops/devices/{id}` | 软删除并吊销设备/凭据/绑定 | Header:`If-Match` | 吊销后的 `OpsDevice` | `{"id":"<uuid>","status":"revoked","activation_state":"REVOKED","credential_status":"revoked"}` |
| `POST /api/v1/ops/devices/{id}/credentials/rotate` | 轮换平台 HMAC 密钥,旧密钥立即失效 | 无正文 | 只显示一次的新 `secret`、`key_version`、`issued_at` | `{"device_id":"<uuid>","secret":"<64位hex>","key_version":2,"issued_at":"..."}` |
| `POST /api/v1/ops/devices/{id}/claims` | 为未绑定设备生成 24 小时绑定码 | 无正文 | 只显示一次的 `claim_code` 和过期时间 | `{"device_id":"<uuid>","claim_code":"CLAIM-...","issued_at":"...","expires_at":"..."}` |
| `POST /api/v1/ops/devices/{id}/activation-transitions` | 执行受控激活状态迁移 | Body:`target_state`、`reason`、`expected_revision` | 更新后的 `OpsDevice` | `{"target_state":"CLAIMABLE","reason":"工厂质检通过","expected_revision":1}` |
| `GET /api/v1/ops/devices/{id}/activation-transitions` | 查询完整激活审计历史 | Path:设备 UUID | `items: DeviceActivationTransition[]` | `{"items":[{"from_state":"FACTORY","to_state":"CLAIMABLE","reason":"工厂质检通过","activation_revision":2,"created_at":"..."}]}` |
激活状态:`FACTORY → CLAIMABLE → BOUND → PROVISIONING → ACTIVE → LOST/REVOKED`。设备不能自行修改该状态。
## 10. 火山硬件智能体 WebSocket
### 10.1 建连
```text
wss://ai-gateway.vei.volces.com/v1/realtime?bot=botM4gWQ9A9y&wait_for_session_update=true
```
输入 Header:
| Header | 说明 |
|---|---|
| `X-Auth-Type` | 固定 `1` |
| `X-Product-Key` | 平台预配返回的 Product Key |
| `X-Device-Name` | 平台预配返回的 Device Name |
| `X-Random-Num` | 本次随机整数 |
| `X-Timestamp` | Unix 秒 |
| `X-Instance-Id` | 平台预配返回的 Instance ID |
| `X-Signature` | 使用火山 Device Secret 计算的 HMAC-SHA256 Base64 |
| `X-Hardware-Id` | 稳定 Hardware ID |
签名正文顺序固定:
```text
auth_type=1&device_name={name}&random_num={random}&product_key={key}&timestamp={unix}&instance_id={instance}
```
### 10.2 动态配置输入
连接打开后立即发送:
```json
{
"event_id": "windows-session-update-<uuid>",
"type": "session.update",
"session": {
"object": "realtime.session",
"model": "",
"input_audio_format": "pcm16",
"output_audio_format": "pcm16",
"config": {
"ASRConfig": { "TurnDetectionMode": 0 },
"LLMConfig": { "SystemMessages": ["<平台冻结的人设与授权上下文>"] }
},
"agent_config": { "EnableConversationStateCallback": false }
}
}
```
输出:`session.created` 或 `session.updated`;在收到确认前不得开启麦克风。
### 10.3 音频和文本输入
上行音频:PCM16 little-endian、16kHz、单声道,建议每 20ms 320 个采样。
```json
{ "event_id": "audio-<uuid>", "type": "input_audio_buffer.append", "audio": "<PCM Base64>" }
```
文本测试:
```json
{
"event_id": "text-<uuid>",
"type": "conversation.item.create",
"item": { "type": "message", "role": "user", "content": [{ "type": "input_text", "text": "你好" }], "interrupt_mode": 1 }
}
```
### 10.4 设备消费的输出事件
| 事件 | 输出内容 | 设备动作 |
|---|---|---|
| `input_audio_buffer.speech_started/stopped` | 服务端 VAD 状态 | 更新交互状态 |
| `conversation.item.input_audio_transcription.completed` | 用户最终字幕 | 上传平台 `turns:batch` |
| `response.audio.delta` | Base64 PCM16、16kHz、单声道 | 连续缓冲并按声卡采样率重采样播放 |
| `response.audio.done` | 本轮音频结束 | 排空播放缓冲 |
| `response.audio_transcript.delta/done` | 智能体增量/最终字幕 | 只把最终字幕上传平台 |
| `response.function_call_arguments.done` | 工具名、参数和 `call_id` | 调用平台受控工具并回传结果 |
| `response.done` | 本轮完成 | 继续监听 |
| `error` | 供应商错误 | 脱敏后上报 `session_failed` |
注意:low-load WebSocket 下行是 16kHz。把它按 24kHz 播放会出现约 1.5 倍速、音调升高和明显电子感。
## 11. 固件管理接口
### 11.1 家庭/租户授权接口
| 方法与路径 | 介绍 | 输入 | 输出 |
|---|---|---|---|
| `GET /api/v1/households/{id}/firmware-signing-keys` | 查询可信 Ed25519 公钥 | Path `household id` | `FirmwareSigningKey[]` |
| `POST /api/v1/households/{id}/firmware-signing-keys` | 登记签名公钥 | `key_id`、`public_key_base64` | `201`,创建结果 |
| `POST /api/v1/households/{id}/firmware-signing-keys/revoke` | 吊销公钥 | `key_id` | `FirmwareSigningKey` |
| `GET /api/v1/households/{id}/firmware-releases` | 查询固件版本 | Path `household id` | `FirmwareRelease[]` |
| `POST /api/v1/households/{id}/firmware-releases` | 创建 `UPLOADING` 版本 | `PrepareFirmwareReleaseRequest` | 版本和短期上传地址 |
| `GET /api/v1/firmware-releases/{id}/impact` | 发布/暂停/退役前影响评估 | Path `release id` | `FirmwareReleaseImpact` |
| `POST /api/v1/firmware-releases/{id}/upload-session` | 重新签发上传地址 | 无正文 | `FirmwareRelease` |
| `POST /api/v1/firmware-releases/{id}/finalize` | 校验大小、SHA-256、Ed25519 后发布 | 无正文 | `FirmwareRelease` |
| `POST /api/v1/firmware-releases/{id}/pause` | 暂停版本和未投递命令 | 无正文 | `FirmwareRelease` |
| `POST /api/v1/firmware-releases/{id}/resume` | 恢复版本分发 | 无正文 | `FirmwareRelease` |
| `POST /api/v1/firmware-releases/{id}/retire` | 退役版本并保留审计 | 无正文 | `FirmwareRelease` |
| `GET /api/v1/households/{id}/firmware-rollouts` | 查询 OTA 批次 | Path `household id` | `FirmwareRollout[]` |
| `POST /api/v1/households/{id}/firmware-rollouts` | 启动灰度升级 | `StartFirmwareRolloutRequest` | `202 FirmwareRollout` |
| `GET /api/v1/firmware-rollouts/{id}` | 查询逐设备升级进度 | Path `rollout id` | `FirmwareRollout` |
| `POST /api/v1/firmware-rollouts/{id}/pause` | 暂停批次 | 无正文 | `FirmwareRollout` |
| `POST /api/v1/firmware-rollouts/{id}/rollback` | 创建回滚批次 | `rollback_release_id` | 新的 `FirmwareRollout` |
### 11.2 运维固件接口
以下接口全部额外要求 `X-Tenant-Id`:
| 方法与路径 | 介绍 | 输入 | 输出 |
|---|---|---|---|
| `GET /api/v1/ops/firmware-signing-keys` | 查询租户可信公钥 | 无 | `FirmwareSigningKey[]` |
| `POST /api/v1/ops/firmware-signing-keys` | 登记可信公钥 | `key_id`、`public_key_base64` | `201` |
| `POST /api/v1/ops/firmware-signing-keys/revoke` | 吊销公钥 | `key_id` | `FirmwareSigningKey` |
| `GET /api/v1/ops/firmware-releases` | 按 `model/status` 查询版本 | Query `model`、`status` 可选 | `FirmwareRelease[]` |
| `POST /api/v1/ops/firmware-releases` | 创建租户级版本 | `PrepareOpsFirmwareReleaseRequest` | `201 FirmwareRelease` |
| `POST /api/v1/ops/firmware-releases/{id}/upload-session` | 续签上传地址 | 无正文 | `FirmwareRelease` |
| `POST /api/v1/ops/firmware-releases/{id}/finalize` | 正式发布 | 无正文 | `FirmwareRelease` |
| `POST /api/v1/ops/firmware-releases/{id}/pause` | 暂停 | 无正文 | `FirmwareRelease` |
| `POST /api/v1/ops/firmware-releases/{id}/resume` | 恢复 | 无正文 | `FirmwareRelease` |
| `POST /api/v1/ops/firmware-releases/{id}/retire` | 退役 | 无正文 | `FirmwareRelease` |
| `GET /api/v1/ops/firmware-releases/{id}/download` | 运维下载制品 | Path `release id` | 下载地址、摘要、签名元数据 |
| `GET /api/v1/ops/firmware-rollouts` | 查询 OTA 批次 | 无 | `FirmwareRollout[]` |
| `POST /api/v1/ops/firmware-rollouts` | 启动 OTA | `StartFirmwareRolloutRequest` | `202 FirmwareRollout` |
| `GET /api/v1/ops/firmware-rollouts/{id}` | 查询逐设备进度 | Path `rollout id` | `FirmwareRollout` |
| `POST /api/v1/ops/firmware-rollouts/{id}/pause` | 暂停 OTA | 无正文 | `FirmwareRollout` |
固件请求示例:
```json
{ "key_id": "release-key-2026-01", "public_key_base64": "<32字节Ed25519公钥的Base64>" }
```
```json
{
"model": "echoear",
"version": "1.1.0",
"expected_size": 2097152,
"expected_digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"signing_key_id": "release-key-2026-01",
"signature": "<firmware-manifest-v1签名Base64>"
}
```
```json
{ "release_id": "<uuid>", "name": "第一批灰度", "target_percentage": 10, "device_ids": ["<device-uuid>"] }
```
典型版本输出:
```json
{ "request_id": "req-fw", "data": { "id": "<uuid>", "model": "echoear", "version": "1.1.0", "status": "READY", "sha256": "bbbb...", "created_at": "2026-09-05T07:00:00Z" } }
```
典型批次输出:
```json
{ "request_id": "req-rollout", "data": { "id": "<uuid>", "release_id": "<uuid>", "name": "第一批灰度", "status": "RUNNING", "target_percentage": 10, "targeted": 1, "succeeded": 0, "failed": 0 } }
```
## 12. 运维诊断设备动作
| 方法与路径 | 介绍 | 输入 | 输出 | 示例 |
|---|---|---|---|---|
| `POST /api/v1/ops/diagnostics/devices/{id}/config-refresh` | 强制设备重新拉取配置 | `X-Tenant-Id`、`Idempotency-Key`、设备 UUID;Body:`reason` | `action=refresh_config`、命令 ID/状态、`queued` | 入:`{"reason":"远端配置与影子不一致"}`;出:`{"action":"refresh_config","device_id":"<uuid>","command_id":"<uuid>","command_status":"QUEUED","queued":true}` |
| `POST /api/v1/ops/diagnostics/devices/{id}/revoke` | 紧急吊销异常设备 | `X-Tenant-Id`、`Idempotency-Key`、设备 UUID;Body:`reason` | `action=revoke_device`、设备 ID、`queued` | 入:`{"reason":"设备密钥疑似泄露"}`;出:`{"action":"revoke_device","device_id":"<uuid>","queued":false}` |
## 13. 主要错误码
| HTTP | 错误码 | 含义与处理 |
|---:|---|---|
| 400 | `VALIDATION_FAILED` | 字段、Header、时间或幂等键不符合契约 |
| 401 | `DEVICE_AUTHENTICATION_FAILED` | 检查平台 Device Secret、时钟、URI、正文摘要和 Base64URL |
| 403 | `DEVICE_ACTIVATION_REQUIRED` | 当前激活状态不允许该操作 |
| 403 | `TRANSCRIPT_STORAGE_NOT_ALLOWED` | 当前授权不允许保存字幕 |
| 403 | `FIRMWARE_DOWNLOAD_DENIED` | 设备不是灰度目标或版本不可下载 |
| 404 | `RESOURCE_NOT_FOUND` | 资源不存在或不属于当前主体/设备 |
| 409 | `IDEMPOTENCY_KEY_REUSED` | 同一幂等键对应不同正文,客户端必须换业务请求 |
| 409 | `DEVICE_ACTIVATION_CONFLICT` | 激活状态或修订号冲突,刷新后再处理 |
| 409 | `HARDWARE_AGENT_HARDWARE_MISMATCH` | Hardware ID 与已注册记录不一致,禁止覆盖 |
| 409 | `HARDWARE_SESSION_PROOF_INVALID` | `session_nonce`/配置摘要/Bot/传输不匹配,丢弃本次会话重新创建 |
| 412 | `VERSION_CONFLICT` | `If-Match` 过期,先重新 GET |
| 426 | `FIRMWARE_UPDATE_REQUIRED` | 当前固件低于最低版本 |
| 429 | `USAGE_QUOTA_EXCEEDED` | 额度或并发限制达到上限 |
| 503 | `HARDWARE_AGENT_PROVISIONING_UNAVAILABLE` | 平台 Product Secret/产品配置不可用 |
| 503 | `HARDWARE_AGENT_PROVISIONING_FAILED` | 火山动态注册失败;按 `request_id` 排障,可退避重试 |
## 14. 设备端推荐调用顺序
```text
上电
→ GET /device/v2/bootstrap
→ GET /device/v2/config
→ GET /device/v2/commands
→ POST /device/v2/telemetry:batch
首次绑定后
→ POST /device/v3/hardware-agent-credentials:provision
→ 安全保存火山 Device Secret
每次聊天
→ POST /device/v3/agent-sessions
→ 连接火山 WebSocket
→ session.update
→ 收到 session.created/session.updated
→ POST /device/v3/agent-sessions/{id}/events (session_started)
→ 16k PCM 双向音频
→ POST /device/v3/agent-sessions/{id}/turns:batch
→ 周期 events (session_updated)
→ events (session_ended/session_failed)
```
## 15. 机器可读契约
- 完整 OpenAPI:[`../openapi/openapi.yaml`](../openapi/openapi.yaml)
- 设备实现与安全说明:[`device-api-reference.md`](device-api-reference.md)
- 设备接入说明:[`device-integration.md`](device-integration.md)
- 固件灰度说明:[`firmware-rollout.md`](firmware-rollout.md)
若本文与 OpenAPI 或运行时代码不一致,以同一发布版本的 OpenAPI 和处理器为准,并应同步修正文档。
+576
View File
@@ -0,0 +1,576 @@
# 设备接口文档
> 适用范围:平台设备管理、EchoEar/ESP32-S3 设备接入、火山引擎硬件对话智能体。
> 契约基线:当前工作区实际路由、处理器与 `openapi/openapi.yaml`,更新日期 2026-09-05。
> 生产 API:`https://121.41.46.238`(正式域名启用后只替换 Base URL,不改变路径)。
## 1. 接入模型
设备同时使用两套彼此独立的身份:
| 身份/密钥 | 用途 | 生成与保存位置 | 是否下发设备 |
|---|---|---|---|
| 平台 `device_id` | 标识平台登记设备 | 平台生成;设备保存公开编号 | 是 |
| 平台 Device Secret | 调用 `/device/*` 时做 HMAC 鉴权 | 平台逐设备生成;平台加密保存,设备安全区保存 | 是,只在登记/轮换时显示一次 |
| 火山 `instance_id` / `product_key` | 标识火山实例和产品 | 平台配置 | 可随预配响应返回,非秘密 |
| 火山 Product Secret | 调用 DynamicRegister 的产品级秘密 | 只保存在平台 KMS/Secret | **否** |
| 火山 `device_name` / `device_secret` | 设备直连火山时使用的一机一密 | 平台代设备动态注册;平台加密保存,设备安全区保存 | 是,只返回给已认证的对应设备 |
| Bot ID | 选择硬件对话智能体 | 平台按设备绑定 | 会话响应返回,非秘密 |
核心原则:设备固件不内置 Product Secret。设备先用平台 Device Secret 认证,由平台代设备调用火山动态注册,再把该设备专属的火山 Device Secret 返回给设备。
### 1.1 火山 License 的准确含义
火山硬件对话智能体的 License 是产品侧可用额度。平台调用 DynamicRegister 时,火山自动消耗当前产品的一份有效额度并返回一机一密;设备不接收一段可安装的 License 明文,也不需要向平台提交 License 安装回执。
因此,当前有效契约中:
- 没有 `/device/v3/hardware-agent-license:ack` 路由;
- 没有设备端 `license` 字段;
- 没有 `AVAILABLE → ALLOCATED → ACTIVE` 的设备 License 安装状态机;
- 平台可以维护额度监控或影子账本,但不能用它替代火山的真实额度,也不能阻断已正确取得一机一密的设备。
火山返回额度耗尽、过期等错误时,平台统一转换为 `HARDWARE_AGENT_PROVISIONING_FAILED`,同时通过内部日志和请求编号保留供应商错误供运维定位。
## 2. 通用响应格式
成功响应:
```json
{
"request_id": "req-...",
"data": {}
}
```
失败响应:
```json
{
"request_id": "req-...",
"error": {
"code": "VALIDATION_FAILED",
"message": "请求参数不正确",
"retryable": false,
"field_errors": [],
"details": {}
}
}
```
排障时应记录 `request_id`、HTTP 状态码和稳定错误码,不记录任何 Secret、Token、完整人设或完整对话正文。
## 3. 设备 HMAC 鉴权
所有 `/device/v2/*` 和 `/device/v3/*` 请求必须携带:
| 请求头 | 说明 |
|---|---|
| `X-Device-Id` | 平台设备公开 UUID |
| `X-Device-Timestamp` | RFC3339 UTC 时间,默认允许前后 5 分钟 |
| `X-Device-Nonce` | 本次请求唯一的 16—128 字符随机值 |
| `X-Device-Content-SHA256` | 原始 HTTP 正文的 SHA-256 小写十六进制;空正文也要计算 |
| `X-Device-Signature` | HMAC-SHA256 结果的无填充 Base64URL |
签名原文必须逐字使用实际请求 URI(含查询字符串):
```text
UPPERCASE_HTTP_METHOD
REQUEST_URI_WITH_EXACT_QUERY_STRING
X-Device-Timestamp原值
X-Device-Nonce原值
X-Device-Content-SHA256原值
```
伪代码:
```text
bodyHash = hexLower(SHA256(rawBody))
canonical = methodUpper + "\n" + requestURI + "\n" + timestamp + "\n" + nonce + "\n" + bodyHash
signature = base64UrlNoPadding(HMAC_SHA256(platformDeviceSecret, canonical))
```
注意事项:
- JSON 签名后不得重新格式化或改变字段顺序;服务端校验的是收到的原始字节。
- 每次网络重试都生成新的 `timestamp`、`nonce` 和签名。
- 需要幂等的业务请求保留原 `Idempotency-Key` 和原正文。
- 时间未同步时先通过安全时间源校时;不能关闭时间窗或重放保护。
## 4. 设备激活状态机
主流程:
```text
FACTORY → CLAIMABLE → BOUND → PROVISIONING → ACTIVE
│
├─→ LOST
└─→ REVOKED
```
| 状态 | 含义 | 允许的设备操作 |
|---|---|---|
| `FACTORY` | 只有出厂身份,等待质检放行 | 不允许设备 API 鉴权 |
| `CLAIMABLE` | 可生成/消费绑定码 | 启动、配置、命令、遥测;不能通话 |
| `BOUND` | 已绑定家庭和使用人 | 可申请火山一机一密;不能创建会话 |
| `PROVISIONING` | 正在注册或恢复火山运行凭据 | 可重复预配;不能创建会话 |
| `ACTIVE` | 平台身份、绑定和运行凭据就绪 | 全部设备接口 |
| `LOST` | 设备挂失 | 设备鉴权和新会话被拒绝 |
| `REVOKED` | 永久吊销 | 终态,不可恢复 |
代码允许的恢复/解绑边包括:`BOUND → CLAIMABLE`、`PROVISIONING → BOUND`、`ACTIVE → CLAIMABLE/PROVISIONING/LOST`、`LOST → CLAIMABLE/ACTIVE`;任意非 `REVOKED` 状态均可受控转为 `REVOKED`。状态变更必须由平台业务或运维接口执行,设备不能自行声明已激活。
每次变更增加 `activation_revision` 并保存审计历史。进入 `ACTIVE` 时,硬件智能体设备必须已经绑定且存在有效火山一机一密。
## 5. 首次激活完整流程
1. 运维调用 `POST /api/v1/ops/devices` 登记设备,取得一次性平台 Device Secret。
2. 工厂烧录/安全注入 `device_id`、平台 Device Secret、API Base URL 和稳定 `hardware_id`;不得烧录 Product Secret。
3. 运维将设备从 `FACTORY` 放行到 `CLAIMABLE`,并生成绑定码。
4. 用户 App/Web 调用 `POST /api/v1/device-claims` 消费绑定码,平台把设备变为 `BOUND`。
5. 设备使用平台 HMAC 调用 `POST /device/v3/hardware-agent-credentials:provision`。
6. 平台把状态推进到 `PROVISIONING`,自动建立产品默认 Bot/传输/最低 SDK 绑定,再使用服务端 Product Secret 调用火山 DynamicRegister。
7. 火山消耗产品 License 额度并返回该 `device_name` 对应的 `device_secret`。
8. 平台加密保存火山 Device Secret,并通过 `no-store` 响应返回给同一设备,然后自动完成到 `ACTIVE` 的迁移。
9. 设备把火山一机一密写入 NVS 加密分区/安全芯片;以后重复预配会返回已保存的同一凭据,不重复注册。
10. 每次用户发起聊天时,设备先创建平台硬件智能体会话,注入本次人设和上下文后,再直连火山。
## 6. 设备端接口总览
### 6.1 启动、配置、命令和固件(v2 公共能力)
| 方法 | 路径 | 状态要求 | 幂等键 | 作用 |
|---|---|---|---|---|
| `GET` | `/device/v2/bootstrap` | `CLAIMABLE/BOUND/PROVISIONING/ACTIVE` | 否 | 上报启动版本并取得绑定和兼容性 |
| `GET` | `/device/v2/config` | 同上 | 否 | 拉取期望配置;支持 `If-None-Match` |
| `GET` | `/device/v2/commands` | 同上 | 否 | 拉取待执行命令 |
| `POST` | `/device/v2/commands/{id}/ack` | 同上 | 否 | 上报命令阶段或终态 |
| `POST` | `/device/v2/telemetry:batch` | 同上 | 是 | 批量上报设备遥测 |
| `GET` | `/device/v2/firmware-releases/{id}/download` | 同上 | 否 | 下载受控固件制品 |
`bootstrap` 示例:
```http
GET /device/v2/bootstrap?firmware_version=1.0.0&board_rev=echoear-v1&rtc_sdk_version=1.0.0&codec=pcm_s16le&sample_rate=16000&device_time=2026-09-03T08%3A00%3A00Z&network=wifi
```
配置拉取建议:首次不带 `If-None-Match`;保存响应 `ETag`,后续携带该值。HTTP `304` 表示继续使用本地有效配置。
### 6.2 旧 RTC 通话(v2,仅兼容旧设备)
| 方法 | 路径 | 说明 |
|---|---|---|
| `POST` | `/device/v2/conversations` | 创建旧 RTC 通话 |
| `POST` | `/device/v2/conversations/{id}/activate` | 入房成功后激活供应商任务 |
| `GET` | `/device/v2/conversations/{id}` | 查询通话权威状态 |
| `POST` | `/device/v2/conversations/{id}/heartbeat` | 通话心跳 |
| `POST` | `/device/v2/conversations/{id}/stop` | 停止通话 |
| `POST` | `/device/v2/conversations/{id}/turns:batch` | 补传最终字幕 |
新 EchoEar 固件的 `voice_runtime=volc_hardware_agent` 时不得调用这组 RTC 创建/激活接口。
### 6.3 录音上传(v2 公共媒体能力)
| 方法 | 路径 | 作用 |
|---|---|---|
| `POST` | `/device/v2/conversations/{id}/upload-batches` | 创建分片上传批次 |
| `GET` | `/device/v2/upload-batches/{id}` | 查询上传校验状态 |
| `POST` | `/device/v2/upload-batches/{id}/complete` | 通知平台分片已上传 |
| `POST` | `/device/v2/media-segments/{id}/local-delete-ack` | 回报本地密文已删除 |
只有 `ACTIVE` 设备且本次隐私策略允许时才能使用;没有录音授权不得采集、缓存或上传 PCM。
## 7. 火山硬件智能体接口(v3)
### 7.1 申请火山一机一密
```http
POST /device/v3/hardware-agent-credentials:provision
Idempotency-Key: 2ee3b675-59c0-4d93-a189-6db7f8db3750
Cache-Control: no-store
Content-Type: application/json
```
状态要求:`BOUND`、`PROVISIONING` 或 `ACTIVE`。
请求:
```json
{
"hardware_id": "echoear-b43a451634a8"
}
```
`hardware_id` 必须来自稳定硬件身份(如 eFuse/Chip ID 的规范化值),设备重启、升级和恢复出厂后保持不变。
成功响应:
```json
{
"request_id": "req-...",
"data": {
"instance_id": "<instance-id>",
"product_key": "<product-key>",
"device_name": "echoear-...",
"hardware_id": "echoear-b43a451634a8",
"device_secret": "<volc-device-secret>",
"credential_ref": "<non-secret-ref>",
"credential_version": 1,
"credential_source": "platform_dynamic_registration",
"issued_at": "2026-09-03T08:00:00Z"
}
}
```
安全要求:
- 响应带 `Cache-Control: no-store` 和 `Pragma: no-cache`。
- `device_secret` 只写入设备安全存储,不进入普通配置文件、串口日志、崩溃转储或平台遥测。
- `credential_source=platform_stored` 表示幂等返回平台已保存的凭据,不代表重新消耗 License。
- `hardware_id` 与已注册记录不一致时返回冲突,不允许覆盖原凭据。
常见错误:
| HTTP | 错误码 | 处理 |
|---|---|---|
| `400` | `VALIDATION_FAILED` | 修正 `hardware_id` 或幂等键 |
| `409` | `HARDWARE_AGENT_BINDING_REQUIRED` | 先在平台绑定 Bot/产品配置 |
| `409` | `HARDWARE_AGENT_PRODUCT_MISMATCH` | 运维核对实例、产品和设备绑定 |
| `409` | `HARDWARE_AGENT_HARDWARE_MISMATCH` | 停止自动重试,核对硬件身份 |
| `409` | `DEVICE_ACTIVATION_REQUIRED` | 先完成用户绑定,进入 `BOUND` |
| `503` | `HARDWARE_AGENT_PROVISIONING_UNAVAILABLE` | 平台未配置动态注册;联系运维 |
| `503` | `HARDWARE_AGENT_PROVISIONING_FAILED` | 使用原幂等键、原正文和新签名退避重试;由 `request_id` 查火山原始错误 |
### 7.2 创建本次硬件智能体会话
```http
POST /device/v3/agent-sessions
Idempotency-Key: d7f08e7a-9e1d-455f-b8ec-a745a34d8790
Content-Type: application/json
```
状态要求:仅 `ACTIVE`。每次聊天创建一个新会话,不复用上一通响应。
请求示例:
```json
{
"device_time": "2026-09-03T08:05:00Z",
"monotonic_ms": 123456,
"capabilities": {
"hardware_agent": true,
"sdk_version": "1.0.0-echoear-platform.1",
"transports": ["low_load"],
"input_audio_format": "pcm_s16le_16000_mono",
"output_audio_format": "pcm_s16le_16000_mono",
"server_event_callback": false,
"transcript_upload": true,
"dynamic_session_config": true,
"credential_rotation": true,
"near_pcm_recording": false,
"far_pcm_recording": false
},
"network": {
"type": "wifi",
"rssi": -52
},
"requested_goal": "open_conversation",
"config_revision": 1
}
```
约束:
- `transports` 只能包含 `low_load`、`high_quality`,至少一个。
- `transcript_upload`、`dynamic_session_config`、`credential_rotation` 必须为 `true`。
- EchoEar 当前使用 `low_load`;`server_event_callback=false` 时设备事件和最终字幕上传是权威回流。
- `config_revision` 必须使用最近一次 `/device/v2/config` 返回的版本。
成功响应核心字段:
```json
{
"request_id": "req-...",
"data": {
"conversation_id": "<uuid>",
"status": "SESSION_READY",
"runtime_type": "volc_hardware_agent",
"runtime": {
"provider": "volcengine",
"bot_id": "<bot-id>",
"transport": "low_load",
"provider_session_ref": "<provider-session-ref>",
"session_nonce": "<memory-only-secret>",
"config_digest": "<sha256-hex>",
"wait_for_session_update": true
},
"session_config": {
"dialog_id": "<uuid>",
"system_prompt": "<本次冻结的人设提示>",
"persona_tone": "<语气>",
"authorized_context": "<已授权背景/近期摘要/长期记忆>",
"prompt_bundle_version": "<version>"
},
"privacy": {
"retention_mode": "transcript_only",
"record_near": false,
"record_far": false,
"upload_allowed": false
},
"session": {
"engine_mode": "volc_hardware_agent",
"heartbeat_interval_sec": 10,
"allocation_expires_at": "2026-09-03T08:10:00Z"
}
}
}
```
设备处理顺序:
1. 只在运行内存保存 `session_nonce`。
2. 使用预配取得的一机一密连接响应指定的 Bot 和传输模式。
3. 以 `dialog_id` 作为本次业务对话标识。
4. 在开启麦克风前通过 SDK 动态配置或 WebSocket `session.update` 注入 `system_prompt`、`persona_tone` 和 `authorized_context`。
5. 收到火山配置已应用确认后,再上报 `session_started`。
`tool_scope_token` 如存在也只能保存在运行内存,并且仅用于响应指定的 `tool_url` 和本次会话。
### 7.3 上报会话事件
```http
POST /device/v3/agent-sessions/{conversation_id}/events
Content-Type: application/json
```
开始事件:
```json
{
"event_id": "evt-start-0001",
"sequence_no": 1,
"event_type": "session_started",
"occurred_at": "2026-09-03T08:05:03Z",
"provider_session_ref": "<response-runtime-provider-session-ref>",
"session_nonce": "<response-runtime-session-nonce>",
"provider_connection_ref": "<actual-volc-connection-ref>",
"bot_id": "<response-runtime-bot-id>",
"transport": "low_load",
"config_digest": "<response-runtime-config-digest>",
"dynamic_config_applied": true
}
```
后续事件类型:
- `session_updated`:按 `heartbeat_interval_sec` 上报存活;`sequence_no` 严格递增。
- `session_ended`:正常结束。
- `session_failed`:异常结束,可附加稳定、脱敏的 `error_code`。
响应:
```json
{
"request_id": "req-...",
"data": {
"conversation_id": "<uuid>",
"status": "ACTIVE",
"action": "continue",
"duplicate": false,
"accepted_at": "2026-09-03T08:05:03Z"
}
}
```
`action=stop_session` 时,设备必须立即关闭麦克风和火山连接,然后补报 `session_ended` 或 `session_failed`。相同 `event_id` 重放时必须保持原正文一致。
### 7.4 批量上传最终话轮
```http
POST /device/v3/agent-sessions/{conversation_id}/turns:batch
Idempotency-Key: 1710b8cc-9bf2-45e8-a16d-f32c3b80cbbe
Content-Type: application/json
```
请求:
```json
{
"turns": [
{
"turn_key": "user-final-0001",
"sequence_no": 1,
"speaker": "subject",
"start_at": "2026-09-03T08:05:10Z",
"end_at": "2026-09-03T08:05:12Z",
"final_text": "今天天气怎么样?",
"interrupted": false
},
{
"turn_key": "agent-final-0002",
"sequence_no": 2,
"speaker": "agent",
"start_at": "2026-09-03T08:05:12Z",
"end_at": "2026-09-03T08:05:15Z",
"final_text": "我来帮您看看。",
"interrupted": false
}
]
}
```
只上传最终字幕;不得上传增量识别结果。单批 1—500 条,`speaker` 只能为 `subject` 或 `agent`。同一 `turn_key`/`sequence_no` 的重试必须保持正文完全一致。
成功响应:
```json
{
"request_id": "req-...",
"data": {
"conversation_id": "<uuid>",
"accepted_count": 2,
"duplicate_count": 0
}
}
```
## 8. App/Web 的设备接口
以下接口使用用户 OIDC Bearer Token,不使用设备 HMAC:
| 方法 | 路径 | 作用 |
|---|---|---|
| `POST` | `/api/v1/device-claims` | 消费绑定码并绑定设备 |
| `GET` | `/api/v1/devices` | 列出当前用户可管理设备 |
| `GET` | `/api/v1/devices/{id}` | 查看设备详情 |
| `GET` | `/api/v1/devices/{id}/binding-impact` | 变更绑定前评估影响 |
| `POST` | `/api/v1/devices/{id}/binding-change` | 解绑或改绑 |
| `GET` | `/api/v1/devices/{id}/presence` | 查看在线状态 |
| `GET` | `/api/v1/devices/{id}/twin` | 查看设备影子 |
| `PATCH` | `/api/v1/devices/{id}/twin` | 修改期望配置;必须携带 `If-Match` |
| `GET` | `/api/v1/devices/{id}/commands` | 查看命令历史 |
| `POST` | `/api/v1/devices/{id}/commands` | 下发设备命令 |
绑定码由平台/运维为 `CLAIMABLE` 设备生成,App 只消费,不自行生成。解绑会影响设备激活状态和火山运行凭据,必须先调用 `binding-impact` 展示影响并要求用户确认。
## 9. 平台运维设备接口
所有接口要求 OIDC Bearer Token 和有效的 `X-Tenant-Id: <tenant-uuid>`,且仅独立运维账号可调用。
| 方法 | 路径 | 作用 |
|---|---|---|
| `GET` | `/api/v1/ops/devices` | 设备列表 |
| `POST` | `/api/v1/ops/devices` | 登记设备并一次性返回平台 Device Secret |
| `GET` | `/api/v1/ops/device-assignment-options` | 可选家庭/长者列表 |
| `PATCH` | `/api/v1/ops/devices/{id}` | 修改设备;必须携带 `If-Match` |
| `DELETE` | `/api/v1/ops/devices/{id}` | 吊销设备;必须携带 `If-Match` |
| `POST` | `/api/v1/ops/devices/{id}/credentials/rotate` | 轮换平台 Device Secret,只返回一次明文 |
| `POST` | `/api/v1/ops/devices/{id}/claims` | 生成一次性绑定码 |
| `POST` | `/api/v1/ops/devices/{id}/activation-transitions` | 人工迁移激活状态 |
| `GET` | `/api/v1/ops/devices/{id}/activation-transitions` | 查询激活迁移历史 |
### 9.1 登记硬件智能体设备
```json
{
"model": "echoear",
"hardware_revision": "esp32-s3",
"minimum_security_version": "1",
"minimum_firmware_version": "1.0.0",
"household_id": "",
"subject_id": "",
"voice_runtime": "volc_hardware_agent",
"activation_state": "FACTORY"
}
```
Instance ID、Product Key、默认 Bot、传输模式和最低 SDK 由平台产品配置统一持有。设备第一次预配时自动生成绑定,运维创建设备或设备自身都不能覆盖这些字段。
响应中的 `data.credential.secret` 只显示一次。运维系统应立即通过受控注入工具写入设备安全区,不得复制到工单、聊天记录或普通数据库字段。
### 9.2 人工迁移激活状态
```json
{
"target_state": "CLAIMABLE",
"reason": "工厂质检通过",
"expected_revision": 1
}
```
并发控制:列表/详情返回的设备版本对应 `ETag`;修改和删除使用 `If-Match`。激活迁移单独使用 `expected_revision`。版本不一致返回 `412 VERSION_CONFLICT` 或 `409 DEVICE_ACTIVATION_CONFLICT`,刷新后再决定,不能强制覆盖。
## 10. 幂等、重试和本地持久化
| 数据 | 本地策略 |
|---|---|
| 平台 Device Secret | 安全区长期保存;轮换时原子替换 |
| 火山 Device Secret | 安全区长期保存;不要每次启动都预配 |
| `session_nonce` | 仅本次通话运行内存 |
| `tool_scope_token` | 仅本次通话运行内存 |
| 最终字幕待上传队列 | 授权允许时加密持久化,成功确认后删除 |
| 增量字幕/原始音频 | 默认不长期保存 |
重试规则:
- `Idempotency-Key` 长度 16—128 字符。
- HTTP 超时或 `retryable=true`:原业务正文 + 原幂等键 + 新 HMAC nonce/signature,指数退避。
- `401`:先检查平台 Device Secret、时钟、请求 URI、正文摘要和 Base64URL 编码,不要盲目轮换密钥。
- `409 IDEMPOTENCY_KEY_REUSED`:同一幂等键配了不同正文;这是客户端错误。
- `HARDWARE_SESSION_PROOF_INVALID`:丢弃当前会话内存数据,重新创建新会话,不尝试其他设备的 nonce。
- `action=stop_session`:优先停止音频和供应商连接,再做结束确认及本地队列收尾。
## 11. EchoEar 固件接入清单
出厂只写入:
- 平台 `device_id`;
- 平台 Device Secret;
- `hardware_id`;
- API Base URL 和服务器信任锚;
- 固件签名信任锚。
首次绑定后设备取得并安全保存:
- 火山 `instance_id`;
- 火山 `product_key`;
- 火山 `device_name`;
- 火山 Device Secret;
- `credential_version`。
绝不写入设备:
- 火山 Product Secret;
- 平台数据库主密钥/KMS 密钥;
- 其他设备的 Device Secret;
- 固定的会话 nonce 或工具 Token;
- 人设、长期记忆和聊天记录的永久明文副本。
会话侧必须做到:
- 每通先请求平台会话配置,再连接火山;
- 人设、近期摘要和长期记忆以本次 `session_config` 为准;
- 动态配置确认前不打开麦克风;
- 设备事件和最终字幕可靠回流平台;
- 平台下发 `stop_session` 时可在一个心跳周期内停止。
## 12. 当前实现边界
- 当前 API 已实现平台 HMAC、激活状态机、火山动态注册、v3 会话控制面、事件回流和最终字幕上传。
- 设备音频是设备直连火山,平台不代理每一帧音频;平台通过设备事件和字幕回流维护会话权威状态。
- Windows/EchoEar 客户端仍需按目标 SDK 完成真实音频 HAL、火山协议、断线重连和安全存储验收。
- `/device/v3/hardware-agent-license:ack` 处理器代码虽曾存在,但没有路由、没有生产契约,也不应由设备调用。
- 完整字段定义以 [`openapi/openapi.yaml`](../openapi/openapi.yaml) 为机器可读基准;本文件是设备研发和联调的实施说明。
## 13. 联调最小验收用例
1. 错误平台签名返回 `401`,日志不泄露设备是否存在。
2. 同 nonce 重放被拒绝;相同业务幂等键配新 nonce 可安全重试。
3. `CLAIMABLE` 设备不能预配,`BOUND` 设备可以预配但不能创建会话。
4. 首次预配成功取得火山一机一密;重复预配不重复注册。
5. Product Secret 在设备固件、NVS、日志和网络响应中均不存在。
6. `ACTIVE` 设备创建 v3 会话并得到正确 Bot ID、人设和授权上下文。
7. 未确认动态配置的 `session_started` 被拒绝。
8. 正常开始、心跳、最终字幕、结束全部回流平台。
9. 平台返回 `stop_session` 后设备关闭连接并补报结束。
10. 火山 License 额度耗尽时预配失败,扩容后使用原幂等请求可恢复;设备无需安装 License 或 ACK。
+55
View File
@@ -1,5 +1,21 @@
# 设备接入与启动配置联调说明
## 设备激活状态机
平台从V50开始独立维护 `activation_state`,不再用通用的 `device.status` 代替激活生命周期:
`FACTORY → CLAIMABLE → BOUND → PROVISIONING → ACTIVE → LOST/REVOKED`
- `FACTORY`:只有设备身份和平台HMAC凭据,生产质检完成后由运维放行。
- `CLAIMABLE`:可以生成一次性绑定码;设备可同步基础配置,但不能发起对话。
- `BOUND`:已经绑定家庭和长者,可以调用火山设备凭据预配接口。
- `PROVISIONING`:平台正在代设备调用火山 DynamicRegister 并保存一机一密;动态注册成功、有效绑定和设备凭据就绪后进入 `ACTIVE`。火山 License 是产品侧动态注册额度,不是设备安装包,也没有设备回执。
- `ACTIVE`:允许创建和上报对话。硬件智能体会话接口只接受该状态。
- `LOST`:设备身份认证、对话和火山运行凭据立即停用;核验归属和硬件身份后可恢复到 `ACTIVE`。
- `REVOKED`:终态。设备平台凭据、火山凭据、绑定和未使用绑定码全部吊销,不能恢复。
每次迁移都会增加 `activation_revision` 并写入 `device_activation_transition`。运维人工迁移接口为 `POST /api/v1/ops/devices/{id}/activation-transitions`,请求必须携带当前 `expected_revision`;用户认领、设备预配和删除由对应业务接口自动迁移,不应由客户端伪造状态。
## 1. 当前接口
| 方法 | 路径 | 作用 |
@@ -270,6 +286,41 @@ powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\smoke-sim-firmware
脚本会验证创建、首次激活、相同幂等键重放、Worker调用、两次签名回调去重和最终`ACTIVE`状态,并断言供应商仅启动一次。脚本只使用合成数据,验证结束后按本次通话编号精确清理数据库痕迹。
## 11.5 火山硬件对话智能体 v3 控制面
设备首次准备火山连接凭据时,先使用既有平台设备签名调用`POST /device/v3/hardware-agent-credentials:provision`并提交稳定`hardware_id`。平台从产品级配置解析Instance ID、Product Key、默认Bot、传输方式和最低SDK版本,自动建立当前设备的运行绑定;再从KMS或只读Secret文件加载Product Secret,以平台生成的Device Name代设备调用火山DynamicRegister。Product Secret永不到达设备。平台把返回的`device_secret`用独立的`VOLC_HARDWARE_DEVICE_CREDENTIAL_MASTER_KEY`执行AES-256-GCM加密后保存,只在设备认证且`Cache-Control: no-store`的响应中返回给同一设备。设备使用安全芯片、系统KeyStore或Windows Safe Storage保存这份一机一密,后续用于火山WebSocket签名。
火山硬件对话智能体 License 是产品侧可用额度。平台调用 DynamicRegister 时由火山自动消耗一份有效额度并返回对应设备的一机一密;设备不会收到 License 明文,也不需要提交安装回执。当前有效路由不包含`POST /device/v3/hardware-agent-license:ack`,预配响应也不包含`license`字段。平台可以维护额度监控或影子账本,但不得把自建池作为设备运行凭据来源。额度耗尽或到期时,应根据火山动态注册错误告警并扩容,扩容后重试原预配请求。
同一设备和Hardware ID重复调用时,平台解密返回既有一机一密,不重复注册;Hardware ID、产品或实例不一致时返回409,必须走受控轮换而不是覆盖。Product Secret、Device Secret明文不得进入日志、审计详情、错误响应、通话记录或测试报告。自动回归的mock密钥仅允许在隔离测试进程中注入,不得写入正式设备缓存。
设备期望配置中的`voice_runtime`为`volc_hardware_agent`时,不再调用`POST /device/v2/conversations`创建RTC房间。首次预配后设备要重新拉取配置版本,并读取平台发布的`minimum_sdk_version`执行本地预检;云端会以产品默认绑定中的同一版本再次门禁,过低时返回HTTP 426和`HARDWARE_AGENT_SDK_UPDATE_REQUIRED`。每次用户开始聊天必须先调用`POST /device/v3/agent-sessions`,不得复用上一次响应。创建请求必须声明SDK版本、输入/输出音频格式、传输模式、可靠字幕队列、动态会话配置、凭证轮换以及近/远端PCM录制能力。平台会在同一事务中冻结这些能力、有效授权、已发布人设、档案、最近三次已完成通话摘要和最多五条人工确认的长期记忆,并返回:
- `runtime.bot_id`、传输模式和仅本次有效的`session_nonce`;
- `session_config.dialog_id`,必须作为火山会话的业务对话标识;
- `system_prompt`、`persona_tone`和`authorized_context`,必须在开始发送音频前通过SDK动态配置或标准WebSocket的`session.update`注入;
- `config_digest`,用于设备日志和平台诊断比对,不得当作供应商凭据;
- 可选的`tool_url`和`tool_scope_token`,只允许配置为本次火山会话的只读记忆工具;令牌绑定本次通话和供应商会话引用,设备不得持久化或转给其他会话;
- 本次冻结的隐私策略和会话存活间隔。
设备收到火山`session.created`后必须先应用本次`session.update`,收到确认后才向`POST /device/v3/agent-sessions/{id}/events`提交`session_started`。开始事件必须同时提交火山实际会话关联号、`bot_id`、传输模式、平台原样返回的`config_digest`和`dynamic_config_applied=true`;任何一项不一致都不会进入`ACTIVE`,此时不得打开麦克风。通话期间按响应中的心跳间隔提交`session_updated`;响应`action=continue`时保持会话,收到`action=stop_session`时必须立即关闭火山连接,然后提交`session_ended`或`session_failed`。这样,家庭撤回授权、运营停话和设备吊销即使发生在设备直连火山期间,也会收敛到设备执行。
平台不是靠轮询火山控制台才知道通话状态:v3设备事件在同一事务内更新权威通话状态,既有数据库触发器随`ACTIVE/COMPLETED/FAILED`写入最小`web_event`,已登录SaaS页面通过认证SSE收到`conversation.started/completed/failed`并刷新列表。`session_updated`只续租并更新硬件事件水位,不向家庭页面逐心跳广播,避免每10秒放大为整页查询;运维诊断仍可读取最后事件时间和序号。
录音能力采用保守门禁:只有创建请求同时声明`near_pcm_recording=true`和`far_pcm_recording=true`时,平台才可能依据既有授权返回`transcript_and_audio`。缺任一能力都会把本次会话强制降级为`transcript_only`(没有字幕授权时为`none`),并把`record_near`、`record_far`、`upload_allowed`全部置为`false`,设备不得以混音或供应商未知格式冒充双轨录音。
最终字幕写入本地加密重试队列,使用`POST /device/v3/agent-sessions/{id}/turns:batch`上传。只上传用户最终识别事件和智能体`response.audio_transcript.done`,不得长期保存或上传增量字幕;同一`turn_key`或`sequence_no`重放必须保持正文完全一致。高质量传输可额外配置火山服务端状态/字幕回调作为旁路证据;低负载传输按火山当前约束没有该服务端回调,因此设备事件与终稿上传是强制能力,创建请求必须声明`transcript_upload=true`。
Product Secret只保存在平台KMS/Secret挂载中;平台注册结果中的`device_secret`在平台数据库加密保存,并只下发给对应设备的安全区或供应商SDK凭据存储。普通会话响应和日志不得包含该值。当前Windows标准WebSocket测试程序覆盖平台代理注册和真实低负载协议;高质量方案的火山硬件SDK二进制和板级音频HAL仍必须由目标芯片工程集成后执行真机验收。
本地控制面闭环可执行:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\smoke-hardware-agent.ps1
```
脚本先证明过低SDK被HTTP 426拒绝、未完成动态配置不能激活,再验证产品默认Bot绑定、每次会话上下文预取、录音能力安全降级、开始事件、最终字幕、平台停话、`stop_session`下发、设备结束确认、能力快照和数据库状态收敛,并精确清理临时会话及绑定。
## 12. 错误处理
| HTTP状态 | 错误码 | 处理建议 |
@@ -279,6 +330,8 @@ powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\smoke-sim-firmware
| 503 | `DEVICE_AUTHENTICATION_UNAVAILABLE` | 退避后使用新随机数重试,不关闭鉴权 |
| 503 | `DEVICE_CONFIG_UNAVAILABLE` / `DEVICE_BOOTSTRAP_FAILED` | 退避重试并保留上一次有效配置 |
| 503 | `RUNTIME_CONFIG_UNAVAILABLE` | 生产环境没有命中活动语音路由;停止创建通话,由运营发布或恢复目标范围配置 |
| 426 | `HARDWARE_AGENT_SDK_UPDATE_REQUIRED` | 当前火山硬件智能体SDK低于设备绑定门禁;停止创建会话并升级固件/SDK |
| 503 | `HARDWARE_AGENT_PROVISIONING_FAILED` | 火山动态注册失败;用原幂等键、原正文和新签名退避重试,并由运维根据`request_id`检查产品License额度、有效期和火山原始错误 |
| 403 | `CONSENT_REQUIRED` | 不重试,等待有效授权 |
| 403 | `TRANSCRIPT_STORAGE_NOT_ALLOWED` | 停止补传,等待留存策略或授权恢复,不得本地绕过 |
| 409 | `DEVICE_NOT_BOUND` / `CONVERSATION_ALREADY_ACTIVE` | 修复绑定或查询已有通话 |
@@ -288,6 +341,8 @@ powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\smoke-sim-firmware
| 409 | `CONFIG_VERSION_CONFLICT` | 先重新获取设备配置再尝试创建新通话 |
| 409 | `PROVIDER_EVENT_CONFLICT` | 供应商事件编号复用且正文不同,转人工排查 |
| 409 | `CONVERSATION_STATE_CONFLICT` | 查询云端当前通话状态,不要强制覆盖状态机 |
| 401 | `HARDWARE_SESSION_PROOF_INVALID` | 丢弃本地会话,不得尝试其他设备或其他会话的nonce;重新创建会话 |
| 409 | `HARDWARE_AGENT_EVENT_CONFLICT` | 保留原事件和单调序号,查询当前会话状态并人工核对,不得覆盖 |
| 409 | `TRANSCRIPT_TURN_CONFLICT` | 保留本地终稿和冲突明细,禁止覆盖云端终稿,转入人工对账 |
| 409 | `TELEMETRY_EVENT_CONFLICT` | 保留冲突事件并停止推进本地确认点,人工核对事件键和单调序号 |
| 404 | `TELEMETRY_CONVERSATION_NOT_FOUND` | 删除错误通话引用,不得改用任意长者编号重试 |
+857
View File
@@ -0,0 +1,857 @@
# 设备注册到聊天记录上传接口文档
## 1. 设备请求平台时的请求头
运营后台和用户 App 调用 `/api/v1/*` 时使用:
```http
Authorization: Bearer <用户访问令牌>
```
设备调用 `/device/*` 时使用以下 HMAC 请求头:
| 请求头 | 中文含义 | 计算方式 |
|---|---|---|
| `X-Device-Id` | 平台设备编号 | 平台登记设备时返回的 `credential.device_id` |
| `X-Device-Timestamp` | 请求时间 | 当前 UTC 时间,RFC3339 格式,例如 `2026-09-05T09:20:00Z` |
| `X-Device-Nonce` | 防重放随机数 | 每个请求重新生成,长度 16–128 字符,不得重复 |
| `X-Device-Content-SHA256` | 请求正文摘要 | `hex_lower(SHA256(rawBody))`;GET 使用空字节正文 |
| `X-Device-Signature` | 请求签名 | `base64url_no_padding(HMAC-SHA256(platformDeviceSecret, canonical))` |
| `Idempotency-Key` | 业务幂等键 | 仅指定接口需要;同一次请求重试必须保持不变,长度 16–128 字符 |
| `Content-Type` | 正文类型 | 有 JSON 正文时为 `application/json` |
签名原文 `canonical`:
```text
{大写HTTP方法}\n{请求路径和查询参数}\n{X-Device-Timestamp}\n{X-Device-Nonce}\n{X-Device-Content-SHA256}
```
计算示例:
```text
body = UTF8('{"hardware_id":"echoear-a1b2c3"}')
bodySha256 = hex_lower(SHA256(body))
timestamp = "2026-09-05T09:20:00Z"
nonce = "d8b81b916ddd4f4584a80a82"
canonical =
"POST\n" +
"/device/v3/hardware-agent-credentials:provision\n" +
timestamp + "\n" +
nonce + "\n" +
bodySha256
signature = base64url_no_padding(
HMAC-SHA256(platformDeviceSecret, UTF8(canonical))
)
```
注意:
- 摘要必须针对实际发送的原始字节计算,签名后不能再次格式化 JSON。
- 查询参数属于签名内容,例如 `/device/v2/commands?limit=10`。
- GET 空正文 SHA-256 固定为 `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`。
- `platformDeviceSecret` 是平台注册时返回的设备密钥,不是火山 `Product Secret`,也不是火山 `device_secret`。
- 平台统一响应:成功为 `{"request_id":"...","data":...}`;失败为 `{"request_id":"...","error":...}`。
## 2. 完整流程
| 顺序 | 调用方 | 动作 | 接口/地址 | 结果 |
|---:|---|---|---|---|
| 1 | 运营平台 | 登记设备 | `POST /api/v1/ops/devices` | 返回平台设备 ID、平台设备密钥和绑定码 |
| 2 | 用户 App | 绑定设备 | `POST /api/v1/device-claims` | 状态变为 `BOUND` |
| 3 | 设备 | 获取火山运行凭据 | `POST /device/v3/hardware-agent-credentials:provision` | 平台动态注册火山设备,消耗一个 License,状态变为 `ACTIVE` |
| 4 | 设备 | 检查固件 | `GET /device/v2/bootstrap` | 得到 `firmware_compatible` |
| 5 | 设备 | 获取设备配置 | `GET /device/v2/config` | 得到配置版本和运行时配置 |
| 6 | 设备 | 判断是否继续 | `firmware_compatible=false` 时停止聊天并进入 OTA 流程 | 只有兼容固件可以继续 |
| 7 | 设备 | 创建对话会话 | `POST /device/v3/agent-sessions` | 获取 Bot ID、系统提示词、人设和授权记忆 |
| 8 | 设备 | 连接火山 | 火山 WebSocket + `session.update` | 注入本次人设并建立 16 kHz PCM 对话 |
| 9 | 设备 | 上报会话开始 | `POST /device/v3/agent-sessions/{id}/events` | 平台会话变为 `ACTIVE` |
| 10 | 设备 | 上传最终聊天文本 | `POST /device/v3/agent-sessions/{id}/turns:batch` | 用户和智能体最终文本入库 |
| 11 | 设备 | 上报会话结束 | 同一个 events 接口,`session_ended` | 平台会话变为 `COMPLETED` |
激活状态:
```text
FACTORY → CLAIMABLE → BOUND → PROVISIONING → ACTIVE → LOST/REVOKED
```
设备重置流程:
```text
停止语音 → 上传剩余聊天记录 → 上报session_ended → POST /device/v3/factory-reset
→ 平台解除绑定并暂停火山凭据 → 设备按local_actions清理 → 重启进入CLAIMABLE
```
## 3. 接口
### 3.1 登记设备
地址:
```http
POST /api/v1/ops/devices
```
输入:
| 字段 | 类型 | 必填 | 中文含义 |
|---|---|---:|---|
| `model` | string | 是 | 设备型号 |
| `hardware_revision` | string | 否 | 硬件版本 |
| `minimum_security_version` | string | 否 | 最低安全版本 |
| `minimum_firmware_version` | string | 否 | 最低固件版本 |
| `household_id` | UUID | 否 | 直接绑定的家庭编号;走绑定码时不传 |
| `subject_id` | UUID | 否 | 直接绑定的服务对象编号;走绑定码时不传 |
| `voice_runtime` | enum | 是 | 传 `volc_hardware_agent` |
| `hardware_agent_bot_id` | string | 否 | 兼容字段;当前方案不传,由平台产品配置决定 |
| `hardware_agent_transport` | enum | 否 | `low_load` 或 `high_quality`;建议由平台配置 |
| `hardware_agent_minimum_sdk_version` | string | 否 | 最低硬件智能体 SDK 版本 |
| `activation_state` | enum | 是 | `FACTORY`、`CLAIMABLE` 或 `BOUND`;一般传 `CLAIMABLE` |
输出:
| 字段 | 类型 | 中文含义 |
|---|---|---|
| `device.id` | UUID | 平台设备编号 |
| `device.status` | string | 设备业务状态 |
| `device.activation_state` | enum | 激活状态 |
| `device.activation_revision` | integer | 激活状态版本 |
| `device.model` | string | 设备型号 |
| `device.hardware_revision` | string/null | 硬件版本 |
| `device.minimum_security_version` | string/null | 最低安全版本 |
| `device.minimum_firmware_version` | string/null | 最低固件版本 |
| `device.voice_runtime` | enum | 语音运行时 |
| `device.version` | integer | 设备记录版本 |
| `credential.device_id` | UUID | 平台设备编号 |
| `credential.secret` | string | 设备调用平台所用的 HMAC 密钥,仅明文返回一次 |
| `credential.key_version` | integer | 平台设备密钥版本 |
| `credential.issued_at` | datetime | 密钥签发时间 |
| `claim.claim_code` | string/null | 一次性绑定码 |
| `claim.issued_at` | datetime/null | 绑定码签发时间 |
| `claim.expires_at` | datetime/null | 绑定码过期时间 |
示例:
```http
POST /api/v1/ops/devices
Authorization: Bearer <ops-token>
Content-Type: application/json
{
"model": "echoear-esp32s3",
"hardware_revision": "v1.0",
"minimum_firmware_version": "1.0.0",
"voice_runtime": "volc_hardware_agent",
"activation_state": "CLAIMABLE"
}
```
```json
{
"request_id": "req-register-001",
"data": {
"device": {
"id": "10000000-0000-0000-0000-000000000512",
"status": "registered",
"activation_state": "CLAIMABLE",
"activation_revision": 1,
"model": "echoear-esp32s3",
"voice_runtime": "volc_hardware_agent",
"version": 1
},
"credential": {
"device_id": "10000000-0000-0000-0000-000000000512",
"secret": "<platform-device-secret>",
"key_version": 1,
"issued_at": "2026-09-05T09:00:00Z"
},
"claim": {
"claim_code": "ABCD-EFGH-IJKL-MNOP",
"issued_at": "2026-09-05T09:00:00Z",
"expires_at": "2026-09-06T09:00:00Z"
}
}
}
```
### 3.2 用户绑定设备
地址:
```http
POST /api/v1/device-claims
```
输入:
| 字段 | 类型 | 必填 | 中文含义 |
|---|---|---:|---|
| `claim_code` | string | 是 | 一次性绑定码,不能填写设备 UUID |
| `household_id` | UUID | 是 | 家庭编号 |
| `subject_id` | UUID | 是 | 设备服务对象编号 |
输出:
| 字段 | 类型 | 中文含义 |
|---|---|---|
| `binding_id` | UUID | 设备绑定关系编号 |
| `device_id` | UUID | 平台设备编号 |
| `household_id` | UUID | 家庭编号 |
| `subject_id` | UUID | 服务对象编号 |
| `bound_at` | datetime | 绑定时间 |
示例:
```http
POST /api/v1/device-claims
Authorization: Bearer <user-token>
Content-Type: application/json
{
"claim_code": "ABCD-EFGH-IJKL-MNOP",
"household_id": "10000000-0000-0000-0000-000000000211",
"subject_id": "10000000-0000-0000-0000-000000000411"
}
```
```json
{
"request_id": "req-bind-001",
"data": {
"binding_id": "10000000-0000-0000-0000-000000000611",
"device_id": "10000000-0000-0000-0000-000000000512",
"household_id": "10000000-0000-0000-0000-000000000211",
"subject_id": "10000000-0000-0000-0000-000000000411",
"bound_at": "2026-09-05T09:10:00Z"
}
}
```
### 3.3 占用 License 并获取火山设备凭据
地址:
```http
POST /device/v3/hardware-agent-credentials:provision
```
输入:
| 字段 | 位置 | 类型 | 必填 | 中文含义 |
|---|---|---|---:|---|
| `Idempotency-Key` | Header | string | 是 | 凭据申请幂等键,16–128 字符 |
| `hardware_id` | Body | string | 是 | 不随固件升级变化的物理硬件标识 |
输出:
| 字段 | 类型 | 中文含义 |
|---|---|---|
| `instance_id` | string | 火山硬件智能体实例编号 |
| `product_key` | string | 火山产品标识 |
| `device_name` | string | 火山设备名称 |
| `hardware_id` | string | 已绑定的物理硬件标识 |
| `device_secret` | string | 火山设备专属密钥,需要加密保存 |
| `credential_ref` | string | 平台内部凭据引用 |
| `credential_version` | integer | 火山设备凭据版本 |
| `credential_source` | enum | `platform_dynamic_registration` 为新注册,`platform_stored` 为复用已有凭据 |
| `issued_at` | datetime | 凭据签发时间 |
示例:
```http
POST /device/v3/hardware-agent-credentials:provision
Idempotency-Key: provision-device-00000512-v1
Content-Type: application/json
<设备HMAC请求头>
{"hardware_id":"echoear-esp32s3-a1b2c3d4"}
```
```json
{
"request_id": "req-provision-001",
"data": {
"instance_id": "6a92945cc0c4689234aef656",
"product_key": "6a9568047dd4b23eacab9b1a",
"device_name": "dt-10000000000000000000000000000512",
"hardware_id": "echoear-esp32s3-a1b2c3d4",
"device_secret": "<volc-device-secret>",
"credential_ref": "volc-device-secret://10000000-0000-0000-0000-000000000512",
"credential_version": 1,
"credential_source": "platform_dynamic_registration",
"issued_at": "2026-09-05T09:12:00Z"
}
}
```
### 3.4 设备恢复出厂
地址:
```http
POST /device/v3/factory-reset
```
输入:
| 字段 | 类型 | 必填 | 中文含义 |
|---|---|---:|---|
| Header `Idempotency-Key` | string | 是 | 本次重置幂等键,16–128 字符;重试保持不变 |
| `reset_id` | UUID | 是 | 本次重置唯一编号 |
| `reset_counter` | integer | 是 | 设备安全存储中的单调递增重置次数 |
| `occurred_at` | datetime | 是 | 设备确认重置的 UTC 时间;不得晚于请求时间 5 分钟 |
| `trigger` | enum | 是 | `physical_long_press`、`device_menu` 或 `service_tool` |
| `firmware_version` | string | 是 | 当前固件版本 |
| `pending_turn_count` | integer | 是 | 未上传聊天记录数;必须为 `0` |
| `active_conversation_id` | UUID | 否 | 设备认为仍在运行的会话编号 |
输出:
| 字段 | 类型 | 中文含义 |
|---|---|---|
| `reset_id` | UUID | 重置编号 |
| `device_id` | UUID | 平台设备编号 |
| `activation_state` | enum | 重置后固定为 `CLAIMABLE` |
| `reset_counter` | integer | 平台接受的重置次数 |
| `binding_removed` | boolean | 是否关闭了原用户绑定 |
| `previous_binding_id` | UUID/null | 原绑定关系编号 |
| `provider_credential_status` | enum | 火山设备凭据状态,通常为 `SUSPENDED` |
| `license_action` | enum | 固定为 `RETAINED`,重置不释放火山 License |
| `ended_conversation_count` | integer | 平台强制结束的残留会话数 |
| `cancelled_command_count` | integer | 平台取消的待执行命令数 |
| `config_revision` | integer | 重置后的设备配置版本 |
| `claim.code` | string | 新的一次性绑定码 |
| `claim.issued_at` | datetime | 绑定码签发时间 |
| `claim.expires_at` | datetime | 绑定码过期时间 |
| `local_actions` | string[] | 设备收到成功响应后必须执行的本地清理动作 |
| `reset_at` | datetime | 平台完成重置的时间 |
平台同时把旧用户侧的设备状态置为离线,并推送 `device.binding.reset` 事件。设备收到成功响应后,按 `local_actions` 清理本地火山凭据、用户上下文、会话与网络配置;设备平台身份和 HMAC 密钥不得删除。
示例:
```http
POST /device/v3/factory-reset
Idempotency-Key: factory-reset-00000512-0001
Content-Type: application/json
<设备HMAC请求头>
{
"reset_id": "10000000-0000-0000-0000-000000000901",
"reset_counter": 1,
"occurred_at": "2026-09-05T09:30:00Z",
"trigger": "physical_long_press",
"firmware_version": "1.1.0",
"pending_turn_count": 0
}
```
```json
{
"request_id": "req-reset-001",
"data": {
"reset_id": "10000000-0000-0000-0000-000000000901",
"device_id": "10000000-0000-0000-0000-000000000512",
"activation_state": "CLAIMABLE",
"reset_counter": 1,
"binding_removed": true,
"previous_binding_id": "10000000-0000-0000-0000-000000000611",
"provider_credential_status": "SUSPENDED",
"license_action": "RETAINED",
"ended_conversation_count": 0,
"cancelled_command_count": 0,
"config_revision": 9,
"claim": {
"code": "QRST-UVWX-YZ23-4567",
"issued_at": "2026-09-05T09:30:01Z",
"expires_at": "2026-09-06T09:30:01Z"
},
"local_actions": [
"clear_volc_credential",
"clear_user_context",
"clear_session_state",
"clear_network_credentials",
"reboot_to_claimable"
],
"reset_at": "2026-09-05T09:30:01Z"
}
}
```
设备必须保留平台 `device_id`、平台 HMAC `secret` 和 `hardware_id`;其余用户上下文、火山凭据、会话状态和网络配置按 `local_actions` 清除。
### 3.5 检查固件版本
地址:
```http
GET /device/v2/bootstrap
```
输入:
| 字段 | 位置 | 类型 | 必填 | 中文含义 |
|---|---|---|---:|---|
| `firmware_version` | Query | string | 是 | 当前固件版本 |
| `device_time` | Query | datetime | 是 | 设备 UTC 时间,RFC3339 |
| `board_rev` | Query | string | 否 | 硬件版本 |
| `rtc_sdk_version` | Query | string | 否 | 火山 SDK 版本 |
| `codec` | Query | string | 否 | 音频格式,例如 `pcm16` |
| `sample_rate` | Query | integer | 否 | 音频采样率,当前为 `16000` |
| `network` | Query | string | 否 | 网络类型,例如 `wifi` |
输出:
| 字段 | 类型 | 中文含义 |
|---|---|---|
| `server_time` | datetime | 平台 UTC 时间 |
| `clock_skew_ms` | integer | 平台时间减设备时间的毫秒差 |
| `device.device_id` | UUID | 平台设备编号 |
| `device.status` | string | 设备业务状态 |
| `device.model` | string | 设备型号 |
| `device.hardware_revision` | string/null | 硬件版本 |
| `device.minimum_security_version` | string/null | 最低安全版本 |
| `device.minimum_firmware_version` | string/null | 最低固件版本 |
| `device.firmware_compatible` | boolean | 当前固件是否允许继续聊天 |
| `device.config_revision` | integer | 当前平台配置版本 |
| `device.binding.bound` | boolean | 是否已绑定 |
| `device.binding.household_id` | UUID/null | 家庭编号 |
| `device.binding.subject_id` | UUID/null | 服务对象编号 |
| `device.binding.subject_name` | string/null | 服务对象名称 |
示例:
```http
GET /device/v2/bootstrap?firmware_version=1.0.0&board_rev=v1.0&rtc_sdk_version=1.1.0&codec=pcm16&sample_rate=16000&network=wifi&device_time=2026-09-05T09%3A15%3A00Z
<设备HMAC请求头>
```
```json
{
"request_id": "req-bootstrap-001",
"data": {
"server_time": "2026-09-05T09:15:01Z",
"clock_skew_ms": 1000,
"device": {
"device_id": "10000000-0000-0000-0000-000000000512",
"status": "registered",
"model": "echoear-esp32s3",
"minimum_firmware_version": "1.0.0",
"firmware_compatible": true,
"config_revision": 8,
"binding": {
"bound": true,
"household_id": "10000000-0000-0000-0000-000000000211",
"subject_id": "10000000-0000-0000-0000-000000000411",
"subject_name": "测试用户"
}
}
}
}
```
### 3.6 获取设备配置
地址:
```http
GET /device/v2/config
```
输入:
| 字段 | 位置 | 类型 | 必填 | 中文含义 |
|---|---|---|---:|---|
| `If-None-Match` | Header | string | 否 | 本地配置 ETag;未变化时返回 `304` |
输出:
| 字段 | 类型 | 中文含义 |
|---|---|---|
| Response Header `ETag` | string | 当前配置 ETag |
| `device_id` | UUID | 平台设备编号 |
| `revision` | integer | 配置版本 |
| `desired_config` | object | 平台期望设备应用的配置 |
| `desired_config.voice_runtime` | enum | 语音运行时 |
| `desired_config.hardware_agent_transport` | enum | 火山传输方式 |
| `desired_config.minimum_sdk_version` | string | 最低 SDK 版本 |
| `reported_revision` | integer/null | 设备此前报告的配置版本 |
| `binding` | object | 当前绑定信息,字段与 bootstrap 相同 |
示例:
```http
GET /device/v2/config
If-None-Match: "7"
<设备HMAC请求头>
```
```json
{
"request_id": "req-config-001",
"data": {
"device_id": "10000000-0000-0000-0000-000000000512",
"revision": 8,
"desired_config": {
"voice_runtime": "volc_hardware_agent",
"hardware_agent_transport": "low_load",
"minimum_sdk_version": "1.1.0"
},
"reported_revision": 7,
"binding": {
"bound": true,
"household_id": "10000000-0000-0000-0000-000000000211",
"subject_id": "10000000-0000-0000-0000-000000000411"
}
}
}
```
### 3.7 创建会话并获取系统提示词
地址:
```http
POST /device/v3/agent-sessions
```
输入:
| 字段 | 类型 | 必填 | 中文含义 |
|---|---|---:|---|
| Header `Idempotency-Key` | string | 是 | 创建会话幂等键,16–128 字符 |
| `device_time` | datetime | 是 | 设备 UTC 时间 |
| `monotonic_ms` | integer | 否 | 开机后的单调时钟毫秒数 |
| `capabilities.hardware_agent` | boolean | 是 | 必须为 `true` |
| `capabilities.sdk_version` | string | 是 | 硬件智能体 SDK 版本 |
| `capabilities.transports` | string[] | 是 | 支持的传输方式 |
| `capabilities.input_audio_format` | string | 是 | 上行音频能力,当前为 `pcm_s16le_16000_mono` |
| `capabilities.output_audio_format` | string | 是 | 下行音频能力,当前为 `pcm_s16le_16000_mono` |
| `capabilities.server_event_callback` | boolean | 是 | 当前传 `false` |
| `capabilities.transcript_upload` | boolean | 是 | 必须为 `true` |
| `capabilities.dynamic_session_config` | boolean | 是 | 必须为 `true` |
| `capabilities.credential_rotation` | boolean | 是 | 必须为 `true` |
| `capabilities.near_pcm_recording` | boolean | 是 | 是否支持近端录音 |
| `capabilities.far_pcm_recording` | boolean | 是 | 是否支持远端录音 |
| `network.type` | string | 是 | 网络类型 |
| `network.rssi` | integer | 否 | Wi-Fi RSSI |
| `requested_goal` | string | 是 | 固定为 `open_conversation` |
| `config_revision` | integer | 是 | `/device/v2/config` 返回的配置版本 |
输出:
| 字段 | 类型 | 中文含义 |
|---|---|---|
| `conversation_id` | UUID | 平台会话编号 |
| `status` | string | 正常为 `SESSION_READY` |
| `runtime_type` | string | `volc_hardware_agent` |
| `runtime.provider` | string | 服务商,当前为 `volcengine` |
| `runtime.bot_id` | string | 本次使用的 Bot ID |
| `runtime.transport` | string | 本次传输方式 |
| `runtime.provider_session_ref` | string | 火山会话引用 |
| `runtime.session_nonce` | string | 会话证明随机值 |
| `runtime.config_digest` | string | 本次动态配置摘要 |
| `runtime.wait_for_session_update` | boolean | 是否必须先应用动态人设 |
| `session_config.dialog_id` | string | 本次对话标识 |
| `session_config.system_prompt` | string | 系统提示词/核心人设 |
| `session_config.persona_tone` | string | 人设语气和表达风格 |
| `session_config.authorized_context` | string | 授权的用户背景、近期摘要和长期记忆 |
| `session_config.prompt_bundle_version` | string | 提示词版本 |
| `session_config.tool_url` | URL/null | 工具调用地址 |
| `session_config.tool_scope_token` | string/null | 本次会话工具令牌 |
| `privacy.retention_mode` | string | 数据保留模式 |
| `privacy.record_near` | boolean | 是否允许近端录音 |
| `privacy.record_far` | boolean | 是否允许远端录音 |
| `privacy.upload_allowed` | boolean | 是否允许上传聊天记录 |
| `session.engine_mode` | string | 会话引擎模式 |
| `session.heartbeat_interval_sec` | integer | 会话状态上报间隔 |
| `session.allocation_expires_at` | datetime | 会话分配过期时间 |
示例:
```http
POST /device/v3/agent-sessions
Idempotency-Key: session-device-00000512-0001
Content-Type: application/json
<设备HMAC请求头>
{
"device_time": "2026-09-05T09:20:00Z",
"monotonic_ms": 812345,
"capabilities": {
"hardware_agent": true,
"sdk_version": "1.1.0-echoear",
"transports": ["low_load"],
"input_audio_format": "pcm_s16le_16000_mono",
"output_audio_format": "pcm_s16le_16000_mono",
"server_event_callback": false,
"transcript_upload": true,
"dynamic_session_config": true,
"credential_rotation": true,
"near_pcm_recording": false,
"far_pcm_recording": false
},
"network": {"type":"wifi","rssi":-52},
"requested_goal": "open_conversation",
"config_revision": 8
}
```
```json
{
"request_id": "req-session-001",
"data": {
"conversation_id": "10000000-0000-0000-0000-000000000801",
"status": "SESSION_READY",
"runtime_type": "volc_hardware_agent",
"runtime": {
"provider": "volcengine",
"bot_id": "botM4gWQ9A9y",
"transport": "low_load",
"provider_session_ref": "provider-session-ref",
"session_nonce": "<session-nonce>",
"config_digest": "<config-digest>",
"wait_for_session_update": true
},
"session_config": {
"dialog_id": "dialog-00000801",
"system_prompt": "你是面向长者的陪伴助手……",
"persona_tone": "率真、自然",
"authorized_context": "经授权的背景和记忆……",
"prompt_bundle_version": "persona-v12"
},
"privacy": {
"retention_mode": "transcript_only",
"record_near": false,
"record_far": false,
"upload_allowed": true
},
"session": {
"engine_mode": "hardware_agent",
"heartbeat_interval_sec": 30,
"allocation_expires_at": "2026-09-05T09:25:00Z"
}
}
}
```
### 3.8 连接火山并注入人设
地址:
```text
wss://ai-gateway.vei.volces.com/v1/realtime?bot={bot_id}&wait_for_session_update=true
```
输入:
| 字段 | 位置 | 中文含义 | 来源/计算 |
|---|---|---|---|
| `bot` | Query | Bot ID | 创建会话返回的 `runtime.bot_id` |
| `X-Auth-Type` | Header | 鉴权类型 | 固定为 `1` |
| `X-Product-Key` | Header | 火山产品标识 | provision 返回的 `product_key` |
| `X-Device-Name` | Header | 火山设备名称 | provision 返回的 `device_name` |
| `X-Random-Num` | Header | 随机数 | 每次连接重新生成 |
| `X-Timestamp` | Header | Unix 秒 | 当前时间 |
| `X-Instance-Id` | Header | 火山实例编号 | provision 返回的 `instance_id` |
| `X-Hardware-Id` | Header | 物理硬件标识 | provision 使用的 `hardware_id` |
| `X-Signature` | Header | 火山鉴权签名 | `base64(HMAC-SHA256(device_secret, canonical))` |
| `session.update` | WebSocket JSON | 动态会话配置 | 注入平台返回的人设和记忆 |
输出:
| 事件 | 中文含义 |
|---|---|
| `session.created` / `session.updated` | 火山会话创建/动态人设生效 |
| `conversation.item.input_audio_transcription.completed` | 用户最终识别文本 |
| `response.audio_transcript.done` | 智能体最终回复文本 |
| 音频数据 | 智能体返回的 PCM16 音频,按 16 kHz 单声道播放 |
| error 事件 | 火山连接或会话失败 |
示例:
```text
canonical = "auth_type=1&device_name={device_name}&random_num={random_num}&product_key={product_key}&timestamp={timestamp}&instance_id={instance_id}"
X-Signature = base64(HMAC-SHA256(device_secret, canonical))
```
```json
{
"event_id": "evt-session-update-001",
"type": "session.update",
"session": {
"object": "realtime.session",
"model": "",
"input_audio_format": "pcm16",
"output_audio_format": "pcm16",
"config": {
"ASRConfig": {"TurnDetectionMode": 0},
"LLMConfig": {
"SystemMessages": [
"<system_prompt>\n表达风格:<persona_tone>\n授权上下文:<authorized_context>\n会话标识:<dialog_id>"
]
}
},
"agent_config": {"EnableConversationStateCallback": false}
}
}
```
### 3.9 上报会话事件
地址:
```http
POST /device/v3/agent-sessions/{conversation_id}/events
```
输入:
| 字段 | 类型 | 必填 | 中文含义 |
|---|---|---:|---|
| Path `conversation_id` | UUID | 是 | 平台会话编号 |
| `event_id` | string | 是 | 事件唯一编号 |
| `sequence_no` | integer | 是 | 事件顺序号,从 1 开始递增 |
| `event_type` | enum | 是 | `session_started`、`session_updated`、`session_ended` 或 `session_failed` |
| `occurred_at` | datetime | 是 | 事件发生时间 |
| `provider_session_ref` | string | 是 | 火山会话引用 |
| `session_nonce` | string | 是 | 会话证明随机值 |
| `provider_connection_ref` | string | 启动时必填 | WebSocket 连接标识 |
| `bot_id` | string | 启动时必填 | 实际连接的 Bot ID |
| `transport` | string | 启动时必填 | 实际传输方式 |
| `config_digest` | string | 启动时必填 | 已应用的人设配置摘要 |
| `dynamic_config_applied` | boolean | 启动时必填 | 必须为 `true` |
| `error_code` | string | 失败时填写 | 失败错误码 |
输出:
| 字段 | 类型 | 中文含义 |
|---|---|---|
| `conversation_id` | UUID | 平台会话编号 |
| `status` | string | 会话状态,开始后为 `ACTIVE`,正常结束后为 `COMPLETED` |
| `action` | enum | `continue` 或 `stop_session` |
| `duplicate` | boolean | 是否为重复事件 |
| `accepted_at` | datetime | 平台接收时间 |
示例:
开始请求:
```json
{
"event_id": "device-event-start-001",
"sequence_no": 1,
"event_type": "session_started",
"occurred_at": "2026-09-05T09:20:03Z",
"provider_session_ref": "provider-session-ref",
"session_nonce": "<session-nonce>",
"provider_connection_ref": "ws-connection-001",
"bot_id": "botM4gWQ9A9y",
"transport": "low_load",
"config_digest": "<config-digest>",
"dynamic_config_applied": true
}
```
结束示例:
```json
{
"event_id": "device-event-end-001",
"sequence_no": 2,
"event_type": "session_ended",
"occurred_at": "2026-09-05T09:25:00Z",
"provider_session_ref": "provider-session-ref",
"session_nonce": "<session-nonce>"
}
```
响应示例:
```json
{
"request_id": "req-event-001",
"data": {
"conversation_id": "10000000-0000-0000-0000-000000000801",
"status": "ACTIVE",
"action": "continue",
"duplicate": false,
"accepted_at": "2026-09-05T09:20:03Z"
}
}
```
### 3.10 上传聊天记录
地址:
```http
POST /device/v3/agent-sessions/{conversation_id}/turns:batch
```
输入:
| 字段 | 类型 | 必填 | 中文含义 |
|---|---|---:|---|
| Path `conversation_id` | UUID | 是 | 平台会话编号 |
| Header `Idempotency-Key` | string | 是 | 本批记录幂等键,16–128 字符 |
| `turns` | array | 是 | 最终聊天记录数组,每批 1–500 条 |
| `turns[].turn_key` | string | 是 | 会话内唯一的轮次标识,最长 255 字符 |
| `turns[].sequence_no` | integer | 是 | 对话顺序号,会话内唯一且大于 0 |
| `turns[].speaker` | enum | 是 | 用户为 `subject`,智能体为 `agent` |
| `turns[].start_at` | datetime | 是 | 本轮开始时间 |
| `turns[].end_at` | datetime | 是 | 本轮结束时间,不早于开始时间 |
| `turns[].final_text` | string | 是 | 最终文本,最长 20000 字符 |
| `turns[].interrupted` | boolean | 是 | 本轮是否被打断 |
输出:
| 字段 | 类型 | 中文含义 |
|---|---|---|
| `conversation_id` | UUID | 平台会话编号 |
| `accepted_count` | integer | 本次新写入的记录数 |
| `duplicate_count` | integer | 本次幂等重复的记录数 |
示例:
```http
POST /device/v3/agent-sessions/10000000-0000-0000-0000-000000000801/turns:batch
Idempotency-Key: turns-conversation-00000801-0001
Content-Type: application/json
<设备HMAC请求头>
{
"turns": [
{
"turn_key": "user-final-001",
"sequence_no": 1,
"speaker": "subject",
"start_at": "2026-09-05T09:20:10Z",
"end_at": "2026-09-05T09:20:12Z",
"final_text": "今天天气怎么样?",
"interrupted": false
},
{
"turn_key": "agent-final-001",
"sequence_no": 2,
"speaker": "agent",
"start_at": "2026-09-05T09:20:12Z",
"end_at": "2026-09-05T09:20:16Z",
"final_text": "我来帮你看看。",
"interrupted": false
}
]
}
```
```json
{
"request_id": "req-turns-001",
"data": {
"conversation_id": "10000000-0000-0000-0000-000000000801",
"accepted_count": 2,
"duplicate_count": 0
}
}
```
File diff suppressed because one or more lines are too long
@@ -0,0 +1,49 @@
{
"schema_version": 1,
"diagram_type": "sequence",
"meta": {
"title": "设备从注册到聊天结束的完整流程",
"locale": "zh-CN",
"output": "docs/diagrams/device-conversation-lifecycle.html",
"viewBox": [1080, 590],
"quality_profile": "showcase",
"column_fit": "spread"
},
"participants": [
{ "id": "ops", "type": "frontend", "label": "运维平台", "sublabel": "登记 / 放行" },
{ "id": "app", "type": "frontend", "label": "用户 App", "sublabel": "扫码绑定" },
{ "id": "device", "type": "external", "label": "EchoEar", "sublabel": "ESP32-S3" },
{ "id": "saas", "type": "backend", "label": "自有 SaaS", "sublabel": "设备控制面" },
{ "id": "volcIot", "type": "cloud", "label": "火山 IoT", "sublabel": "DynamicRegister" },
{ "id": "volcAgent", "type": "cloud", "label": "火山智能体", "sublabel": "实时对话" }
],
"segments": [
{ "from": 150, "to": 226, "label": "一、平台注册与绑定" },
{ "from": 232, "to": 310, "label": "二、首次预配(仅一次)" },
{ "from": 316, "to": 424, "label": "三、每次开始聊天" },
{ "from": 430, "to": 520, "label": "四、聊天与结束" }
],
"messages": [
{ "id": "register", "from": "ops", "to": "saas", "y": 166, "label": "登记设备,生成平台 Device Secret", "variant": "emphasis" },
{ "id": "inject", "from": "saas", "to": "device", "y": 194, "label": "安全注入平台身份(无 Product Secret)", "variant": "security" },
{ "id": "claim", "from": "app", "to": "saas", "y": 222, "label": "消费绑定码:CLAIMABLE → BOUND", "variant": "emphasis" },
{ "id": "provision", "from": "device", "to": "saas", "y": 250, "label": "请求 hardware-agent-credentials:provision", "variant": "emphasis" },
{ "id": "dynamicRegister", "from": "saas", "to": "volcIot", "y": 278, "label": "DynamicRegister:新设备消耗 1 个 License", "variant": "security" },
{ "id": "active", "from": "saas", "to": "device", "y": 306, "label": "加密保存并下发一机一密 → ACTIVE", "variant": "return" },
{ "id": "start", "from": "device", "to": "saas", "y": 334, "label": "POST /device/v3/agent-sessions", "variant": "emphasis" },
{ "id": "config", "from": "saas", "to": "device", "y": 362, "label": "返回 Bot、人设、授权上下文与 nonce", "variant": "return" },
{ "id": "connect", "from": "device", "to": "volcAgent", "y": 390, "label": "一机一密建连并注入 session.update", "variant": "security" },
{ "id": "started", "from": "device", "to": "saas", "y": 418, "label": "配置确认后上报 session_started", "variant": "emphasis" },
{ "id": "audio", "from": "device", "to": "volcAgent", "y": 446, "label": "双向音频 / 打断 / 工具调用", "variant": "default" },
{ "id": "sync", "from": "device", "to": "saas", "y": 474, "label": "session_updated + 最终字幕回流", "variant": "dashed" },
{ "id": "ended", "from": "device", "to": "saas", "y": 506, "label": "断开火山并上报 ended / failed → 摘要记忆", "variant": "emphasis" }
],
"activations": [
{ "participant": "saas", "from": 160, "to": 228, "type": "backend" },
{ "participant": "saas", "from": 240, "to": 312, "type": "backend" },
{ "participant": "volcIot", "from": 268, "to": 286, "type": "cloud" },
{ "participant": "saas", "from": 324, "to": 424, "type": "backend" },
{ "participant": "volcAgent", "from": 380, "to": 512, "type": "cloud" },
{ "participant": "saas", "from": 468, "to": 514, "type": "backend" }
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

@@ -0,0 +1,32 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Archify visual-check · device-conversation-lifecycle.html</title>
<style>
*{box-sizing:border-box}body{margin:0;padding:24px;background:#e9eef5;color:#172033;font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}header{max-width:1500px;margin:0 auto 18px}h1{margin:0 0 6px;font-size:20px}p{margin:0;color:#526176}.grid{max-width:1500px;margin:auto;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px}figure{margin:0;padding:10px;background:white;border:1px solid #c9d4e3;border-radius:12px;box-shadow:0 10px 30px rgba(15,23,42,.08)}img{display:block;width:100%;height:auto;border:1px solid #e2e8f0}figcaption{padding:9px 4px 2px;color:#526176}@media(max-width:900px){.grid{grid-template-columns:1fr}}
</style>
</head>
<body>
<header><h1>Archify visual-check</h1><p>device-conversation-lifecycle.html · automated containment pass · visual review pending</p></header>
<main class="grid">
<figure>
<img src="device-conversation-lifecycle.visual-check.1440x900.light.png" alt="light 1440 by 900">
<figcaption><strong>LIGHT</strong> · 1440×900 · containment pass</figcaption>
</figure>
<figure>
<img src="device-conversation-lifecycle.visual-check.1440x900.dark.png" alt="dark 1440 by 900">
<figcaption><strong>DARK</strong> · 1440×900 · containment pass</figcaption>
</figure>
<figure>
<img src="device-conversation-lifecycle.visual-check.2048x1320.light.png" alt="light 2048 by 1320">
<figcaption><strong>LIGHT</strong> · 2048×1320 · containment pass</figcaption>
</figure>
<figure>
<img src="device-conversation-lifecycle.visual-check.2048x1320.dark.png" alt="dark 2048 by 1320">
<figcaption><strong>DARK</strong> · 2048×1320 · containment pass</figcaption>
</figure>
</main>
</body>
</html>
@@ -0,0 +1,547 @@
{
"schemaVersion": 1,
"ok": true,
"command": "visual-check",
"status": "pass",
"visualReview": "pending",
"artifact": {
"path": "E:\\Codex\\story\\platform\\docs\\diagrams\\device-conversation-lifecycle.html",
"sha256": "7fade308f989a6d95791c6d018dfcf4c5f50aafb8f6ce6d94c907943362eeea0",
"bytes": 715115
},
"state": {
"detail": "read",
"motion": "still"
},
"chrome": {
"status": "available",
"executable": "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"
},
"diagnostics": [],
"containment": {
"status": "pass",
"viewports": [
{
"width": 1440,
"height": 900,
"theme": "light",
"innerWidth": 1440,
"innerHeight": 900,
"scrollWidth": 1440,
"scrollHeight": 900,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1376,
"diagramWidth": 1346,
"viewBoxWidth": 1080,
"minimumProjectedNodeTextPx": 7,
"minimumProjectedNodeText": "登记 / 放行",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 51,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
},
{
"width": 1600,
"height": 1000,
"theme": "light",
"innerWidth": 1600,
"innerHeight": 1000,
"scrollWidth": 1600,
"scrollHeight": 1000,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1456,
"diagramWidth": 1426,
"viewBoxWidth": 1080,
"minimumProjectedNodeTextPx": 7,
"minimumProjectedNodeText": "登记 / 放行",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 51,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
},
{
"width": 1920,
"height": 1080,
"theme": "light",
"innerWidth": 1920,
"innerHeight": 1080,
"scrollWidth": 1920,
"scrollHeight": 1080,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1602,
"diagramWidth": 1572,
"viewBoxWidth": 1080,
"minimumProjectedNodeTextPx": 7,
"minimumProjectedNodeText": "登记 / 放行",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 51,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
},
{
"width": 2048,
"height": 1320,
"theme": "light",
"innerWidth": 2048,
"innerHeight": 1320,
"scrollWidth": 2048,
"scrollHeight": 1320,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1920,
"diagramWidth": 1870,
"viewBoxWidth": 1080,
"minimumProjectedNodeTextPx": 7,
"minimumProjectedNodeText": "登记 / 放行",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 41,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
}
]
},
"readability": {
"status": "pass",
"minimumProjectedNodeTextPx": 6,
"viewports": [
{
"width": 1440,
"height": 900,
"theme": "light",
"innerWidth": 1440,
"innerHeight": 900,
"scrollWidth": 1440,
"scrollHeight": 900,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1376,
"diagramWidth": 1346,
"viewBoxWidth": 1080,
"minimumProjectedNodeTextPx": 7,
"minimumProjectedNodeText": "登记 / 放行",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 51,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
},
{
"width": 1600,
"height": 1000,
"theme": "light",
"innerWidth": 1600,
"innerHeight": 1000,
"scrollWidth": 1600,
"scrollHeight": 1000,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1456,
"diagramWidth": 1426,
"viewBoxWidth": 1080,
"minimumProjectedNodeTextPx": 7,
"minimumProjectedNodeText": "登记 / 放行",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 51,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
},
{
"width": 1920,
"height": 1080,
"theme": "light",
"innerWidth": 1920,
"innerHeight": 1080,
"scrollWidth": 1920,
"scrollHeight": 1080,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1602,
"diagramWidth": 1572,
"viewBoxWidth": 1080,
"minimumProjectedNodeTextPx": 7,
"minimumProjectedNodeText": "登记 / 放行",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 51,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
},
{
"width": 2048,
"height": 1320,
"theme": "light",
"innerWidth": 2048,
"innerHeight": 1320,
"scrollWidth": 2048,
"scrollHeight": 1320,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1920,
"diagramWidth": 1870,
"viewBoxWidth": 1080,
"minimumProjectedNodeTextPx": 7,
"minimumProjectedNodeText": "登记 / 放行",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 41,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
}
]
},
"viewerChrome": {
"status": "pass",
"viewports": [
{
"width": 1440,
"height": 900,
"theme": "light",
"innerWidth": 1440,
"innerHeight": 900,
"scrollWidth": 1440,
"scrollHeight": 900,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1376,
"diagramWidth": 1346,
"viewBoxWidth": 1080,
"minimumProjectedNodeTextPx": 7,
"minimumProjectedNodeText": "登记 / 放行",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 51,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
},
{
"width": 1600,
"height": 1000,
"theme": "light",
"innerWidth": 1600,
"innerHeight": 1000,
"scrollWidth": 1600,
"scrollHeight": 1000,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1456,
"diagramWidth": 1426,
"viewBoxWidth": 1080,
"minimumProjectedNodeTextPx": 7,
"minimumProjectedNodeText": "登记 / 放行",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 51,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
},
{
"width": 1920,
"height": 1080,
"theme": "light",
"innerWidth": 1920,
"innerHeight": 1080,
"scrollWidth": 1920,
"scrollHeight": 1080,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1602,
"diagramWidth": 1572,
"viewBoxWidth": 1080,
"minimumProjectedNodeTextPx": 7,
"minimumProjectedNodeText": "登记 / 放行",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 51,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
},
{
"width": 2048,
"height": 1320,
"theme": "light",
"innerWidth": 2048,
"innerHeight": 1320,
"scrollWidth": 2048,
"scrollHeight": 1320,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1920,
"diagramWidth": 1870,
"viewBoxWidth": 1080,
"minimumProjectedNodeTextPx": 7,
"minimumProjectedNodeText": "登记 / 放行",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 41,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
}
]
},
"captures": {
"status": "pass",
"screenshots": [
{
"width": 1440,
"height": 900,
"theme": "light",
"innerWidth": 1440,
"innerHeight": 900,
"scrollWidth": 1440,
"scrollHeight": 900,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1376,
"diagramWidth": 1346,
"viewBoxWidth": 1080,
"minimumProjectedNodeTextPx": 7,
"minimumProjectedNodeText": "登记 / 放行",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 51,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light",
"file": "device-conversation-lifecycle.visual-check.1440x900.light.png"
},
{
"width": 1440,
"height": 900,
"theme": "dark",
"innerWidth": 1440,
"innerHeight": 900,
"scrollWidth": 1440,
"scrollHeight": 900,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1376,
"diagramWidth": 1346,
"viewBoxWidth": 1080,
"minimumProjectedNodeTextPx": 7,
"minimumProjectedNodeText": "登记 / 放行",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 51,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "dark",
"file": "device-conversation-lifecycle.visual-check.1440x900.dark.png"
},
{
"width": 2048,
"height": 1320,
"theme": "light",
"innerWidth": 2048,
"innerHeight": 1320,
"scrollWidth": 2048,
"scrollHeight": 1320,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1920,
"diagramWidth": 1870,
"viewBoxWidth": 1080,
"minimumProjectedNodeTextPx": 7,
"minimumProjectedNodeText": "登记 / 放行",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 41,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light",
"file": "device-conversation-lifecycle.visual-check.2048x1320.light.png"
},
{
"width": 2048,
"height": 1320,
"theme": "dark",
"innerWidth": 2048,
"innerHeight": 1320,
"scrollWidth": 2048,
"scrollHeight": 1320,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1920,
"diagramWidth": 1870,
"viewBoxWidth": 1080,
"minimumProjectedNodeTextPx": 7,
"minimumProjectedNodeText": "登记 / 放行",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 41,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "dark",
"file": "device-conversation-lifecycle.visual-check.2048x1320.dark.png"
}
],
"contactSheet": "device-conversation-lifecycle.visual-check.html"
},
"sidecars": {
"receipt": "device-conversation-lifecycle.visual-check.json",
"contactSheet": "device-conversation-lifecycle.visual-check.html"
}
}
@@ -0,0 +1,29 @@
# EchoEar reboot history investigation — 2026-09-06
User reports loss of prior conversation context after power cycling and asks how the platform receives the conversation record.
## Existing path verified in source
- Device receives final subject transcription and agent audio transcript events, queues them outside the WebSocket receive callback, and POSTs to `/device/v3/agent-sessions/{conversation_id}/turns:batch`.
- Backend authenticates the bound device, checks transcript retention and consent, deduplicates final turns, and writes `conversation_turn`. The web app exposes these through Chat records → Conversation detail; API: `GET /api/v1/conversations/{id}/turns`.
- This path uploads text, not near/far audio recordings. Earlier device logs contain successful HTTP POSTs during exchanges but did not distinguish accepted transcript counts. New firmware logs accepted/duplicate counts without logging transcript content.
- Each new connection creates a fresh provider session. Authorized platform context is supplied through `session_config.authorized_context`, which firmware includes in `LLMConfig.SystemMessages`. Record storage and cross-session context are separate steps.
## Defects and local changes
1. Device omitted periodic session renewal. The server starts a lease on `session_started`, and `session_updated` renews it. Firmware now reads the server heartbeat interval, renews outside the audio callback using the existing reusable HTTP connection, and honors `stop_session`.
2. Recent summary selection excluded all `FAILED` conversations. It now accepts ready summaries from completed or failed conversations while preserving tenant/subject checks and existing consent gates. No raw transcript bypass or automatic long-term memory creation was added.
3. Hardware normal completion requested extraction generation 1; the extraction processor ignores a generation below the highest stored turn sequence. Hardware completion, reported failure, and server stop-timeout recovery now schedule the latest stored sequence with a deduplicated terminal extraction key. Existing extraction consent and content safety checks remain.
## Verification and pending work
**Device update 2026-09-06 10:20:** COM6 reconnected. Final heartbeat firmware `0x2ccf40` flashed with hash verification and booted to connected standby. The real user test session `157f866d-c364-4ae8-b169-50ca07024e5a` completed normally with 3 turns (including the preceding diagnostic sentence) and 2 READY summaries. Periodic heartbeats succeeded. Native Volcano callbacks were received for input and output, but contain only device identity and do not honor the attempted per-session callback override in this low_load test. callbacks memory3 validates these as device_probe without writing transcripts; device upload remains canonical. User has been asked to power-cycle and ask which joke was just told; actual recall outcome is pending. See the newest section of `volc-native-subtitle-callback.md`.
**Update 2026-09-06:** Supersedes the deployment/test limitations below. The user-supplied Desktop PEM successfully authenticated root. Backend `0.78.3-rc.subtitle-memory1` is now deployed; independent PostgreSQL regression tests actually passed (not skipped), including failed-session memory eligibility and terminal extraction generation. Production has stored transcripts and READY summaries (one failed session has 50 turns). All three backend readiness checks pass. Native callback protocol smoke checks also pass; real provider callbacks and device reboot recall remain unverified because COM6 is absent. The latest firmware build is `0x2ccf40`, still **not flashed**. See `volc-native-subtitle-callback.md` for deployment commands and outstanding checks. Earlier entries below are retained as investigation history.
- Firmware build passed: `firmware/echoear-platform-agent/build/voice-history-heartbeat-build.log`; binary size `0x2ccd50`.
- `go test ./internal/orchestration ./internal/httpapi ./internal/extraction` passed the available tests. New database regression cases cover failed/completed/active summary eligibility, other-subject exclusion, and extraction generation/deduplication. They were **skipped**, not validated against a database: `TEST_DATABASE_URL` is unset and Docker daemon is unavailable.
- **Firmware is not flashed.** COM6 disappeared after the user's power cycle. COM3 is a different USB device; COM4/5 are Bluetooth ports. Do not flash them.
- **Backend changes are not deployed.** Existing deployment key was rejected for `root@121.41.46.238`. Requested the actual SSH username; never request passwords/private key content.
- Awaiting USB reconnection and server access. Next verify actual transcript row counts, summary READY status, subject consent state, extraction job status, new context inclusion, and a real power-cycle recall test. Do not claim that online records or memory restoration have been verified.
- Last verified flashed app remains HTTP-reuse version `0x2cc8a0`; user confirmed continuous speech. Preserve its playback/display improvements and NVS during the next app-only flash.
+23
View File
@@ -138,3 +138,26 @@ powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\smoke-firmware-rol
A/B启动确认批次在OpenAPI 0.62.0下扩展可选回执阶段,不增加旧设备必填字段或主状态枚举。启用设备先将制品写入非活动分区并持久化启动尝试,再依次上报`staging`、`reboot_pending`和`boot_verifying`;健康通过才提交成功,健康失败或超时则恢复旧分区并提交`FAILED/rolled_back`及回退版本。云端将三类中间阶段保持为`INSTALLING`,同时保留完整`last_result`供页面展示。低强度验证只执行定向Go用例、前端类型/生产构建和一次B分区启动失败自动回A分区的纵向冒烟,不把模拟结果作为真实Bootloader验收。详细协议见[设备A/B分区固件升级与启动确认协议](device-firmware-ab-protocol.md)。
OpenAPI 0.63.0和V38进一步为每台固件分配保存去重的阶段事件。直接命令ACK与批量遥测补交在更新命令/分配的同一事务内写入事件,重复结果摘要通过SHA-256唯一键保持幂等;批次详情按设备返回完整时间线,页面显示云端接收时间、阶段、命令状态和稳定结果码。`cmd/firmware-conformance`可把该时间线与设备侧Bootloader/分区证据进行离线一致性校验,详细格式和15项检查见[固件真机联调一致性证据采集与校验说明](firmware-conformance-evidence.md)。
## 9. 维护端租户级固件管理
V46和OpenAPI 0.74.0在保留家庭端固件历史及设备下载协议的前提下,引入维护端租户级固件制品与OTA批次。维护端创建的`firmware_release.household_id`和`firmware_rollout.household_id`为空,`created_source=OPS`;家庭端已有记录继续使用原家庭范围,二者共享租户、型号、硬件版本和业务版本唯一性,避免同一租户发布相互冲突的活动制品。
维护中心“固件管理”页面支持选择客户组织、登记或吊销可信公钥、浏览器计算SHA-256后直传私有对象、恢复中断上传、完成签名校验、暂停、恢复、退役和人工下载。人工下载与设备下载都只签发短期私有地址;人工下载额外记录`ops.firmware.release.download`审计事件,设备下载继续要求设备身份、目标批次、版本状态和分配状态全部通过。
维护端接口使用`X-Tenant-Id`固定客户组织边界:
| 方法与路径 | 用途 |
|---|---|
| `GET/POST /api/v1/ops/firmware-signing-keys` | 查询或登记租户可信公钥 |
| `POST /api/v1/ops/firmware-signing-keys/revoke` | 在引用保护通过后吊销公钥 |
| `GET/POST /api/v1/ops/firmware-releases` | 查询租户固件台账或创建租户级上传记录 |
| `POST /api/v1/ops/firmware-releases/{id}/upload-session` | 恢复未完成的私有上传 |
| `POST /api/v1/ops/firmware-releases/{id}/finalize` | 校验对象和签名并发布版本 |
| `POST /api/v1/ops/firmware-releases/{id}/pause` | 暂停版本分发和关联在途批次 |
| `POST /api/v1/ops/firmware-releases/{id}/resume` | 恢复版本分发,不自动恢复旧批次 |
| `POST /api/v1/ops/firmware-releases/{id}/retire` | 永久退役版本 |
| `GET /api/v1/ops/firmware-releases/{id}/download` | 获取带摘要、签名和过期时间的短期人工下载地址 |
| `GET/POST /api/v1/ops/firmware-rollouts` | 查询OTA批次或按兼容设备和灰度比例创建升级批次 |
| `GET /api/v1/ops/firmware-rollouts/{id}` | 查询逐设备下载、安装和确认进度 |
| `POST /api/v1/ops/firmware-rollouts/{id}/pause` | 暂停批次并取消尚未投递的升级命令 |
@@ -84,6 +84,10 @@ docker run --rm -v "${PWD}:/src" -w /src golang:1.26.6-alpine `
npm --prefix web audit --audit-level=high
docker run --rm -v "${PWD}:/src" zricethezav/gitleaks:v8.30.1 `
detect --source=/src --config=/src/.gitleaks.toml --no-banner
docker run --rm -v "${PWD}:/src" `
-v data-terminal-trivy-cache:/root/.cache/trivy aquasec/trivy:0.74.0 `
config --ignorefile /src/.trivyignore.yaml --exit-code 1 `
--severity HIGH,CRITICAL /src
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock `
-v data-terminal-trivy-cache:/root/.cache/trivy aquasec/trivy:0.74.0 `
image --scanners vuln --ignore-unfixed --severity HIGH,CRITICAL `
+34
View File
@@ -0,0 +1,34 @@
# Viking managed memory integration
Configured collection: `memory`, resource `mem-3a1174a1`, project `default`, Beijing endpoint. The existing collection has emotional-companionship extraction rules `event_v1` and `profile_v1` (assistant isolation enabled). This is the managed Viking memory service, not OpenViking.
## Runtime
API and worker accept `VIKING_MEMORY_RESOURCE_ID`, `VIKING_MEMORY_API_KEY_FILE`, `VIKING_MEMORY_EVENT_TYPE` and `VIKING_MEMORY_PROFILE_TYPE`. An empty resource ID disables this integration. The API key is a server-only file; it is never returned to a device or written into a firmware image. The endpoint is HTTPS; redirects are disabled. Error logs contain status/code, never vendor response bodies or transcripts.
Terminal conversation extraction requests also enqueue an independent `conversation.viking_sync_requested` outbox event. The worker submits final user and assistant turns with AddSession. Frozen transcript/memory consent and current subject/memory/transcript eligibility are checked. No user-only or diagnostic assistant-only conversation is submitted. The database remains the source of full transcripts.
User identity is SHA-256 of the tenant and subject UUIDs, prefixed `u_`; it survives device restarts and changes of device binding. Assistant identity is the stable platform companion ID, currently shared across platform persona versions. Every conversation has a distinct `c_<conversation UUID without hyphens>_<batch>` session identifier. Batches contain up to 100 messages. A retry uses the same identifier; a late transcript revision replaces that conversation's batch. Audit digests avoid rewriting unchanged successful submissions. Initial indexing/extraction is asynchronous.
At hardware session creation the API retrieves the current user profile and recent events before opening the allocation transaction, with a three-second overall budget. It revalidates the subject binding and memory consent before incorporating up to 600 characters into the frozen context. Extracted memories are explicitly labeled as fallible background, not instructions or manually confirmed facts. The existing in-call memory-search endpoint also searches Viking, then falls back to local curated memories. Actual use of that tool by the hardware cloud runtime still depends on provider tool support; startup context does not depend on it. Lookup failure leaves normal conversation available.
There are no firmware/audio changes and no database migrations. Native subtitle callbacks remain in device-probe mode; the reliable device transcript upload supplies these sessions. Pending memory extraction is independent of streamed audio playback.
## Lifecycle and limits
Current consent withdrawal immediately gates future Viking reads/writes. Subject privacy erasure calls scoped event/profile deletion before completing the provider-index deletion subtask. Vendor extraction is asynchronous; late vendor extraction after an erasure request needs follow-up reconciliation before treating provider erasure as fully verified. Individual locally curated-memory deletion does not delete independently extracted Viking events/profiles. For the present integration, use the Viking console for editing or deleting those derived entries; a unified platform memory-management UI is not implemented. Do not describe the existing local memory list as a complete Viking list.
Automatic extraction begins on conversation completion/failure and final transcript delivery. A sudden power-off relies on the existing heartbeat recovery to close the conversation. Startup recalls recent events plus profile; recall of older topic-specific events during the same session depends on the in-call search tool. Full historical backfill is explicit; enabling the integration does not automatically export all past conversations.
## Verification
Deployment on 2026-09-06: API and worker `0.78.4-rc.viking1`; callbacks remain `0.78.3-rc.subtitle-memory3`. All three readiness endpoints returned 200. Current symlink points to `/opt/data-terminal-cloud/releases/0.78.4-rc.viking1`; rollback uses the previous `0.78.3-rc.subtitle-memory1` directory including its `compose.subtitle.yaml` override. The same three compose files remain required.
One real conversation (`157f866d-c364-4ae8-b169-50ca07024e5a`, three final messages) was explicitly queued for backfill. Viking produced two event memories, and the production service retrieved 76 characters of startup memory. Synthetic event/profile entries were deleted and subsequent queries returned zero. Device power-cycle/spoken recall is pending user verification; server retrieval alone is not proof of successful audible recall.
- Real API-key calls verified against the configured collection: AddSession, event search, profile search.
- Synthetic test used a separate user namespace; no test preference is attached to the real user.
- Tests cover vendor result scope filtering, error redaction, stable identity/session IDs, scoped deletion, SQL outbox deduplication, late transcript replacement, frozen consent, cross-tenant rejection, and subject-bound startup context.
- A dedicated PostgreSQL/Redis environment is used for integration tests, separate from production data.
Official API references: https://www.volcengine.com/docs/84313/1783353 , https://www.volcengine.com/docs/84313/2100967 , https://www.volcengine.com/docs/84313/2100966 , https://www.volcengine.com/docs/84313/1783358 , https://www.volcengine.com/docs/84313/1783359 .
@@ -0,0 +1,230 @@
{
"schema_version": 1,
"diagram_type": "architecture",
"meta": {
"title": "火山硬件对话智能体迁移后目标架构",
"locale": "zh-CN",
"quality_profile": "showcase",
"viewBox": [1360, 820]
},
"components": [
{
"id": "elder",
"type": "external",
"label": "长者用户",
"sublabel": "说话、打断、挂机",
"pos": [40, 230],
"size": [130, 72]
},
{
"id": "device",
"type": "frontend",
"label": "陪伴硬件",
"sublabel": "麦克风、扬声器、本地队列",
"pos": [230, 230],
"size": [180, 72]
},
{
"id": "hardwareSdk",
"type": "frontend",
"label": "火山硬件智能体 SDK",
"sublabel": "高质量 UDP/RTC 主链",
"pos": [490, 230],
"size": [200, 72]
},
{
"id": "volcAgent",
"type": "external",
"label": "火山硬件对话智能体",
"sublabel": "Bot、ASR/LLM/TTS、会话",
"pos": [770, 230],
"size": [220, 72]
},
{
"id": "deviceRegistry",
"type": "external",
"label": "火山产品与设备管理",
"sublabel": "Product、Device、License、Bot 绑定",
"pos": [770, 50],
"size": [220, 72]
},
{
"id": "platformApi",
"type": "backend",
"label": "平台会话控制面",
"sublabel": "授权、冻结快照、心跳、停止",
"pos": [490, 470],
"size": [200, 76]
},
{
"id": "toolGateway",
"type": "security",
"label": "工具与记忆网关",
"sublabel": "MCP/Function Calling、短期会话令牌",
"pos": [1070, 230],
"size": [220, 76]
},
{
"id": "dataStore",
"type": "database",
"label": "业务主数据",
"sublabel": "PostgreSQL、pgvector、Redis",
"pos": [770, 470],
"size": [220, 76]
},
{
"id": "asyncPipeline",
"type": "backend",
"label": "异步内容与运营流水线",
"sublabel": "字幕、摘要、故事、费用、审计",
"pos": [1070, 470],
"size": [220, 76]
},
{
"id": "objectStorage",
"type": "database",
"label": "私有对象存储",
"sublabel": "可选双轨录音与导出",
"pos": [230, 650],
"size": [180, 72]
},
{
"id": "webApps",
"type": "frontend",
"label": "家庭端与运营端",
"sublabel": "设备、通话、隐私、配置、诊断",
"pos": [490, 650],
"size": [200, 72]
}
],
"boundaries": [
{
"kind": "region",
"label": "设备侧",
"wraps": ["device", "hardwareSdk"],
"pad": 22
},
{
"kind": "region",
"label": "火山引擎托管域",
"wraps": ["volcAgent", "deviceRegistry"],
"pad": 22
},
{
"kind": "security-group",
"label": "自有平台可信域(业务事实唯一主库)",
"wraps": ["platformApi", "toolGateway", "dataStore", "asyncPipeline", "objectStorage", "webApps"],
"pad": 24
}
],
"connections": [
{
"id": "humanVoice",
"from": "elder",
"to": "device",
"label": "双向语音",
"variant": "emphasis"
},
{
"id": "audioFrames",
"from": "device",
"to": "hardwareSdk",
"label": "PCM/设备事件",
"variant": "emphasis"
},
{
"id": "agentLink",
"from": "hardwareSdk",
"to": "volcAgent",
"label": "SDK 直连会话",
"variant": "emphasis",
"labelAt": [730, 316]
},
{
"id": "provisioning",
"from": "deviceRegistry",
"to": "hardwareSdk",
"label": "设备凭证与 Bot 绑定",
"variant": "security"
},
{
"id": "prepareSession",
"from": "device",
"to": "platformApi",
"label": "HTTPS 会话控制、字幕与状态",
"variant": "security"
},
{
"id": "toolCalls",
"from": "volcAgent",
"to": "toolGateway",
"label": "MCP / FC",
"variant": "security"
},
{
"id": "toolData",
"from": "toolGateway",
"to": "dataStore",
"label": "租户与长者范围查询",
"variant": "security"
},
{
"id": "conversationFacts",
"from": "platformApi",
"to": "dataStore",
"label": "会话/授权快照",
"variant": "emphasis"
},
{
"id": "asyncJobs",
"from": "dataStore",
"to": "asyncPipeline",
"label": "Outbox 可靠任务",
"variant": "dashed",
"labelAt": [1030, 560]
},
{
"id": "mediaUpload",
"from": "device",
"to": "objectStorage",
"label": "可选加密录音直传",
"variant": "dashed",
"fromSide": "bottom",
"toSide": "top",
"labelAt": [320, 316]
},
{
"id": "webControl",
"from": "webApps",
"to": "platformApi",
"label": "HTTPS/OIDC",
"variant": "security"
}
],
"cards": [
{
"dot": "emerald",
"title": "保留不动",
"items": [
"平台继续掌握设备、授权、通话、记忆、费用和审计主数据",
"字幕后的摘要、故事、人工确认记忆和隐私删除链路继续复用"
]
},
{
"dot": "amber",
"title": "替换范围",
"items": [
"设备不再领取平台 RTC Token,也不再等待云端 StartVoiceChat",
"火山硬件 SDK 直接建立会话,旧 RTC 房间与 Agent Task 仅保留兼容期"
]
},
{
"dot": "rose",
"title": "上线硬门槛",
"items": [
"必须验证服务端强制停话、设备凭证吊销、字幕/用量回传和近远端录音能力",
"任何一项无法闭环时,先限定为试点能力,不替换现网主链"
]
}
]
}
+61
View File
@@ -0,0 +1,61 @@
# 火山字幕回调与重启记忆修复(2026-09-06)
## 设备实测更新(北京时间 10:20)
- **已烧录并启动**:COM6,ESP32-S3 MAC b4:3a:45:16:34:a8。最终固件 app `0x2ccf40`,日志 `build/native-subtitle-stable-{build,flash,device}.log`,烧录哈希校验成功。NVS/Wi-Fi/绑定均保留;设备已联网待机。
- 实际用户测试会话 `157f866d-c364-4ae8-b169-50ca07024e5a`:设备保存 3 条文字(其中 1 条为前置播放测试),双方实际对话保存成功,状态 COMPLETED,2 条 READY 摘要。设备约每 15 秒续期成功。之后创建的新会话已携带非空历史上下文;实际断电后口头回忆仍需用户反馈。
- **真实火山回调已收到,但不能据此宣称按会话入库完成。** 当前 low_load 回调只有 message/signature;解码后包含 productKey、deviceName、type、data,说话者 userId 为 input/output,没有 job_id、session_id 或 conversation_id。
- 平台确实下发了带 conversation_id 的 URL 和 43 字符会话签名。固件 session.update 使用官方 AgentConfig.ServerMessageURLForRTS/ServerMessageSignatureForRTS 字段,但实测仍回调控制台静态 URL 和静态签名。额外测试 ServerMessageUrl/ServerMessageSignature 别名也未改变结果,该实验已从最终固件移除。
- 不允许通过“设备最新会话”归属回调,否则旧会话延迟字幕可能写入重启后的新会话。**保持 VOLC_SUBTITLE_PRIMARY=false。设备 turns:batch 仍为唯一正式记录来源。**
- callbacks 服务已更新为 `0.78.3-rc.subtitle-memory3`(api/worker 仍为 memory1),静态回调校验签名、设备身份及格式后返回 200,仅记无正文的验证日志,不存第二份文字。支持 Base64 JSON 和 subv 二进制封装。错误签名返回 401。公网验证前后 conversation_turn 行数不变,服务 ready。
- 两个控制台配置值不变。其静态配置已能将字幕发送到平台,**尚未完成独立的、可靠的会话归档**。独立入库前必须解决会话身份传递,并按实测 input/output 更新和验证说话者映射;不能直接开启 primary。
- 下一步供应商核对事项:低负载 session.update 是否支持动态回调 URL/签名?支持时需要的准确字段/生效时机是什么?不支持时能否在字幕中返回与 session.created.job_id 相同的编号?必要时评估平台管理每设备独立智能体及启动前冻结配置,但尚未创建、修改或批量克隆任何火山智能体。
以下为上一轮部署时的设计和验证记录;与本节冲突的“未烧录/未实测”描述已由本节更新取代。
## 已上线
- 后端:`0.78.3-rc.subtitle-memory1`,服务器 `/opt/data-terminal-cloud/current` 指向同名 release。
- `POST /callbacks/v1/volcengine/subtitles` 支持没有 Content-Type 的火山原生请求:外层 message/signature,message 解码后为 `subv` + 四字节大端长度 + subtitle JSON。
- 当前 `VOLC_SUBTITLE_PRIMARY=false`:回调仅验证,不存第二份文字;设备原有 turns:batch 仍是正式记录来源。
- 有效摘要现在包含已失败的会话,仍限定租户、用户、授权及 READY 状态。硬件结束、失败、超时按实际最大 turn sequence 安排摘要提取。
- 无数据库迁移;未发布工作区其他改动。上一个 release 可用于回退。
## 火山控制台填写
- ServerMessageUrl:`https://121.41.46.238/callbacks/v1/volcengine/subtitles`
- ServerMessageSignature:使用本机被 Git 忽略的 `.deploy-artifacts/volc-subtitle-console-settings.txt` 中同名值。此文档不复制密钥。
这两项是基础配置,不能单靠基础 URL 推断归属会话。新固件上报 `native_subtitle_callback=true` 后,平台每次创建会话返回 `session_config.subtitle_callback_url`(带 conversation_id)和 `subtitle_callback_signature`(绑定该会话的 HMAC 值)。固件在 session.update 的 agent_config 中设置 `ServerMessageURLForRTS` 和 `ServerMessageSignatureForRTS`,同时设置 config.SubtitleConfig.SubtitleMode=1。火山按普通自定义 signature 原样回传它,平台进行比较。
已冻结的会话各自保留记录来源和说话者映射,不能用“设备当前最新会话”归档延迟回调。完成的分句全部保留,不能只保留 paragraph=true;稳定键按 userId、roundId、sequence 去重。没有供应商时间戳的回调只记录接收时间,不声称等于实际说话时间。网络乱序的分句按入库顺序分配平台序号。
## 尚未完成的设备验证
- COM6 尚未出现,COM3 的 VID:PID 为 1D6B:011E,是另一台设备;COM4/5 为蓝牙。**没有烧录新固件**。
- 最新编译:`firmware/echoear-platform-agent/build/native-subtitle-build.log`,app 大小 `0x2ccf40`;保留此前连续播放和眼睛动画修复,新增心跳续期及字幕配置。
- 不能把平台发出的合成 HTTP 检查当成真实火山回调。当前 low_load 模式是否发送原生服务端字幕还需用设备验证;服务端状态回调仍关闭。
- 先烧录 app 分区,保留 NVS/Wi-Fi/绑定。确认真实用户和助手字幕均能到达,并核对实际 userId 与冻结映射。未知或有歧义的说话者禁止入库。未验证前不得启用 primary。
- primary 只作用于新会话;固件收到 subtitle_source=provider 后停止设备文字上传,平台也拒绝该会话的设备补传,防止双份记录。切换后还需验证重连、断电回忆、分句完整性及播放连续性。
## 验证结果
- 单元测试:协议截断/错误长度、缺字段、错误签名、跨会话签名、无 Content-Type、说话者歧义等通过。
- 独立临时 PostgreSQL(不是生产数据库):完成/失败/活跃会话的摘要选择、隔离其他用户、末尾提取版本与去重、字幕 probe 不入库、primary 入库与重复处理、未知说话者、冲突、撤回授权测试通过。日志 `.deploy-artifacts/subtitle-db-test.log`。
- 公网 HTTPS(正常证书校验)协议检查:错误签名 401,正确签名 probe 200;原会话记录数测试前后均为 5,无测试聊天写入。
- API、callbacks、worker 的 `/health/ready` 均 ready,线上版本接口返回新版本。
## 运维命令
当前上线额外使用 `deploy/server/compose.subtitle.yaml` 固定三个后端服务镜像和回调环境变量。后续重启这版必须保留该文件:
```sh
cd /opt/data-terminal-cloud/current
docker compose -p data-terminal-cloud --env-file deploy/server/.env \
-f compose.yaml -f deploy/server/compose.override.yaml \
-f deploy/server/compose.subtitle.yaml up -d --no-deps api callbacks worker
```
首次启动曾因 Windows 编译文件无 Linux 执行位被拒,已自动回退并修正为打包前 chmod 0755,随后成功上线。部署源码在本机 `.deploy-artifacts/subtitle-release`,包含明确文件清单;保留服务器基线的其他源码。
官方格式依据:[实时字幕(对话记录)](https://docs.volcengine.com/docs/6348/1337284),查阅于 2026-09-06。
+5 -1
View File
@@ -11,9 +11,12 @@
| 事件类型 | 产生条件 | 资源类型 | 状态示例 |
|---|---|---|---|
| `device.presence.changed` | 首次有效心跳使设备上线,或后台发现心跳超时使设备离线 | `device` | `online`、`offline` |
| `device.binding.reset` | 设备主动恢复出厂并解除当前用户绑定 | `device` | `CLAIMABLE` |
| `conversation.started` | 通话进入`ACTIVE` | `conversation` | `ACTIVE` |
| `conversation.completed` | 通话进入`COMPLETED` | `conversation` | `COMPLETED` |
| `conversation.failed` | 通话进入`FAILED` | `conversation` | `FAILED` |
上述通话事件由`conversation.status`统一触发,与底层运行时无关;`legacy_voicechat`和`volc_hardware_agent`都会产生同一套家庭侧SSE语义。硬件智能体的周期`session_updated`只作为平台租约和诊断证据,不逐次生成SSE事件。
| `story.created` | 新故事候选建立 | `story` | 故事当前状态 |
| `story.revised` | 故事版本增加 | `story` | 故事当前状态 |
| `media.upload.completed` | 媒体上传批次完成 | `upload_batch` | `COMPLETED` |
@@ -105,7 +108,8 @@ sequenceDiagram
3. 设备此前不存在在线记录或已离线时,写入一次`online`事件;连续心跳只延长最后出现时间。
4. Redis继续承担低延迟在线查询,PostgreSQL状态表承担状态转换和事件可靠性。
5. Worker每个心跳周期扫描一次;最后出现时间超过两倍会话租约的记录切换为`offline`并只写入一次事件。
6. 新的有效心跳可再次把离线记录切回在线并产生新的上线事件。
6. 设备恢复出厂时,平台在同一事务内把旧用户侧在线状态改为`offline`、发送`device.binding.reset`并解除绑定。
7. 新的有效心跳可再次把离线记录切回在线并产生新的上线事件;恢复出厂后的设备要先完成重新绑定。
## 7. 浏览器重连与去重
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

@@ -0,0 +1,32 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Archify visual-check · 火山硬件对话智能体迁移架构.html</title>
<style>
*{box-sizing:border-box}body{margin:0;padding:24px;background:#e9eef5;color:#172033;font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}header{max-width:1500px;margin:0 auto 18px}h1{margin:0 0 6px;font-size:20px}p{margin:0;color:#526176}.grid{max-width:1500px;margin:auto;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px}figure{margin:0;padding:10px;background:white;border:1px solid #c9d4e3;border-radius:12px;box-shadow:0 10px 30px rgba(15,23,42,.08)}img{display:block;width:100%;height:auto;border:1px solid #e2e8f0}figcaption{padding:9px 4px 2px;color:#526176}@media(max-width:900px){.grid{grid-template-columns:1fr}}
</style>
</head>
<body>
<header><h1>Archify visual-check</h1><p>火山硬件对话智能体迁移架构.html · automated containment pass · visual review pending</p></header>
<main class="grid">
<figure>
<img src="火山硬件对话智能体迁移架构.visual-check.1440x900.light.png" alt="light 1440 by 900">
<figcaption><strong>LIGHT</strong> · 1440×900 · containment pass</figcaption>
</figure>
<figure>
<img src="火山硬件对话智能体迁移架构.visual-check.1440x900.dark.png" alt="dark 1440 by 900">
<figcaption><strong>DARK</strong> · 1440×900 · containment pass</figcaption>
</figure>
<figure>
<img src="火山硬件对话智能体迁移架构.visual-check.2048x1320.light.png" alt="light 2048 by 1320">
<figcaption><strong>LIGHT</strong> · 2048×1320 · containment pass</figcaption>
</figure>
<figure>
<img src="火山硬件对话智能体迁移架构.visual-check.2048x1320.dark.png" alt="dark 2048 by 1320">
<figcaption><strong>DARK</strong> · 2048×1320 · containment pass</figcaption>
</figure>
</main>
</body>
</html>
@@ -0,0 +1,547 @@
{
"schemaVersion": 1,
"ok": true,
"command": "visual-check",
"status": "pass",
"visualReview": "pending",
"artifact": {
"path": "E:\\Codex\\story\\platform\\docs\\火山硬件对话智能体迁移架构.html",
"sha256": "baefbdc9512b0614c236f59cc80d8a14b8fb9489a3a6281214d636ad9fb68d8c",
"bytes": 721387
},
"state": {
"detail": "read",
"motion": "still"
},
"chrome": {
"status": "available",
"executable": "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"
},
"diagnostics": [],
"containment": {
"status": "pass",
"viewports": [
{
"width": 1440,
"height": 900,
"theme": "light",
"innerWidth": 1440,
"innerHeight": 900,
"scrollWidth": 1440,
"scrollHeight": 900,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1042,
"diagramWidth": 1012,
"viewBoxWidth": 1360,
"minimumProjectedNodeTextPx": 6.697058823529412,
"minimumProjectedNodeText": "说话、打断、挂机",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 51,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
},
{
"width": 1600,
"height": 1000,
"theme": "light",
"innerWidth": 1600,
"innerHeight": 1000,
"scrollWidth": 1600,
"scrollHeight": 1000,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1082,
"diagramWidth": 1052,
"viewBoxWidth": 1360,
"minimumProjectedNodeTextPx": 6.961764705882353,
"minimumProjectedNodeText": "说话、打断、挂机",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 51,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
},
{
"width": 1920,
"height": 1080,
"theme": "light",
"innerWidth": 1920,
"innerHeight": 1080,
"scrollWidth": 1920,
"scrollHeight": 1080,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1216,
"diagramWidth": 1186,
"viewBoxWidth": 1360,
"minimumProjectedNodeTextPx": 7.848529411764706,
"minimumProjectedNodeText": "说话、打断、挂机",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 51,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
},
{
"width": 2048,
"height": 1320,
"theme": "light",
"innerWidth": 2048,
"innerHeight": 1320,
"scrollWidth": 2048,
"scrollHeight": 1320,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1619,
"diagramWidth": 1569,
"viewBoxWidth": 1360,
"minimumProjectedNodeTextPx": 9,
"minimumProjectedNodeText": "说话、打断、挂机",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 41,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
}
]
},
"readability": {
"status": "pass",
"minimumProjectedNodeTextPx": 6,
"viewports": [
{
"width": 1440,
"height": 900,
"theme": "light",
"innerWidth": 1440,
"innerHeight": 900,
"scrollWidth": 1440,
"scrollHeight": 900,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1042,
"diagramWidth": 1012,
"viewBoxWidth": 1360,
"minimumProjectedNodeTextPx": 6.697058823529412,
"minimumProjectedNodeText": "说话、打断、挂机",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 51,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
},
{
"width": 1600,
"height": 1000,
"theme": "light",
"innerWidth": 1600,
"innerHeight": 1000,
"scrollWidth": 1600,
"scrollHeight": 1000,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1082,
"diagramWidth": 1052,
"viewBoxWidth": 1360,
"minimumProjectedNodeTextPx": 6.961764705882353,
"minimumProjectedNodeText": "说话、打断、挂机",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 51,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
},
{
"width": 1920,
"height": 1080,
"theme": "light",
"innerWidth": 1920,
"innerHeight": 1080,
"scrollWidth": 1920,
"scrollHeight": 1080,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1216,
"diagramWidth": 1186,
"viewBoxWidth": 1360,
"minimumProjectedNodeTextPx": 7.848529411764706,
"minimumProjectedNodeText": "说话、打断、挂机",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 51,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
},
{
"width": 2048,
"height": 1320,
"theme": "light",
"innerWidth": 2048,
"innerHeight": 1320,
"scrollWidth": 2048,
"scrollHeight": 1320,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1619,
"diagramWidth": 1569,
"viewBoxWidth": 1360,
"minimumProjectedNodeTextPx": 9,
"minimumProjectedNodeText": "说话、打断、挂机",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 41,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
}
]
},
"viewerChrome": {
"status": "pass",
"viewports": [
{
"width": 1440,
"height": 900,
"theme": "light",
"innerWidth": 1440,
"innerHeight": 900,
"scrollWidth": 1440,
"scrollHeight": 900,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1042,
"diagramWidth": 1012,
"viewBoxWidth": 1360,
"minimumProjectedNodeTextPx": 6.697058823529412,
"minimumProjectedNodeText": "说话、打断、挂机",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 51,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
},
{
"width": 1600,
"height": 1000,
"theme": "light",
"innerWidth": 1600,
"innerHeight": 1000,
"scrollWidth": 1600,
"scrollHeight": 1000,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1082,
"diagramWidth": 1052,
"viewBoxWidth": 1360,
"minimumProjectedNodeTextPx": 6.961764705882353,
"minimumProjectedNodeText": "说话、打断、挂机",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 51,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
},
{
"width": 1920,
"height": 1080,
"theme": "light",
"innerWidth": 1920,
"innerHeight": 1080,
"scrollWidth": 1920,
"scrollHeight": 1080,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1216,
"diagramWidth": 1186,
"viewBoxWidth": 1360,
"minimumProjectedNodeTextPx": 7.848529411764706,
"minimumProjectedNodeText": "说话、打断、挂机",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 51,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
},
{
"width": 2048,
"height": 1320,
"theme": "light",
"innerWidth": 2048,
"innerHeight": 1320,
"scrollWidth": 2048,
"scrollHeight": 1320,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1619,
"diagramWidth": 1569,
"viewBoxWidth": 1360,
"minimumProjectedNodeTextPx": 9,
"minimumProjectedNodeText": "说话、打断、挂机",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 41,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light"
}
]
},
"captures": {
"status": "pass",
"screenshots": [
{
"width": 1440,
"height": 900,
"theme": "light",
"innerWidth": 1440,
"innerHeight": 900,
"scrollWidth": 1440,
"scrollHeight": 900,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1042,
"diagramWidth": 1012,
"viewBoxWidth": 1360,
"minimumProjectedNodeTextPx": 6.697058823529412,
"minimumProjectedNodeText": "说话、打断、挂机",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 51,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light",
"file": "火山硬件对话智能体迁移架构.visual-check.1440x900.light.png"
},
{
"width": 1440,
"height": 900,
"theme": "dark",
"innerWidth": 1440,
"innerHeight": 900,
"scrollWidth": 1440,
"scrollHeight": 900,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1042,
"diagramWidth": 1012,
"viewBoxWidth": 1360,
"minimumProjectedNodeTextPx": 6.697058823529412,
"minimumProjectedNodeText": "说话、打断、挂机",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 51,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "dark",
"file": "火山硬件对话智能体迁移架构.visual-check.1440x900.dark.png"
},
{
"width": 2048,
"height": 1320,
"theme": "light",
"innerWidth": 2048,
"innerHeight": 1320,
"scrollWidth": 2048,
"scrollHeight": 1320,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1619,
"diagramWidth": 1569,
"viewBoxWidth": 1360,
"minimumProjectedNodeTextPx": 9,
"minimumProjectedNodeText": "说话、打断、挂机",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 41,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "light",
"file": "火山硬件对话智能体迁移架构.visual-check.2048x1320.light.png"
},
{
"width": 2048,
"height": 1320,
"theme": "dark",
"innerWidth": 2048,
"innerHeight": 1320,
"scrollWidth": 2048,
"scrollHeight": 1320,
"overflowX": false,
"overflowY": false,
"ok": true,
"readerWidth": 1619,
"diagramWidth": 1569,
"viewBoxWidth": 1360,
"minimumProjectedNodeTextPx": 9,
"minimumProjectedNodeText": "说话、打断、挂机",
"minimumProjectedNodeTextDetail": "context",
"minimumRequiredNodeTextPx": 6,
"readabilityOk": true,
"hasLegend": true,
"hasNavigationDock": true,
"legendDockIntersectionArea": 0,
"dockStageIntersectionArea": 0,
"dockStageGap": 10.21875,
"requiredDockStageGap": 10,
"viewerChromeStageOk": true,
"viewerChromeReserve": 41,
"viewerChromeActive": true,
"viewerChromeOk": true,
"resolvedTheme": "dark",
"file": "火山硬件对话智能体迁移架构.visual-check.2048x1320.dark.png"
}
],
"contactSheet": "火山硬件对话智能体迁移架构.visual-check.html"
},
"sidecars": {
"receipt": "火山硬件对话智能体迁移架构.visual-check.json",
"contactSheet": "火山硬件对话智能体迁移架构.visual-check.html"
}
}
@@ -0,0 +1,496 @@
# 火山硬件对话智能体迁移设计
> 状态:方案评审稿
> 适用基线:`v0.70.0-rc.1`
> 编制日期:2026-08-31
## 1. 结论
本次迁移建议采用“保留业务控制面,替换会话执行面”的方式,不对现有平台做推倒重建。
- 保留:设备身份与绑定、家庭和长者档案、授权、通话主记录、配置灰度、字幕存储、摘要/故事/事实候选、人工确认记忆、隐私导出删除、录音管理、费用额度、告警、诊断、固件和审计。
- 替换:设备领取 RTC Token、设备加入平台分配的 RTC 房间、平台 Worker 调用 `StartVoiceChat/StopVoiceChat`、Agent RTC 用户和 RTC Task 对账。
- 新增:火山产品/设备/Bot/License 映射、硬件智能体设备凭证生命周期、硬件会话适配器、动态会话配置、MCP/Function Calling 工具网关、硬件会话事件和用量归集。
- 兼容:旧 RTC VoiceChat 链路和新硬件智能体链路至少并行一个灰度周期,按设备能力位和灰度配置选择,不按固件版本猜测。
推荐终端优先采用火山硬件智能体“高质量方案”(UDP/RTC)。现有终端本身已有 RTC、弱网恢复和网络切换验收基础,和该方案的目标更一致。火山官方同时提供低负载 WebSocket 方案;只有在量产板的 RAM、CPU、功耗或网络实测不满足高质量 SDK 时,才切换到低负载方案。官方对比说明高质量方案弱网和实时互动更好,低负载方案资源消耗更低。[火山官方 SDK 集成指引](https://www.volcengine.com/docs/6348/1913817?lang=zh)
## 2. 当前链路与目标链路
### 2.1 当前链路
```text
设备 -> 平台创建通话 -> 平台签发 RTC Token -> 设备加入 RTC 房间
-> 设备确认入房 -> Worker 调用 StartVoiceChat -> 豆包 Agent 入房
-> 火山/设备回传字幕和状态 -> 平台存储与内容处理
-> 设备或平台发起停止 -> Worker 调用 StopVoiceChat -> 通话终态
```
当前关键实现:
- `/device/v2/conversations` 创建通话并返回 RTC Grant;
- `/activate` 在设备入房后可靠投递 `conversation.agent_start_requested`;
- `voiceprovider.VolcengineClient` 把平台稳定任务契约映射为 `StartVoiceChat/StopVoiceChat`;
- RTC 房间、设备 RTC 用户、Agent RTC 用户和供应商 Task 是诊断主关联键;
- 最终字幕进入 `conversation_turn`,继续触发摘要、故事、事实和记忆流水线。
### 2.2 目标链路
```text
设备 -> 平台准备硬件会话并冻结授权/配置/背景
-> 设备使用火山硬件智能体 SDK 直连 Bot
-> SDK 收发音频并提供字幕、状态、Function Calling 事件
-> 设备把最终字幕和状态回传平台
-> Bot 通过 MCP/Function Calling 访问平台受控记忆/业务工具
-> 用户、平台或授权撤回触发停止 -> 设备关闭 SDK 会话 -> 平台收敛终态
```
火山硬件智能体的标准协议使用 `product_key + device_name + device_secret` 建立设备身份,连接时指定与产品关联的 `bot`。设备首次可通过 `DynamicRegister` 获取设备独享密钥;WebSocket 标准协议支持字幕事件、端侧 Function Calling、主动播报和文本提问。[火山标准协议文档](https://www.volcengine.com/docs/6348/1904602?lang=zh)
## 3. 架构原则
### 3.1 平台数据库仍是业务事实唯一主库
火山侧负责实时对话执行,不成为家庭、长者、授权、记忆和费用策略的主数据源。平台必须保存每次会话实际使用的:
- 产品、设备、Bot 和 License 的脱敏引用;
- 平台路由版本、角色版本、声音版本、模型版本和提示词版本;
- 发送给火山的会话配置摘要及 SHA-256;
- 授权和背景资料冻结快照;
- 火山会话 ID、传输方案、状态时间线、字幕来源和用量来源。
### 3.2 设备必须先通过平台业务门禁,再连接智能体
设备拥有火山凭证不等于拥有业务授权。每次对话前仍必须先调用平台准备接口,由平台检查:
- 设备已登记、未隔离、未吊销并已绑定长者;
- 固件和硬件智能体 SDK 版本满足最低要求;
- `voice_chat` 授权有效;
- 客户组织、设备并发、时长和费用额度未超限;
- 角色、模型、音色和 Bot 路由已发布;
- 背景资料和记忆快照生成成功或按既定规则无记忆降级。
平台拒绝准备时,设备不得调用火山 SDK 建立对话。
### 3.3 不把长期记忆主库直接迁到火山
现有记忆包含人工确认、授权撤回、版本、来源、租户/长者隔离和隐私删除证明。迁移后继续使用平台 `pgvector` 和记忆工具:
- 会话启动时,把已授权的短背景摘要作为动态会话参数注入;
- 通话中通过平台 MCP/Function Calling 工具查询记忆;
- 工具令牌绑定 `conversation_id + provider_session_id + device_id + subject_id`,短期有效;
- 模型只能提交查询内容,不能自行指定租户或长者;
- 火山原生长期记忆只可作为可删除、可对账的派生缓存,不能成为主库。
火山硬件智能体产品能力包含 MCP、联网问答、长期记忆和设备管理,但这不改变本项目的业务数据权威边界。[火山硬件智能体产品页](https://www.volcengine.com/product/ConversationalAI-Embedded)
## 4. 功能迁移矩阵
| 现有功能 | 迁移方式 | 新责任边界 | 结论 |
|---|---|---|---|
| 设备 HMAC 身份、绑定、家庭归属 | 原样保留;新增火山设备映射 | 平台仍负责业务身份,火山凭证只负责智能体建连 | 保留并扩展 |
| `/bootstrap`、配置、命令、遥测 | 原样保留,增加硬件智能体能力位和 SDK 信息 | 平台 | 保留 |
| RTC 房间分配与 Token | 新运行时不再使用 | 火山硬件 SDK 自行建连 | 新链路废弃 |
| `StartVoiceChat/StopVoiceChat` | 新运行时不再调用;旧链路继续兼容 | 设备 SDK / 火山硬件智能体 | 新链路废弃 |
| `s2s_pure/s2s_hybrid/cascade` | 不再作为设备会话模式;改为平台内部服务档位或 Bot/模型配置 | 平台路由 + 火山 Bot | 重定义 |
| 角色、提示词、模型、音色灰度 | 平台继续版本化;基础配置同步 Bot,会话差异通过动态参数注入 | 平台为权威,火山为执行副本 | 保留并改造 |
| 欢迎语、主动播报 | 使用 Bot/会话配置;本地系统提示可用 `input_tts` | 火山 SDK + 设备应用 | 迁移 |
| 背景资料冻结 | 准备会话时生成;等待 `session.updated` 后才开放麦克风 | 平台生成,设备注入 | 保留并改造 |
| 实时打断、VAD、ASR、TTS | 使用火山硬件智能体原生能力 | 火山 | 下沉 |
| 最终字幕 | 设备监听最终事件,写入本地加密队列,批量调用现有 `turns:batch` | 设备可靠上传,平台去重存储 | 保留并改造 |
| 字幕服务端回调 | 若火山提供则作为第二来源,按供应商事件 ID 去重 | 火山回调 + 平台 | 待商务/技术确认 |
| 摘要、开放话题、故事、事实候选 | 输入仍为平台最终话轮 | 平台 Worker | 原样保留 |
| 人工确认记忆、向量检索 | 继续使用现有数据库和流程 | 平台 | 原样保留 |
| 通话中记忆工具 | 从 VoiceChat 模板 URL 改为 MCP/Function Calling 工具网关 | 平台工具网关 | 改造 |
| 设备音量、诊断等指令 | 云端命令链保留;安全的本地动作可同时暴露为端侧 FC | 平台命令 + 设备白名单执行器 | 扩展 |
| 授权撤回停止 | 心跳返回 stop,设备关闭 SDK;必须补充服务端强制终止或凭证吊销 | 平台发令,火山/设备执行 | 关键改造 |
| 双轨录音 | 需验证 SDK 是否提供近端采集和远端解码 PCM 回调 | 设备录制 + 对象存储 | 有条件迁移 |
| 用量与费用 | 增加 License、AI 对话时长和模型用量来源;区分估算与账单 | 平台费用域 | 改造 |
| 诊断工作台 | 房间/Task 改为产品、设备、Bot、License、会话、传输和事件 | 平台 | 改造 |
| 配置灰度与回滚 | 增加 `hardware_agent` 运行时;按设备稳定分桶 | 平台 | 扩展 |
| 固件发布 | SDK、License 兼容、产品信息和安全存储纳入固件门禁 | 平台 + 设备 | 扩展 |
| 隐私导出/删除 | 平台数据链路保留;新增火山会话历史/设备数据删除与凭证吊销对账 | 平台主导 | 扩展 |
| 发布候选验收 | 替换 RTC 三模式矩阵,新增硬件 SDK 真机矩阵 | 平台验收工具 | 改造 |
## 5. 终端改造
### 5.1 新增硬件智能体运行时模块
建议固件同时保留两个实现:
```text
VoiceRuntime
├── LegacyRTCRuntime 现有 RTC + 平台 StartVoiceChat
└── VolcHardwareAgentRuntime 新硬件智能体 SDK
```
平台配置返回显式 `voice_runtime`:
```json
{
"voice_runtime": "volc_hardware_agent",
"runtime_revision": 12,
"transport": "high_quality",
"minimum_sdk_version": "<现场冻结版本>"
}
```
设备只在完整校验配置后切换运行时;切换失败回退上一有效配置。不要根据 `engine_mode` 或固件版本自动推断。
### 5.2 凭证与激活
设备侧需要保存:
- 火山 `instance_id`、`product_key`、`device_name`;
- 动态注册换取的设备独享 `device_secret`;
- 当前凭证版本、注册时间和最后成功鉴权时间。
安全要求:
- `product_secret`只允许保存在平台KMS或Secret挂载中,不能进入设备固件、应用、安装包或设备日志;
- 设备使用平台设备身份调用`/device/v3/hardware-agent-credentials:provision`,由平台完成DynamicRegister并下发本设备的`device_secret`;
- `device_secret` 使用安全芯片、TEE 或设备密钥封装后持久化;
- 日志、遥测和崩溃转储不得输出任何 Secret 或完整签名;
- 必须验证设备 Secret 的轮换、吊销和售后换板流程。
### 5.3 会话状态机
建议复用现有通话状态:
```text
PREPARING -> SESSION_READY -> STARTING_AGENT -> ACTIVE
-> FAILED
ACTIVE -> STOPPING -> COMPLETED
-> FAILED
```
数据库可继续把 `SESSION_READY` 对外兼容映射为 `ROOM_ISSUED`,但新 `/device/v3` 契约应使用真实语义,不再暴露不存在的 RTC Room。
终端事件映射:
| 火山/SDK 事件 | 平台动作 |
|---|---|
| SDK 初始化成功 | 更新遥测,不代表会话成功 |
| `session.created` | 保存火山会话标识,等待会话配置 |
| `session.updated` | 调用平台 activate,允许采集和播放 |
| 用户最终 ASR | 写入本地可靠字幕队列 |
| Agent 最终字幕 | 写入本地可靠字幕队列 |
| Function Call | 白名单校验后端侧执行,或转发平台工具网关 |
| Agent 错误/断开 | 上报稳定错误码,按策略恢复或停止 |
| 正常关闭 | 调用平台 stop 并冲刷字幕、用量和录音队列 |
## 6. 平台接口设计
现有 `/device/v2` 的创建响应把 `rtc` 定义为必填对象。为了不破坏量产固件,建议新增 `/device/v3/agent-sessions`,不要修改 v2 字段语义。
### 6.1 准备会话
`POST /device/v3/agent-sessions`
请求沿用设备 HMAC 和幂等键,声明:
- `capabilities.hardware_agent=true`;
- SDK 版本、传输方案、音频格式;
- 是否支持字幕可靠队列、动态会话配置、凭证轮换、近/远端 PCM 录制;
- 网络和设备时间。
响应建议:
```json
{
"conversation_id": "...",
"status": "SESSION_READY",
"runtime": {
"provider": "volc_hardware_agent",
"bot_id": "<已关联 Bot>",
"transport": "high_quality",
"session_nonce": "<短期、单次>",
"config_digest": "sha256:...",
"wait_for_session_update": true
},
"session_config": {
"dialog_id": "<平台生成的非个人标识>",
"system_context": "<已授权且脱敏的本次背景>",
"tool_scope_token": "<短期令牌>"
},
"privacy": {},
"heartbeat_interval_sec": 10,
"expires_at": "..."
}
```
响应不得返回 `product_secret` 或 `device_secret`。
### 6.2 激活会话
`POST /device/v3/agent-sessions/{conversation_id}/activate`
请求包含 `provider_session_id`、`connected_at`、`transport`、`bot_id`、`config_digest` 和设备侧关联号。平台核对准备记录和配置摘要后进入 `ACTIVE`。
### 6.3 心跳
继续使用严格递增序号,但把 `rtc` 改为:
```json
{
"agent_link": {
"connected": true,
"input_active": true,
"output_active": true,
"provider_session_id": "...",
"reconnect_count": 0
}
}
```
平台继续返回 `continue`、`stop`、`stop_after_turn`;新增 `rotate_provider_credential` 和 `refresh_session_config` 前,必须有显式设备能力位。
### 6.4 字幕
复用 `/device/v2/conversations/{id}/turns:batch` 或在 v3 提供同构接口。只上传最终字幕,不把增量字幕写入长期存储。保持 `turn_key + sequence_no` 去重和冲突检测。
### 6.5 停止
设备先请求 SDK 停止并停止采集,再调用平台 stop。平台在以下任一条件满足后收敛 `COMPLETED`:
- 收到可信服务端结束事件;
- 设备报告关闭成功且心跳租约结束;
- 强制终止接口确认成功。
如果只能依赖设备报告,授权撤回和设备失控时无法做到服务端 fail-close,不能直接通过生产隐私门禁。
## 7. 后端模块与数据模型
### 7.1 运行时抽象
现有 `voiceprovider.Client` 的 `Start/Query/Stop` 契约只适合 RTC VoiceChat。建议新增:
```go
type ConversationRuntime interface {
Prepare(context.Context, PrepareRequest) (PrepareResult, error)
Terminate(context.Context, TerminateRequest) error
Reconcile(context.Context, SessionRef) (RuntimeState, error)
}
```
- `LegacyVoiceChatRuntime` 封装现有 `voiceprovider.Client`;
- `VolcHardwareAgentRuntime` 不负责创建音频会话,只负责设备/Bot 配置同步、服务端终止、事件对账和用量拉取;
- 通话状态机、幂等、Outbox、审计和租约留在 `orchestration`。
### 7.2 新表建议
`hardware_agent_device_binding`
- `device_id`、`provider`、`instance_ref`、`product_ref`;
- `provider_device_name`、`hardware_id_digest`;
- `bot_ref`、`license_ref`、`credential_version`;
- `status`、`registered_at`、`revoked_at`、`last_verified_at`。
`conversation_runtime_session`
- `conversation_id`、`runtime_type`、`transport`;
- `provider_session_ref`、`provider_device_ref`、`bot_ref`;
- `session_nonce_digest`、`config_digest`;
- `status`、`connected_at`、`last_event_at`、`ended_at`;
- 唯一约束:一个通话最多一个活动运行时会话;同一供应商会话只能映射一个通话。
`hardware_agent_config_sync`
- 平台版本 ID、Bot/产品引用、期望摘要、实际摘要;
- 同步状态、供应商请求引用、最后错误码和验证时间;
- 用于发现火山控制台手工修改造成的配置漂移。
### 7.3 事件归一化
扩展现有 `provider_event`,归一化为:
- `hardware_agent.session.started`;
- `hardware_agent.session.updated`;
- `hardware_agent.transcript.final`;
- `hardware_agent.function_call`;
- `hardware_agent.usage.final`;
- `hardware_agent.session.stopped`;
- `hardware_agent.session.failed`。
设备上传事件和服务端回调必须使用不同 `source_type`,最终以 `provider_event_key` 和话轮键双层去重。
## 8. 工具、记忆和业务动作
### 8.1 工具分类
| 类型 | 示例 | 执行位置 | 控制要求 |
|---|---|---|---|
| 设备本地无副作用 | 查询电量、读取音量 | 设备 | 白名单、参数校验 |
| 设备本地可逆动作 | 调整音量、切换灯效 | 设备 | 范围限制、结果回传 |
| 平台只读工具 | 检索已确认记忆、读取已授权日程 | 平台工具网关 | 会话短令牌、租户/长者反查 |
| 平台有副作用工具 | 创建提醒、联系家人、修改资料 | 平台业务 API | 用户确认、幂等键、权限和审计 |
| 高风险动作 | 删除数据、转账、医疗决策 | 不直接开放给模型 | 使用既有人工流程 |
### 8.2 工具网关安全
- 工具调用必须映射到活动通话,不能只信任模型传来的 `subject_id`;
- 参数使用固定 JSON Schema,拒绝未知字段;
- 每个调用有稳定 `call_id`,平台按 `conversation_id + call_id` 幂等;
- 返回内容限制长度并做提示词注入隔离;
- 工具超时返回结构化降级结果,不中断音频主链;
- 全量记录工具名、结果状态、耗时和资源版本,不记录不必要的敏感正文。
## 9. 隐私、录音和删除
### 9.1 授权撤回
当前系统在撤回字幕、原录音、故事整理或个性化记忆授权时,会把活动通话写入停止状态并投递可靠停止任务。迁移后必须实现等价闭环:
1. 平台事务内写入撤回、通话停止意图、Outbox 和审计;
2. 在线设备通过心跳/SSE 命令立即关闭 SDK 会话;
3. 平台调用火山服务端终止或吊销接口兜底;
4. 未确认停止前禁止新建会话和工具访问;
5. 超时进入阻断告警和人工处置,而不是静默标记完成。
### 9.2 双轨录音
这是迁移中最可能发生范围变化的功能。PoC 必须确认:
- 能否取得送入 SDK 前的近端 PCM;
- 能否取得 SDK 解码后、播放前的远端 PCM;
- 打断、重连、丢包隐藏和采样率变化后时间轴能否对齐;
- SDK 是否允许在本地进行受授权的录制;
- 若使用火山云端录制,能否满足近/远轨分离、短期访问、删除和审计要求。
如不能取得远端 PCM,不能把混音录音冒充现有双轨录音。第一阶段应明确限定为 `transcript_only`,待产品接受范围变更或供应商能力补齐后再开放录音。
### 9.3 删除和凭证吊销
主体隐私删除增加两个子任务:
- 删除或匿名化火山侧与该设备/会话相关的可识别数据;
- 吊销设备智能体凭证和 License 绑定,并保存最小删除证明。
供应商无删除/吊销 API 时,需要人工控制台证据和对账任务,但该方式不适合大规模量产。
## 10. 用量、费用与 License
现有七类用量可保留,但新增来源维度:
```text
source = provider_callback | device_session | provider_bill
runtime = legacy_voicechat | volc_hardware_agent
```
建议新增类别:
- `HARDWARE_AGENT_LICENSE`:设备 License 购买、激活、到期和转移;
- `HARDWARE_AGENT_VOICE`:AI 对话时长;
- `HARDWARE_AGENT_MODEL`:模型推理额度;
- 视频、声音复刻、联网问答等扩展资源按实际开通单独归类。
设备上报只用于实时额度预估,最终账单以火山账单/用量接口为准,并执行会话、设备、账期三级对账。火山产品页显示 License 按设备提供不同对话时长和模型额度,实际采购价格、超额规则、License 回收和换机政策必须在合同中冻结。[火山硬件智能体产品页](https://www.volcengine.com/product/ConversationalAI-Embedded)
## 11. 灰度迁移步骤
### 阶段 0:供应商能力封板
在编码前取得正式 SDK、API、License 和数据处理条款,完成第 13 节问题闭环。输出一份签字的能力矩阵,未确认项不能用口头承诺替代。
### 阶段 1:独立真机 PoC
- 1 个测试产品、1 个 Bot、3 台候选板;
- 跑通高质量 SDK 初始化、动态注册、建连、打断、重连、字幕和结束;
- 验证近/远端 PCM、强制停话、凭证吊销、License 更换;
- 暂不连接生产家庭/长者数据。
### 阶段 2:平台双运行时
- 新增运行时抽象、表和 `/device/v3`;
- 旧 v2/RTC 链路保持不变;
- 新设备只接固定 Bot,先打通授权、状态、字幕、内容处理和诊断;
- 火山配置先由人工控制台维护,平台保存摘要和漂移检查。
### 阶段 3:个性化与工具
- 接入动态会话参数和冻结背景;
- 接入平台记忆 MCP/Function Calling;
- 接入角色、模型、音色、Bot 的发布和回滚;
- 接入用量、License、隐私删除和供应商对账。
### 阶段 4:灰度试点
- 内部设备 -> 友好家庭 -> 小比例真实家庭;
- 每个设备的运行时由不可变灰度版本决定;
- 出现阻断问题时只切回 `legacy_rtc`,不修改历史通话运行时;
- 灰度期间两个运行时分别统计成功率、首响、打断、重连、字幕、费用和投诉。
### 阶段 5:主链切换与退役
只有新链路连续通过真机、隐私、费用和运行恢复门禁后,才把新出厂设备默认设为硬件智能体。旧 `StartVoiceChat` 代码、RTC Token 签发和模板不能立即删除,至少保留到全部在役设备完成升级或退出支持期。
## 12. 验收与回滚门禁
建议新的真机最小矩阵至少覆盖 3 台量产候选设备、6 通会话:
1. 正常完整通话;
2. 用户打断和 Agent 被打断;
3. Wi-Fi/蜂窝或批准网络切换恢复;
4. 平台受控停止;
5. 活动通话中授权撤回;
6. 设备断电重启后的字幕队列恢复和资源回收。
每通必须核对:
- 平台通话、火山会话、设备和 Bot 关联唯一;
- 音频上行、下行、首响、打断和恢复符合现场冻结标准;
- 最终字幕完整、顺序稳定、重放不重复;
- 背景和工具调用严格限制在当前租户、长者和通话;
- 结束后 SDK、License 占用、平台租约和工具令牌全部释放;
- 设备、平台、火山、字幕、用量和隐私证据引用完整。
建议上线 SLO 在试点前冻结,不在代码中写死。至少包含:建连成功率、首次回复时延、打断成功率、异常重连成功率、字幕完整率、停止收敛时延、重复计费率、供应商账单差异和凭证吊销生效时延。
回滚规则:
- 回滚只影响新会话;活动会话按原运行时完成或受控停止;
- 数据库中的 `runtime_type` 和配置摘要不可改写;
- 设备切回旧运行时前必须确认旧 RTC SDK 和配置仍在;
- 无法强制停话、出现跨租户工具访问、删除不收敛或凭证泄露时,立即停止硬件智能体新会话灰度。
## 13. 必须向火山确认的问题
以下问题未得到书面答案前,不建议承诺完整替换日期:
1. 是否提供按设备或会话的服务端强制终止接口?设备离线时多久生效?
2. `device_secret` 是否支持轮换、吊销、批量禁用和换板迁移?
3. 产品、设备、Bot 绑定和 License 是否都有 OpenAPI,而不是只能操作控制台?
4. 是否有服务端会话开始、结束、最终字幕、错误和用量回调?签名和重放保护是什么?
5. 是否允许传入平台生成的 `conversation_id/dialog_id` 并在回调和账单中原样返回?
6. 动态会话参数可以覆盖哪些 Bot 配置?何时生效?是否保证收到 `session.updated` 后再开始识别?
7. 高质量 SDK 是否同时提供近端输入 PCM 和远端播放 PCM 回调?采样率、声道和时间戳如何定义?
8. Function Calling/MCP 的服务端鉴权、超时、重试、幂等和最大正文限制是什么?
9. License 的激活、到期、换机、回收、离线宽限和超额计费规则是什么?
10. 能否按设备、Bot、会话、租户标签导出用量并与账单对账?
11. 会话音频、字幕、日志和模型上下文分别保留多久?如何导出和删除?数据地域在哪里?
12. SDK 支持的芯片、编译器、RTOS/Linux、RAM/Flash/CPU、AEC/AGC/ANS、网络端口和证书更新要求是什么?
13. 高质量和低负载方案能否在同一产品/设备上切换?切换是否会改变 Bot、License、回调和计费语义?
14. 量产并发、建连 QPS、设备数、Bot 数和回调吞吐的配额如何申请和压测?
## 14. 主要风险
| 风险 | 影响 | 控制措施 |
|---|---|---|
| 产品级 Secret 被固件提取 | 同产品设备可被仿冒或批量注册 | 一次性安全注册、设备独享 Secret、安全存储、吊销演练 |
| 火山控制台配置漂移 | 平台记录与实际 Bot 行为不一致 | 平台配置摘要、定时对账、变更工单、生产禁止无审计手改 |
| 只能由设备报告会话结束 | 授权撤回时不能服务端强制停止 | 以供应商强停/吊销能力作为上线硬门槛 |
| 字幕只有设备单路回传 | 断电或固件故障造成内容缺口 | 本地加密可靠队列、服务端回调双源、终稿补传和差异告警 |
| SDK 无远端 PCM | 双轨录音功能无法等价迁移 | PoC 提前验证;不满足时明确限定 `transcript_only` |
| License 与内部设备解绑不同步 | 换机失败、成本浪费或越权使用 | License 台账、绑定状态机、每日对账和售后流程 |
| Bot 数量或动态参数受限 | 现有多租户灰度模型无法直接复制 | 少量基础 Bot + 会话动态参数;配置版本与供应商摘要冻结 |
| 工具调用越权或重复 | 读取他人记忆或执行重复动作 | 会话范围令牌、服务端反查、Schema、幂等、确认和审计 |
## 15. 评审建议
甲方方案评审时可以把范围分成三层:
- 必须等价迁移:正常语音、打断、设备绑定、授权门禁、通话状态、最终字幕、摘要/故事、人工记忆、额度、诊断和受控停止;
- 需要供应商能力确认:服务端强停、设备凭证吊销、字幕/用量服务端回调、动态会话参数、MCP 鉴权;
- 可能需要范围调整:双轨录音、三种旧 `engine_mode`、RTC 房间级诊断和旧 VoiceChat 模板。
推荐先让甲方确认“硬件对话智能体只替换语音会话执行层,现有业务平台继续作为控制面和数据主库”。确认这条边界后,再进入 SDK PoC 和接口改造,能避免后续把火山控制台误当成整个业务平台替代品。