diff --git a/docs/backend/FRONTEND_LOCKED_MEDIA_UNLOCK_API.md b/docs/backend/FRONTEND_LOCKED_MEDIA_UNLOCK_API.md deleted file mode 100644 index 43db75fc..00000000 --- a/docs/backend/FRONTEND_LOCKED_MEDIA_UNLOCK_API.md +++ /dev/null @@ -1,496 +0,0 @@ -# 前端锁消息解锁 API 接入文档 - -## 1. 文档状态 - -本文档描述 `POST /api/chat/unlock-private` 与 `GET /api/chat/history` 的最新接口协议,覆盖后端已有锁消息和前端临时创建锁消息两种场景。 - -当前协议已经实现并通过自动化测试。前端联调前可通过目标环境的 `/openapi.json` 确认 `UnlockPrivateRequest` 已包含 `messageId`、`lockType` 和 `clientLockId`。 - -`unlock-private` 响应已收敛为紧凑结构。历史兼容别名不再返回,前端必须使用本文列出的标准 camelCase 字段。 - -环境地址: - -| 环境 | API Base URL | -| --- | --- | -| test 测试环境 | `https://testapi.banlv-ai.com` | -| pro 预发环境 | `https://proapi.banlv-ai.com` | -| production 生产环境 | `https://api.banlv-ai.com` | - -## 2. 功能目标 - -前端可以先展示一条本地锁消息。用户点击解锁时,即使该消息还没有后端 `messageId`,后端也会创建对应的聊天记录,并根据余额决定是否生成真实内容。 - -核心规则: - -1. 余额不足时不生成真实语音、图片或私密内容,不扣积分,但会保存锁消息并返回真实 `messageId`。 -2. 用户充值后,前端使用同一个 `clientLockId` 再次解锁,后端更新原消息,不新增另一条聊天记录。 -3. 成功扣积分并持久化解锁后,`history` 才返回解锁状态。 -4. 刷新页面不会自动解锁。未扣积分、未消耗免费私密额度时,`history` 仍返回 `locked=true`。 -5. 前端只能使用 `unlocked` 或 `lockDetail.locked` 判断锁状态,不能根据 URL 是否为空判断。 - -## 3. 身份认证 - -两个接口都使用当前聊天用户的登录 Token: - -```http -Authorization: Bearer -Content-Type: application/json -``` - -前端不得保存或传递 Supabase service key。 - -## 4. 解锁接口 - -### 4.1 请求地址 - -```http -POST /api/chat/unlock-private -``` - -生产环境完整地址: - -```http -POST https://api.banlv-ai.com/api/chat/unlock-private -``` - -### 4.2 请求参数 - -请求体为 JSON: - -```json -{ - "messageId": "可选,有就传", - "lockType": "voice_message", - "clientLockId": "前端生成的唯一ID,可选但强烈建议" -} -``` - -| 字段 | 类型 | 是否必填 | 示例 | 说明 | -| --- | --- | --- | --- | --- | -| `messageId` | string | 否 | `"9a43..."` | 后端真实消息 ID。已有消息时传;前端临时锁消息首次解锁时可以不传。 | -| `lockType` | string | 临时锁消息必填 | `"voice_message"` | 锁类型。没有有效 `messageId` 时必须传。 | -| `clientLockId` | string | 否,强烈建议 | `"fb_voice_3737_001"` | 前端锁卡片唯一 ID,用于充值后重试、刷新找回和避免重复创建。 | - -`lockType` 支持以下值: - -| 标准值 | 兼容别名 | 积分价格 | -| --- | --- | ---: | -| `voice_message` | `voice`、`audio` | 20 | -| `image_paywall` | `image`、`photo`、`picture` | 40 | -| `private_message` | `private`、`private_topic`、`private_room` | 10 | - -建议前端只使用标准值。 - -### 4.3 `clientLockId` 生成规则 - -前端在创建锁卡片时生成一次,之后不得改变: - -```ts -const clientLockId = crypto.randomUUID(); -``` - -同一条锁消息在以下场景中必须继续使用同一个值: - -- 第一次点击解锁; -- 余额不足后进入充值; -- 充值成功后重新点击解锁; -- 网络超时后重试; -- 前端重新渲染同一条本地锁卡片。 - -首次响应拿到后端 `messageId` 后,前端应同时保存 `messageId` 和 `clientLockId`。 - -### 4.4 最小请求示例 - -解锁已有后端消息: - -```bash -curl -X POST 'https://api.banlv-ai.com/api/chat/unlock-private' \ - -H 'Authorization: Bearer ' \ - -H 'Content-Type: application/json' \ - -d '{"messageId":""}' -``` - -解锁前端临时语音锁消息: - -```bash -curl -X POST 'https://api.banlv-ai.com/api/chat/unlock-private' \ - -H 'Authorization: Bearer ' \ - -H 'Content-Type: application/json' \ - -d '{ - "lockType":"voice_message", - "clientLockId":"codex_test_voice_001" - }' -``` - -充值后建议把三个字段都传回: - -```json -{ - "messageId": "", - "lockType": "voice_message", - "clientLockId": "codex_test_voice_001" -} -``` - -## 5. 统一响应结构 - -```json -{ - "code": 200, - "message": "success", - "success": true, - "data": {} -} -``` - -业务解锁失败通常也返回 HTTP 200。前端必须读取 `data.unlocked` 和 `data.reason`,不能只检查 HTTP 状态或顶层 `success`。 - -重要字段: - -| 字段 | 类型 | 说明 | -| --- | --- | --- | -| `unlocked` | boolean | 本次操作后是否已真实解锁。 | -| `reason` | string/null | 本次操作结果原因。 | -| `messageId` | string | 后端真实消息 ID。首次临时锁请求后也会返回。 | -| `clientLockId` | string/null | 后端识别到的前端锁 ID。 | -| `lockType` | string/null | 规范化后的锁类型。 | -| `content` | string/null | 当前消息文字;私密内容未解锁时可以为 `null`。 | -| `type` | string | `text` 或 `voice`。 | -| `audioUrl` | string | 锁定语音为空字符串,成功解锁后是完整 URL。 | -| `image.url` | string/null | 图片完整 URL;是否展示仍由 `lockDetail.locked` 决定。 | -| `creditBalance` | number/null | 操作后的当前积分。 | -| `creditsCharged` | number | 本次实际扣除积分;失败或免费额度解锁为 0。 | -| `requiredCredits` | number | 当前锁类型需要的积分。 | -| `shortfallCredits` | number | 余额不足时还差多少积分。 | -| `lockDetail.locked` | boolean | 前端锁卡片的最终展示开关。 | -| `lockDetail.showContent` | boolean | 是否允许展示文字内容。 | -| `lockDetail.showUpgrade` | boolean | 是否展示充值入口。 | -| `lockDetail.reason` | string/null | 锁定原因。 | - -以下重复或与解锁无关的字段已经删除:`message_id`、`messageType`、`reply`、`response`、`audio_url`、`displayMessage`、`localizedMessage`、`paywallTriggered`、`showUpgrade`、`timestamp`、`isGuest`、亲密度字段以及 `blocked` 系列字段。 - -## 6. 余额不足 - -请求: - -```json -{ - "lockType": "voice_message", - "clientLockId": "fb_voice_37370387172559600_001" -} -``` - -代表性响应: - -```json -{ - "code": 200, - "message": "success", - "success": true, - "data": { - "unlocked": false, - "reason": "insufficient_credits", - "type": "voice", - "messageId": "", - "clientLockId": "fb_voice_37370387172559600_001", - "lockType": "voice_message", - "content": "I left a voice message for you. Unlock it to listen.", - "audioUrl": "", - "creditBalance": 5, - "creditsCharged": 0, - "requiredCredits": 20, - "shortfallCredits": 15, - "lockDetail": { - "locked": true, - "showContent": true, - "showUpgrade": true, - "reason": "voice_message", - "detail": { - "messageId": "", - "clientLockId": "fb_voice_37370387172559600_001", - "lockType": "voice_message", - "requiredCredits": 20 - } - } - } -} -``` - -前端处理: - -1. 使用返回的 `messageId` 替换本地临时消息 ID。 -2. 保留原 `clientLockId`。 -3. 保持锁定状态并打开充值页面。 -4. 充值完成后再次调用解锁接口。 -5. 请求期间禁用解锁按钮,避免用户连续点击。 - -## 7. 解锁成功 - -### 7.1 语音成功 - -后端先确认并扣除 20 积分,然后生成真实文字和语音,更新同一条聊天记录: - -```json -{ - "code": 200, - "message": "success", - "success": true, - "data": { - "unlocked": true, - "reason": "ok", - "type": "voice", - "messageId": "", - "clientLockId": "fb_voice_37370387172559600_001", - "lockType": "voice_message", - "content": "I made this voice just for you.", - "audioUrl": "https://api.banlv-ai.com/audio/.mp3", - "creditBalance": 10, - "creditsCharged": 20, - "requiredCredits": 20, - "shortfallCredits": 0, - "lockDetail": { - "locked": false, - "showContent": true, - "showUpgrade": false - } - } -} -``` - -前端收到后直接用本次响应替换锁卡片,播放地址读取 `data.audioUrl`。 - -### 7.2 图片成功 - -```json -{ - "unlocked": true, - "reason": "ok", - "messageId": "", - "clientLockId": "fb_image_001", - "lockType": "image_paywall", - "image": { - "type": "elio_schedule", - "url": "https://dbapi.banlv-ai.com/storage/v1/object/public/" - }, - "creditBalance": 60, - "creditsCharged": 40, - "requiredCredits": 40, - "lockDetail": { - "locked": false, - "showContent": true, - "showUpgrade": false - } -} -``` - -图片读取 `data.image.url`。后端已有图片锁消息可能在锁定时也返回真实 URL,因为 URL 本身不作为解锁凭证;前端仍必须根据 `lockDetail.locked` 遮挡或禁止查看。 - -前端临时创建、尚未确定图片内容的锁消息,在成功扣积分前可能没有图片 URL;成功后后端选择图片并返回 URL。 - -### 7.3 私密文本成功 - -前端临时私密消息按 10 积分生成并解锁: - -```json -{ - "unlocked": true, - "reason": "ok", - "messageId": "", - "clientLockId": "fb_private_001", - "lockType": "private_message", - "content": "", - "creditsCharged": 10, - "requiredCredits": 10, - "lockDetail": { - "locked": false, - "showContent": true, - "showUpgrade": false - } -} -``` - -已有后端私密文本仍优先使用每日免费私密额度:登录用户默认每天 2 条,游客默认每天 1 条。成功消耗免费额度后也会持久解锁,但 `creditsCharged=0`。 - -## 8. History 接口 - -### 8.1 请求 - -```http -GET /api/chat/history?limit=50&offset=0 -Authorization: Bearer -``` - -```bash -curl 'https://api.banlv-ai.com/api/chat/history?limit=50&offset=0' \ - -H 'Authorization: Bearer ' -``` - -`limit=1&offset=0` 返回最新的一条数据库聊天记录展开后的消息;接口最终按聊天展示顺序返回 `data.messages`。 - -### 8.2 锁定消息示例 - -```json -{ - "role": "assistant", - "type": "voice", - "content": "I left a voice message for you. Unlock it to listen.", - "id": "", - "created_at": "2026-07-13T08:00:00+00:00", - "audioUrl": null, - "image": { - "type": null, - "url": null - }, - "lockDetail": { - "locked": true, - "showContent": true, - "showUpgrade": true, - "reason": "voice_message", - "detail": { - "messageId": "", - "clientLockId": "fb_voice_37370387172559600_001", - "lockType": "voice_message", - "requiredCredits": 20 - } - } -} -``` - -### 8.3 前端展示规则 - -```ts -const locked = message.lockDetail?.locked === true; -const showText = message.lockDetail?.showContent !== false; - -if (locked) { - // 展示锁卡片、遮罩或解锁按钮 -} else { - // 展示真实内容 -} -``` - -各类型规则: - -| 状态 | `lockDetail.locked` | URL/内容行为 | -| --- | ---: | --- | -| 锁定语音 | `true` | `audioUrl=null`,不能播放。 | -| 已解锁语音 | `false` | `audioUrl` 是完整可播放 URL。 | -| 锁定图片 | `true` | `image.url` 可能有值,也可能为空;必须遮挡。 | -| 已解锁图片 | `false` | 使用完整 `image.url` 展示。 | -| 锁定私密文本 | `true` | 根据 `showContent` 决定是否显示 `content`。 | -| 已解锁私密文本 | `false` | 展示完整 `content`。 | - -## 9. 推荐 TypeScript 类型 - -```ts -type LockType = "voice_message" | "image_paywall" | "private_message"; - -interface UnlockPrivateRequest { - messageId?: string; - lockType?: LockType; - clientLockId?: string; -} - -interface LockDetail { - locked: boolean; - showContent: boolean; - showUpgrade: boolean; - reason?: string | null; - hint?: string | null; - actionLabel?: string | null; - detail?: { - messageId?: string; - clientLockId?: string; - lockType?: LockType; - requiredCredits?: number; - refundedCredits?: number; - } | null; -} - -interface UnlockPrivateData { - unlocked: boolean; - reason?: string | null; - messageId: string; - clientLockId?: string | null; - lockType?: LockType | null; - type: "text" | "voice"; - content?: string | null; - audioUrl: string; - image: { - type?: string | null; - url?: string | null; - }; - creditBalance?: number | null; - creditsCharged: number; - requiredCredits: number; - shortfallCredits: number; - lockDetail: LockDetail; -} -``` - -## 10. 异常与前端处理 - -| `data.reason` | 含义 | 前端处理 | -| --- | --- | --- | -| `ok` | 成功扣费并解锁 | 用响应替换锁卡片。 | -| `not_locked` | 消息已经不是锁定状态 | 直接按已解锁内容展示,不再扣费。 | -| `insufficient_credits` | 积分不足 | 保存返回的 `messageId`,保持锁定并进入充值。 | -| `content_generation_failed` | 临时锁内容生成失败 | 后端尝试退回本次积分;保持锁定,允许稍后重试。 | -| `voice_generation_failed` | 已有语音消息生成失败 | 未扣积分,保持锁定并允许重试。 | -| `quota_exhausted` | 已有私密文本每日免费额度用完 | 展示 `lockDetail.hint`。 | -| `invalid_lock_type` | `lockType` 不支持 | 记录前端错误并停止请求。 | -| `not_found` | 消息不存在且没有有效 `lockType` | 禁用当前解锁按钮或重新同步 history。 | - -认证失败会返回 HTTP 401。请求字段类型不合法可能返回 HTTP 422。 - -## 11. 前端完整流程 - -```text -创建本地锁卡片并生成 clientLockId - -> 用户点击解锁 - -> POST /api/chat/unlock-private - -> unlocked=true:替换卡片并展示真实内容 - -> insufficient_credits:保存 messageId,打开充值 - -> 充值成功 - -> 使用相同 messageId + lockType + clientLockId 重试 - -> unlocked=true:替换卡片 - -> 页面刷新 - -> GET /api/chat/history - -> 只按 lockDetail.locked 恢复锁定或解锁状态 -``` - -## 12. 测试方法 - -必须使用测试账号,不要用真实付费用户验证扣费。 - -1. 准备余额低于 20 的测试账号。 -2. 不传 `messageId`,使用唯一 `clientLockId` 请求 `voice_message`。 -3. 确认 `unlocked=false`、`creditsCharged=0`,并取得非空 `messageId`。 -4. 调用 history,确认同一 `messageId` 存在且 `locked=true`、`audioUrl=null`。 -5. 给测试账号增加到至少 20 积分。 -6. 使用相同 `messageId`、`lockType`、`clientLockId` 再次请求。 -7. 确认 `unlocked=true`、`creditsCharged=20`、`audioUrl` 非空。 -8. 再次调用 history,确认同一消息 `locked=false` 且 `audioUrl` 非空。 -9. 对图片 40 积分和私密文本 10 积分重复验证。 -10. 测试结束后记录测试账号积分变化;生成的聊天记录会保留在 history 中,没有自动清理接口。 - -后端自动化测试: - -```powershell -.\.venv\Scripts\python.exe -m pytest tests\test_private_unlock.py -q -``` - -当前结果:`20 passed`;私密解锁、图片 paywall、私密相册和私密空间相关回归共 `48 passed`。 - -## 13. 兼容性与回滚 - -- 已有前端继续只传 `messageId` 的调用方式保持兼容。 -- 请求参数保持兼容:已有前端继续只传 `messageId` 仍可使用。 -- 响应字段有收敛:旧别名不再返回,前端需要按第 5、9 节使用标准字段。 -- 本次功能不需要数据库迁移,通过现有 `chat_messages` 和解锁记录保存状态。 -- 如果上线后需要回滚,只回滚本次后端路由和 schema 代码即可;已生成的聊天记录可保留,不影响旧 history 读取。 -- 上线前需将代码提交并推送到目标环境对应分支:`test`、`pro` 或 `main`,再部署对应环境。 - -## 14. Needs confirmation - -- `https://api.cozsweet.com` 是否继续作为生产 API 别名,不在本次代码和环境配置中确认;本文统一使用正式生产地址 `https://api.banlv-ai.com`。 diff --git a/docs/backend/FRONTEND_MULTI_CHARACTER_API.md b/docs/backend/FRONTEND_MULTI_CHARACTER_API.md deleted file mode 100644 index 28b43f6b..00000000 --- a/docs/backend/FRONTEND_MULTI_CHARACTER_API.md +++ /dev/null @@ -1,448 +0,0 @@ -# 单角色应用迁移为多角色聊天架构 - -## 1. 文档目标 - -本文档定义 CozSweet 从单角色 Elio 迁移到多角色架构时,后端需要完成的接口、数据模型和兼容策略调整。 - -迁移目标: - -1. 前端通过统一的 Character Catalog 边界读取角色目录,当前由本地只读目录提供,后端只识别稳定角色 ID。 -2. 同一用户可以分别与多个角色聊天。 -3. 不同角色的聊天历史、媒体、锁内容、私密相册和打赏归属必须隔离。 -4. 钱包、VIP、支付套餐和每日免费额度继续按用户全局共享。 -5. 现有客户端和历史数据继续默认归属于 Elio。 - -本文档中的标准角色标识: - -| 类型 | Elio 示例 | 用途 | -| --- | --- | --- | -| `id` | `character_elio` | 数据库关联、API 请求和缓存隔离 | -| `slug` | `elio` | 前端 URL 和外部入口 | - -后端业务接口必须使用 `id`,不能使用可能变化的展示名称。 - -环境地址: - -| 环境 | API Base URL | -| --- | --- | -| test 测试环境 | `https://testapi.banlv-ai.com` | -| pro 预发环境 | `https://proapi.banlv-ai.com` | -| production 生产环境 | `https://api.banlv-ai.com` | - -## 2. 前端 Character Catalog - -后端角色目录协议尚未确定。过渡阶段由前端本地只读 Catalog 提供角色,并使用 `slug` 解析角色路径、使用 `id` 调用后端业务接口。业务页面只消费 Catalog 边界;后续切换为服务端目录时不改变角色路由和业务组件接口。 - -当前角色: - -| `id` | `slug` | `displayName` | 头像 | 页面封面 | 私密空间 Banner | -| --- | --- | --- | --- | --- | --- | -| `character_elio` | `elio` | Elio Silvestri | `/images/avatar/elio.png` | `/images/cover/elio.png` | `/images/private-room/banner/elio.png` | -| `character_maya` | `maya` | Maya Tan | `/images/avatar/maya.png` | `/images/cover/maya.png` | `/images/private-room/banner/maya.png` | -| `character_nayeli` | `nayeli` | Nayeli Cervantes | `/images/avatar/nayeli.png` | `/images/cover/nayeli.png` | `/images/private-room/banner/nayeli.png` | - -当前开发环境开放表中的全部角色。Catalog 同时保存短名称、页面资源、空聊天问候语、排序及 Chat、Private Room、Tip 能力。后端配置中的角色 ID 必须与前端目录一致。 - -## 3. 聊天接口调整 - -### 3.1 通用规则 - -以下接口新增标准字段 `characterId`: - -```text -POST /api/chat/send -GET /api/chat/history -POST /api/chat/unlock-private -POST /api/chat/unlock-history -``` - -`characterId` 使用前端本地角色目录中的 `id`,例如 `character_elio`,不能传 slug 或显示名称。 - -后端必须使用以下维度定位聊天数据: - -```text -正式用户:userId + characterId -游客:guest identity/device identity + characterId -``` - -任何历史查询、消息发送、解锁和配额判断都不能只按用户查找。 - -### 3.2 发送消息 - -```http -POST /api/chat/send -Content-Type: application/json -Authorization: Bearer -``` - -现有请求体增加 `characterId`,其他字段保持原协议: - -```json -{ - "characterId": "character_elio", - "message": "Hello Elio", - "image": "", - "imageId": "", - "imageThumbUrl": "", - "imageMediumUrl": "", - "imageOriginalUrl": "", - "imageWidth": 0, - "imageHeight": 0, - "useWebSocket": false -} -``` - -后端处理要求: - -1. 校验角色存在且已启用。 -2. 使用 `characterId` 选择对应角色 Prompt、模型配置和聊天上下文。 -3. 只加载该用户与该角色的历史。 -4. 新消息和生成结果必须写入相同 `characterId`。 -5. 响应继续沿用现有 `ChatSendResponse`,不额外返回前端不使用的角色字段。 - -如果后端启用 WebSocket,连接握手或每条发送帧也必须携带 `characterId`,不能依赖服务端进程内的“当前角色”状态。 - -### 3.3 获取聊天历史 - -```http -GET /api/chat/history?characterId=character_elio&limit=50&offset=0 -Authorization: Bearer -``` - -| 参数 | 类型 | 是否必填 | 说明 | -| --- | --- | --- | --- | -| `characterId` | string | 是 | 角色业务 ID。 | -| `limit` | integer | 否 | 保持现有分页规则。 | -| `offset` | integer | 否 | 保持现有分页规则。 | - -响应结构和分页字段保持不变。`messages`、`total` 和 `offset` 必须只统计当前角色。 - -禁止在当前角色历史中返回其他角色的消息,即使消息属于同一用户。 - -### 3.4 解锁单条消息 - -```http -POST /api/chat/unlock-private -Content-Type: application/json -Authorization: Bearer -``` - -请求体增加 `characterId`: - -```json -{ - "characterId": "character_elio", - "messageId": "message-id", - "lockType": "voice_message", - "clientLockId": "client-generated-id" -} -``` - -现有 `messageId`、`lockType` 和 `clientLockId` 规则保持不变。 - -后端必须验证: - -- 已存在消息的 `character_id` 与请求 `characterId` 一致。 -- 临时锁消息不存在 `messageId` 时,新建消息必须写入请求角色。 -- `clientLockId` 的幂等范围至少包含身份和角色,推荐唯一键: - -```text -identity + character_id + client_lock_id -``` - -跨角色解锁返回 `CHARACTER_MISMATCH`,不能扣积分,也不能修改消息。 - -### 3.5 一键解锁历史 - -```http -POST /api/chat/unlock-history -Content-Type: application/json -Authorization: Bearer -``` - -请求体由空请求调整为: - -```json -{ - "characterId": "character_elio" -} -``` - -后端只能统计和解锁当前角色的锁消息。`requiredCredits`、解锁数量和返回 messages 都必须按当前角色计算。 - -## 4. 角色最新消息接口 - -角色列表需要一次性获取各角色的最新消息,避免对每个角色分别请求 history。 - -### 4.1 请求 - -```http -GET /api/chat/previews -Authorization: Bearer -``` - -### 4.2 响应 - -```json -{ - "success": true, - "message": "success", - "data": { - "items": [ - { - "characterId": "character_elio", - "message": { - "id": "message-id", - "role": "assistant", - "type": "text", - "content": "How was your day?", - "createdAt": "2026-07-17T10:00:00Z" - } - }, - { - "characterId": "character_aria", - "message": null - } - ] - } -} -``` - -- `items` 按前端本地角色目录顺序返回当前可用角色。 -- `message` 使用现有 ChatMessage wire 结构;没有历史时返回 `null`。 -- 锁消息继续遵循现有内容隐藏规则,不能通过预览接口泄露 URL 或锁定内容。 -- 查询应批量完成,避免后端内部出现按角色逐条查询的 N+1 问题。 - -## 5. 私密空间接口调整 - -### 5.1 获取相册 - -标准请求改为: - -```http -GET /api/private-room/albums?characterId=character_elio&limit=20 -Authorization: Bearer -``` - -兼容期继续接受旧参数: - -```http -GET /api/private-room/albums?character=elio&limit=20 -``` - -参数优先级: - -1. 存在 `characterId` 时使用标准 ID。 -2. 兼容期仅存在 `character` 时,将旧 slug 解析为角色 ID。 -3. 两者同时存在但指向不同角色时返回 `CHARACTER_MISMATCH`。 -4. 两者都缺失时,兼容期默认 Elio。 - -响应结构保持不变,不增加前端未使用的角色字段。 - -### 5.2 解锁相册 - -接口路径保持不变: - -```http -POST /api/private-room/albums/{albumId}/unlock -``` - -`albumId` 已能唯一确定角色,因此请求体不增加 `characterId`。后端必须根据 album 关联的角色进行权限、积分和解锁记录校验。 - -## 6. Tip 支付接口调整 - -### 6.1 创建订单 - -现有接口: - -```http -POST /api/payment/create-order -``` - -请求体增加可选字段 `recipientCharacterId`: - -```json -{ - "planId": "tip_coffee_medium", - "payChannel": "stripe", - "autoRenew": false, - "recipientCharacterId": "character_elio" -} -``` - -后端必须先根据 `planId` 判断订单类型: - -| 订单类型 | `recipientCharacterId` 规则 | -| --- | --- | -| Tip | 必填,并校验角色存在且启用 | -| VIP | 忽略,不保存 | -| Top-up | 忽略,不保存 | - -Tip 订单和最终支付记录需要保存 `recipient_character_id`,用于归属、统计和后续展示。 - -支付套餐、创建订单响应和订单状态响应不增加角色字段。 - -## 7. 数据模型调整 - -### 7.1 Characters 表 - -后台建议至少保存: - -| 字段 | 说明 | -| --- | --- | -| `id` | 主键,稳定角色 ID。 | -| `slug` | 唯一 URL 标识。 | -| `display_name` | 展示名称。 | -| `avatar_url` | 头像地址。 | -| `enabled` | 是否对用户启用,仅后台使用。 | -| `sort_order` | 运营排序,仅后台使用。 | -| `created_at` | 创建时间。 | -| `updated_at` | 更新时间。 | - -`enabled` 和 `sort_order` 是后端管理字段,不返回前端。 - -### 7.2 业务表 - -以下数据增加 `character_id` 外键: - -- conversations 或等价会话表; -- chat messages; -- 锁消息、解锁记录和 `clientLockId` 幂等记录; -- private albums 及相册解锁记录; -- Tip 订单或 Tip 业务记录。 - -推荐索引: - -```text -(user_id, character_id, created_at) -(guest_id, character_id, created_at) -(character_id, enabled) -(identity, character_id, client_lock_id) UNIQUE -``` - -如果当前系统没有 conversations 表,可以继续使用 messages,但所有历史查询必须显式包含 `character_id`。 - -## 8. 历史数据迁移 - -上线新接口前执行: - -1. 创建 Elio 角色,固定 `id=character_elio`、`slug=elio`。 -2. 为相关业务表增加可空 `character_id`。 -3. 将现有消息、锁记录、相册和 Tip 记录全部回填为 `character_elio`。 -4. 检查不存在空角色或无法关联的数据。 -5. 创建联合索引和外键。 -6. 将必要业务表的 `character_id` 调整为非空。 - -回填前后需要核对每张表的记录数量,迁移不能删除历史聊天或解锁状态。 - -## 9. 兼容发布策略 - -### 第一阶段:后端兼容 - -- 上线角色 ID 配置和新字段。 -- Chat 请求缺少 `characterId` 时暂时默认 `character_elio`。 -- Private Room 缺少角色时暂时默认 Elio。 -- Tip 旧客户端缺少 recipient 时,旧 Tip 订单暂时默认 Elio。 -- 记录缺少角色参数的调用次数和客户端版本。 - -### 第二阶段:前端迁移 - -- 新前端所有请求显式发送角色 ID。 -- 动态路由和支付回跳携带角色上下文。 -- 观察错误率、跨角色校验和旧请求占比。 - -### 第三阶段:收紧协议 - -- Chat 接口缺少 `characterId` 时返回 `CHARACTER_REQUIRED`。 -- Tip 订单缺少 recipient 时返回 `CHARACTER_REQUIRED`。 -- 删除 Private Room 旧 `character` 参数。 -- 删除默认 Elio 的接口兼容逻辑。 - -由于 PWA 和浏览器可能长期缓存旧前端,第三阶段只能在旧请求流量降至可接受水平后执行。 - -只有“完全缺少角色字段”的旧请求可以在兼容期默认 Elio。显式传入不存在、禁用或不匹配角色时不得回退 Elio。 - -## 10. 错误响应 - -统一失败 envelope: - -```json -{ - "success": false, - "message": "Character is not available", - "error": "CHARACTER_DISABLED" -} -``` - -新增错误: - -| HTTP 状态 | error | 场景 | -| ---: | --- | --- | -| `400` | `CHARACTER_REQUIRED` | 收紧协议后缺少角色字段。 | -| `404` | `CHARACTER_NOT_FOUND` | `id` 或兼容 slug 不存在。 | -| `403` | `CHARACTER_DISABLED` | 角色存在但未启用。 | -| `409` | `CHARACTER_MISMATCH` | 消息、相册或重复参数属于不同角色。 | - -角色错误不能扣积分、创建消息、创建订单或修改任何解锁状态。 - -## 11. 安全和一致性要求 - -- 角色 ID 必须由后端查询验证,不能信任客户端展示信息。 -- 用户只能读取自己的某个角色会话,不能通过替换 characterId 读取其他用户数据。 -- messageId、albumId 和 clientLockId 必须同时校验身份与角色归属。 -- 角色禁用后不能继续发送、解锁、创建相册订单或 Tip 订单。 -- 角色禁用不删除历史数据,恢复启用后历史仍可读取。 -- 日志可以记录 `characterId`,但不能记录聊天正文、Token 或私密媒体 URL。 -- Analytics 和业务统计统一使用角色 ID,不使用 displayName。 - -## 12. 联调验收 - -后端完成后至少验证: - -1. 前端本地目录中的每个角色 ID 均可调用 Chat、Private Room 和 Tip 业务接口。 -2. Login Token 和 Guest Token 均可按角色 ID 聊天。 -3. 同一用户在 Elio 和其他角色中的历史完全隔离。 -4. 发送消息、历史分页、单条解锁和历史解锁都限定当前角色。 -5. 使用 Elio 的 messageId 配合其他角色 ID 解锁时返回 `CHARACTER_MISMATCH`,且不扣积分。 -6. Private Room 的标准 characterId 与旧 character slug 在兼容期结果一致。 -7. Tip 订单正确保存 `recipient_character_id`,VIP 和 Top-up 不保存。 -8. 角色预览接口一次返回各启用角色的最新消息,没有历史时返回 `null`。 -9. 后端禁用角色后,直接访问业务接口返回 `CHARACTER_DISABLED`;前端发布时同步移除本地记录。 -10. 缺少角色字段的旧请求在兼容期仍进入 Elio。 -11. 历史数据回填前后记录数量、积分和解锁状态一致。 - -## 13. OpenAPI 参考 - -```yaml -paths: - /api/chat/history: - get: - parameters: - - in: query - name: characterId - required: true - schema: - type: string - - in: query - name: limit - required: false - schema: - type: integer - - in: query - name: offset - required: false - schema: - type: integer - /api/chat/unlock-history: - post: - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [characterId] - properties: - characterId: - type: string -``` diff --git a/docs/backend/FRONTEND_MULTI_ROLE_CHAT_API.md b/docs/backend/FRONTEND_MULTI_ROLE_CHAT_API.md index 971b8585..7acd4825 100644 --- a/docs/backend/FRONTEND_MULTI_ROLE_CHAT_API.md +++ b/docs/backend/FRONTEND_MULTI_ROLE_CHAT_API.md @@ -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/characters?capability=chat +Authorization: Bearer ``` -聊天业务统一使用响应里的 `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/chat/send @@ -30,73 +97,56 @@ Content-Type: application/json Authorization: Bearer ``` -### 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 ' \ - -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/chat/history?characterId=maya-tan&limit=50&offset=0 Authorization: Bearer ``` -| 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": "", - "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/chat/previews @@ -105,20 +155,29 @@ Authorization: Bearer ```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": "", + "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/chat/unlock-private @@ -126,44 +185,144 @@ Content-Type: application/json Authorization: Bearer ``` +已有后端消息: + ```json { "characterId": "maya-tan", - "messageId": "", - "lockType": "voice_message", - "clientLockId": "maya-card-001" + "messageId": "" } ``` -`messageId` 存在时,后端会核对消息角色。拿 Elio 的 `messageId` 配 Maya 的 `characterId` 会返回 HTTP `409 / CHARACTER_MISMATCH`,且不会扣积分。 +前端 Promotion 锁卡片: -前端临时伪造锁卡片没有 `messageId` 时,仍传 `characterId + lockType + clientLockId`。`clientLockId` 的幂等查找范围已包含账号和角色,同一个 ID 可以在不同角色下分别使用。 +```json +{ + "characterId": "maya-tan", + "lockType": "voice_message", + "clientLockId": "" +} +``` -## 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/chat/unlock-history Content-Type: application/json Authorization: Bearer -``` -```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` 可从响应补齐。 diff --git a/src/app/chat/chat-screen.tsx b/src/app/chat/chat-screen.tsx index 3b3030bf..d701b0ec 100644 --- a/src/app/chat/chat-screen.tsx +++ b/src/app/chat/chat-screen.tsx @@ -98,7 +98,7 @@ export function ChatScreen() { [imageMessageId, visibleMessages], ); - // isGuest 派生(chat-screen 是唯一知道这个值的地方 —— chat 机器无 isGuest 字段) + // Guest status belongs to auth state and is not duplicated in the Chat actor. const isGuest = deriveIsGuest(authState.loginStatus); // 发送能力由后端统一返回,当前只处理积分不足引导。 @@ -251,7 +251,6 @@ export function ChatScreen() {
- {/* isGuest 派生自 auth.loginStatus */} void) | undefined; const init = async () => { - // const isDev = AppEnvUtil.isDevelopment(); - const isDev = false; - // 提前挂 beforeinstallprompt 监听,避免用户点 OK 时事件已在更早时机丢失。 pwaUtil.prepareInstallPrompt(); const tryScheduleDialog = async () => { - const shouldShowDialog = await shouldShowPwaInstallDialog(isDev); + const shouldShowDialog = await shouldShowPwaInstallDialog(); if (!mounted || !shouldShowDialog || timer !== undefined) return; // 达到聊天数量阈值后稍作停顿,避免弹窗与消息刷新挤在同一瞬间。 - const delay = isDev - ? DEVELOPMENT_DIALOG_DELAY_MS - : PRODUCTION_DIALOG_DELAY_MS; timer = window.setTimeout(() => { if (!mounted) return; showPwaDialog(); - }, delay); + }, DIALOG_DELAY_MS); }; await tryScheduleDialog(); @@ -76,26 +64,18 @@ export function PwaInstallOverlay({ enabled }: PwaInstallOverlayProps) { return null; } -async function shouldShowPwaInstallDialog(isDevelopment: boolean) { - // PWA 支持 / 安装 双重门禁(两种环境都检查)。 +async function shouldShowPwaInstallDialog() { if (!pwaUtil.canShowInstallEntry()) return false; - - // 开发环境:跳过"只弹一次"门禁,每次进入都弹,方便 UI 测试。 - if (isDevelopment) return true; - - // 生产环境:永久只弹一次(防骚扰)。 return canShowPwaInstallPromptOnce(); } function showPwaDialog() { if (typeof document === "undefined") return; - // 记录已显示,后续不再弹出。 + // Persist before rendering so remounts cannot schedule a second dialog. void recordPwaInstallPromptShown(); - // 创建挂载点 const root = document.createElement("div"); document.body.appendChild(root); - // 用 React DOM 渲染(通过 import) import("react-dom/client").then(({ createRoot }) => { const reactRoot = createRoot(root); reactRoot.render(); @@ -116,13 +96,7 @@ function PwaInstallDialogMount({ rootEl.remove(); }} onInstall={async () => { - // 走 pwaUtil.install(): - // - 优先使用已缓存的 deferred prompt - // - 如未缓存,再等待 deferred 事件 ready(3s 超时) - // - 调浏览器原生 prompt - // - 返 "accepted" / "dismissed" / "unavailable" - // 当前不消费返回值 —— 用户已经看到 dialog 点了 OK,是否真装上是浏览器的事。 - // 后续可在此接 metrics 上报(pwa_accepted / pwa_dismissed / pwa_unavailable)。 + // The utility owns deferred-prompt lookup and browser prompting. await pwaUtil.install(); }} /> diff --git a/src/data/repositories/chat_repository.ts b/src/data/repositories/chat_repository.ts index 979818ec..9323911c 100644 --- a/src/data/repositories/chat_repository.ts +++ b/src/data/repositories/chat_repository.ts @@ -103,7 +103,7 @@ export class ChatRepository implements IChatRepository { /** * 批量覆盖写入:先清空本地存储,再写入新列表。 - * 保留 Dart 端的「先清后写」语义(用于同步场景的整批刷新)。 + * 用于网络历史同步后的整批刷新。 */ async saveMessagesToLocal( messages: readonly ChatMessage[], @@ -126,7 +126,7 @@ export class ChatRepository implements IChatRepository { /** * 获取本地消息数量。 - * 替代 Dart 的同步 `int get localMessageCount`——Dexie 异步 API 决定必须 async。 + * Dexie 查询是异步操作,因此始终返回 Promise。 */ async getLocalMessageCount(cacheIdentity?: string): Promise> { return this.localMessages.getMessageCount(cacheIdentity); diff --git a/src/stores/chat/chat-context.tsx b/src/stores/chat/chat-context.tsx index adebe37e..105909fc 100644 --- a/src/stores/chat/chat-context.tsx +++ b/src/stores/chat/chat-context.tsx @@ -8,12 +8,7 @@ import { chatMachine } from "./chat-machine"; import type { ChatEvent, ChatState as MachineContext } from "./chat-machine"; import { appendPromotionMessage } from "./helper/promotion"; -/** - * 对外暴露的 State 形状 - * - * isGuest 字段已删 —— 由 chat-screen 派生自 `auth.loginStatus === "guest"`。 - * 消费方(chat-screen)通过 `useAuthState()` 取 loginStatus,本地派生 isGuest。 - */ +/** Chat UI reads this projection instead of the machine snapshot directly. */ interface ChatState { characterId: string; characterErrorCode: MachineContext["characterErrorCode"]; @@ -25,7 +20,7 @@ interface ChatState { canSendMessage: boolean; upgradePromptVisible: boolean; upgradeReason: MachineContext["upgradeReason"]; - /** history 初始加载完成标志(local → network → save 跑完) —— UI 可用此显示 loading */ + /** True as soon as a local snapshot or the first network result is renderable. */ historyLoaded: boolean; hasMoreHistory: boolean; isLoadingMoreHistory: boolean; diff --git a/src/stores/chat/chat-events.ts b/src/stores/chat/chat-events.ts index 22b00285..02d67a87 100644 --- a/src/stores/chat/chat-events.ts +++ b/src/stores/chat/chat-events.ts @@ -1,26 +1,14 @@ /** - * Chat 状态机:事件联合 - * - * 事件名与原 Dart ChatEvent 对齐。 - * - * 历史:原本有 `ChatAuthStatusChanged`(chat 机器刷新 isGuest)—— 已删。 - * 鉴权状态变化(loginStatus)由 通过 `useAuthState()` 订阅, - * chat 机器不感知鉴权。 - * - * 鉴权生命周期事件(本轮新增): - * - `ChatGuestLogin`:游客进入 /chat —— 拉服务器端首屏 - * - `ChatUserLogin`:其他登录用户进入 /chat —— 拉服务器端首屏 - * - `ChatLogout`:正式登录用户登出 —— 清消息 - * - * 设计:所有历史 / 配额相关副作用全部通过派发事件给 chat 机器处理, - * chat-screen 不直接读 AuthStorage(除 token 取值)。 + * Public events for one character-scoped Chat actor. + * Authentication synchronization dispatches the lifecycle events; history, + * quota, send, and unlock side effects remain owned by the machine. */ import type { PendingChatUnlockKind } from "@/lib/navigation/chat_unlock_session"; import type { ChatLockType } from "@/data/schemas/chat"; import type { PendingChatPromotion } from "@/data/storage/navigation"; export type ChatEvent = - // 鉴权生命周期(登录态变化后由 ChatAuthSync 派) + // Authentication lifecycle dispatched by ChatAuthSync. | { type: "ChatGuestLogin" } | { type: "ChatUserLogin"; token: string } | { type: "ChatLogout" } @@ -40,7 +28,7 @@ export type ChatEvent = } | { type: "ChatOlderHistoryLoadFailed"; error: unknown } | { type: "ChatHistoryRefreshRequested" } - // 业务事件 + // Chat domain events. | { type: "ChatSendMessage"; content: string } | { type: "ChatSendImage"; imageBase64: string } | { diff --git a/src/stores/chat/helper/history.ts b/src/stores/chat/helper/history.ts index d61c3bdd..c1253d15 100644 --- a/src/stores/chat/helper/history.ts +++ b/src/stores/chat/helper/history.ts @@ -2,7 +2,7 @@ import type { UiMessage } from "@/stores/chat/ui-message"; import type { ChatState } from "../chat-state"; -// The initial history request remains bounded even though pagination is disabled. +// Initial and subsequent history pages use the same bounded page size. export const CHAT_HISTORY_LIMIT = 50; export function applyHistoryLoadedOutput(