docs(chat): consolidate multi-role protocol

This commit is contained in:
2026-07-20 19:08:24 +08:00
parent edf50e9cc4
commit 9f176272c1
12 changed files with 276 additions and 1117 deletions
+252 -93
View File
@@ -1,28 +1,95 @@
# CozSweet 多角色聊天前端对接
# CozSweet 多角色聊天权威协议
## 3. Character IDs
## 1. 状态与范围
前端先调用:
本文是前端仓库中多角色目录、聊天、解锁、缓存隔离及关联业务的唯一人工维护协议。旧的单角色迁移方案和独立锁媒体协议已删除,不再作为实现或联调依据。
协议描述当前前端已经执行的行为,不描述尚未落地的后端迁移步骤。字段和路径由下列机器可验证入口约束:
| 边界 | 实现位置 |
| --- | --- |
| API 路径与方法 | `src/data/services/api/api_contract.json` |
| 请求与响应字段 | `src/data/schemas/character``src/data/schemas/chat` |
| 角色 ID、slug 与本地资源 | `src/data/constants/character.ts` |
| Chat UI 消息身份 | `src/stores/chat/ui-message.ts` |
| 本地缓存身份 | `src/data/repositories/chat_cache_identity.ts``src/lib/chat/chat_cache_keys.ts` |
如本文与上述实现发生差异,修改代码时必须在同一变更中更新本文,不能新增第二份并行协议。
## 2. 角色目录与路由
### 2.1 角色身份
`id` 是 API、状态机、缓存和数据归属使用的稳定业务身份;`slug` 只用于 URL。
| `id` | `slug` | 展示名称 |
| --- | --- | --- |
| `elio` | `elio` | Elio Silvestri |
| `maya-tan` | `maya` | Maya Tan |
| `nayeli-cervantes` | `nayeli` | Nayeli Cervantes |
不得向业务 API 发送 slug、展示名称、`@handle` 或旧 ID。旧 ID `character_elio``character_maya``character_nayeli` 仅用于本地持久化数据迁移。
### 2.2 角色目录接口
```http
GET <API_BASE_URL>/api/characters?capability=chat
Authorization: Bearer <TOKEN>
```
聊天业务统一使用响应里的 `items[].id`。当前规范 ID 是
标准响应数据
```json
{
"items": [
{
"id": "maya-tan",
"displayName": "Maya Tan",
"isActive": true,
"capabilities": {
"chat": true,
"privateContent": true
},
"sortOrder": 20
}
],
"defaultCharacterId": "elio"
}
```
前端只接纳同时满足以下条件的角色:
1. `id` 存在于本地资源目录;
2. `isActive=true`
3. `capabilities.chat=true`
服务端目录决定角色是否可聊天及排序;本地目录继续提供 slug、图片、文案和 Tip 能力。`privateRoom` 只有在本地能力和服务端 `privateContent` 同时开启时可用。
远端目录返回前,生产环境只使用 Elio 作为临时目录;非生产环境可使用完整本地目录。远端目录加载成功后,以合并结果为准。
### 2.3 页面路由
```text
elio
maya-tan
nayeli-cervantes
/characters/{slug}/splash
/characters/{slug}/chat
/characters/{slug}/private-room
/characters/{slug}/tip
```
只允许传递角色目录响应中的 `items[].id`,不要传展示名或 `@handle`
当前 Splash 是角色详情入口,不是全角色列表页。以下旧地址保留为默认角色重定向,并原样保留查询参数:
角色响应中的 `capabilities` 是后端权威开关。`chat=false` 时不得开放输入框;直接调用聊天接口会得到 `CHARACTER_DISABLED`,不会返回 Elio 历史。
```text
/splash -> /characters/elio/splash
/chat -> /characters/elio/chat
/private-room -> /characters/elio/private-room
/tip -> /characters/elio/tip
```
## 4. Send Message
Chat 和 Private Room Provider 都以 `characterId` 为边界。路由角色变化时必须创建新的 Actor,不能在旧 Actor 内切换角色。
### Request URL
## 3. Chat HTTP 协议
### 3.1 发送消息
```http
POST <API_BASE_URL>/api/chat/send
@@ -30,73 +97,56 @@ Content-Type: application/json
Authorization: Bearer <TOKEN>
```
### Parameters
| Field | Type | Required | Example | Meaning |
| --- | --- | --- | --- | --- |
| `characterId` | string | 新前端必传 | `maya-tan` | 当前聊天角色 ID |
| `message` | string | 与图片至少一项有值 | `Hello Maya` | 用户文本,最多 4000 字符 |
| `imageId` | string | 否 | `abc123` | 上传图片 ID |
| `imageThumbUrl` | string | 否 | `/images/...` | 缩略图 |
| `imageMediumUrl` | string | 否 | `/images/...` | 模型识图使用 |
| `imageOriginalUrl` | string | 否 | `/images/...` | 原图 |
| `imageWidth` | integer | 否 | `1080` | 图片宽 |
| `imageHeight` | integer | 否 | `1440` | 图片高 |
| `useWebSocket` | boolean | 否 | `false` | 是否同时通过已连接 WS 推送 |
```bash
curl -X POST 'https://proapi.banlv-ai.com/api/chat/send' \
-H 'Authorization: Bearer <TOKEN>' \
-H 'Content-Type: application/json' \
-d '{"characterId":"maya-tan","message":"Hello Maya","useWebSocket":false}'
```json
{
"characterId": "maya-tan",
"message": "Hello Maya",
"useWebSocket": false
}
```
响应沿用现有发送结构。前端仍读取 `data.reply``data.messageId``data.audioUrl``data.image``data.lockDetail`,无需从响应推断角色。
请求规则:
## 5. Chat History
- `characterId` 必填;
- `message` 最大 4000 字符;
- `message``imageId``imageThumbUrl``imageMediumUrl``imageOriginalUrl` 至少一项有效;
- 图片请求可附带 `imageWidth``imageHeight`
- `useWebSocket` 缺失时按 `false` 处理。
前端使用的响应字段为:
```text
reply, audioUrl, messageId, isGuest, timestamp, image, lockDetail,
canSendMessage, creditBalance, creditsCharged, requiredCredits,
shortfallCredits
```
`messageId` 是后端消息身份,不是 React key。锁状态只读取 `lockDetail.locked``lockDetail.reason`
### 3.2 获取历史
```http
GET <API_BASE_URL>/api/chat/history?characterId=maya-tan&limit=50&offset=0
Authorization: Bearer <TOKEN>
```
| Query | Type | Required | Rule |
| --- | --- | --- | --- |
| `characterId` | string | 新前端必传 | 只返回该角色数据 |
| `limit` | integer | 否 | `1-200`,默认 `50` |
| `offset` | integer | 否 | 最小 `0` |
| Query | 必填 | 规则 |
| --- | --- | --- |
| `characterId` | 是 | 角色业务 ID |
| `limit` | 否 | 前端默认 50 |
| `offset` | 否 | 前端默认 0 |
`messages``total` 都只统计指定角色。切换角色时必须重新请求 history,不能复用上一个角色的本地数组。
响应数据包含 `messages``total``limit``offset`,并可包含当前私密额度快照。消息字段:
```json
{
"code": 200,
"success": true,
"message": "success",
"data": {
"messages": [
{
"role": "user",
"type": "text",
"content": "Hello Maya",
"id": "<MESSAGE_ID>",
"created_at": "2026-07-17T09:00:00Z",
"audioUrl": null,
"image": {"type": null, "url": null},
"lockDetail": {"locked": false, "showContent": true, "showUpgrade": false}
}
],
"total": 1,
"limit": 50,
"offset": 0,
"isVip": false
}
}
```text
role, type, content, id, createdAt/created_at, audioUrl, image, lockDetail
```
## 6. Latest Previews
前端兼容 `createdAt``created_at` 输入,解析后统一为 `createdAt`。缺失的可空载荷会由 Schema 归一化,业务组件不直接读取原始 envelope。
角色会话列表可一次请求所有可聊天角色的最后一条消息:
本地快照可先进入可渲染状态;网络历史随后覆盖缓存并更新 UI。网络同步期间新增的乐观消息按 `displayId` 保留。向上拉取更早页面时也按 `displayId` 去重。
### 3.3 最新消息预览
```http
GET <API_BASE_URL>/api/chat/previews
@@ -105,20 +155,29 @@ Authorization: Bearer <TOKEN>
```json
{
"code": 200,
"success": true,
"data": {
"items": [
{"characterId": "elio", "message": {"id": "...", "role": "assistant", "type": "text", "content": "..."}},
{"characterId": "maya-tan", "message": null}
]
}
"items": [
{
"characterId": "elio",
"message": {
"id": "<MESSAGE_ID>",
"role": "assistant",
"type": "text",
"content": "How was your day?"
}
},
{
"characterId": "maya-tan",
"message": null
}
]
}
```
锁定私密文本不会泄露内容,锁定语音不会返回 `audioUrl`(文字仍正常返回)。图片沿用现有协议,URL 可以存在;前端必须以 `lockDetail.locked=true` 覆盖展示,不能把“URL 非空”当作已解锁
接口可用时,Splash 以批量结果更新各角色预览;接口失败时,当前角色可回退到 `history?limit=1&offset=0`。预览缓存仍使用角色会话身份隔离
## 7. Unlock One Message
## 4. 解锁协议
### 4.1 解锁单条消息
```http
POST <API_BASE_URL>/api/chat/unlock-private
@@ -126,44 +185,144 @@ Content-Type: application/json
Authorization: Bearer <TOKEN>
```
已有后端消息:
```json
{
"characterId": "maya-tan",
"messageId": "<MESSAGE_ID>",
"lockType": "voice_message",
"clientLockId": "maya-card-001"
"messageId": "<MESSAGE_ID>"
}
```
`messageId` 存在时,后端会核对消息角色。拿 Elio 的 `messageId` 配 Maya 的 `characterId` 会返回 HTTP `409 / CHARACTER_MISMATCH`,且不会扣积分。
前端 Promotion 锁卡片:
前端临时伪造锁卡片没有 `messageId` 时,仍传 `characterId + lockType + clientLockId``clientLockId` 的幂等查找范围已包含账号和角色,同一个 ID 可以在不同角色下分别使用。
```json
{
"characterId": "maya-tan",
"lockType": "voice_message",
"clientLockId": "<STABLE_CLIENT_LOCK_ID>"
}
```
## 8. Unlock Current Character History
请求必须包含 `characterId`,并至少包含 `messageId``lockType`。标准 `lockType` 为:
```text
voice_message
image_paywall
private_message
```
前端解析的响应字段:
```text
unlocked, content, messageId, clientLockId, lockType, audioUrl, image,
reason, creditBalance, creditsCharged, requiredCredits, shortfallCredits
```
处理规则:
1. `unlocked=true` 时更新内容、音频、图片和锁状态;
2. `unlocked=false` 时保持锁定,并根据 `reason` 决定是否打开支付流程;
3. `reason=not_found` 不打开支付流程;
4. 响应 `messageId` 只写入 `remoteId`,不得替换 `displayId`
5. Promotion 重试继续使用同一个 `clientLockId`
6. 跨支付、登录和图片 Overlay 恢复时同时保存显示身份与后端身份。
### 4.2 解锁当前角色历史
```http
POST <API_BASE_URL>/api/chat/unlock-history
Content-Type: application/json
Authorization: Bearer <TOKEN>
```
```json
{"characterId":"maya-tan"}
```
费用、锁消息数量、成功解锁数量及 `messageIds` 都只计算当前角色。钱包余额仍是账号全局余额
请求和返回消息都限定当前角色。前端完成解锁后重新读取该角色历史,不复用其他角色 Actor 的消息
## 12. Tip Attribution
### 4.3 角色错误
只有 Tip 订单使用角色归属
前端识别以下后端错误码
```json
{
"planId": "tip_coffee_usd_4_99",
"payChannel": "stripe",
"autoRenew": false,
"recipientCharacterId": "maya-tan"
}
| 错误码 | 前端行为 |
| --- | --- |
| `CHARACTER_DISABLED` | 禁止继续发送并刷新角色目录 |
| `CHARACTER_NOT_FOUND` | 清理待恢复导航、刷新目录并返回默认角色 |
| `CHARACTER_MISMATCH` | 不进入支付,刷新当前角色历史 |
| `CHARACTER_STATE_UNAVAILABLE` | 保留会话并提示稍后重试 |
任何角色错误都不得在前端回退为另一个角色的历史。
## 5. UI 消息身份协议
网络字段继续使用 `messageId`;前端 UI 严格区分三种身份:
| 字段 | 用途 | 是否稳定 |
| --- | --- | --- |
| `displayId` | React key、DOM 定位、Overlay URL、解锁目标 | 消息生命周期内必须稳定 |
| `remoteId` | 后端 `messageId`、媒体缓存、解锁 API | 后端返回后可补充 |
| `clientId` | 乐观发送消息或无后端 ID 的回复 | 创建时生成 |
标准 `displayId`
```text
历史消息 server:{encodedRemoteId}:{assistant|user}
同角色重复 ID server:{encodedRemoteId}:{role}:{occurrence}
无后端 ID 历史 legacy:{stableHash}
乐观文本 client:message:{uuid}
乐观图片 client:image:{uuid}
无 ID 回复 client:reply:{uuid}
错误提示 client:error:{uuid}
空会话问候语 greeting:{characterId}
Promotion promotion:{clientLockId}
```
Tip 订单必须携带 `recipientCharacterId`。VIP 和积分充值会忽略该字段
同一个后端 ID 可能同时出现在 user 和 assistant 记录中,因此不得直接使用 `remoteId` 作为 React key。解锁前后 `displayId` 不变;图片 Overlay 始终写入 `displayId`,但读取时兼容旧的 `remoteId` URL
## 6. 本地缓存与会话隔离
缓存 owner 规则:
```text
正式用户或已有用户 ID 的会话 user:{userId}
没有用户 ID 的 Guest 会话 device:{deviceId}
```
正式账号缺少持久化用户 ID 时跳过 Chat 缓存,不得回退到设备级或匿名缓存。
消息和媒体共同使用会话键:
```text
conversationKey = {ownerKey}::character:{encodeURIComponent(characterId)}
```
媒体键在该会话下继续包含后端消息 ID 和媒体类型:
```text
{conversationKey}:{remoteId}:{image|audio}
```
旧的无角色 owner key 迁移到 Elio;包含旧角色 ID 的 key 迁移到当前后端 ID。迁移是幂等的,不能覆盖已经存在的当前命名空间数据。
切换用户、Guest/正式账号或角色时,不得复用前一个 `conversationKey`。切换角色会销毁旧 Chat Actor;请求取消信号会阻止旧响应写入新角色状态。
## 7. 关联业务边界
- Private Room 列表请求使用 `GET /api/private-room/albums?characterId={id}&limit=20`;相册解锁由 `albumId` 定位,body 只发送 `expectedCost`
- Tip 创建订单时发送 `recipientCharacterId`VIP 和 Top-up 不依赖角色归属。
- 登录、支付和解锁回跳保存原角色动态 URL,不能降级为通用 `/chat`
- Analytics 可以使用 `characterId`,聊天正文不属于路由或身份协议的一部分。
## 8. 变更验收
协议相关变更至少验证:
1. Character、Chat、Storage、Navigation 和 Payment 的 TypeScript 类型检查;
2. `src/data/services/api/__tests__/multi_character_api.test.ts`
3. `src/stores/chat/__tests__`
4. `src/data/storage/chat/__tests__``src/data/storage/navigation/__tests__`
5. `src/app/chat/__tests__` 及 Chat 组件测试;
6. 同一用户的不同角色、Guest 与正式账号、账号切换后的历史和媒体均不串联;
7. 快速切换角色时,旧请求不能覆盖新 Actor;
8. 动态 URL、旧地址重定向、登录和支付回跳恢复正确角色;
9. 解锁后 `displayId` 不变,`remoteId` 可从响应补齐。