feat(characters): support character-scoped conversations
This commit is contained in:
@@ -14,6 +14,7 @@
|
|||||||
"./src/data/services/api",
|
"./src/data/services/api",
|
||||||
"./src/data/dto/auth",
|
"./src/data/dto/auth",
|
||||||
"./src/data/dto/chat",
|
"./src/data/dto/chat",
|
||||||
|
"./src/data/dto/character",
|
||||||
"./src/data/dto/feedback",
|
"./src/data/dto/feedback",
|
||||||
"./src/data/dto/feedback/response",
|
"./src/data/dto/feedback/response",
|
||||||
"./src/data/dto/metrics",
|
"./src/data/dto/metrics",
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
"./src/data/schemas/auth/request",
|
"./src/data/schemas/auth/request",
|
||||||
"./src/data/schemas/auth/response",
|
"./src/data/schemas/auth/response",
|
||||||
"./src/data/schemas/chat",
|
"./src/data/schemas/chat",
|
||||||
|
"./src/data/schemas/character",
|
||||||
"./src/data/schemas/chat/request",
|
"./src/data/schemas/chat/request",
|
||||||
"./src/data/schemas/chat/response",
|
"./src/data/schemas/chat/response",
|
||||||
"./src/data/schemas/feedback",
|
"./src/data/schemas/feedback",
|
||||||
|
|||||||
@@ -0,0 +1,544 @@
|
|||||||
|
# 单角色应用迁移为多角色聊天架构
|
||||||
|
|
||||||
|
## 1. 文档目标
|
||||||
|
|
||||||
|
本文档定义 CozSweet 从单角色 Elio 迁移到多角色架构时,后端需要完成的接口、数据模型和兼容策略调整。
|
||||||
|
|
||||||
|
迁移目标:
|
||||||
|
|
||||||
|
1. 后端提供统一角色目录。
|
||||||
|
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. 角色目录接口
|
||||||
|
|
||||||
|
### 2.1 请求
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET <API_BASE_URL>/api/characters
|
||||||
|
Authorization: Bearer <TOKEN>
|
||||||
|
```
|
||||||
|
|
||||||
|
接口支持正式用户 Login Token 和游客 Guest Token。
|
||||||
|
|
||||||
|
### 2.2 成功响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "success",
|
||||||
|
"data": {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "character_elio",
|
||||||
|
"slug": "elio",
|
||||||
|
"displayName": "Elio Silvestri",
|
||||||
|
"avatarUrl": "https://cdn.example.com/characters/elio/avatar.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "character_aria",
|
||||||
|
"slug": "aria",
|
||||||
|
"displayName": "Aria",
|
||||||
|
"avatarUrl": "https://cdn.example.com/characters/aria/avatar.jpg"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
公开字段只有:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 是否必填 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `id` | string | 是 | 稳定、唯一、不可变的角色业务 ID。 |
|
||||||
|
| `slug` | string | 是 | 稳定、唯一、URL 安全的角色标识。 |
|
||||||
|
| `displayName` | string | 是 | 前端展示名称。 |
|
||||||
|
| `avatarUrl` | string | 是 | HTTPS 头像地址。 |
|
||||||
|
|
||||||
|
后端只返回启用角色,并按照后台运营顺序排列 `items`。前端直接使用数组顺序,不执行二次排序。
|
||||||
|
|
||||||
|
响应中不要增加以下字段:
|
||||||
|
|
||||||
|
- `enabled`
|
||||||
|
- `sortOrder`
|
||||||
|
- `coverUrl`
|
||||||
|
- `chatBackgroundUrl`
|
||||||
|
- `tagline`
|
||||||
|
- `emptyChatGreeting`
|
||||||
|
- `capabilities`
|
||||||
|
- 创建人、更新时间等后台管理字段
|
||||||
|
|
||||||
|
当前约定所有返回的角色都支持聊天、私密空间和打赏。
|
||||||
|
|
||||||
|
### 2.3 角色字段规则
|
||||||
|
|
||||||
|
- `id` 建议使用不可枚举 UUID 或稳定业务 ID,最大长度 64。
|
||||||
|
- `slug` 只允许小写字母、数字和连字符,建议校验 `^[a-z0-9]+(?:-[a-z0-9]+)*$`。
|
||||||
|
- `slug` 发布后不能修改;必须修改时应保留旧 slug 重定向映射。
|
||||||
|
- `displayName` 去除首尾空白后不能为空,建议最大长度 80。
|
||||||
|
- `avatarUrl` 必须为有效 HTTPS URL。
|
||||||
|
- `id` 和 `slug` 都需要数据库唯一约束。
|
||||||
|
|
||||||
|
角色目录不需要分页,也不返回 `total`。
|
||||||
|
|
||||||
|
## 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 <TOKEN>
|
||||||
|
```
|
||||||
|
|
||||||
|
现有请求体增加 `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 <TOKEN>
|
||||||
|
```
|
||||||
|
|
||||||
|
| 参数 | 类型 | 是否必填 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `characterId` | string | 是 | 角色业务 ID。 |
|
||||||
|
| `limit` | integer | 否 | 保持现有分页规则。 |
|
||||||
|
| `offset` | integer | 否 | 保持现有分页规则。 |
|
||||||
|
|
||||||
|
响应结构和分页字段保持不变。`messages`、`total` 和 `offset` 必须只统计当前角色。
|
||||||
|
|
||||||
|
禁止在当前角色历史中返回其他角色的消息,即使消息属于同一用户。
|
||||||
|
|
||||||
|
### 3.4 解锁单条消息
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/chat/unlock-private
|
||||||
|
Content-Type: application/json
|
||||||
|
Authorization: Bearer <TOKEN>
|
||||||
|
```
|
||||||
|
|
||||||
|
请求体增加 `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 <TOKEN>
|
||||||
|
```
|
||||||
|
|
||||||
|
请求体由空请求调整为:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"characterId": "character_elio"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
后端只能统计和解锁当前角色的锁消息。`requiredCredits`、解锁数量和返回 messages 都必须按当前角色计算。
|
||||||
|
|
||||||
|
## 4. 角色最新消息接口
|
||||||
|
|
||||||
|
角色列表需要一次性获取各角色的最新消息,避免对每个角色分别请求 history。
|
||||||
|
|
||||||
|
### 4.1 请求
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/chat/previews
|
||||||
|
Authorization: Bearer <TOKEN>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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 <TOKEN>
|
||||||
|
```
|
||||||
|
|
||||||
|
兼容期继续接受旧参数:
|
||||||
|
|
||||||
|
```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. 兼容发布策略
|
||||||
|
|
||||||
|
### 第一阶段:后端兼容
|
||||||
|
|
||||||
|
- 上线角色目录和新字段。
|
||||||
|
- 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. `GET /api/characters` 只返回 4 个公开字段。
|
||||||
|
2. 角色目录只包含启用角色,并严格按照后台排序。
|
||||||
|
3. Login Token 和 Guest Token 均可获取角色目录并聊天。
|
||||||
|
4. 同一用户在 Elio 和其他角色中的历史完全隔离。
|
||||||
|
5. 发送消息、历史分页、单条解锁和历史解锁都限定当前角色。
|
||||||
|
6. 使用 Elio 的 messageId 配合其他角色 ID 解锁时返回 `CHARACTER_MISMATCH`,且不扣积分。
|
||||||
|
7. Private Room 的标准 characterId 与旧 character slug 在兼容期结果一致。
|
||||||
|
8. Tip 订单正确保存 `recipient_character_id`,VIP 和 Top-up 不保存。
|
||||||
|
9. 角色预览接口一次返回各启用角色的最新消息,没有历史时返回 `null`。
|
||||||
|
10. 禁用角色从目录消失,直接访问业务接口返回 `CHARACTER_DISABLED`。
|
||||||
|
11. 缺少角色字段的旧请求在兼容期仍进入 Elio。
|
||||||
|
12. 历史数据回填前后记录数量、积分和解锁状态一致。
|
||||||
|
|
||||||
|
## 13. OpenAPI 参考
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
paths:
|
||||||
|
/api/characters:
|
||||||
|
get:
|
||||||
|
summary: List enabled characters in display order
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Enabled characters
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [success, data]
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
const: true
|
||||||
|
data:
|
||||||
|
type: object
|
||||||
|
required: [items]
|
||||||
|
properties:
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
required: [id, slug, displayName, avatarUrl]
|
||||||
|
additionalProperties: false
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
slug:
|
||||||
|
type: string
|
||||||
|
pattern: "^[a-z0-9]+(?:-[a-z0-9]+)*$"
|
||||||
|
displayName:
|
||||||
|
type: string
|
||||||
|
avatarUrl:
|
||||||
|
type: string
|
||||||
|
format: uri
|
||||||
|
/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
|
||||||
|
```
|
||||||
@@ -52,6 +52,7 @@ export function ChatScreen() {
|
|||||||
const authState = useAuthState();
|
const authState = useAuthState();
|
||||||
const authDispatch = useAuthDispatch();
|
const authDispatch = useAuthDispatch();
|
||||||
useSplashLatestMessageSync({
|
useSplashLatestMessageSync({
|
||||||
|
characterId: state.characterId,
|
||||||
historyLoaded: state.historyLoaded,
|
historyLoaded: state.historyLoaded,
|
||||||
loginStatus: authState.loginStatus,
|
loginStatus: authState.loginStatus,
|
||||||
messages: state.historyMessages,
|
messages: state.historyMessages,
|
||||||
@@ -182,6 +183,7 @@ export function ChatScreen() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<ChatArea
|
<ChatArea
|
||||||
|
characterId={state.characterId}
|
||||||
messages={visibleMessages}
|
messages={visibleMessages}
|
||||||
isReplyingAI={state.isReplyingAI}
|
isReplyingAI={state.isReplyingAI}
|
||||||
scrollToBottomSignal={state.outgoingMessageRevision}
|
scrollToBottomSignal={state.outgoingMessageRevision}
|
||||||
@@ -220,6 +222,7 @@ export function ChatScreen() {
|
|||||||
|
|
||||||
{selectedImageMessage?.imageUrl ? (
|
{selectedImageMessage?.imageUrl ? (
|
||||||
<FullscreenImageViewer
|
<FullscreenImageViewer
|
||||||
|
characterId={state.characterId}
|
||||||
messageId={selectedImageMessage.id}
|
messageId={selectedImageMessage.id}
|
||||||
imageUrl={selectedImageMessage.imageUrl}
|
imageUrl={selectedImageMessage.imageUrl}
|
||||||
imagePaywalled={selectedImageMessage.imagePaywalled === true}
|
imagePaywalled={selectedImageMessage.imagePaywalled === true}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
import { LoaderCircle } from "lucide-react";
|
import { LoaderCircle } from "lucide-react";
|
||||||
|
|
||||||
import type { UiMessage } from "@/data/dto/chat";
|
import type { UiMessage } from "@/data/dto/chat";
|
||||||
|
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
|
||||||
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
|
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -45,6 +46,7 @@ const ReplyingAnimation = lazy(() =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
export interface ChatAreaProps {
|
export interface ChatAreaProps {
|
||||||
|
characterId?: string;
|
||||||
messages: readonly UiMessage[];
|
messages: readonly UiMessage[];
|
||||||
isReplyingAI: boolean;
|
isReplyingAI: boolean;
|
||||||
scrollToBottomSignal?: number;
|
scrollToBottomSignal?: number;
|
||||||
@@ -61,6 +63,7 @@ export interface ChatAreaProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ChatArea({
|
export function ChatArea({
|
||||||
|
characterId = DEFAULT_CHARACTER_ID,
|
||||||
messages,
|
messages,
|
||||||
isReplyingAI,
|
isReplyingAI,
|
||||||
scrollToBottomSignal = 0,
|
scrollToBottomSignal = 0,
|
||||||
@@ -235,6 +238,7 @@ export function ChatArea({
|
|||||||
<AiDisclosureBanner />
|
<AiDisclosureBanner />
|
||||||
|
|
||||||
{renderMessagesWithDateHeaders(
|
{renderMessagesWithDateHeaders(
|
||||||
|
characterId,
|
||||||
messages,
|
messages,
|
||||||
getMessageKey,
|
getMessageKey,
|
||||||
isUnlockingMessage,
|
isUnlockingMessage,
|
||||||
@@ -270,6 +274,7 @@ function isNearBottom(scrollNode: HTMLElement): boolean {
|
|||||||
|
|
||||||
/** 渲染消息列表(按日期分组插入分隔条) */
|
/** 渲染消息列表(按日期分组插入分隔条) */
|
||||||
function renderMessagesWithDateHeaders(
|
function renderMessagesWithDateHeaders(
|
||||||
|
characterId: string,
|
||||||
messages: readonly UiMessage[],
|
messages: readonly UiMessage[],
|
||||||
getMessageKey: ChatMessageKeyResolver,
|
getMessageKey: ChatMessageKeyResolver,
|
||||||
isUnlockingMessage?: boolean,
|
isUnlockingMessage?: boolean,
|
||||||
@@ -284,6 +289,7 @@ function renderMessagesWithDateHeaders(
|
|||||||
<DateHeader key={item.key} date={item.date} />
|
<DateHeader key={item.key} date={item.date} />
|
||||||
) : (
|
) : (
|
||||||
<MessageBubble
|
<MessageBubble
|
||||||
|
characterId={characterId}
|
||||||
key={item.key}
|
key={item.key}
|
||||||
messageId={item.message.id}
|
messageId={item.message.id}
|
||||||
content={item.message.content}
|
content={item.message.content}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import { useCachedChatMediaUrl } from "@/lib/chat/use_cached_chat_media_url";
|
import { useCachedChatMediaUrl } from "@/lib/chat/use_cached_chat_media_url";
|
||||||
|
|
||||||
export interface ChatMediaImageProps {
|
export interface ChatMediaImageProps {
|
||||||
|
characterId: string;
|
||||||
messageId?: string;
|
messageId?: string;
|
||||||
remoteUrl: string;
|
remoteUrl: string;
|
||||||
alt?: string;
|
alt?: string;
|
||||||
@@ -28,6 +29,7 @@ export interface ChatMediaImageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ChatMediaImage({
|
export function ChatMediaImage({
|
||||||
|
characterId,
|
||||||
messageId,
|
messageId,
|
||||||
remoteUrl,
|
remoteUrl,
|
||||||
alt = "",
|
alt = "",
|
||||||
@@ -45,6 +47,7 @@ export function ChatMediaImage({
|
|||||||
const [errorSrc, setErrorSrc] = useState<string | null>(null);
|
const [errorSrc, setErrorSrc] = useState<string | null>(null);
|
||||||
const { mediaUrl, isUsingCachedMedia, reportMediaError } =
|
const { mediaUrl, isUsingCachedMedia, reportMediaError } =
|
||||||
useCachedChatMediaUrl({
|
useCachedChatMediaUrl({
|
||||||
|
characterId,
|
||||||
messageId,
|
messageId,
|
||||||
remoteUrl,
|
remoteUrl,
|
||||||
kind: "image",
|
kind: "image",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { ChatMediaImage } from "./chat-media-image";
|
|||||||
import styles from "./fullscreen-image-viewer.module.css";
|
import styles from "./fullscreen-image-viewer.module.css";
|
||||||
|
|
||||||
export interface FullscreenImageViewerProps {
|
export interface FullscreenImageViewerProps {
|
||||||
|
characterId: string;
|
||||||
messageId?: string;
|
messageId?: string;
|
||||||
imageUrl: string;
|
imageUrl: string;
|
||||||
imagePaywalled?: boolean;
|
imagePaywalled?: boolean;
|
||||||
@@ -27,6 +28,7 @@ export interface FullscreenImageViewerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FullscreenImageViewer({
|
export function FullscreenImageViewer({
|
||||||
|
characterId,
|
||||||
messageId,
|
messageId,
|
||||||
imageUrl,
|
imageUrl,
|
||||||
imagePaywalled = false,
|
imagePaywalled = false,
|
||||||
@@ -51,6 +53,7 @@ export function FullscreenImageViewer({
|
|||||||
aria-label="Locked fullscreen image"
|
aria-label="Locked fullscreen image"
|
||||||
>
|
>
|
||||||
<ChatMediaImage
|
<ChatMediaImage
|
||||||
|
characterId={characterId}
|
||||||
messageId={messageId}
|
messageId={messageId}
|
||||||
remoteUrl={imageUrl}
|
remoteUrl={imageUrl}
|
||||||
className={styles.paywallImage}
|
className={styles.paywallImage}
|
||||||
@@ -100,6 +103,7 @@ export function FullscreenImageViewer({
|
|||||||
variant="unstyled"
|
variant="unstyled"
|
||||||
/>
|
/>
|
||||||
<ChatMediaImage
|
<ChatMediaImage
|
||||||
|
characterId={characterId}
|
||||||
messageId={messageId}
|
messageId={messageId}
|
||||||
remoteUrl={imageUrl}
|
remoteUrl={imageUrl}
|
||||||
className={styles.viewerImage}
|
className={styles.viewerImage}
|
||||||
|
|||||||
@@ -9,8 +9,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ChatMediaImage } from "./chat-media-image";
|
import { ChatMediaImage } from "./chat-media-image";
|
||||||
|
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
|
||||||
|
|
||||||
export interface ImageBubbleProps {
|
export interface ImageBubbleProps {
|
||||||
|
characterId?: string;
|
||||||
messageId?: string;
|
messageId?: string;
|
||||||
imageUrl: string; // base64 data URI 或 URL
|
imageUrl: string; // base64 data URI 或 URL
|
||||||
imagePaywalled?: boolean;
|
imagePaywalled?: boolean;
|
||||||
@@ -18,6 +20,7 @@ export interface ImageBubbleProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ImageBubble({
|
export function ImageBubble({
|
||||||
|
characterId = DEFAULT_CHARACTER_ID,
|
||||||
messageId,
|
messageId,
|
||||||
imageUrl,
|
imageUrl,
|
||||||
imagePaywalled = false,
|
imagePaywalled = false,
|
||||||
@@ -52,6 +55,7 @@ export function ImageBubble({
|
|||||||
aria-label={canOpen ? "Open image in fullscreen" : undefined}
|
aria-label={canOpen ? "Open image in fullscreen" : undefined}
|
||||||
>
|
>
|
||||||
<ChatMediaImage
|
<ChatMediaImage
|
||||||
|
characterId={characterId}
|
||||||
messageId={messageId}
|
messageId={messageId}
|
||||||
remoteUrl={imageUrl}
|
remoteUrl={imageUrl}
|
||||||
className={imageClassName}
|
className={imageClassName}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { MessageContent } from "./message-content";
|
|||||||
import styles from "./chat-area.module.css";
|
import styles from "./chat-area.module.css";
|
||||||
|
|
||||||
export interface MessageBubbleProps {
|
export interface MessageBubbleProps {
|
||||||
|
characterId: string;
|
||||||
messageId?: string;
|
messageId?: string;
|
||||||
content: string;
|
content: string;
|
||||||
imageUrl?: string | null;
|
imageUrl?: string | null;
|
||||||
@@ -33,6 +34,7 @@ export interface MessageBubbleProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function MessageBubble({
|
export function MessageBubble({
|
||||||
|
characterId,
|
||||||
messageId,
|
messageId,
|
||||||
content,
|
content,
|
||||||
imageUrl,
|
imageUrl,
|
||||||
@@ -61,6 +63,7 @@ export function MessageBubble({
|
|||||||
<MessageAvatar isFromAI={true} />
|
<MessageAvatar isFromAI={true} />
|
||||||
<div className={styles.bubbleInlineSpacer} aria-hidden="true" />
|
<div className={styles.bubbleInlineSpacer} aria-hidden="true" />
|
||||||
<MessageContent
|
<MessageContent
|
||||||
|
characterId={characterId}
|
||||||
content={content}
|
content={content}
|
||||||
imageUrl={imageUrl}
|
imageUrl={imageUrl}
|
||||||
imagePaywalled={imagePaywalled}
|
imagePaywalled={imagePaywalled}
|
||||||
@@ -90,6 +93,7 @@ export function MessageBubble({
|
|||||||
>
|
>
|
||||||
<div className={styles.bubbleAvatarSpacer} aria-hidden="true" />
|
<div className={styles.bubbleAvatarSpacer} aria-hidden="true" />
|
||||||
<MessageContent
|
<MessageContent
|
||||||
|
characterId={characterId}
|
||||||
content={content}
|
content={content}
|
||||||
imageUrl={imageUrl}
|
imageUrl={imageUrl}
|
||||||
imagePaywalled={imagePaywalled}
|
imagePaywalled={imagePaywalled}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { VoiceBubble } from "./voice-bubble";
|
|||||||
import styles from "./chat-area.module.css";
|
import styles from "./chat-area.module.css";
|
||||||
|
|
||||||
export interface MessageContentProps {
|
export interface MessageContentProps {
|
||||||
|
characterId: string;
|
||||||
content: string;
|
content: string;
|
||||||
imageUrl?: string | null;
|
imageUrl?: string | null;
|
||||||
imagePaywalled?: boolean;
|
imagePaywalled?: boolean;
|
||||||
@@ -27,6 +28,7 @@ export interface MessageContentProps {
|
|||||||
const IMAGE_PLACEHOLDER = "[图片]";
|
const IMAGE_PLACEHOLDER = "[图片]";
|
||||||
|
|
||||||
export function MessageContent({
|
export function MessageContent({
|
||||||
|
characterId,
|
||||||
content,
|
content,
|
||||||
imageUrl,
|
imageUrl,
|
||||||
imagePaywalled,
|
imagePaywalled,
|
||||||
@@ -90,6 +92,7 @@ export function MessageContent({
|
|||||||
<>
|
<>
|
||||||
{hasImage && imageUrl && (
|
{hasImage && imageUrl && (
|
||||||
<ImageBubble
|
<ImageBubble
|
||||||
|
characterId={characterId}
|
||||||
messageId={messageId}
|
messageId={messageId}
|
||||||
imageUrl={imageUrl}
|
imageUrl={imageUrl}
|
||||||
imagePaywalled={imagePaywalled}
|
imagePaywalled={imagePaywalled}
|
||||||
@@ -98,6 +101,7 @@ export function MessageContent({
|
|||||||
)}
|
)}
|
||||||
{shouldRenderVoiceMessage ? (
|
{shouldRenderVoiceMessage ? (
|
||||||
<VoiceBubble
|
<VoiceBubble
|
||||||
|
characterId={characterId}
|
||||||
messageId={messageId}
|
messageId={messageId}
|
||||||
audioUrl={audioUrl}
|
audioUrl={audioUrl}
|
||||||
isFromAI={isFromAI}
|
isFromAI={isFromAI}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useCachedChatMediaUrl } from "@/lib/chat/use_cached_chat_media_url";
|
|||||||
import styles from "./voice-bubble.module.css";
|
import styles from "./voice-bubble.module.css";
|
||||||
|
|
||||||
export interface VoiceBubbleProps {
|
export interface VoiceBubbleProps {
|
||||||
|
characterId: string;
|
||||||
messageId?: string;
|
messageId?: string;
|
||||||
audioUrl?: string | null;
|
audioUrl?: string | null;
|
||||||
isFromAI: boolean;
|
isFromAI: boolean;
|
||||||
@@ -17,6 +18,7 @@ export interface VoiceBubbleProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function VoiceBubble({
|
export function VoiceBubble({
|
||||||
|
characterId,
|
||||||
messageId,
|
messageId,
|
||||||
audioUrl,
|
audioUrl,
|
||||||
isFromAI,
|
isFromAI,
|
||||||
@@ -31,6 +33,7 @@ export function VoiceBubble({
|
|||||||
const hasAudio = audioUrl != null && audioUrl.length > 0;
|
const hasAudio = audioUrl != null && audioUrl.length > 0;
|
||||||
const { mediaUrl, isUsingCachedMedia, reportMediaError } =
|
const { mediaUrl, isUsingCachedMedia, reportMediaError } =
|
||||||
useCachedChatMediaUrl({
|
useCachedChatMediaUrl({
|
||||||
|
characterId,
|
||||||
messageId,
|
messageId,
|
||||||
remoteUrl: audioUrl,
|
remoteUrl: audioUrl,
|
||||||
kind: "audio",
|
kind: "audio",
|
||||||
|
|||||||
@@ -9,12 +9,14 @@ import { getLatestSplashMessagePreview } from "@/lib/chat/splash_latest_message_
|
|||||||
import { useSplashLatestMessageCache } from "@/providers/splash-latest-message-provider";
|
import { useSplashLatestMessageCache } from "@/providers/splash-latest-message-provider";
|
||||||
|
|
||||||
export interface UseSplashLatestMessageSyncInput {
|
export interface UseSplashLatestMessageSyncInput {
|
||||||
|
characterId: string;
|
||||||
historyLoaded: boolean;
|
historyLoaded: boolean;
|
||||||
loginStatus: LoginStatus;
|
loginStatus: LoginStatus;
|
||||||
messages: readonly UiMessage[];
|
messages: readonly UiMessage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSplashLatestMessageSync({
|
export function useSplashLatestMessageSync({
|
||||||
|
characterId,
|
||||||
historyLoaded,
|
historyLoaded,
|
||||||
loginStatus,
|
loginStatus,
|
||||||
messages,
|
messages,
|
||||||
@@ -26,7 +28,7 @@ export function useSplashLatestMessageSync({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
hasObservedInitialSnapshotRef.current = false;
|
hasObservedInitialSnapshotRef.current = false;
|
||||||
identityPromiseRef.current = null;
|
identityPromiseRef.current = null;
|
||||||
}, [loginStatus]);
|
}, [characterId, loginStatus]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!historyLoaded) return;
|
if (!historyLoaded) return;
|
||||||
@@ -39,7 +41,8 @@ export function useSplashLatestMessageSync({
|
|||||||
const preview = getLatestSplashMessagePreview(messages);
|
const preview = getLatestSplashMessagePreview(messages);
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
identityPromiseRef.current ??= resolveSplashLatestMessageCacheIdentity();
|
identityPromiseRef.current ??=
|
||||||
|
resolveSplashLatestMessageCacheIdentity(characterId);
|
||||||
void identityPromiseRef.current.then((identity) => {
|
void identityPromiseRef.current.then((identity) => {
|
||||||
if (!cancelled && identity) {
|
if (!cancelled && identity) {
|
||||||
cache.set(identity, preview);
|
cache.set(identity, preview);
|
||||||
@@ -49,5 +52,5 @@ export function useSplashLatestMessageSync({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [cache, historyLoaded, messages]);
|
}, [cache, characterId, historyLoaded, messages]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export const DEFAULT_CHARACTER_ID = "character_elio";
|
||||||
|
export const DEFAULT_CHARACTER_SLUG = "elio";
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { Character } from "@/data/dto/character";
|
||||||
|
|
||||||
|
describe("Character", () => {
|
||||||
|
it("keeps only the four public character fields", () => {
|
||||||
|
const character = Character.fromJson({
|
||||||
|
id: "character_elio",
|
||||||
|
slug: "elio",
|
||||||
|
displayName: " Elio Silvestri ",
|
||||||
|
avatarUrl: "https://cdn.example.com/elio.jpg",
|
||||||
|
enabled: true,
|
||||||
|
sortOrder: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(character.toJson()).toEqual({
|
||||||
|
id: "character_elio",
|
||||||
|
slug: "elio",
|
||||||
|
displayName: "Elio Silvestri",
|
||||||
|
avatarUrl: "https://cdn.example.com/elio.jpg",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unsafe slugs and non-HTTPS avatars", () => {
|
||||||
|
expect(() =>
|
||||||
|
Character.from({
|
||||||
|
id: "character_aria",
|
||||||
|
slug: "Aria Profile",
|
||||||
|
displayName: "Aria",
|
||||||
|
avatarUrl: "https://cdn.example.com/aria.jpg",
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
|
expect(() =>
|
||||||
|
Character.from({
|
||||||
|
id: "character_aria",
|
||||||
|
slug: "aria",
|
||||||
|
displayName: "Aria",
|
||||||
|
avatarUrl: "http://cdn.example.com/aria.jpg",
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import {
|
||||||
|
CharacterSchema,
|
||||||
|
type CharacterData,
|
||||||
|
type CharacterInput,
|
||||||
|
} from "@/data/schemas/character";
|
||||||
|
|
||||||
|
export class Character {
|
||||||
|
declare readonly id: string;
|
||||||
|
declare readonly slug: string;
|
||||||
|
declare readonly displayName: string;
|
||||||
|
declare readonly avatarUrl: string;
|
||||||
|
|
||||||
|
private constructor(input: CharacterInput) {
|
||||||
|
Object.assign(this, CharacterSchema.parse(input));
|
||||||
|
Object.freeze(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
static from(input: CharacterInput): Character {
|
||||||
|
return new Character(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromJson(json: unknown): Character {
|
||||||
|
return Character.from(json as CharacterInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
toJson(): CharacterData {
|
||||||
|
return CharacterSchema.parse(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import {
|
||||||
|
CharactersResponseSchema,
|
||||||
|
type CharactersResponseData,
|
||||||
|
type CharactersResponseInput,
|
||||||
|
} from "@/data/schemas/character";
|
||||||
|
|
||||||
|
import { Character } from "./character";
|
||||||
|
|
||||||
|
export class CharactersResponse {
|
||||||
|
declare readonly items: Character[];
|
||||||
|
|
||||||
|
private constructor(input: CharactersResponseInput) {
|
||||||
|
const data = CharactersResponseSchema.parse(input);
|
||||||
|
Object.assign(this, {
|
||||||
|
items: data.items.map((item) => Character.from(item)),
|
||||||
|
});
|
||||||
|
Object.freeze(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
static from(input: CharactersResponseInput): CharactersResponse {
|
||||||
|
return new CharactersResponse(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromJson(json: unknown): CharactersResponse {
|
||||||
|
return CharactersResponse.from(json as CharactersResponseInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
toJson(): CharactersResponseData {
|
||||||
|
return CharactersResponseSchema.parse({
|
||||||
|
items: this.items.map((item) => item.toJson()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./character";
|
||||||
|
export * from "./characters_response";
|
||||||
@@ -1,21 +1,30 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
|
||||||
import { UnlockPrivateRequest } from "@/data/dto/chat";
|
import { UnlockPrivateRequest } from "@/data/dto/chat";
|
||||||
|
|
||||||
describe("UnlockPrivateRequest", () => {
|
describe("UnlockPrivateRequest", () => {
|
||||||
it("serializes an existing backend message unlock", () => {
|
it("serializes an existing backend message unlock", () => {
|
||||||
expect(
|
expect(
|
||||||
UnlockPrivateRequest.from({ messageId: "message-1" }).toJson(),
|
UnlockPrivateRequest.from({
|
||||||
).toEqual({ messageId: "message-1" });
|
characterId: DEFAULT_CHARACTER_ID,
|
||||||
|
messageId: "message-1",
|
||||||
|
}).toJson(),
|
||||||
|
).toEqual({
|
||||||
|
characterId: DEFAULT_CHARACTER_ID,
|
||||||
|
messageId: "message-1",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("serializes a temporary promotion lock without a fake message id", () => {
|
it("serializes a temporary promotion lock without a fake message id", () => {
|
||||||
expect(
|
expect(
|
||||||
UnlockPrivateRequest.from({
|
UnlockPrivateRequest.from({
|
||||||
|
characterId: DEFAULT_CHARACTER_ID,
|
||||||
lockType: "voice_message",
|
lockType: "voice_message",
|
||||||
clientLockId: "promotion-1",
|
clientLockId: "promotion-1",
|
||||||
}).toJson(),
|
}).toJson(),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
|
characterId: DEFAULT_CHARACTER_ID,
|
||||||
lockType: "voice_message",
|
lockType: "voice_message",
|
||||||
clientLockId: "promotion-1",
|
clientLockId: "promotion-1",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from "./send_message_request";
|
export * from "./send_message_request";
|
||||||
export * from "./unlock_private_request";
|
export * from "./unlock_private_request";
|
||||||
|
export * from "./unlock_history_request";
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from "@/data/schemas/chat/request/send_message_request";
|
} from "@/data/schemas/chat/request/send_message_request";
|
||||||
|
|
||||||
export class SendMessageRequest {
|
export class SendMessageRequest {
|
||||||
|
declare readonly characterId: string;
|
||||||
declare readonly message: string;
|
declare readonly message: string;
|
||||||
declare readonly image: string;
|
declare readonly image: string;
|
||||||
declare readonly imageId: string;
|
declare readonly imageId: string;
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import {
|
||||||
|
UnlockHistoryRequestSchema,
|
||||||
|
type UnlockHistoryRequestData,
|
||||||
|
type UnlockHistoryRequestInput,
|
||||||
|
} from "@/data/schemas/chat/request/unlock_history_request";
|
||||||
|
|
||||||
|
export class UnlockHistoryRequest {
|
||||||
|
declare readonly characterId: string;
|
||||||
|
|
||||||
|
private constructor(input: UnlockHistoryRequestInput) {
|
||||||
|
Object.assign(this, UnlockHistoryRequestSchema.parse(input));
|
||||||
|
Object.freeze(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
static from(input: UnlockHistoryRequestInput): UnlockHistoryRequest {
|
||||||
|
return new UnlockHistoryRequest(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
toJson(): UnlockHistoryRequestData {
|
||||||
|
return UnlockHistoryRequestSchema.parse(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from "@/data/schemas/chat/request/unlock_private_request";
|
} from "@/data/schemas/chat/request/unlock_private_request";
|
||||||
|
|
||||||
export class UnlockPrivateRequest {
|
export class UnlockPrivateRequest {
|
||||||
|
declare readonly characterId: string;
|
||||||
declare readonly messageId?: string;
|
declare readonly messageId?: string;
|
||||||
declare readonly lockType?: UnlockPrivateRequestData["lockType"];
|
declare readonly lockType?: UnlockPrivateRequestData["lockType"];
|
||||||
declare readonly clientLockId?: string;
|
declare readonly clientLockId?: string;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"characterId": "character_elio",
|
||||||
"message": "Look at this sunset.",
|
"message": "Look at this sunset.",
|
||||||
"image": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD...",
|
"image": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD...",
|
||||||
"imageId": "img_mock_001",
|
"imageId": "img_mock_001",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"characterId": "character_elio",
|
||||||
"message": "I missed you today.",
|
"message": "I missed you today.",
|
||||||
"image": "",
|
"image": "",
|
||||||
"imageId": "",
|
"imageId": "",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
{
|
{
|
||||||
|
"characterId": "character_elio",
|
||||||
"messageId": "msg_private_locked_001"
|
"messageId": "msg_private_locked_001"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
import { LoginStatus } from "@/data/dto/auth";
|
import { LoginStatus } from "@/data/dto/auth";
|
||||||
import { Result } from "@/utils/result";
|
import { Result } from "@/utils/result";
|
||||||
|
|
||||||
import { resolveChatCacheOwnerKey } from "../chat_cache_identity";
|
import {
|
||||||
|
buildChatConversationKey,
|
||||||
|
resolveChatCacheOwnerKey,
|
||||||
|
} from "../chat_cache_identity";
|
||||||
|
|
||||||
function createStorageState(input: {
|
function createStorageState(input: {
|
||||||
provider: (typeof LoginStatus)[keyof typeof LoginStatus] | null;
|
provider: (typeof LoginStatus)[keyof typeof LoginStatus] | null;
|
||||||
@@ -22,6 +25,15 @@ function createStorageState(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("chat cache identity", () => {
|
describe("chat cache identity", () => {
|
||||||
|
it("isolates the same owner by character", () => {
|
||||||
|
const elio = buildChatConversationKey("user:account-1", "character_elio");
|
||||||
|
const aria = buildChatConversationKey("user:account-1", "character_aria");
|
||||||
|
|
||||||
|
expect(elio).toBe("user:account-1::character:character_elio");
|
||||||
|
expect(aria).toBe("user:account-1::character:character_aria");
|
||||||
|
expect(elio).not.toBe(aria);
|
||||||
|
});
|
||||||
|
|
||||||
it("uses the current user id for authenticated and guest sessions", async () => {
|
it("uses the current user id for authenticated and guest sessions", async () => {
|
||||||
const authenticated = createStorageState({
|
const authenticated = createStorageState({
|
||||||
provider: LoginStatus.Email,
|
provider: LoginStatus.Email,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
|
||||||
import { Result } from "@/utils/result";
|
import { Result } from "@/utils/result";
|
||||||
|
|
||||||
import { ChatMediaCacheCoordinator } from "../chat_media_cache_coordinator";
|
import { ChatMediaCacheCoordinator } from "../chat_media_cache_coordinator";
|
||||||
@@ -22,13 +23,43 @@ describe("ChatMediaCacheCoordinator identity isolation", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const result = await coordinator.getCachedMedia({
|
const result = await coordinator.getCachedMedia({
|
||||||
|
characterId: DEFAULT_CHARACTER_ID,
|
||||||
messageId: "message-1",
|
messageId: "message-1",
|
||||||
kind: "image",
|
kind: "image",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toEqual(Result.ok(null));
|
expect(result).toEqual(Result.ok(null));
|
||||||
expect(getMedia).toHaveBeenCalledTimes(1);
|
expect(getMedia).toHaveBeenCalledTimes(1);
|
||||||
expect(getMedia).toHaveBeenCalledWith("user:account-a:message-1:image");
|
expect(getMedia).toHaveBeenCalledWith(
|
||||||
|
"user:account-a::character:character_elio:message-1:image",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not share a media key between characters", async () => {
|
||||||
|
const getMedia = vi.fn(async () => Result.ok(null));
|
||||||
|
const coordinator = new ChatMediaCacheCoordinator(
|
||||||
|
{
|
||||||
|
getMedia,
|
||||||
|
saveMedia: vi.fn(async () => Result.ok(undefined)),
|
||||||
|
},
|
||||||
|
async () => Result.ok("user:account-a"),
|
||||||
|
);
|
||||||
|
|
||||||
|
await coordinator.getCachedMedia({
|
||||||
|
characterId: "character_elio",
|
||||||
|
messageId: "shared-message-id",
|
||||||
|
kind: "image",
|
||||||
|
});
|
||||||
|
await coordinator.getCachedMedia({
|
||||||
|
characterId: "character_aria",
|
||||||
|
messageId: "shared-message-id",
|
||||||
|
kind: "image",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getMedia.mock.calls).toEqual([
|
||||||
|
["user:account-a::character:character_elio:shared-message-id:image"],
|
||||||
|
["user:account-a::character:character_aria:shared-message-id:image"],
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not fall back to an anonymous cache without an identity", async () => {
|
it("does not fall back to an anonymous cache without an identity", async () => {
|
||||||
@@ -45,6 +76,7 @@ describe("ChatMediaCacheCoordinator identity isolation", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const result = await coordinator.getCachedMedia({
|
const result = await coordinator.getCachedMedia({
|
||||||
|
characterId: DEFAULT_CHARACTER_ID,
|
||||||
messageId: "message-1",
|
messageId: "message-1",
|
||||||
kind: "audio",
|
kind: "audio",
|
||||||
});
|
});
|
||||||
@@ -75,11 +107,12 @@ describe("ChatMediaCacheCoordinator identity isolation", () => {
|
|||||||
|
|
||||||
const result = await coordinator.cacheRemoteMedia(
|
const result = await coordinator.cacheRemoteMedia(
|
||||||
{
|
{
|
||||||
|
characterId: DEFAULT_CHARACTER_ID,
|
||||||
messageId: "message-expired",
|
messageId: "message-expired",
|
||||||
kind: "image",
|
kind: "image",
|
||||||
remoteUrl: "https://media.example/expired.jpg",
|
remoteUrl: "https://media.example/expired.jpg",
|
||||||
},
|
},
|
||||||
"user:account-a",
|
"user:account-a::character:character_elio",
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(Result.isErr(result)).toBe(true);
|
expect(Result.isErr(result)).toBe(true);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
|
||||||
import { ChatMessage, ChatSendResponse } from "@/data/dto/chat";
|
import { ChatMessage, ChatSendResponse } from "@/data/dto/chat";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -64,14 +65,17 @@ describe("chat media cache helpers", () => {
|
|||||||
audioUrl: "https://example.com/a.mp3",
|
audioUrl: "https://example.com/a.mp3",
|
||||||
image: { type: "jpg", url: "https://example.com/a.jpg" },
|
image: { type: "jpg", url: "https://example.com/a.jpg" },
|
||||||
}),
|
}),
|
||||||
|
DEFAULT_CHARACTER_ID,
|
||||||
),
|
),
|
||||||
).toEqual([
|
).toEqual([
|
||||||
{
|
{
|
||||||
|
characterId: DEFAULT_CHARACTER_ID,
|
||||||
messageId: "msg-1",
|
messageId: "msg-1",
|
||||||
kind: "image",
|
kind: "image",
|
||||||
remoteUrl: "https://example.com/a.jpg",
|
remoteUrl: "https://example.com/a.jpg",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
characterId: DEFAULT_CHARACTER_ID,
|
||||||
messageId: "msg-1",
|
messageId: "msg-1",
|
||||||
kind: "audio",
|
kind: "audio",
|
||||||
remoteUrl: "https://example.com/a.mp3",
|
remoteUrl: "https://example.com/a.mp3",
|
||||||
@@ -87,6 +91,7 @@ describe("chat media cache helpers", () => {
|
|||||||
audioUrl: "https://example.com/a.mp3",
|
audioUrl: "https://example.com/a.mp3",
|
||||||
image: { type: "png", url: "data:image/png;base64,abc" },
|
image: { type: "png", url: "data:image/png;base64,abc" },
|
||||||
}),
|
}),
|
||||||
|
DEFAULT_CHARACTER_ID,
|
||||||
),
|
),
|
||||||
).toEqual([]);
|
).toEqual([]);
|
||||||
});
|
});
|
||||||
@@ -98,6 +103,7 @@ describe("chat media cache helpers", () => {
|
|||||||
audioUrl: "https://example.com/locked.mp3",
|
audioUrl: "https://example.com/locked.mp3",
|
||||||
lockDetail: { locked: true, reason: "voice_message" },
|
lockDetail: { locked: true, reason: "voice_message" },
|
||||||
}),
|
}),
|
||||||
|
DEFAULT_CHARACTER_ID,
|
||||||
),
|
),
|
||||||
).toEqual([]);
|
).toEqual([]);
|
||||||
});
|
});
|
||||||
@@ -108,9 +114,11 @@ describe("chat media cache helpers", () => {
|
|||||||
makeResponse({
|
makeResponse({
|
||||||
audioUrl: "https://example.com/unlocked.mp3",
|
audioUrl: "https://example.com/unlocked.mp3",
|
||||||
}),
|
}),
|
||||||
|
DEFAULT_CHARACTER_ID,
|
||||||
),
|
),
|
||||||
).toEqual([
|
).toEqual([
|
||||||
{
|
{
|
||||||
|
characterId: DEFAULT_CHARACTER_ID,
|
||||||
messageId: "msg-1",
|
messageId: "msg-1",
|
||||||
kind: "audio",
|
kind: "audio",
|
||||||
remoteUrl: "https://example.com/unlocked.mp3",
|
remoteUrl: "https://example.com/unlocked.mp3",
|
||||||
@@ -121,13 +129,38 @@ describe("chat media cache helpers", () => {
|
|||||||
it("deduplicates media targets", () => {
|
it("deduplicates media targets", () => {
|
||||||
expect(
|
expect(
|
||||||
uniqueMediaTargets([
|
uniqueMediaTargets([
|
||||||
{ messageId: "msg-1", kind: "image", remoteUrl: "https://x/a.jpg" },
|
{
|
||||||
{ messageId: "msg-1", kind: "image", remoteUrl: "https://x/a.jpg" },
|
characterId: DEFAULT_CHARACTER_ID,
|
||||||
{ messageId: "msg-1", kind: "audio", remoteUrl: "https://x/a.jpg" },
|
messageId: "msg-1",
|
||||||
|
kind: "image",
|
||||||
|
remoteUrl: "https://x/a.jpg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
characterId: DEFAULT_CHARACTER_ID,
|
||||||
|
messageId: "msg-1",
|
||||||
|
kind: "image",
|
||||||
|
remoteUrl: "https://x/a.jpg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
characterId: DEFAULT_CHARACTER_ID,
|
||||||
|
messageId: "msg-1",
|
||||||
|
kind: "audio",
|
||||||
|
remoteUrl: "https://x/a.jpg",
|
||||||
|
},
|
||||||
]),
|
]),
|
||||||
).toEqual([
|
).toEqual([
|
||||||
{ messageId: "msg-1", kind: "image", remoteUrl: "https://x/a.jpg" },
|
{
|
||||||
{ messageId: "msg-1", kind: "audio", remoteUrl: "https://x/a.jpg" },
|
characterId: DEFAULT_CHARACTER_ID,
|
||||||
|
messageId: "msg-1",
|
||||||
|
kind: "image",
|
||||||
|
remoteUrl: "https://x/a.jpg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
characterId: DEFAULT_CHARACTER_ID,
|
||||||
|
messageId: "msg-1",
|
||||||
|
kind: "audio",
|
||||||
|
remoteUrl: "https://x/a.jpg",
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { CharactersResponse } from "@/data/dto/character";
|
||||||
|
import type { ICharacterRepository } from "@/data/repositories/interfaces";
|
||||||
|
import { CharacterApi, characterApi } from "@/data/services/api/character_api";
|
||||||
|
import { Result } from "@/utils/result";
|
||||||
|
|
||||||
|
import { createLazySingleton } from "./lazy_singleton";
|
||||||
|
|
||||||
|
export class CharacterRepository implements ICharacterRepository {
|
||||||
|
constructor(private readonly api: CharacterApi) {}
|
||||||
|
|
||||||
|
getCharacters(): Promise<Result<CharactersResponse>> {
|
||||||
|
return Result.wrap(() => this.api.getCharacters());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getCharacterRepository = createLazySingleton<ICharacterRepository>(
|
||||||
|
() => new CharacterRepository(characterApi),
|
||||||
|
);
|
||||||
@@ -4,6 +4,9 @@ import { UserStorage, type IUserStorage } from "@/data/storage/user";
|
|||||||
import { Result, type Result as ResultT } from "@/utils/result";
|
import { Result, type Result as ResultT } from "@/utils/result";
|
||||||
|
|
||||||
export type ChatCacheIdentityResolver = () => Promise<ResultT<string>>;
|
export type ChatCacheIdentityResolver = () => Promise<ResultT<string>>;
|
||||||
|
export type ChatConversationKeyResolver = (
|
||||||
|
characterId: string,
|
||||||
|
) => Promise<ResultT<string>>;
|
||||||
|
|
||||||
type ChatCacheAuthStorage = Pick<
|
type ChatCacheAuthStorage = Pick<
|
||||||
IAuthStorage,
|
IAuthStorage,
|
||||||
@@ -49,3 +52,21 @@ export async function resolveChatCacheOwnerKey(
|
|||||||
|
|
||||||
return Result.err(new Error("Chat cache identity is unavailable."));
|
return Result.err(new Error("Chat cache identity is unavailable."));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildChatConversationKey(
|
||||||
|
ownerKey: string,
|
||||||
|
characterId: string,
|
||||||
|
): string {
|
||||||
|
if (ownerKey.trim().length === 0 || characterId.trim().length === 0) {
|
||||||
|
throw new Error("Chat owner and character identities must not be empty.");
|
||||||
|
}
|
||||||
|
return `${ownerKey}::character:${encodeURIComponent(characterId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveChatConversationKey(
|
||||||
|
characterId: string,
|
||||||
|
): Promise<ResultT<string>> {
|
||||||
|
const ownerResult = await resolveChatCacheOwnerKey();
|
||||||
|
if (Result.isErr(ownerResult)) return ownerResult;
|
||||||
|
return Result.ok(buildChatConversationKey(ownerResult.data, characterId));
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
uniqueMediaTargets,
|
uniqueMediaTargets,
|
||||||
} from "./chat_media_cache_helpers";
|
} from "./chat_media_cache_helpers";
|
||||||
import {
|
import {
|
||||||
|
buildChatConversationKey,
|
||||||
resolveChatCacheOwnerKey,
|
resolveChatCacheOwnerKey,
|
||||||
type ChatCacheIdentityResolver,
|
type ChatCacheIdentityResolver,
|
||||||
} from "./chat_cache_identity";
|
} from "./chat_cache_identity";
|
||||||
@@ -59,7 +60,7 @@ export class ChatMediaCacheCoordinator {
|
|||||||
input: ChatMediaLookupInput,
|
input: ChatMediaLookupInput,
|
||||||
): Promise<Result<LocalChatMediaRow | null>> {
|
): Promise<Result<LocalChatMediaRow | null>> {
|
||||||
return Result.wrap(async () => {
|
return Result.wrap(async () => {
|
||||||
const ownerKey = await this._requireIdentity();
|
const ownerKey = await this._requireIdentity(input.characterId);
|
||||||
const cacheKey = this._buildMediaCacheKey(ownerKey, input);
|
const cacheKey = this._buildMediaCacheKey(ownerKey, input);
|
||||||
const result = await this.mediaStorage.getMedia(cacheKey);
|
const result = await this.mediaStorage.getMedia(cacheKey);
|
||||||
if (Result.isErr(result)) throw result.error;
|
if (Result.isErr(result)) throw result.error;
|
||||||
@@ -73,7 +74,10 @@ export class ChatMediaCacheCoordinator {
|
|||||||
): Promise<Result<LocalChatMediaRow>> {
|
): Promise<Result<LocalChatMediaRow>> {
|
||||||
return Result.wrap(
|
return Result.wrap(
|
||||||
async () => {
|
async () => {
|
||||||
const ownerKey = await this._requireIdentity(identity);
|
const ownerKey = await this._requireIdentity(
|
||||||
|
input.characterId,
|
||||||
|
identity,
|
||||||
|
);
|
||||||
return this._cacheRemoteMediaOrThrow(input, ownerKey);
|
return this._cacheRemoteMediaOrThrow(input, ownerKey);
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -85,32 +89,39 @@ export class ChatMediaCacheCoordinator {
|
|||||||
|
|
||||||
async prefetchMediaForMessages(
|
async prefetchMediaForMessages(
|
||||||
messages: readonly ChatMessage[],
|
messages: readonly ChatMessage[],
|
||||||
|
characterId: string,
|
||||||
identity?: string,
|
identity?: string,
|
||||||
): Promise<Result<void>> {
|
): Promise<Result<void>> {
|
||||||
return this._prefetchMediaTargets(
|
return this._prefetchMediaTargets(
|
||||||
messages.flatMap((message) => getMessageMediaTargets(message)),
|
messages.flatMap((message) =>
|
||||||
|
getMessageMediaTargets(message, characterId),
|
||||||
|
),
|
||||||
|
characterId,
|
||||||
identity,
|
identity,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async prefetchMediaForSendResponse(
|
async prefetchMediaForSendResponse(
|
||||||
response: ChatSendResponse,
|
response: ChatSendResponse,
|
||||||
|
characterId: string,
|
||||||
identity?: string,
|
identity?: string,
|
||||||
): Promise<Result<void>> {
|
): Promise<Result<void>> {
|
||||||
return this._prefetchMediaTargets(
|
return this._prefetchMediaTargets(
|
||||||
getSendResponseMediaTargets(response),
|
getSendResponseMediaTargets(response, characterId),
|
||||||
|
characterId,
|
||||||
identity,
|
identity,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _prefetchMediaTargets(
|
private async _prefetchMediaTargets(
|
||||||
targets: readonly CacheRemoteChatMediaInput[],
|
targets: readonly CacheRemoteChatMediaInput[],
|
||||||
|
characterId: string,
|
||||||
identity?: string,
|
identity?: string,
|
||||||
): Promise<Result<void>> {
|
): Promise<Result<void>> {
|
||||||
return Result.wrap(async () => {
|
return Result.wrap(async () => {
|
||||||
const uniqueTargets = uniqueMediaTargets(targets);
|
const uniqueTargets = uniqueMediaTargets(targets);
|
||||||
if (uniqueTargets.length === 0) return;
|
if (uniqueTargets.length === 0) return;
|
||||||
const ownerKey = await this._requireIdentity(identity);
|
const ownerKey = await this._requireIdentity(characterId, identity);
|
||||||
for (const target of uniqueTargets) {
|
for (const target of uniqueTargets) {
|
||||||
try {
|
try {
|
||||||
await this._cacheRemoteMediaOrThrow(target, ownerKey);
|
await this._cacheRemoteMediaOrThrow(target, ownerKey);
|
||||||
@@ -199,7 +210,10 @@ export class ChatMediaCacheCoordinator {
|
|||||||
return savedResult.data;
|
return savedResult.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _requireIdentity(identity?: string): Promise<string> {
|
private async _requireIdentity(
|
||||||
|
characterId: string,
|
||||||
|
identity?: string,
|
||||||
|
): Promise<string> {
|
||||||
if (identity !== undefined) {
|
if (identity !== undefined) {
|
||||||
if (identity.length === 0) {
|
if (identity.length === 0) {
|
||||||
throw new Error("Chat cache identity must not be empty.");
|
throw new Error("Chat cache identity must not be empty.");
|
||||||
@@ -211,7 +225,7 @@ export class ChatMediaCacheCoordinator {
|
|||||||
if (result.data.length === 0) {
|
if (result.data.length === 0) {
|
||||||
throw new Error("Chat cache identity must not be empty.");
|
throw new Error("Chat cache identity must not be empty.");
|
||||||
}
|
}
|
||||||
return result.data;
|
return buildChatConversationKey(result.data, characterId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _buildMediaCacheKey(
|
private _buildMediaCacheKey(
|
||||||
|
|||||||
@@ -18,14 +18,17 @@ export function buildChatMediaCacheKey(input: {
|
|||||||
|
|
||||||
export function getMessageMediaTargets(
|
export function getMessageMediaTargets(
|
||||||
message: ChatMessage,
|
message: ChatMessage,
|
||||||
|
characterId: string,
|
||||||
): CacheRemoteChatMediaInput[] {
|
): CacheRemoteChatMediaInput[] {
|
||||||
const targets: CacheRemoteChatMediaInput[] = [];
|
const targets: CacheRemoteChatMediaInput[] = [];
|
||||||
pushMediaTarget(targets, {
|
pushMediaTarget(targets, {
|
||||||
|
characterId,
|
||||||
messageId: message.id,
|
messageId: message.id,
|
||||||
kind: "image",
|
kind: "image",
|
||||||
remoteUrl: message.image.url,
|
remoteUrl: message.image.url,
|
||||||
});
|
});
|
||||||
pushMediaTarget(targets, {
|
pushMediaTarget(targets, {
|
||||||
|
characterId,
|
||||||
messageId: message.id,
|
messageId: message.id,
|
||||||
kind: "audio",
|
kind: "audio",
|
||||||
remoteUrl: message.audioUrl,
|
remoteUrl: message.audioUrl,
|
||||||
@@ -35,15 +38,18 @@ export function getMessageMediaTargets(
|
|||||||
|
|
||||||
export function getSendResponseMediaTargets(
|
export function getSendResponseMediaTargets(
|
||||||
response: ChatSendResponse,
|
response: ChatSendResponse,
|
||||||
|
characterId: string,
|
||||||
): CacheRemoteChatMediaInput[] {
|
): CacheRemoteChatMediaInput[] {
|
||||||
const targets: CacheRemoteChatMediaInput[] = [];
|
const targets: CacheRemoteChatMediaInput[] = [];
|
||||||
pushMediaTarget(targets, {
|
pushMediaTarget(targets, {
|
||||||
|
characterId,
|
||||||
messageId: response.messageId,
|
messageId: response.messageId,
|
||||||
kind: "image",
|
kind: "image",
|
||||||
remoteUrl: response.image.url,
|
remoteUrl: response.image.url,
|
||||||
});
|
});
|
||||||
if (!response.lockDetail.locked) {
|
if (!response.lockDetail.locked) {
|
||||||
pushMediaTarget(targets, {
|
pushMediaTarget(targets, {
|
||||||
|
characterId,
|
||||||
messageId: response.messageId,
|
messageId: response.messageId,
|
||||||
kind: "audio",
|
kind: "audio",
|
||||||
remoteUrl: response.audioUrl,
|
remoteUrl: response.audioUrl,
|
||||||
@@ -61,7 +67,7 @@ export function uniqueMediaTargets(
|
|||||||
): CacheRemoteChatMediaInput[] {
|
): CacheRemoteChatMediaInput[] {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
return targets.filter((target) => {
|
return targets.filter((target) => {
|
||||||
const key = `${target.messageId}:${target.kind}:${target.remoteUrl}`;
|
const key = `${target.characterId}:${target.messageId}:${target.kind}:${target.remoteUrl}`;
|
||||||
if (seen.has(key)) return false;
|
if (seen.has(key)) return false;
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
return true;
|
return true;
|
||||||
@@ -71,6 +77,7 @@ export function uniqueMediaTargets(
|
|||||||
function pushMediaTarget(
|
function pushMediaTarget(
|
||||||
targets: CacheRemoteChatMediaInput[],
|
targets: CacheRemoteChatMediaInput[],
|
||||||
input: {
|
input: {
|
||||||
|
characterId: string;
|
||||||
messageId: string;
|
messageId: string;
|
||||||
kind: ChatMediaKind;
|
kind: ChatMediaKind;
|
||||||
remoteUrl: string | null;
|
remoteUrl: string | null;
|
||||||
@@ -84,6 +91,7 @@ function pushMediaTarget(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
targets.push({
|
targets.push({
|
||||||
|
characterId: input.characterId,
|
||||||
messageId: input.messageId,
|
messageId: input.messageId,
|
||||||
kind: input.kind,
|
kind: input.kind,
|
||||||
remoteUrl: input.remoteUrl,
|
remoteUrl: input.remoteUrl,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
ChatHistoryResponse,
|
ChatHistoryResponse,
|
||||||
ChatSendResponse,
|
ChatSendResponse,
|
||||||
SendMessageRequest,
|
SendMessageRequest,
|
||||||
|
UnlockHistoryRequest,
|
||||||
UnlockHistoryResponse,
|
UnlockHistoryResponse,
|
||||||
UnlockPrivateRequest,
|
UnlockPrivateRequest,
|
||||||
UnlockPrivateResponse,
|
UnlockPrivateResponse,
|
||||||
@@ -14,11 +15,13 @@ export class ChatRemoteDataSource {
|
|||||||
constructor(private readonly api: ChatApi) {}
|
constructor(private readonly api: ChatApi) {}
|
||||||
|
|
||||||
async sendMessage(
|
async sendMessage(
|
||||||
|
characterId: string,
|
||||||
message: string,
|
message: string,
|
||||||
options?: { image?: string; useWebSocket?: boolean },
|
options?: { image?: string; useWebSocket?: boolean },
|
||||||
): Promise<Result<ChatSendResponse>> {
|
): Promise<Result<ChatSendResponse>> {
|
||||||
return Result.wrap(async () => {
|
return Result.wrap(async () => {
|
||||||
const request = SendMessageRequest.from({
|
const request = SendMessageRequest.from({
|
||||||
|
characterId,
|
||||||
message,
|
message,
|
||||||
image: options?.image ?? "",
|
image: options?.image ?? "",
|
||||||
useWebSocket: options?.useWebSocket ?? false,
|
useWebSocket: options?.useWebSocket ?? false,
|
||||||
@@ -28,10 +31,11 @@ export class ChatRemoteDataSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getHistory(
|
async getHistory(
|
||||||
|
characterId: string,
|
||||||
limit = 50,
|
limit = 50,
|
||||||
offset = 0,
|
offset = 0,
|
||||||
): Promise<Result<ChatHistoryResponse>> {
|
): Promise<Result<ChatHistoryResponse>> {
|
||||||
return Result.wrap(() => this.api.getHistory(limit, offset));
|
return Result.wrap(() => this.api.getHistory(characterId, limit, offset));
|
||||||
}
|
}
|
||||||
|
|
||||||
async unlockPrivateMessage(
|
async unlockPrivateMessage(
|
||||||
@@ -42,7 +46,11 @@ export class ChatRemoteDataSource {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async unlockHistory(): Promise<Result<UnlockHistoryResponse>> {
|
async unlockHistory(
|
||||||
return Result.wrap(() => this.api.unlockHistory());
|
characterId: string,
|
||||||
|
): Promise<Result<UnlockHistoryResponse>> {
|
||||||
|
return Result.wrap(() =>
|
||||||
|
this.api.unlockHistory(UnlockHistoryRequest.from({ characterId })),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,18 +36,20 @@ export class ChatRepository implements IChatRepository {
|
|||||||
|
|
||||||
/** 发送一条消息。 */
|
/** 发送一条消息。 */
|
||||||
async sendMessage(
|
async sendMessage(
|
||||||
|
characterId: string,
|
||||||
message: string,
|
message: string,
|
||||||
options?: { image?: string; useWebSocket?: boolean },
|
options?: { image?: string; useWebSocket?: boolean },
|
||||||
): Promise<Result<ChatSendResponse>> {
|
): Promise<Result<ChatSendResponse>> {
|
||||||
return this.remote.sendMessage(message, options);
|
return this.remote.sendMessage(characterId, message, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取聊天历史,分页参数默认 limit=50, offset=0。 */
|
/** 获取聊天历史,分页参数默认 limit=50, offset=0。 */
|
||||||
async getHistory(
|
async getHistory(
|
||||||
|
characterId: string,
|
||||||
limit = 50,
|
limit = 50,
|
||||||
offset = 0,
|
offset = 0,
|
||||||
): Promise<Result<ChatHistoryResponse>> {
|
): Promise<Result<ChatHistoryResponse>> {
|
||||||
return this.remote.getHistory(limit, offset);
|
return this.remote.getHistory(characterId, limit, offset);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 解锁单条历史付费 / 私密消息。 */
|
/** 解锁单条历史付费 / 私密消息。 */
|
||||||
@@ -58,8 +60,8 @@ export class ChatRepository implements IChatRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 一键解锁历史锁定消息。 */
|
/** 一键解锁历史锁定消息。 */
|
||||||
async unlockHistory(): Promise<Result<UnlockHistoryResponse>> {
|
async unlockHistory(characterId: string): Promise<Result<UnlockHistoryResponse>> {
|
||||||
return this.remote.unlockHistory();
|
return this.remote.unlockHistory(characterId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 把本地缓存中的单条锁定消息标记为已解锁。 */
|
/** 把本地缓存中的单条锁定消息标记为已解锁。 */
|
||||||
@@ -133,17 +135,24 @@ export class ChatRepository implements IChatRepository {
|
|||||||
|
|
||||||
async prefetchMediaForMessages(
|
async prefetchMediaForMessages(
|
||||||
messages: readonly ChatMessage[],
|
messages: readonly ChatMessage[],
|
||||||
|
characterId: string,
|
||||||
cacheIdentity?: string,
|
cacheIdentity?: string,
|
||||||
): Promise<Result<void>> {
|
): Promise<Result<void>> {
|
||||||
return this.mediaCache.prefetchMediaForMessages(messages, cacheIdentity);
|
return this.mediaCache.prefetchMediaForMessages(
|
||||||
|
messages,
|
||||||
|
characterId,
|
||||||
|
cacheIdentity,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async prefetchMediaForSendResponse(
|
async prefetchMediaForSendResponse(
|
||||||
response: ChatSendResponse,
|
response: ChatSendResponse,
|
||||||
|
characterId: string,
|
||||||
cacheIdentity?: string,
|
cacheIdentity?: string,
|
||||||
): Promise<Result<void>> {
|
): Promise<Result<void>> {
|
||||||
return this.mediaCache.prefetchMediaForSendResponse(
|
return this.mediaCache.prefetchMediaForSendResponse(
|
||||||
response,
|
response,
|
||||||
|
characterId,
|
||||||
cacheIdentity,
|
cacheIdentity,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
export * from "./auth_repository";
|
export * from "./auth_repository";
|
||||||
export * from "./chat_repository";
|
export * from "./chat_repository";
|
||||||
|
export * from "./character_repository";
|
||||||
export * from "./feedback_repository";
|
export * from "./feedback_repository";
|
||||||
export * from "./metrics_repository";
|
export * from "./metrics_repository";
|
||||||
export * from "./payment_repository";
|
export * from "./payment_repository";
|
||||||
@@ -11,6 +12,7 @@ export * from "./private_room_repository";
|
|||||||
export * from "./user_repository";
|
export * from "./user_repository";
|
||||||
export * from "./interfaces/iauth_repository";
|
export * from "./interfaces/iauth_repository";
|
||||||
export * from "./interfaces/ichat_repository";
|
export * from "./interfaces/ichat_repository";
|
||||||
|
export * from "./interfaces/icharacter_repository";
|
||||||
export * from "./interfaces/ifeedback_repository";
|
export * from "./interfaces/ifeedback_repository";
|
||||||
export * from "./interfaces/imetrics_repository";
|
export * from "./interfaces/imetrics_repository";
|
||||||
export * from "./interfaces/ipayment_repository";
|
export * from "./interfaces/ipayment_repository";
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import type { CharactersResponse } from "@/data/dto/character";
|
||||||
|
import type { Result } from "@/utils/result";
|
||||||
|
|
||||||
|
export interface ICharacterRepository {
|
||||||
|
getCharacters(): Promise<Result<CharactersResponse>>;
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import type { ChatImageData, ChatLockType } from "@/data/schemas/chat";
|
|||||||
import type { LocalChatMediaRow } from "@/data/storage/chat";
|
import type { LocalChatMediaRow } from "@/data/storage/chat";
|
||||||
|
|
||||||
export interface ChatMediaLookupInput {
|
export interface ChatMediaLookupInput {
|
||||||
|
characterId: string;
|
||||||
messageId: string;
|
messageId: string;
|
||||||
kind: ChatMediaKind;
|
kind: ChatMediaKind;
|
||||||
}
|
}
|
||||||
@@ -34,6 +35,7 @@ export interface UnlockedPrivateMessageLocalPatch {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface UnlockPrivateMessageInput {
|
export interface UnlockPrivateMessageInput {
|
||||||
|
characterId: string;
|
||||||
messageId?: string;
|
messageId?: string;
|
||||||
lockType?: ChatLockType;
|
lockType?: ChatLockType;
|
||||||
clientLockId?: string;
|
clientLockId?: string;
|
||||||
@@ -42,12 +44,17 @@ export interface UnlockPrivateMessageInput {
|
|||||||
export interface IChatRepository {
|
export interface IChatRepository {
|
||||||
/** 发送一条消息。 */
|
/** 发送一条消息。 */
|
||||||
sendMessage(
|
sendMessage(
|
||||||
|
characterId: string,
|
||||||
message: string,
|
message: string,
|
||||||
options?: { image?: string; useWebSocket?: boolean },
|
options?: { image?: string; useWebSocket?: boolean },
|
||||||
): Promise<Result<ChatSendResponse>>;
|
): Promise<Result<ChatSendResponse>>;
|
||||||
|
|
||||||
/** 获取聊天历史,分页参数默认 limit=50, offset=0。 */
|
/** 获取聊天历史,分页参数默认 limit=50, offset=0。 */
|
||||||
getHistory(limit?: number, offset?: number): Promise<Result<ChatHistoryResponse>>;
|
getHistory(
|
||||||
|
characterId: string,
|
||||||
|
limit?: number,
|
||||||
|
offset?: number,
|
||||||
|
): Promise<Result<ChatHistoryResponse>>;
|
||||||
|
|
||||||
/** 解锁单条历史付费 / 私密消息。 */
|
/** 解锁单条历史付费 / 私密消息。 */
|
||||||
unlockPrivateMessage(
|
unlockPrivateMessage(
|
||||||
@@ -55,7 +62,7 @@ export interface IChatRepository {
|
|||||||
): Promise<Result<UnlockPrivateResponse>>;
|
): Promise<Result<UnlockPrivateResponse>>;
|
||||||
|
|
||||||
/** 一键解锁历史锁定消息。 */
|
/** 一键解锁历史锁定消息。 */
|
||||||
unlockHistory(): Promise<Result<UnlockHistoryResponse>>;
|
unlockHistory(characterId: string): Promise<Result<UnlockHistoryResponse>>;
|
||||||
|
|
||||||
/** 把本地缓存中的单条锁定消息标记为已解锁。 */
|
/** 把本地缓存中的单条锁定消息标记为已解锁。 */
|
||||||
markPrivateMessageUnlockedInLocal(
|
markPrivateMessageUnlockedInLocal(
|
||||||
@@ -99,12 +106,14 @@ export interface IChatRepository {
|
|||||||
/** 后台预缓存历史消息中的图片 / 音频。失败不影响聊天主流程。 */
|
/** 后台预缓存历史消息中的图片 / 音频。失败不影响聊天主流程。 */
|
||||||
prefetchMediaForMessages(
|
prefetchMediaForMessages(
|
||||||
messages: readonly ChatMessage[],
|
messages: readonly ChatMessage[],
|
||||||
|
characterId: string,
|
||||||
cacheIdentity?: string,
|
cacheIdentity?: string,
|
||||||
): Promise<Result<void>>;
|
): Promise<Result<void>>;
|
||||||
|
|
||||||
/** 后台预缓存发送响应中的图片 / 音频。失败不影响聊天主流程。 */
|
/** 后台预缓存发送响应中的图片 / 音频。失败不影响聊天主流程。 */
|
||||||
prefetchMediaForSendResponse(
|
prefetchMediaForSendResponse(
|
||||||
response: ChatSendResponse,
|
response: ChatSendResponse,
|
||||||
|
characterId: string,
|
||||||
cacheIdentity?: string,
|
cacheIdentity?: string,
|
||||||
): Promise<Result<void>>;
|
): Promise<Result<void>>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
export * from "./iauth_repository";
|
export * from "./iauth_repository";
|
||||||
export * from "./ichat_repository";
|
export * from "./ichat_repository";
|
||||||
|
export * from "./icharacter_repository";
|
||||||
export * from "./ifeedback_repository";
|
export * from "./ifeedback_repository";
|
||||||
export * from "./imetrics_repository";
|
export * from "./imetrics_repository";
|
||||||
export * from "./ipayment_repository";
|
export * from "./ipayment_repository";
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type {
|
|||||||
import type { Result } from "@/utils/result";
|
import type { Result } from "@/utils/result";
|
||||||
|
|
||||||
export interface GetPrivateAlbumsInput {
|
export interface GetPrivateAlbumsInput {
|
||||||
character?: string;
|
characterId?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const CharacterSchema = z.object({
|
||||||
|
id: z.string().min(1).max(64),
|
||||||
|
slug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
|
||||||
|
displayName: z.string().trim().min(1).max(80),
|
||||||
|
avatarUrl: z.url().refine((url) => url.startsWith("https://"), {
|
||||||
|
message: "avatarUrl must use HTTPS",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CharacterInput = z.input<typeof CharacterSchema>;
|
||||||
|
export type CharacterData = z.output<typeof CharacterSchema>;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { CharacterSchema } from "./character";
|
||||||
|
|
||||||
|
export const CharactersResponseSchema = z.object({
|
||||||
|
items: z.array(CharacterSchema).default([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CharactersResponseInput = z.input<typeof CharactersResponseSchema>;
|
||||||
|
export type CharactersResponseData = z.output<typeof CharactersResponseSchema>;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./character";
|
||||||
|
export * from "./characters_response";
|
||||||
@@ -4,3 +4,4 @@
|
|||||||
|
|
||||||
export * from "./send_message_request";
|
export * from "./send_message_request";
|
||||||
export * from "./unlock_private_request";
|
export * from "./unlock_private_request";
|
||||||
|
export * from "./unlock_history_request";
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from "../../nullable-defaults";
|
} from "../../nullable-defaults";
|
||||||
|
|
||||||
export const SendMessageRequestSchema = z.object({
|
export const SendMessageRequestSchema = z.object({
|
||||||
|
characterId: z.string().min(1),
|
||||||
message: stringOrEmpty,
|
message: stringOrEmpty,
|
||||||
image: stringOrEmpty,
|
image: stringOrEmpty,
|
||||||
imageId: stringOrEmpty,
|
imageId: stringOrEmpty,
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const UnlockHistoryRequestSchema = z.object({
|
||||||
|
characterId: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type UnlockHistoryRequestInput = z.input<
|
||||||
|
typeof UnlockHistoryRequestSchema
|
||||||
|
>;
|
||||||
|
export type UnlockHistoryRequestData = z.output<
|
||||||
|
typeof UnlockHistoryRequestSchema
|
||||||
|
>;
|
||||||
@@ -7,6 +7,7 @@ import { ChatLockTypeSchema } from "../chat_lock_type";
|
|||||||
|
|
||||||
export const UnlockPrivateRequestSchema = z
|
export const UnlockPrivateRequestSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
characterId: z.string().min(1),
|
||||||
messageId: z.string().min(1).optional(),
|
messageId: z.string().min(1).optional(),
|
||||||
lockType: ChatLockTypeSchema.optional(),
|
lockType: ChatLockTypeSchema.optional(),
|
||||||
clientLockId: z.string().min(1).optional(),
|
clientLockId: z.string().min(1).optional(),
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const httpClientMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("../http_client", () => ({
|
||||||
|
httpClient: httpClientMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { CharacterApi } from "../character_api";
|
||||||
|
|
||||||
|
describe("CharacterApi", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
httpClientMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads characters and preserves the backend order", async () => {
|
||||||
|
httpClientMock.mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: "character_aria",
|
||||||
|
slug: "aria",
|
||||||
|
displayName: "Aria",
|
||||||
|
avatarUrl: "https://cdn.example.com/aria.jpg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "character_elio",
|
||||||
|
slug: "elio",
|
||||||
|
displayName: "Elio Silvestri",
|
||||||
|
avatarUrl: "https://cdn.example.com/elio.jpg",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await new CharacterApi().getCharacters();
|
||||||
|
|
||||||
|
expect(httpClientMock).toHaveBeenCalledWith("/api/characters");
|
||||||
|
expect(response.items.map((character) => character.id)).toEqual([
|
||||||
|
"character_aria",
|
||||||
|
"character_elio",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
SendMessageRequest,
|
||||||
|
UnlockHistoryRequest,
|
||||||
|
UnlockPrivateRequest,
|
||||||
|
} from "@/data/dto/chat";
|
||||||
|
|
||||||
|
const httpClientMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("../http_client", () => ({
|
||||||
|
httpClient: httpClientMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { ChatApi } from "../chat_api";
|
||||||
|
import { PrivateRoomApi } from "../private_room_api";
|
||||||
|
|
||||||
|
const CHARACTER_ID = "character_elio";
|
||||||
|
|
||||||
|
describe("multi-character API contract", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
httpClientMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends characterId with chat messages", async () => {
|
||||||
|
httpClientMock.mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
reply: "Hello",
|
||||||
|
messageId: "message-1",
|
||||||
|
image: { type: null, url: null },
|
||||||
|
lockDetail: { locked: false, reason: null },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await new ChatApi().sendMessage(
|
||||||
|
SendMessageRequest.from({
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
|
message: "Hello",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(httpClientMock).toHaveBeenCalledWith("/api/chat/send", {
|
||||||
|
method: "POST",
|
||||||
|
body: expect.objectContaining({ characterId: CHARACTER_ID }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scopes history to characterId", async () => {
|
||||||
|
httpClientMock.mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
data: { messages: [], total: 0, limit: 50, offset: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
await new ChatApi().getHistory(CHARACTER_ID, 50, 10);
|
||||||
|
|
||||||
|
expect(httpClientMock).toHaveBeenCalledWith("/api/chat/history", {
|
||||||
|
query: { characterId: CHARACTER_ID, limit: 50, offset: 10 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends characterId with private and history unlocks", async () => {
|
||||||
|
const api = new ChatApi();
|
||||||
|
httpClientMock
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
unlocked: false,
|
||||||
|
reason: "not_found",
|
||||||
|
image: { type: null, url: null },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
success: true,
|
||||||
|
data: { unlocked: false, reason: "no_locked_messages" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await api.unlockPrivateMessage(
|
||||||
|
UnlockPrivateRequest.from({
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
|
messageId: "message-1",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await api.unlockHistory(
|
||||||
|
UnlockHistoryRequest.from({ characterId: CHARACTER_ID }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(httpClientMock).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
"/api/chat/unlock-private",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: expect.objectContaining({ characterId: CHARACTER_ID }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(httpClientMock).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
"/api/chat/unlock-history",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: { characterId: CHARACTER_ID },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scopes private-room albums to characterId", async () => {
|
||||||
|
httpClientMock.mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
data: { items: [], creditBalance: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
await new PrivateRoomApi().getAlbums({
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
|
limit: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(httpClientMock).toHaveBeenCalledWith("/api/private-room/albums", {
|
||||||
|
query: { characterId: CHARACTER_ID, limit: 20 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -24,5 +24,6 @@
|
|||||||
"privateRoomAlbumUnlock": { "method": "post", "path": "/api/private-room/albums/{albumId}/unlock" },
|
"privateRoomAlbumUnlock": { "method": "post", "path": "/api/private-room/albums/{albumId}/unlock" },
|
||||||
"metricsPwaEvent": { "method": "post", "path": "/api/metrics/pwa/event" },
|
"metricsPwaEvent": { "method": "post", "path": "/api/metrics/pwa/event" },
|
||||||
"reportUserInfo": { "method": "post", "path": "/api/data/report-user-info" },
|
"reportUserInfo": { "method": "post", "path": "/api/data/report-user-info" },
|
||||||
"feedback": { "method": "post", "path": "/api/feedback" }
|
"feedback": { "method": "post", "path": "/api/feedback" },
|
||||||
|
"characters": { "method": "get", "path": "/api/characters" }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,4 +98,7 @@ export class ApiPath {
|
|||||||
|
|
||||||
// ============ 用户反馈相关 ============
|
// ============ 用户反馈相关 ============
|
||||||
static readonly feedback = apiContract.feedback.path;
|
static readonly feedback = apiContract.feedback.path;
|
||||||
|
|
||||||
|
// ============ 角色相关 ============
|
||||||
|
static readonly characters = apiContract.characters.path;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { CharactersResponse } from "@/data/dto/character";
|
||||||
|
|
||||||
|
import { ApiPath } from "./api_path";
|
||||||
|
import { httpClient } from "./http_client";
|
||||||
|
import { type ApiEnvelope, unwrap } from "./response_helper";
|
||||||
|
|
||||||
|
export class CharacterApi {
|
||||||
|
async getCharacters(): Promise<CharactersResponse> {
|
||||||
|
const envelope = await httpClient<ApiEnvelope<unknown>>(ApiPath.characters);
|
||||||
|
return CharactersResponse.fromJson(unwrap(envelope));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const characterApi = new CharacterApi();
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
ChatHistoryResponse,
|
ChatHistoryResponse,
|
||||||
ChatSendResponse,
|
ChatSendResponse,
|
||||||
SendMessageRequest,
|
SendMessageRequest,
|
||||||
|
UnlockHistoryRequest,
|
||||||
UnlockHistoryResponse,
|
UnlockHistoryResponse,
|
||||||
UnlockPrivateRequest,
|
UnlockPrivateRequest,
|
||||||
UnlockPrivateResponse,
|
UnlockPrivateResponse,
|
||||||
@@ -31,9 +32,13 @@ export class ChatApi {
|
|||||||
/**
|
/**
|
||||||
* 获取聊天历史
|
* 获取聊天历史
|
||||||
*/
|
*/
|
||||||
async getHistory(limit = 50, offset = 0): Promise<ChatHistoryResponse> {
|
async getHistory(
|
||||||
|
characterId: string,
|
||||||
|
limit = 50,
|
||||||
|
offset = 0,
|
||||||
|
): Promise<ChatHistoryResponse> {
|
||||||
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.chatHistory, {
|
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.chatHistory, {
|
||||||
query: { limit, offset },
|
query: { characterId, limit, offset },
|
||||||
});
|
});
|
||||||
return ChatHistoryResponse.fromJson(
|
return ChatHistoryResponse.fromJson(
|
||||||
unwrap(env) as Record<string, unknown>
|
unwrap(env) as Record<string, unknown>
|
||||||
@@ -61,11 +66,14 @@ export class ChatApi {
|
|||||||
/**
|
/**
|
||||||
* 一键解锁历史锁定消息
|
* 一键解锁历史锁定消息
|
||||||
*/
|
*/
|
||||||
async unlockHistory(): Promise<UnlockHistoryResponse> {
|
async unlockHistory(
|
||||||
|
body: UnlockHistoryRequest,
|
||||||
|
): Promise<UnlockHistoryResponse> {
|
||||||
const env = await httpClient<ApiEnvelope<unknown>>(
|
const env = await httpClient<ApiEnvelope<unknown>>(
|
||||||
ApiPath.chatUnlockHistory,
|
ApiPath.chatUnlockHistory,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
body: body.toJson(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return UnlockHistoryResponse.fromJson(
|
return UnlockHistoryResponse.fromJson(
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export * from "./api_path";
|
|||||||
export * from "./api_result";
|
export * from "./api_result";
|
||||||
export * from "./auth_api";
|
export * from "./auth_api";
|
||||||
export * from "./chat_api";
|
export * from "./chat_api";
|
||||||
|
export * from "./character_api";
|
||||||
export * from "./feedback_api";
|
export * from "./feedback_api";
|
||||||
export * from "./http_client";
|
export * from "./http_client";
|
||||||
export * from "./metrics_api";
|
export * from "./metrics_api";
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ import {
|
|||||||
PrivateAlbumUnlockResponse,
|
PrivateAlbumUnlockResponse,
|
||||||
UnlockPrivateAlbumRequest,
|
UnlockPrivateAlbumRequest,
|
||||||
} from "@/data/dto/private-room";
|
} from "@/data/dto/private-room";
|
||||||
|
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
|
||||||
|
|
||||||
import { ApiPath } from "./api_path";
|
import { ApiPath } from "./api_path";
|
||||||
import { httpClient } from "./http_client";
|
import { httpClient } from "./http_client";
|
||||||
import { type ApiEnvelope, unwrap } from "./response_helper";
|
import { type ApiEnvelope, unwrap } from "./response_helper";
|
||||||
|
|
||||||
export interface GetPrivateAlbumsInput {
|
export interface GetPrivateAlbumsInput {
|
||||||
character?: string;
|
characterId?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,7 +22,7 @@ export class PrivateRoomApi {
|
|||||||
ApiPath.privateRoomAlbums,
|
ApiPath.privateRoomAlbums,
|
||||||
{
|
{
|
||||||
query: {
|
query: {
|
||||||
character: input.character ?? "elio",
|
characterId: input.characterId ?? DEFAULT_CHARACTER_ID,
|
||||||
limit: input.limit ?? 20,
|
limit: input.limit ?? 20,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { Result } from "@/utils/result";
|
|||||||
|
|
||||||
const getHistoryMock = vi.hoisted(() => vi.fn());
|
const getHistoryMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
vi.mock("@/data/repositories/chat_repository", () => ({
|
vi.mock("@/data/repositories/chat_repository_loader", () => ({
|
||||||
getChatRepository: () => ({ getHistory: getHistoryMock }),
|
loadChatRepository: async () => ({ getHistory: getHistoryMock }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -31,10 +31,22 @@ describe("fetchSplashLatestMessagePreview", () => {
|
|||||||
|
|
||||||
const result = await fetchSplashLatestMessagePreview();
|
const result = await fetchSplashLatestMessagePreview();
|
||||||
|
|
||||||
expect(getHistoryMock).toHaveBeenCalledWith(1, 0);
|
expect(getHistoryMock).toHaveBeenCalledWith(
|
||||||
|
"character_elio",
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
);
|
||||||
expect(Result.isOk(result) && result.data).toBe("Latest message");
|
expect(Result.isOk(result) && result.data).toBe("Latest message");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("requests the selected character preview", async () => {
|
||||||
|
getHistoryMock.mockResolvedValue(Result.ok({ messages: [] }));
|
||||||
|
|
||||||
|
await fetchSplashLatestMessagePreview("character_aria");
|
||||||
|
|
||||||
|
expect(getHistoryMock).toHaveBeenCalledWith("character_aria", 1, 0);
|
||||||
|
});
|
||||||
|
|
||||||
it("returns null when history is empty", async () => {
|
it("returns null when history is empty", async () => {
|
||||||
getHistoryMock.mockResolvedValue(Result.ok({ messages: [] }));
|
getHistoryMock.mockResolvedValue(Result.ok({ messages: [] }));
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Result, type Result as ResultT } from "@/utils/result";
|
|||||||
import { localChatMediaRowToBlob } from "./chat_media_blob";
|
import { localChatMediaRowToBlob } from "./chat_media_blob";
|
||||||
|
|
||||||
export interface ResolveCachedChatMediaBlobInput {
|
export interface ResolveCachedChatMediaBlobInput {
|
||||||
|
characterId: string;
|
||||||
messageId: string;
|
messageId: string;
|
||||||
kind: ChatMediaKind;
|
kind: ChatMediaKind;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { loadChatRepository } from "@/data/repositories/chat_repository_loader";
|
import { loadChatRepository } from "@/data/repositories/chat_repository_loader";
|
||||||
import {
|
import {
|
||||||
resolveChatCacheOwnerKey,
|
resolveChatConversationKey,
|
||||||
type ChatCacheIdentityResolver,
|
type ChatCacheIdentityResolver,
|
||||||
} from "@/data/repositories/chat_cache_identity";
|
} from "@/data/repositories/chat_cache_identity";
|
||||||
|
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
|
||||||
import { Result, type Result as ResultT } from "@/utils/result";
|
import { Result, type Result as ResultT } from "@/utils/result";
|
||||||
|
|
||||||
import type { SplashLatestMessageCache } from "./splash_latest_message_cache";
|
import type { SplashLatestMessageCache } from "./splash_latest_message_cache";
|
||||||
@@ -10,21 +11,23 @@ import { getLatestSplashMessagePreview } from "./splash_latest_message_preview";
|
|||||||
|
|
||||||
export interface LoadSplashLatestMessagePreviewInput {
|
export interface LoadSplashLatestMessagePreviewInput {
|
||||||
cache: SplashLatestMessageCache;
|
cache: SplashLatestMessageCache;
|
||||||
fetchPreview?: () => Promise<ResultT<string | null>>;
|
characterId?: string;
|
||||||
|
fetchPreview?: (characterId: string) => Promise<ResultT<string | null>>;
|
||||||
resolveIdentity?: ChatCacheIdentityResolver;
|
resolveIdentity?: ChatCacheIdentityResolver;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveSplashLatestMessageCacheIdentity(): Promise<
|
export async function resolveSplashLatestMessageCacheIdentity(
|
||||||
string | null
|
characterId = DEFAULT_CHARACTER_ID,
|
||||||
> {
|
): Promise<string | null> {
|
||||||
const result = await resolveChatCacheOwnerKey();
|
const result = await resolveChatConversationKey(characterId);
|
||||||
return Result.isOk(result) ? result.data : null;
|
return Result.isOk(result) ? result.data : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadSplashLatestMessagePreview({
|
export async function loadSplashLatestMessagePreview({
|
||||||
cache,
|
cache,
|
||||||
|
characterId = DEFAULT_CHARACTER_ID,
|
||||||
fetchPreview = fetchSplashLatestMessagePreview,
|
fetchPreview = fetchSplashLatestMessagePreview,
|
||||||
resolveIdentity = resolveChatCacheOwnerKey,
|
resolveIdentity = () => resolveChatConversationKey(characterId),
|
||||||
}: LoadSplashLatestMessagePreviewInput): Promise<ResultT<string | null>> {
|
}: LoadSplashLatestMessagePreviewInput): Promise<ResultT<string | null>> {
|
||||||
const identityResult = await resolveIdentity();
|
const identityResult = await resolveIdentity();
|
||||||
const identity = Result.isOk(identityResult) ? identityResult.data : null;
|
const identity = Result.isOk(identityResult) ? identityResult.data : null;
|
||||||
@@ -34,18 +37,18 @@ export async function loadSplashLatestMessagePreview({
|
|||||||
if (cached) return Result.ok(cached.message);
|
if (cached) return Result.ok(cached.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await fetchPreview();
|
const result = await fetchPreview(characterId);
|
||||||
if (identity && Result.isOk(result)) {
|
if (identity && Result.isOk(result)) {
|
||||||
cache.set(identity, result.data);
|
cache.set(identity, result.data);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSplashLatestMessagePreview(): Promise<
|
export async function fetchSplashLatestMessagePreview(
|
||||||
ResultT<string | null>
|
characterId = DEFAULT_CHARACTER_ID,
|
||||||
> {
|
): Promise<ResultT<string | null>> {
|
||||||
const repository = await loadChatRepository();
|
const repository = await loadChatRepository();
|
||||||
const result = await repository.getHistory(1, 0);
|
const result = await repository.getHistory(characterId, 1, 0);
|
||||||
if (Result.isErr(result)) return Result.err(result.error);
|
if (Result.isErr(result)) return Result.err(result.error);
|
||||||
|
|
||||||
return Result.ok(getLatestSplashMessagePreview(result.data.messages));
|
return Result.ok(getLatestSplashMessagePreview(result.data.messages));
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { isCacheableRemoteChatMediaUrl } from "./chat_media_url";
|
|||||||
const log = new Logger("LibChatUseCachedChatMediaUrl");
|
const log = new Logger("LibChatUseCachedChatMediaUrl");
|
||||||
|
|
||||||
export interface UseCachedChatMediaUrlInput {
|
export interface UseCachedChatMediaUrlInput {
|
||||||
|
characterId: string;
|
||||||
messageId?: string | null;
|
messageId?: string | null;
|
||||||
remoteUrl?: string | null;
|
remoteUrl?: string | null;
|
||||||
kind: ChatMediaKind;
|
kind: ChatMediaKind;
|
||||||
@@ -36,6 +37,7 @@ interface CachedMediaUrlState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useCachedChatMediaUrl({
|
export function useCachedChatMediaUrl({
|
||||||
|
characterId,
|
||||||
messageId,
|
messageId,
|
||||||
remoteUrl,
|
remoteUrl,
|
||||||
kind,
|
kind,
|
||||||
@@ -47,7 +49,9 @@ export function useCachedChatMediaUrl({
|
|||||||
const [failedCacheKey, setFailedCacheKey] = useState<string | null>(null);
|
const [failedCacheKey, setFailedCacheKey] = useState<string | null>(null);
|
||||||
const fallbackUrl = remoteUrl ?? "";
|
const fallbackUrl = remoteUrl ?? "";
|
||||||
const stateKey =
|
const stateKey =
|
||||||
messageId && remoteUrl ? `${messageId}:${kind}:${remoteUrl}` : "";
|
messageId && remoteUrl
|
||||||
|
? `${characterId}:${messageId}:${kind}:${remoteUrl}`
|
||||||
|
: "";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
@@ -80,6 +84,7 @@ export function useCachedChatMediaUrl({
|
|||||||
|
|
||||||
const resolveCachedMedia = async () => {
|
const resolveCachedMedia = async () => {
|
||||||
const cachedResult = await resolveCachedChatMediaBlob({
|
const cachedResult = await resolveCachedChatMediaBlob({
|
||||||
|
characterId,
|
||||||
messageId,
|
messageId,
|
||||||
kind,
|
kind,
|
||||||
});
|
});
|
||||||
@@ -91,6 +96,7 @@ export function useCachedChatMediaUrl({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cacheResult = await cacheRemoteChatMediaBlob({
|
const cacheResult = await cacheRemoteChatMediaBlob({
|
||||||
|
characterId,
|
||||||
messageId,
|
messageId,
|
||||||
kind,
|
kind,
|
||||||
remoteUrl,
|
remoteUrl,
|
||||||
@@ -114,7 +120,7 @@ export function useCachedChatMediaUrl({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [failedCacheKey, kind, messageId, remoteUrl, stateKey]);
|
}, [characterId, failedCacheKey, kind, messageId, remoteUrl, stateKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -2,17 +2,26 @@
|
|||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
|
||||||
import { ChatProvider } from "@/stores/chat/chat-context";
|
import { ChatProvider } from "@/stores/chat/chat-context";
|
||||||
import { PaymentProvider } from "@/stores/payment/payment-context";
|
import { PaymentProvider } from "@/stores/payment/payment-context";
|
||||||
import { ChatAuthSync } from "@/stores/sync/chat-auth-sync";
|
import { ChatAuthSync } from "@/stores/sync/chat-auth-sync";
|
||||||
import { ChatPaymentSuccessSync } from "@/stores/sync/chat-payment-success-sync";
|
import { ChatPaymentSuccessSync } from "@/stores/sync/chat-payment-success-sync";
|
||||||
import { PaymentSuccessSync } from "@/stores/sync/payment-success-sync";
|
import { PaymentSuccessSync } from "@/stores/sync/payment-success-sync";
|
||||||
|
|
||||||
export function ChatRouteProviders({ children }: { children: ReactNode }) {
|
export interface ChatRouteProvidersProps {
|
||||||
|
children: ReactNode;
|
||||||
|
characterId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatRouteProviders({
|
||||||
|
children,
|
||||||
|
characterId = DEFAULT_CHARACTER_ID,
|
||||||
|
}: ChatRouteProvidersProps) {
|
||||||
return (
|
return (
|
||||||
<PaymentProvider>
|
<PaymentProvider>
|
||||||
<PaymentSuccessSync />
|
<PaymentSuccessSync />
|
||||||
<ChatProvider>
|
<ChatProvider characterId={characterId}>
|
||||||
<ChatAuthSync />
|
<ChatAuthSync />
|
||||||
<ChatPaymentSuccessSync />
|
<ChatPaymentSuccessSync />
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -2,12 +2,21 @@
|
|||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
|
||||||
import { PrivateRoomProvider } from "@/stores/private-room";
|
import { PrivateRoomProvider } from "@/stores/private-room";
|
||||||
|
|
||||||
|
export interface PrivateRoomRouteProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
characterId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function PrivateRoomRouteProvider({
|
export function PrivateRoomRouteProvider({
|
||||||
children,
|
children,
|
||||||
}: {
|
characterId = DEFAULT_CHARACTER_ID,
|
||||||
children: ReactNode;
|
}: PrivateRoomRouteProviderProps) {
|
||||||
}) {
|
return (
|
||||||
return <PrivateRoomProvider>{children}</PrivateRoomProvider>;
|
<PrivateRoomProvider characterId={characterId}>
|
||||||
|
{children}
|
||||||
|
</PrivateRoomProvider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
|
||||||
import { ChatSendResponse } from "@/data/dto/chat";
|
import { ChatSendResponse } from "@/data/dto/chat";
|
||||||
import type { ChatState } from "@/stores/chat/chat-state";
|
import type { ChatState } from "@/stores/chat/chat-state";
|
||||||
import {
|
import {
|
||||||
@@ -29,6 +30,7 @@ function makeResponse(
|
|||||||
|
|
||||||
function makeChatState(overrides: Partial<ChatState> = {}): ChatState {
|
function makeChatState(overrides: Partial<ChatState> = {}): ChatState {
|
||||||
return {
|
return {
|
||||||
|
characterId: DEFAULT_CHARACTER_ID,
|
||||||
messages: [],
|
messages: [],
|
||||||
promotion: null,
|
promotion: null,
|
||||||
outgoingMessageRevision: 0,
|
outgoingMessageRevision: 0,
|
||||||
|
|||||||
@@ -9,11 +9,14 @@ import type { LoadMoreHistoryActorEvent } from "@/stores/chat/machine/actors/his
|
|||||||
import {
|
import {
|
||||||
createLoadHistoryCallback,
|
createLoadHistoryCallback,
|
||||||
createTestChatMachine,
|
createTestChatMachine,
|
||||||
|
TEST_CHAT_MACHINE_INPUT,
|
||||||
} from "./chat-machine.test-utils";
|
} from "./chat-machine.test-utils";
|
||||||
|
|
||||||
describe("chat history flow", () => {
|
describe("chat history flow", () => {
|
||||||
it("enters user ready after history is loaded", async () => {
|
it("enters user ready after history is loaded", async () => {
|
||||||
const actor = createActor(createTestChatMachine()).start();
|
const actor = createActor(createTestChatMachine(), {
|
||||||
|
input: TEST_CHAT_MACHINE_INPUT,
|
||||||
|
}).start();
|
||||||
|
|
||||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||||
await waitFor(actor, (snapshot) =>
|
await waitFor(actor, (snapshot) =>
|
||||||
@@ -45,7 +48,10 @@ describe("chat history flow", () => {
|
|||||||
};
|
};
|
||||||
const machine = chatMachine.provide({
|
const machine = chatMachine.provide({
|
||||||
actors: {
|
actors: {
|
||||||
loadHistory: fromCallback<ChatEvent>(({ sendBack }) => {
|
loadHistory: fromCallback<
|
||||||
|
ChatEvent,
|
||||||
|
{ characterId: string }
|
||||||
|
>(({ sendBack }) => {
|
||||||
sendBack({
|
sendBack({
|
||||||
type: "ChatLocalHistoryLoaded",
|
type: "ChatLocalHistoryLoaded",
|
||||||
output: {
|
output: {
|
||||||
@@ -70,7 +76,9 @@ describe("chat history flow", () => {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const actor = createActor(machine).start();
|
const actor = createActor(machine, {
|
||||||
|
input: TEST_CHAT_MACHINE_INPUT,
|
||||||
|
}).start();
|
||||||
|
|
||||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||||
await waitFor(actor, (snapshot) =>
|
await waitFor(actor, (snapshot) =>
|
||||||
@@ -109,7 +117,10 @@ describe("chat history flow", () => {
|
|||||||
[latestMessage],
|
[latestMessage],
|
||||||
{ total: 120, limit: 50 },
|
{ total: 120, limit: 50 },
|
||||||
),
|
),
|
||||||
loadMoreHistory: fromCallback<LoadMoreHistoryActorEvent>(
|
loadMoreHistory: fromCallback<
|
||||||
|
LoadMoreHistoryActorEvent,
|
||||||
|
{ characterId: string }
|
||||||
|
>(
|
||||||
({ receive, sendBack }) => {
|
({ receive, sendBack }) => {
|
||||||
receive((event) => {
|
receive((event) => {
|
||||||
requests.push(event);
|
requests.push(event);
|
||||||
@@ -143,7 +154,9 @@ describe("chat history flow", () => {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const actor = createActor(machine).start();
|
const actor = createActor(machine, {
|
||||||
|
input: TEST_CHAT_MACHINE_INPUT,
|
||||||
|
}).start();
|
||||||
|
|
||||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||||
await waitFor(actor, (snapshot) =>
|
await waitFor(actor, (snapshot) =>
|
||||||
@@ -183,7 +196,10 @@ describe("chat history flow", () => {
|
|||||||
total: 75,
|
total: 75,
|
||||||
limit: 50,
|
limit: 50,
|
||||||
}),
|
}),
|
||||||
loadMoreHistory: fromCallback<LoadMoreHistoryActorEvent>(
|
loadMoreHistory: fromCallback<
|
||||||
|
LoadMoreHistoryActorEvent,
|
||||||
|
{ characterId: string }
|
||||||
|
>(
|
||||||
({ receive, sendBack }) => {
|
({ receive, sendBack }) => {
|
||||||
receive((event) => {
|
receive((event) => {
|
||||||
requestCount += 1;
|
requestCount += 1;
|
||||||
@@ -209,7 +225,9 @@ describe("chat history flow", () => {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const actor = createActor(machine).start();
|
const actor = createActor(machine, {
|
||||||
|
input: TEST_CHAT_MACHINE_INPUT,
|
||||||
|
}).start();
|
||||||
|
|
||||||
actor.send({ type: "ChatGuestLogin" });
|
actor.send({ type: "ChatGuestLogin" });
|
||||||
await waitFor(actor, (snapshot) =>
|
await waitFor(actor, (snapshot) =>
|
||||||
@@ -240,7 +258,10 @@ describe("chat history flow", () => {
|
|||||||
total: 50,
|
total: 50,
|
||||||
limit: 50,
|
limit: 50,
|
||||||
}),
|
}),
|
||||||
loadMoreHistory: fromCallback<LoadMoreHistoryActorEvent>(
|
loadMoreHistory: fromCallback<
|
||||||
|
LoadMoreHistoryActorEvent,
|
||||||
|
{ characterId: string }
|
||||||
|
>(
|
||||||
({ receive }) => {
|
({ receive }) => {
|
||||||
receive(() => {
|
receive(() => {
|
||||||
requested = true;
|
requested = true;
|
||||||
@@ -250,7 +271,9 @@ describe("chat history flow", () => {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const actor = createActor(machine).start();
|
const actor = createActor(machine, {
|
||||||
|
input: TEST_CHAT_MACHINE_INPUT,
|
||||||
|
}).start();
|
||||||
|
|
||||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||||
await waitFor(actor, (snapshot) =>
|
await waitFor(actor, (snapshot) =>
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ import {
|
|||||||
UnlockPrivateResponse,
|
UnlockPrivateResponse,
|
||||||
type UiMessage,
|
type UiMessage,
|
||||||
} from "@/data/dto/chat";
|
} from "@/data/dto/chat";
|
||||||
|
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
|
||||||
import { chatMachine } from "@/stores/chat/chat-machine";
|
import { chatMachine } from "@/stores/chat/chat-machine";
|
||||||
import type { ChatEvent } from "@/stores/chat/chat-events";
|
import type { ChatEvent } from "@/stores/chat/chat-events";
|
||||||
import type {
|
import type {
|
||||||
UnlockMessageOutput as MachineUnlockMessageOutput,
|
UnlockMessageOutput as MachineUnlockMessageOutput,
|
||||||
UnlockMessageRequest,
|
UnlockMessageRequest,
|
||||||
} from "@/stores/chat/helper/unlock";
|
} from "@/stores/chat/helper/unlock";
|
||||||
|
import type { ChatHistoryActorInput } from "@/stores/chat/machine/actors/history";
|
||||||
|
|
||||||
export interface SendMessageHttpOutput {
|
export interface SendMessageHttpOutput {
|
||||||
response: ChatSendResponse;
|
response: ChatSendResponse;
|
||||||
@@ -29,6 +31,10 @@ export interface TestUnlockMessageOutput {
|
|||||||
response: UnlockPrivateResponse;
|
response: UnlockPrivateResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const TEST_CHAT_MACHINE_INPUT = {
|
||||||
|
characterId: DEFAULT_CHARACTER_ID,
|
||||||
|
};
|
||||||
|
|
||||||
export function makeChatSendResponse(): ChatSendResponse {
|
export function makeChatSendResponse(): ChatSendResponse {
|
||||||
return ChatSendResponse.from({
|
return ChatSendResponse.from({
|
||||||
reply: "",
|
reply: "",
|
||||||
@@ -74,7 +80,7 @@ export function createTestChatMachine(
|
|||||||
loadHistory: createLoadHistoryCallback(options.historyMessages ?? []),
|
loadHistory: createLoadHistoryCallback(options.historyMessages ?? []),
|
||||||
sendMessageHttp: fromPromise<
|
sendMessageHttp: fromPromise<
|
||||||
SendMessageHttpOutput,
|
SendMessageHttpOutput,
|
||||||
{ content: string }
|
{ characterId: string; content: string }
|
||||||
>(async () => {
|
>(async () => {
|
||||||
if (options.sendMessageHttpError) {
|
if (options.sendMessageHttpError) {
|
||||||
throw options.sendMessageHttpError;
|
throw options.sendMessageHttpError;
|
||||||
@@ -84,7 +90,8 @@ export function createTestChatMachine(
|
|||||||
reply: null,
|
reply: null,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
httpMessageQueue: fromCallback<ChatEvent>(({ receive, sendBack }) => {
|
httpMessageQueue: fromCallback<ChatEvent, { characterId: string }>(
|
||||||
|
({ receive, sendBack }) => {
|
||||||
receive((event) => {
|
receive((event) => {
|
||||||
if (event.type !== "ChatSendMessage") return;
|
if (event.type !== "ChatSendMessage") return;
|
||||||
sendBack({ type: "ChatQueuedSendStarted" });
|
sendBack({ type: "ChatQueuedSendStarted" });
|
||||||
@@ -97,8 +104,12 @@ export function createTestChatMachine(
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
return () => undefined;
|
return () => undefined;
|
||||||
}),
|
},
|
||||||
unlockHistory: fromPromise<UnlockHistoryOutput>(async () => {
|
),
|
||||||
|
unlockHistory: fromPromise<
|
||||||
|
UnlockHistoryOutput,
|
||||||
|
{ characterId: string }
|
||||||
|
>(async () => {
|
||||||
if (options.unlockHistoryError) {
|
if (options.unlockHistoryError) {
|
||||||
throw options.unlockHistoryError;
|
throw options.unlockHistoryError;
|
||||||
}
|
}
|
||||||
@@ -112,7 +123,7 @@ export function createTestChatMachine(
|
|||||||
}),
|
}),
|
||||||
unlockMessage: fromPromise<
|
unlockMessage: fromPromise<
|
||||||
MachineUnlockMessageOutput,
|
MachineUnlockMessageOutput,
|
||||||
UnlockMessageRequest
|
UnlockMessageRequest & { characterId: string }
|
||||||
>(async ({ input }) => {
|
>(async ({ input }) => {
|
||||||
const output = options.unlockMessageOutput ?? {
|
const output = options.unlockMessageOutput ?? {
|
||||||
messageId: input.messageId ?? input.displayMessageId,
|
messageId: input.messageId ?? input.displayMessageId,
|
||||||
@@ -136,7 +147,7 @@ export function createLoadHistoryCallback(
|
|||||||
limit: 50,
|
limit: 50,
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
return fromCallback<ChatEvent>(({ sendBack }) => {
|
return fromCallback<ChatEvent, ChatHistoryActorInput>(({ sendBack }) => {
|
||||||
sendBack({
|
sendBack({
|
||||||
type: "ChatLocalHistoryLoaded",
|
type: "ChatLocalHistoryLoaded",
|
||||||
output: {
|
output: {
|
||||||
|
|||||||
@@ -8,13 +8,16 @@ import {
|
|||||||
createLoadHistoryCallback,
|
createLoadHistoryCallback,
|
||||||
createTestChatMachine,
|
createTestChatMachine,
|
||||||
makeChatSendResponse,
|
makeChatSendResponse,
|
||||||
|
TEST_CHAT_MACHINE_INPUT,
|
||||||
type SendMessageHttpOutput,
|
type SendMessageHttpOutput,
|
||||||
type UnlockHistoryOutput,
|
type UnlockHistoryOutput,
|
||||||
} from "./chat-machine.test-utils";
|
} from "./chat-machine.test-utils";
|
||||||
|
|
||||||
describe("chat send flow", () => {
|
describe("chat send flow", () => {
|
||||||
it("allows multiple messages to be queued without leaving ready state", async () => {
|
it("allows multiple messages to be queued without leaving ready state", async () => {
|
||||||
const actor = createActor(createTestChatMachine()).start();
|
const actor = createActor(createTestChatMachine(), {
|
||||||
|
input: TEST_CHAT_MACHINE_INPUT,
|
||||||
|
}).start();
|
||||||
|
|
||||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||||
await waitFor(actor, (snapshot) =>
|
await waitFor(actor, (snapshot) =>
|
||||||
@@ -44,7 +47,9 @@ describe("chat send flow", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("applies direct HTTP actor output with a type-bound action", async () => {
|
it("applies direct HTTP actor output with a type-bound action", async () => {
|
||||||
const actor = createActor(createTestChatMachine()).start();
|
const actor = createActor(createTestChatMachine(), {
|
||||||
|
input: TEST_CHAT_MACHINE_INPUT,
|
||||||
|
}).start();
|
||||||
|
|
||||||
actor.send({ type: "ChatGuestLogin" });
|
actor.send({ type: "ChatGuestLogin" });
|
||||||
await waitFor(actor, (snapshot) =>
|
await waitFor(actor, (snapshot) =>
|
||||||
@@ -70,7 +75,9 @@ describe("chat send flow", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("increments the outgoing revision for a user image", async () => {
|
it("increments the outgoing revision for a user image", async () => {
|
||||||
const actor = createActor(createTestChatMachine()).start();
|
const actor = createActor(createTestChatMachine(), {
|
||||||
|
input: TEST_CHAT_MACHINE_INPUT,
|
||||||
|
}).start();
|
||||||
|
|
||||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||||
await waitFor(actor, (snapshot) =>
|
await waitFor(actor, (snapshot) =>
|
||||||
@@ -95,6 +102,7 @@ describe("chat send flow", () => {
|
|||||||
createTestChatMachine({
|
createTestChatMachine({
|
||||||
sendMessageHttpError: new Error("send failed"),
|
sendMessageHttpError: new Error("send failed"),
|
||||||
}),
|
}),
|
||||||
|
{ input: TEST_CHAT_MACHINE_INPUT },
|
||||||
).start();
|
).start();
|
||||||
|
|
||||||
actor.send({ type: "ChatGuestLogin" });
|
actor.send({ type: "ChatGuestLogin" });
|
||||||
@@ -121,12 +129,13 @@ describe("chat send flow", () => {
|
|||||||
loadHistory: createLoadHistoryCallback([]),
|
loadHistory: createLoadHistoryCallback([]),
|
||||||
sendMessageHttp: fromPromise<
|
sendMessageHttp: fromPromise<
|
||||||
SendMessageHttpOutput,
|
SendMessageHttpOutput,
|
||||||
{ content: string }
|
{ characterId: string; content: string }
|
||||||
>(async () => ({
|
>(async () => ({
|
||||||
response: makeChatSendResponse(),
|
response: makeChatSendResponse(),
|
||||||
reply: null,
|
reply: null,
|
||||||
})),
|
})),
|
||||||
httpMessageQueue: fromCallback<ChatEvent>(({ receive, sendBack }) => {
|
httpMessageQueue: fromCallback<ChatEvent, { characterId: string }>(
|
||||||
|
({ receive, sendBack }) => {
|
||||||
const pending: string[] = [];
|
const pending: string[] = [];
|
||||||
receive((event) => {
|
receive((event) => {
|
||||||
if (event.type !== "ChatSendMessage") return;
|
if (event.type !== "ChatSendMessage") return;
|
||||||
@@ -142,8 +151,12 @@ describe("chat send flow", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
return () => undefined;
|
return () => undefined;
|
||||||
}),
|
},
|
||||||
unlockHistory: fromPromise<UnlockHistoryOutput>(async () => ({
|
),
|
||||||
|
unlockHistory: fromPromise<
|
||||||
|
UnlockHistoryOutput,
|
||||||
|
{ characterId: string }
|
||||||
|
>(async () => ({
|
||||||
unlocked: true,
|
unlocked: true,
|
||||||
reason: "ok",
|
reason: "ok",
|
||||||
shortfallCredits: 0,
|
shortfallCredits: 0,
|
||||||
@@ -151,7 +164,9 @@ describe("chat send flow", () => {
|
|||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const actor = createActor(machine).start();
|
const actor = createActor(machine, {
|
||||||
|
input: TEST_CHAT_MACHINE_INPUT,
|
||||||
|
}).start();
|
||||||
|
|
||||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||||
await waitFor(actor, (snapshot) =>
|
await waitFor(actor, (snapshot) =>
|
||||||
|
|||||||
@@ -1,11 +1,25 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { createActor, waitFor } from "xstate";
|
import { createActor, waitFor } from "xstate";
|
||||||
|
|
||||||
import { createTestChatMachine } from "./chat-machine.test-utils";
|
import {
|
||||||
|
createTestChatMachine,
|
||||||
|
TEST_CHAT_MACHINE_INPUT,
|
||||||
|
} from "./chat-machine.test-utils";
|
||||||
|
|
||||||
describe("chat session flow", () => {
|
describe("chat session flow", () => {
|
||||||
|
it("initializes the conversation from the supplied character", () => {
|
||||||
|
const actor = createActor(createTestChatMachine(), {
|
||||||
|
input: { characterId: "character_aria" },
|
||||||
|
}).start();
|
||||||
|
|
||||||
|
expect(actor.getSnapshot().context.characterId).toBe("character_aria");
|
||||||
|
actor.stop();
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps guest logout disabled and supports user to guest transition", async () => {
|
it("keeps guest logout disabled and supports user to guest transition", async () => {
|
||||||
const actor = createActor(createTestChatMachine()).start();
|
const actor = createActor(createTestChatMachine(), {
|
||||||
|
input: TEST_CHAT_MACHINE_INPUT,
|
||||||
|
}).start();
|
||||||
|
|
||||||
actor.send({ type: "ChatGuestLogin" });
|
actor.send({ type: "ChatGuestLogin" });
|
||||||
await waitFor(actor, (snapshot) =>
|
await waitFor(actor, (snapshot) =>
|
||||||
@@ -29,7 +43,9 @@ describe("chat session flow", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("allows explicit logout from user session", async () => {
|
it("allows explicit logout from user session", async () => {
|
||||||
const actor = createActor(createTestChatMachine()).start();
|
const actor = createActor(createTestChatMachine(), {
|
||||||
|
input: TEST_CHAT_MACHINE_INPUT,
|
||||||
|
}).start();
|
||||||
|
|
||||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||||
await waitFor(actor, (snapshot) =>
|
await waitFor(actor, (snapshot) =>
|
||||||
@@ -54,6 +70,7 @@ describe("chat session flow", () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
{ input: TEST_CHAT_MACHINE_INPUT },
|
||||||
).start();
|
).start();
|
||||||
|
|
||||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||||
@@ -82,7 +99,9 @@ describe("chat session flow", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("switches to guest session when guest login arrives during user session", async () => {
|
it("switches to guest session when guest login arrives during user session", async () => {
|
||||||
const actor = createActor(createTestChatMachine()).start();
|
const actor = createActor(createTestChatMachine(), {
|
||||||
|
input: TEST_CHAT_MACHINE_INPUT,
|
||||||
|
}).start();
|
||||||
|
|
||||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||||
await waitFor(actor, (snapshot) =>
|
await waitFor(actor, (snapshot) =>
|
||||||
@@ -98,7 +117,9 @@ describe("chat session flow", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("ignores repeated user login while an authenticated user session is active", async () => {
|
it("ignores repeated user login while an authenticated user session is active", async () => {
|
||||||
const actor = createActor(createTestChatMachine()).start();
|
const actor = createActor(createTestChatMachine(), {
|
||||||
|
input: TEST_CHAT_MACHINE_INPUT,
|
||||||
|
}).start();
|
||||||
|
|
||||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||||
await waitFor(actor, (snapshot) =>
|
await waitFor(actor, (snapshot) =>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
import {
|
import {
|
||||||
createTestChatMachine,
|
createTestChatMachine,
|
||||||
makeUnlockPrivateResponse,
|
makeUnlockPrivateResponse,
|
||||||
|
TEST_CHAT_MACHINE_INPUT,
|
||||||
} from "./chat-machine.test-utils";
|
} from "./chat-machine.test-utils";
|
||||||
|
|
||||||
describe("chat unlock flow", () => {
|
describe("chat unlock flow", () => {
|
||||||
@@ -33,6 +34,7 @@ describe("chat unlock flow", () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
{ input: TEST_CHAT_MACHINE_INPUT },
|
||||||
).start();
|
).start();
|
||||||
|
|
||||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||||
@@ -80,6 +82,7 @@ describe("chat unlock flow", () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
{ input: TEST_CHAT_MACHINE_INPUT },
|
||||||
).start();
|
).start();
|
||||||
|
|
||||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||||
@@ -137,6 +140,7 @@ describe("chat unlock flow", () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
{ input: TEST_CHAT_MACHINE_INPUT },
|
||||||
).start();
|
).start();
|
||||||
|
|
||||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||||
@@ -179,6 +183,7 @@ describe("chat unlock flow", () => {
|
|||||||
],
|
],
|
||||||
unlockHistoryError: new Error("unlock failed"),
|
unlockHistoryError: new Error("unlock failed"),
|
||||||
}),
|
}),
|
||||||
|
{ input: TEST_CHAT_MACHINE_INPUT },
|
||||||
).start();
|
).start();
|
||||||
|
|
||||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||||
@@ -222,6 +227,7 @@ describe("chat unlock flow", () => {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
{ input: TEST_CHAT_MACHINE_INPUT },
|
||||||
).start();
|
).start();
|
||||||
|
|
||||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||||
@@ -283,6 +289,7 @@ describe("chat unlock flow", () => {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
{ input: TEST_CHAT_MACHINE_INPUT },
|
||||||
).start();
|
).start();
|
||||||
|
|
||||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||||
@@ -342,6 +349,7 @@ describe("chat unlock flow", () => {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
{ input: TEST_CHAT_MACHINE_INPUT },
|
||||||
).start();
|
).start();
|
||||||
|
|
||||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||||
@@ -395,6 +403,7 @@ describe("chat unlock flow", () => {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
{ input: TEST_CHAT_MACHINE_INPUT },
|
||||||
).start();
|
).start();
|
||||||
|
|
||||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||||
@@ -453,6 +462,7 @@ describe("chat unlock flow", () => {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
{ input: TEST_CHAT_MACHINE_INPUT },
|
||||||
).start();
|
).start();
|
||||||
|
|
||||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||||
@@ -520,6 +530,7 @@ describe("chat unlock flow", () => {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
{ input: TEST_CHAT_MACHINE_INPUT },
|
||||||
).start();
|
).start();
|
||||||
|
|
||||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||||
@@ -575,14 +586,16 @@ describe("chat unlock flow", () => {
|
|||||||
actors: {
|
actors: {
|
||||||
unlockMessage: fromPromise<
|
unlockMessage: fromPromise<
|
||||||
UnlockMessageOutput,
|
UnlockMessageOutput,
|
||||||
UnlockMessageRequest
|
UnlockMessageRequest & { characterId: string }
|
||||||
>(async ({ input }) => {
|
>(async ({ input }) => {
|
||||||
capturedRequest = input;
|
capturedRequest = input;
|
||||||
return unlockDeferred;
|
return unlockDeferred;
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const actor = createActor(machine).start();
|
const actor = createActor(machine, {
|
||||||
|
input: TEST_CHAT_MACHINE_INPUT,
|
||||||
|
}).start();
|
||||||
|
|
||||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||||
await waitFor(actor, (snapshot) =>
|
await waitFor(actor, (snapshot) =>
|
||||||
@@ -641,7 +654,9 @@ describe("chat unlock flow", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("clears the insufficient credits prompt after payment succeeds", async () => {
|
it("clears the insufficient credits prompt after payment succeeds", async () => {
|
||||||
const actor = createActor(createTestChatMachine()).start();
|
const actor = createActor(createTestChatMachine(), {
|
||||||
|
input: TEST_CHAT_MACHINE_INPUT,
|
||||||
|
}).start();
|
||||||
|
|
||||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||||
await waitFor(actor, (snapshot) =>
|
await waitFor(actor, (snapshot) =>
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { type Dispatch, type ReactNode, useMemo } from "react";
|
|||||||
import { createActorContext, shallowEqual } from "@xstate/react";
|
import { createActorContext, shallowEqual } from "@xstate/react";
|
||||||
import type { SnapshotFrom } from "xstate";
|
import type { SnapshotFrom } from "xstate";
|
||||||
|
|
||||||
|
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
|
||||||
|
|
||||||
import { chatMachine } from "./chat-machine";
|
import { chatMachine } from "./chat-machine";
|
||||||
import type { ChatEvent, ChatState as MachineContext } from "./chat-machine";
|
import type { ChatEvent, ChatState as MachineContext } from "./chat-machine";
|
||||||
import { appendPromotionMessage } from "./helper/promotion";
|
import { appendPromotionMessage } from "./helper/promotion";
|
||||||
@@ -15,6 +17,7 @@ import { appendPromotionMessage } from "./helper/promotion";
|
|||||||
* 消费方(chat-screen)通过 `useAuthState()` 取 loginStatus,本地派生 isGuest。
|
* 消费方(chat-screen)通过 `useAuthState()` 取 loginStatus,本地派生 isGuest。
|
||||||
*/
|
*/
|
||||||
interface ChatState {
|
interface ChatState {
|
||||||
|
characterId: string;
|
||||||
messages: MachineContext["messages"];
|
messages: MachineContext["messages"];
|
||||||
historyMessages: MachineContext["messages"];
|
historyMessages: MachineContext["messages"];
|
||||||
promotion: MachineContext["promotion"];
|
promotion: MachineContext["promotion"];
|
||||||
@@ -43,10 +46,18 @@ const ChatActorContext = createActorContext(chatMachine);
|
|||||||
|
|
||||||
export interface ChatProviderProps {
|
export interface ChatProviderProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
characterId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatProvider({ children }: ChatProviderProps) {
|
export function ChatProvider({
|
||||||
return <ChatActorContext.Provider>{children}</ChatActorContext.Provider>;
|
children,
|
||||||
|
characterId = DEFAULT_CHARACTER_ID,
|
||||||
|
}: ChatProviderProps) {
|
||||||
|
return (
|
||||||
|
<ChatActorContext.Provider options={{ input: { characterId } }}>
|
||||||
|
{children}
|
||||||
|
</ChatActorContext.Provider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useChatState(): ChatState {
|
export function useChatState(): ChatState {
|
||||||
@@ -73,6 +84,7 @@ type SelectedChatState = Omit<ChatState, "messages">;
|
|||||||
|
|
||||||
function selectChatState(state: ChatSnapshot): SelectedChatState {
|
function selectChatState(state: ChatSnapshot): SelectedChatState {
|
||||||
return {
|
return {
|
||||||
|
characterId: state.context.characterId,
|
||||||
historyMessages: state.context.messages,
|
historyMessages: state.context.messages,
|
||||||
promotion: state.context.promotion,
|
promotion: state.context.promotion,
|
||||||
outgoingMessageRevision: state.context.outgoingMessageRevision,
|
outgoingMessageRevision: state.context.outgoingMessageRevision,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { UiMessage } from "@/data/dto/chat";
|
import type { UiMessage } from "@/data/dto/chat";
|
||||||
import { resolveChatCacheOwnerKey } from "@/data/repositories/chat_cache_identity";
|
import { resolveChatConversationKey } from "@/data/repositories/chat_cache_identity";
|
||||||
import { loadChatRepository } from "@/data/repositories/chat_repository_loader";
|
import { loadChatRepository } from "@/data/repositories/chat_repository_loader";
|
||||||
import { Logger } from "@/utils/logger";
|
import { Logger } from "@/utils/logger";
|
||||||
import { Result } from "@/utils/result";
|
import { Result } from "@/utils/result";
|
||||||
@@ -41,8 +41,10 @@ export function createGreetingMessage(): UiMessage {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveHistoryCacheIdentity(): Promise<string | null> {
|
export async function resolveHistoryCacheIdentity(
|
||||||
const result = await resolveChatCacheOwnerKey();
|
characterId: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const result = await resolveChatConversationKey(characterId);
|
||||||
return Result.isOk(result) ? result.data : null;
|
return Result.isOk(result) ? result.data : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,13 +78,18 @@ export async function readLocalHistorySnapshot(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function syncNetworkHistory(
|
export async function syncNetworkHistory(
|
||||||
|
characterId: string,
|
||||||
localCount: number,
|
localCount: number,
|
||||||
cacheIdentity: string | null,
|
cacheIdentity: string | null,
|
||||||
): Promise<NetworkHistorySyncOutput | null> {
|
): Promise<NetworkHistorySyncOutput | null> {
|
||||||
const chatRepo = await loadChatRepository();
|
const chatRepo = await loadChatRepository();
|
||||||
const greetingMessage = createGreetingMessage();
|
const greetingMessage = createGreetingMessage();
|
||||||
|
|
||||||
const networkResult = await chatRepo.getHistory(CHAT_HISTORY_LIMIT, 0);
|
const networkResult = await chatRepo.getHistory(
|
||||||
|
characterId,
|
||||||
|
CHAT_HISTORY_LIMIT,
|
||||||
|
0,
|
||||||
|
);
|
||||||
if (Result.isErr(networkResult)) {
|
if (Result.isErr(networkResult)) {
|
||||||
log.error("[chat-machine] loadHistory NETWORK FAILED", {
|
log.error("[chat-machine] loadHistory NETWORK FAILED", {
|
||||||
error: networkResult.error,
|
error: networkResult.error,
|
||||||
@@ -97,6 +104,7 @@ export async function syncNetworkHistory(
|
|||||||
if (cacheIdentity) {
|
if (cacheIdentity) {
|
||||||
void chatRepo.prefetchMediaForMessages(
|
void chatRepo.prefetchMediaForMessages(
|
||||||
networkResult.data.messages,
|
networkResult.data.messages,
|
||||||
|
characterId,
|
||||||
cacheIdentity,
|
cacheIdentity,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -134,10 +142,13 @@ export async function syncNetworkHistory(
|
|||||||
* 2. Read network history as the authoritative source.
|
* 2. Read network history as the authoritative source.
|
||||||
* 3. Overwrite local history with network data.
|
* 3. Overwrite local history with network data.
|
||||||
*/
|
*/
|
||||||
export async function readAndSyncHistory(): Promise<ReadAndSyncHistoryOutput> {
|
export async function readAndSyncHistory(
|
||||||
const cacheIdentity = await resolveHistoryCacheIdentity();
|
characterId: string,
|
||||||
|
): Promise<ReadAndSyncHistoryOutput> {
|
||||||
|
const cacheIdentity = await resolveHistoryCacheIdentity(characterId);
|
||||||
const localSnapshot = await readLocalHistorySnapshot(cacheIdentity);
|
const localSnapshot = await readLocalHistorySnapshot(cacheIdentity);
|
||||||
const networkSnapshot = await syncNetworkHistory(
|
const networkSnapshot = await syncNetworkHistory(
|
||||||
|
characterId,
|
||||||
localSnapshot.localCount,
|
localSnapshot.localCount,
|
||||||
cacheIdentity,
|
cacheIdentity,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import {
|
|||||||
chatMachineSetup,
|
chatMachineSetup,
|
||||||
chatRootStateConfig,
|
chatRootStateConfig,
|
||||||
} from "./machine/session-flow";
|
} from "./machine/session-flow";
|
||||||
import { initialState } from "./chat-state";
|
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
|
||||||
|
|
||||||
|
import { createInitialChatState } from "./chat-state";
|
||||||
|
|
||||||
export type { ChatState } from "./chat-state";
|
export type { ChatState } from "./chat-state";
|
||||||
export { initialState } from "./chat-state";
|
export { initialState } from "./chat-state";
|
||||||
@@ -10,7 +12,8 @@ export type { ChatEvent } from "./chat-events";
|
|||||||
|
|
||||||
export const chatMachine = chatMachineSetup.createMachine({
|
export const chatMachine = chatMachineSetup.createMachine({
|
||||||
id: "chat",
|
id: "chat",
|
||||||
context: initialState,
|
context: ({ input }) =>
|
||||||
|
createInitialChatState(input?.characterId ?? DEFAULT_CHARACTER_ID),
|
||||||
...chatRootStateConfig,
|
...chatRootStateConfig,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { UiMessage } from "@/data/dto/chat";
|
|||||||
import type { PendingChatUnlockKind } from "@/lib/navigation/chat_unlock_session";
|
import type { PendingChatUnlockKind } from "@/lib/navigation/chat_unlock_session";
|
||||||
import type { ChatLockType } from "@/data/schemas/chat";
|
import type { ChatLockType } from "@/data/schemas/chat";
|
||||||
import type { PendingChatPromotion } from "@/data/storage/navigation";
|
import type { PendingChatPromotion } from "@/data/storage/navigation";
|
||||||
|
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
|
||||||
import type { ChatPromotionState } from "./helper/promotion";
|
import type { ChatPromotionState } from "./helper/promotion";
|
||||||
|
|
||||||
export type ChatUpgradeReason = "insufficient_credits";
|
export type ChatUpgradeReason = "insufficient_credits";
|
||||||
@@ -24,6 +25,7 @@ export interface ChatUnlockPaywallRequest
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatState {
|
export interface ChatState {
|
||||||
|
characterId: string;
|
||||||
messages: UiMessage[];
|
messages: UiMessage[];
|
||||||
promotion: ChatPromotionState | null;
|
promotion: ChatPromotionState | null;
|
||||||
outgoingMessageRevision: number;
|
outgoingMessageRevision: number;
|
||||||
@@ -51,7 +53,11 @@ export interface ChatState {
|
|||||||
unlockPaywallRequest: ChatUnlockPaywallRequest | null;
|
unlockPaywallRequest: ChatUnlockPaywallRequest | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const initialState: ChatState = {
|
export function createInitialChatState(
|
||||||
|
characterId: string = DEFAULT_CHARACTER_ID,
|
||||||
|
): ChatState {
|
||||||
|
return {
|
||||||
|
characterId,
|
||||||
messages: [],
|
messages: [],
|
||||||
promotion: null,
|
promotion: null,
|
||||||
outgoingMessageRevision: 0,
|
outgoingMessageRevision: 0,
|
||||||
@@ -77,3 +83,6 @@ export const initialState: ChatState = {
|
|||||||
unlockMessageError: null,
|
unlockMessageError: null,
|
||||||
unlockPaywallRequest: null,
|
unlockPaywallRequest: null,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const initialState: ChatState = createInitialChatState();
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ export interface LoadMoreHistoryActorEvent {
|
|||||||
limit: number;
|
limit: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ChatHistoryActorInput {
|
||||||
|
characterId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface LoadMoreHistoryOutput {
|
export interface LoadMoreHistoryOutput {
|
||||||
messages: UiMessage[];
|
messages: UiMessage[];
|
||||||
offset: number;
|
offset: number;
|
||||||
@@ -29,11 +33,14 @@ export interface LoadMoreHistoryOutput {
|
|||||||
limit: number;
|
limit: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const loadHistoryActor = fromCallback<ChatEvent>(({ sendBack }) => {
|
export const loadHistoryActor = fromCallback<
|
||||||
|
ChatEvent,
|
||||||
|
ChatHistoryActorInput
|
||||||
|
>(({ input, sendBack }) => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const cacheIdentity = await resolveHistoryCacheIdentity();
|
const cacheIdentity = await resolveHistoryCacheIdentity(input.characterId);
|
||||||
const localSnapshot = await readLocalHistorySnapshot(cacheIdentity);
|
const localSnapshot = await readLocalHistorySnapshot(cacheIdentity);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
sendBack({
|
sendBack({
|
||||||
@@ -42,6 +49,7 @@ export const loadHistoryActor = fromCallback<ChatEvent>(({ sendBack }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const networkSnapshot = await syncNetworkHistory(
|
const networkSnapshot = await syncNetworkHistory(
|
||||||
|
input.characterId,
|
||||||
localSnapshot.localCount,
|
localSnapshot.localCount,
|
||||||
cacheIdentity,
|
cacheIdentity,
|
||||||
);
|
);
|
||||||
@@ -62,7 +70,8 @@ export const loadHistoryActor = fromCallback<ChatEvent>(({ sendBack }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const loadMoreHistoryActor =
|
export const loadMoreHistoryActor =
|
||||||
fromCallback<LoadMoreHistoryActorEvent>(({ receive, sendBack }) => {
|
fromCallback<LoadMoreHistoryActorEvent, ChatHistoryActorInput>(
|
||||||
|
({ input, receive, sendBack }) => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let running = false;
|
let running = false;
|
||||||
|
|
||||||
@@ -73,6 +82,7 @@ export const loadMoreHistoryActor =
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
const chatRepo = await loadChatRepository();
|
const chatRepo = await loadChatRepository();
|
||||||
const historyResult = await chatRepo.getHistory(
|
const historyResult = await chatRepo.getHistory(
|
||||||
|
input.characterId,
|
||||||
event.limit,
|
event.limit,
|
||||||
event.offset,
|
event.offset,
|
||||||
);
|
);
|
||||||
@@ -89,13 +99,16 @@ export const loadMoreHistoryActor =
|
|||||||
limit: normalizeHistoryLimit(response.limit, event.limit),
|
limit: normalizeHistoryLimit(response.limit, event.limit),
|
||||||
} satisfies LoadMoreHistoryOutput,
|
} satisfies LoadMoreHistoryOutput,
|
||||||
});
|
});
|
||||||
void resolveHistoryCacheIdentity().then((cacheIdentity) => {
|
void resolveHistoryCacheIdentity(input.characterId).then(
|
||||||
|
(cacheIdentity) => {
|
||||||
if (!cacheIdentity) return;
|
if (!cacheIdentity) return;
|
||||||
void chatRepo.prefetchMediaForMessages(
|
void chatRepo.prefetchMediaForMessages(
|
||||||
response.messages,
|
response.messages,
|
||||||
|
input.characterId,
|
||||||
cacheIdentity,
|
cacheIdentity,
|
||||||
);
|
);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
})()
|
})()
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
@@ -110,4 +123,5 @@ export const loadMoreHistoryActor =
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { ExceptionHandler } from "@/core/errors";
|
|||||||
import { MessageQueue } from "@/core/net/message-queue";
|
import { MessageQueue } from "@/core/net/message-queue";
|
||||||
import type { ChatSendResponse } from "@/data/dto/chat";
|
import type { ChatSendResponse } from "@/data/dto/chat";
|
||||||
import { loadChatRepository } from "@/data/repositories/chat_repository_loader";
|
import { loadChatRepository } from "@/data/repositories/chat_repository_loader";
|
||||||
import { resolveChatCacheOwnerKey } from "@/data/repositories/chat_cache_identity";
|
import { resolveChatConversationKey } from "@/data/repositories/chat_cache_identity";
|
||||||
import { Logger } from "@/utils/logger";
|
import { Logger } from "@/utils/logger";
|
||||||
import { Result } from "@/utils/result";
|
import { Result } from "@/utils/result";
|
||||||
|
|
||||||
@@ -17,18 +17,22 @@ type UiMessage = import("@/data/dto/chat").UiMessage;
|
|||||||
|
|
||||||
export const sendMessageHttpActor = fromPromise<
|
export const sendMessageHttpActor = fromPromise<
|
||||||
{ response: ChatSendResponse; reply: UiMessage | null },
|
{ response: ChatSendResponse; reply: UiMessage | null },
|
||||||
{ content: string }
|
{ characterId: string; content: string }
|
||||||
>(async ({ input }) => {
|
>(async ({ input }) => {
|
||||||
return sendMessageViaHttp(input.content);
|
return sendMessageViaHttp(input.characterId, input.content);
|
||||||
});
|
});
|
||||||
|
|
||||||
export const httpMessageQueueActor = fromCallback<ChatEvent>(
|
export const httpMessageQueueActor = fromCallback<
|
||||||
({ sendBack, receive }) => {
|
ChatEvent,
|
||||||
return createMessageQueueActor(sendBack, receive);
|
{ characterId: string }
|
||||||
|
>(
|
||||||
|
({ input, sendBack, receive }) => {
|
||||||
|
return createMessageQueueActor(input.characterId, sendBack, receive);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
function createMessageQueueActor(
|
function createMessageQueueActor(
|
||||||
|
characterId: string,
|
||||||
sendBack: (event: ChatEvent) => void,
|
sendBack: (event: ChatEvent) => void,
|
||||||
receive: (listener: (event: ChatEvent) => void) => void,
|
receive: (listener: (event: ChatEvent) => void) => void,
|
||||||
): () => void {
|
): () => void {
|
||||||
@@ -37,7 +41,7 @@ function createMessageQueueActor(
|
|||||||
queue.setConsumer(async (content) => {
|
queue.setConsumer(async (content) => {
|
||||||
sendBack({ type: "ChatQueuedSendStarted" });
|
sendBack({ type: "ChatQueuedSendStarted" });
|
||||||
try {
|
try {
|
||||||
const output = await sendMessageViaHttp(content);
|
const output = await sendMessageViaHttp(characterId, content);
|
||||||
sendBack({ type: "ChatQueuedHttpDone", output });
|
sendBack({ type: "ChatQueuedHttpDone", output });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = ExceptionHandler.message(error);
|
const errorMessage = ExceptionHandler.message(error);
|
||||||
@@ -62,13 +66,13 @@ function createMessageQueueActor(
|
|||||||
return () => queue.dispose();
|
return () => queue.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendMessageViaHttp(content: string): Promise<{
|
async function sendMessageViaHttp(characterId: string, content: string): Promise<{
|
||||||
response: ChatSendResponse;
|
response: ChatSendResponse;
|
||||||
reply: UiMessage | null;
|
reply: UiMessage | null;
|
||||||
}> {
|
}> {
|
||||||
const chatRepo = await loadChatRepository();
|
const chatRepo = await loadChatRepository();
|
||||||
const cacheIdentityResult = await resolveChatCacheOwnerKey();
|
const cacheIdentityResult = await resolveChatConversationKey(characterId);
|
||||||
const result = await chatRepo.sendMessage(content);
|
const result = await chatRepo.sendMessage(characterId, content);
|
||||||
if (Result.isErr(result)) {
|
if (Result.isErr(result)) {
|
||||||
log.error("[chat-machine] sendMessageHttpActor failed", {
|
log.error("[chat-machine] sendMessageHttpActor failed", {
|
||||||
error: result.error,
|
error: result.error,
|
||||||
@@ -78,6 +82,7 @@ async function sendMessageViaHttp(content: string): Promise<{
|
|||||||
if (Result.isOk(cacheIdentityResult)) {
|
if (Result.isOk(cacheIdentityResult)) {
|
||||||
void chatRepo.prefetchMediaForSendResponse(
|
void chatRepo.prefetchMediaForSendResponse(
|
||||||
result.data,
|
result.data,
|
||||||
|
characterId,
|
||||||
cacheIdentityResult.data,
|
cacheIdentityResult.data,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { fromPromise } from "xstate";
|
import { fromPromise } from "xstate";
|
||||||
|
|
||||||
import { loadChatRepository } from "@/data/repositories/chat_repository_loader";
|
import { loadChatRepository } from "@/data/repositories/chat_repository_loader";
|
||||||
import { resolveChatCacheOwnerKey } from "@/data/repositories/chat_cache_identity";
|
import { resolveChatConversationKey } from "@/data/repositories/chat_cache_identity";
|
||||||
import { Logger } from "@/utils/logger";
|
import { Logger } from "@/utils/logger";
|
||||||
import { Result } from "@/utils/result";
|
import { Result } from "@/utils/result";
|
||||||
|
|
||||||
@@ -22,9 +22,12 @@ export interface UnlockHistoryOutput {
|
|||||||
messages: UiMessage[];
|
messages: UiMessage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const unlockHistoryActor = fromPromise<UnlockHistoryOutput>(async () => {
|
export const unlockHistoryActor = fromPromise<
|
||||||
|
UnlockHistoryOutput,
|
||||||
|
{ characterId: string }
|
||||||
|
>(async ({ input }) => {
|
||||||
const chatRepo = await loadChatRepository();
|
const chatRepo = await loadChatRepository();
|
||||||
const unlockResult = await chatRepo.unlockHistory();
|
const unlockResult = await chatRepo.unlockHistory(input.characterId);
|
||||||
if (Result.isErr(unlockResult)) {
|
if (Result.isErr(unlockResult)) {
|
||||||
log.error("[chat-machine] unlockHistoryActor failed", {
|
log.error("[chat-machine] unlockHistoryActor failed", {
|
||||||
error: unlockResult.error,
|
error: unlockResult.error,
|
||||||
@@ -32,7 +35,7 @@ export const unlockHistoryActor = fromPromise<UnlockHistoryOutput>(async () => {
|
|||||||
throw unlockResult.error;
|
throw unlockResult.error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const history = await readAndSyncHistory();
|
const history = await readAndSyncHistory(input.characterId);
|
||||||
return {
|
return {
|
||||||
unlocked: unlockResult.data.unlocked,
|
unlocked: unlockResult.data.unlocked,
|
||||||
reason: unlockResult.data.reason,
|
reason: unlockResult.data.reason,
|
||||||
@@ -43,11 +46,14 @@ export const unlockHistoryActor = fromPromise<UnlockHistoryOutput>(async () => {
|
|||||||
|
|
||||||
export const unlockMessageActor = fromPromise<
|
export const unlockMessageActor = fromPromise<
|
||||||
UnlockMessageOutput,
|
UnlockMessageOutput,
|
||||||
UnlockMessageRequest
|
UnlockMessageRequest & { characterId: string }
|
||||||
>(async ({ input }) => {
|
>(async ({ input }) => {
|
||||||
const chatRepo = await loadChatRepository();
|
const chatRepo = await loadChatRepository();
|
||||||
const cacheIdentityResult = await resolveChatCacheOwnerKey();
|
const cacheIdentityResult = await resolveChatConversationKey(
|
||||||
|
input.characterId,
|
||||||
|
);
|
||||||
const unlockResult = await chatRepo.unlockPrivateMessage({
|
const unlockResult = await chatRepo.unlockPrivateMessage({
|
||||||
|
characterId: input.characterId,
|
||||||
...(input.messageId ? { messageId: input.messageId } : {}),
|
...(input.messageId ? { messageId: input.messageId } : {}),
|
||||||
...(input.lockType ? { lockType: input.lockType } : {}),
|
...(input.lockType ? { lockType: input.lockType } : {}),
|
||||||
...(input.clientLockId ? { clientLockId: input.clientLockId } : {}),
|
...(input.clientLockId ? { clientLockId: input.clientLockId } : {}),
|
||||||
|
|||||||
@@ -205,7 +205,8 @@ export const guestReadyState = sendMachineSetup.createStateConfig({
|
|||||||
export const guestSendingState = sendMachineSetup.createStateConfig({
|
export const guestSendingState = sendMachineSetup.createStateConfig({
|
||||||
invoke: {
|
invoke: {
|
||||||
src: "sendMessageHttp",
|
src: "sendMessageHttp",
|
||||||
input: ({ event }) => ({
|
input: ({ context, event }) => ({
|
||||||
|
characterId: context.characterId,
|
||||||
content: event.type === "ChatSendMessage" ? event.content : "",
|
content: event.type === "ChatSendMessage" ? event.content : "",
|
||||||
}),
|
}),
|
||||||
onDone: {
|
onDone: {
|
||||||
@@ -222,7 +223,8 @@ export const guestSendingState = sendMachineSetup.createStateConfig({
|
|||||||
export const userSendingViaHttpState = sendMachineSetup.createStateConfig({
|
export const userSendingViaHttpState = sendMachineSetup.createStateConfig({
|
||||||
invoke: {
|
invoke: {
|
||||||
src: "sendMessageHttp",
|
src: "sendMessageHttp",
|
||||||
input: ({ event }) => ({
|
input: ({ context, event }) => ({
|
||||||
|
characterId: context.characterId,
|
||||||
content: event.type === "ChatSendMessage" ? event.content : "",
|
content: event.type === "ChatSendMessage" ? event.content : "",
|
||||||
}),
|
}),
|
||||||
onDone: {
|
onDone: {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createChatPromotionState } from "../helper/promotion";
|
import { createChatPromotionState } from "../helper/promotion";
|
||||||
import { shouldPromptUnlockHistory } from "../helper/unlock";
|
import { shouldPromptUnlockHistory } from "../helper/unlock";
|
||||||
import { initialState } from "../chat-state";
|
import { createInitialChatState } from "../chat-state";
|
||||||
import {
|
import {
|
||||||
guestInitializingState,
|
guestInitializingState,
|
||||||
historyPaginationTransitions,
|
historyPaginationTransitions,
|
||||||
@@ -19,20 +19,20 @@ import {
|
|||||||
|
|
||||||
const startGuestSessionAction = unlockMachineSetup.assign(
|
const startGuestSessionAction = unlockMachineSetup.assign(
|
||||||
({ context }) => ({
|
({ context }) => ({
|
||||||
...initialState,
|
...createInitialChatState(context.characterId),
|
||||||
promotion: context.promotion,
|
promotion: context.promotion,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const startUserSessionAction = unlockMachineSetup.assign(
|
const startUserSessionAction = unlockMachineSetup.assign(
|
||||||
({ context }) => ({
|
({ context }) => ({
|
||||||
...initialState,
|
...createInitialChatState(context.characterId),
|
||||||
promotion: context.promotion,
|
promotion: context.promotion,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const clearChatSessionAction = unlockMachineSetup.assign(() => ({
|
const clearChatSessionAction = unlockMachineSetup.assign(({ context }) => ({
|
||||||
...initialState,
|
...createInitialChatState(context.characterId),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const injectPromotionAction = unlockMachineSetup.assign(({ event }) => {
|
const injectPromotionAction = unlockMachineSetup.assign(({ event }) => {
|
||||||
@@ -75,14 +75,17 @@ const guestSessionState = chatMachineSetup.createStateConfig({
|
|||||||
{
|
{
|
||||||
id: "messageQueue",
|
id: "messageQueue",
|
||||||
src: "httpMessageQueue",
|
src: "httpMessageQueue",
|
||||||
|
input: ({ context }) => ({ characterId: context.characterId }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "loadHistory",
|
id: "loadHistory",
|
||||||
src: "loadHistory",
|
src: "loadHistory",
|
||||||
|
input: ({ context }) => ({ characterId: context.characterId }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "loadMoreHistory",
|
id: "loadMoreHistory",
|
||||||
src: "loadMoreHistory",
|
src: "loadMoreHistory",
|
||||||
|
input: ({ context }) => ({ characterId: context.characterId }),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
on: {
|
on: {
|
||||||
@@ -154,14 +157,17 @@ const userSessionState = chatMachineSetup.createStateConfig({
|
|||||||
{
|
{
|
||||||
id: "messageQueue",
|
id: "messageQueue",
|
||||||
src: "httpMessageQueue",
|
src: "httpMessageQueue",
|
||||||
|
input: ({ context }) => ({ characterId: context.characterId }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "loadHistory",
|
id: "loadHistory",
|
||||||
src: "loadHistory",
|
src: "loadHistory",
|
||||||
|
input: ({ context }) => ({ characterId: context.characterId }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "loadMoreHistory",
|
id: "loadMoreHistory",
|
||||||
src: "loadMoreHistory",
|
src: "loadMoreHistory",
|
||||||
|
input: ({ context }) => ({ characterId: context.characterId }),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
on: {
|
on: {
|
||||||
|
|||||||
@@ -15,10 +15,15 @@ import {
|
|||||||
} from "./actors/unlock";
|
} from "./actors/unlock";
|
||||||
import type { ChatState } from "../chat-state";
|
import type { ChatState } from "../chat-state";
|
||||||
|
|
||||||
|
export interface ChatMachineInput {
|
||||||
|
characterId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const baseChatMachineSetup = setup({
|
export const baseChatMachineSetup = setup({
|
||||||
types: {
|
types: {
|
||||||
context: {} as ChatState,
|
context: {} as ChatState,
|
||||||
events: {} as ChatEvent,
|
events: {} as ChatEvent,
|
||||||
|
input: {} as ChatMachineInput | undefined,
|
||||||
},
|
},
|
||||||
actors: {
|
actors: {
|
||||||
loadHistory: loadHistoryActor,
|
loadHistory: loadHistoryActor,
|
||||||
|
|||||||
@@ -188,6 +188,7 @@ export const unlockingHistoryState = unlockMachineSetup.createStateConfig({
|
|||||||
invoke: {
|
invoke: {
|
||||||
id: "unlockHistory",
|
id: "unlockHistory",
|
||||||
src: "unlockHistory",
|
src: "unlockHistory",
|
||||||
|
input: ({ context }) => ({ characterId: context.characterId }),
|
||||||
onDone: {
|
onDone: {
|
||||||
target: "ready",
|
target: "ready",
|
||||||
actions: applyUnlockHistoryOutputAction,
|
actions: applyUnlockHistoryOutputAction,
|
||||||
@@ -203,11 +204,13 @@ export const unlockingMessageState = unlockMachineSetup.createStateConfig({
|
|||||||
invoke: {
|
invoke: {
|
||||||
id: "unlockMessage",
|
id: "unlockMessage",
|
||||||
src: "unlockMessage",
|
src: "unlockMessage",
|
||||||
input: ({ context }) =>
|
input: ({ context }) => ({
|
||||||
context.unlockingMessage ?? {
|
characterId: context.characterId,
|
||||||
|
...(context.unlockingMessage ?? {
|
||||||
displayMessageId: "",
|
displayMessageId: "",
|
||||||
kind: "private",
|
kind: "private" as const,
|
||||||
},
|
}),
|
||||||
|
}),
|
||||||
onDone: [
|
onDone: [
|
||||||
{
|
{
|
||||||
guard: ({ event }) => event.output.response.unlocked,
|
guard: ({ event }) => event.output.response.unlocked,
|
||||||
|
|||||||
@@ -6,9 +6,29 @@ import {
|
|||||||
loadPrivateRoom,
|
loadPrivateRoom,
|
||||||
makeAlbum,
|
makeAlbum,
|
||||||
makeAlbumsResponse,
|
makeAlbumsResponse,
|
||||||
|
TEST_PRIVATE_ROOM_INPUT,
|
||||||
} from "./private-room-machine.test-utils";
|
} from "./private-room-machine.test-utils";
|
||||||
|
|
||||||
describe("private room album flow", () => {
|
describe("private room album flow", () => {
|
||||||
|
it("loads albums for the supplied character", async () => {
|
||||||
|
let loadedCharacterId: string | null = null;
|
||||||
|
const actor = createActor(
|
||||||
|
createTestPrivateRoomMachine({
|
||||||
|
onLoad: ({ characterId }) => {
|
||||||
|
loadedCharacterId = characterId;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ input: { characterId: "character_aria" } },
|
||||||
|
).start();
|
||||||
|
|
||||||
|
actor.send({ type: "PrivateRoomInit" });
|
||||||
|
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
|
||||||
|
|
||||||
|
expect(actor.getSnapshot().context.characterId).toBe("character_aria");
|
||||||
|
expect(loadedCharacterId).toBe("character_aria");
|
||||||
|
actor.stop();
|
||||||
|
});
|
||||||
|
|
||||||
it("loads only the first non-paginated album page", async () => {
|
it("loads only the first non-paginated album page", async () => {
|
||||||
const actor = await loadPrivateRoom({
|
const actor = await loadPrivateRoom({
|
||||||
loadResponse: makeAlbumsResponse({
|
loadResponse: makeAlbumsResponse({
|
||||||
@@ -26,6 +46,7 @@ describe("private room album flow", () => {
|
|||||||
createTestPrivateRoomMachine({
|
createTestPrivateRoomMachine({
|
||||||
loadError: new Error("albums unavailable"),
|
loadError: new Error("albums unavailable"),
|
||||||
}),
|
}),
|
||||||
|
{ input: TEST_PRIVATE_ROOM_INPUT },
|
||||||
).start();
|
).start();
|
||||||
actor.send({ type: "PrivateRoomInit" });
|
actor.send({ type: "PrivateRoomInit" });
|
||||||
await waitFor(actor, (snapshot) => snapshot.matches("failed"));
|
await waitFor(actor, (snapshot) => snapshot.matches("failed"));
|
||||||
|
|||||||
@@ -4,11 +4,15 @@ import {
|
|||||||
PrivateAlbumsResponse,
|
PrivateAlbumsResponse,
|
||||||
PrivateAlbumUnlockResponse,
|
PrivateAlbumUnlockResponse,
|
||||||
} from "@/data/dto/private-room";
|
} from "@/data/dto/private-room";
|
||||||
|
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
|
||||||
import { privateRoomMachine } from "@/stores/private-room/private-room-machine";
|
import { privateRoomMachine } from "@/stores/private-room/private-room-machine";
|
||||||
|
|
||||||
export const ALBUM_ID = "a1b2c3d4-0000-0000-0000-000000000000";
|
export const ALBUM_ID = "a1b2c3d4-0000-0000-0000-000000000000";
|
||||||
export const COVER_URL =
|
export const COVER_URL =
|
||||||
"https://dbapi.banlv-ai.com/storage/v1/object/public/elio-schedules/01.jpg";
|
"https://dbapi.banlv-ai.com/storage/v1/object/public/elio-schedules/01.jpg";
|
||||||
|
export const TEST_PRIVATE_ROOM_INPUT = {
|
||||||
|
characterId: DEFAULT_CHARACTER_ID,
|
||||||
|
};
|
||||||
|
|
||||||
export function makeAlbum(index = 0) {
|
export function makeAlbum(index = 0) {
|
||||||
return {
|
return {
|
||||||
@@ -62,12 +66,15 @@ export function createTestPrivateRoomMachine(options: {
|
|||||||
unlockResponse?: PrivateAlbumUnlockResponse;
|
unlockResponse?: PrivateAlbumUnlockResponse;
|
||||||
loadError?: Error;
|
loadError?: Error;
|
||||||
unlockError?: Error;
|
unlockError?: Error;
|
||||||
onLoad?: () => void;
|
onLoad?: (input: { characterId: string }) => void;
|
||||||
} = {}) {
|
} = {}) {
|
||||||
return privateRoomMachine.provide({
|
return privateRoomMachine.provide({
|
||||||
actors: {
|
actors: {
|
||||||
loadAlbums: fromPromise(async () => {
|
loadAlbums: fromPromise<
|
||||||
options.onLoad?.();
|
PrivateAlbumsResponse,
|
||||||
|
{ characterId: string }
|
||||||
|
>(async ({ input }) => {
|
||||||
|
options.onLoad?.(input);
|
||||||
if (options.loadError) throw options.loadError;
|
if (options.loadError) throw options.loadError;
|
||||||
return options.loadResponse ?? makeAlbumsResponse();
|
return options.loadResponse ?? makeAlbumsResponse();
|
||||||
}),
|
}),
|
||||||
@@ -82,7 +89,9 @@ export function createTestPrivateRoomMachine(options: {
|
|||||||
export async function loadPrivateRoom(
|
export async function loadPrivateRoom(
|
||||||
options: Parameters<typeof createTestPrivateRoomMachine>[0] = {},
|
options: Parameters<typeof createTestPrivateRoomMachine>[0] = {},
|
||||||
) {
|
) {
|
||||||
const actor = createActor(createTestPrivateRoomMachine(options)).start();
|
const actor = createActor(createTestPrivateRoomMachine(options), {
|
||||||
|
input: TEST_PRIVATE_ROOM_INPUT,
|
||||||
|
}).start();
|
||||||
actor.send({ type: "PrivateRoomInit" });
|
actor.send({ type: "PrivateRoomInit" });
|
||||||
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
|
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
|
||||||
return actor;
|
return actor;
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
|
|
||||||
import type { PrivateRoomState } from "../private-room-state";
|
import type { PrivateRoomState } from "../private-room-state";
|
||||||
|
|
||||||
export const PRIVATE_ROOM_CHARACTER = "elio";
|
|
||||||
export const PRIVATE_ROOM_PAGE_SIZE = 20;
|
export const PRIVATE_ROOM_PAGE_SIZE = 20;
|
||||||
|
|
||||||
export function applyAlbumsResponse(
|
export function applyAlbumsResponse(
|
||||||
|
|||||||
@@ -7,15 +7,14 @@ import type {
|
|||||||
import { getPrivateRoomRepository } from "@/data/repositories/private_room_repository";
|
import { getPrivateRoomRepository } from "@/data/repositories/private_room_repository";
|
||||||
import { Result } from "@/utils/result";
|
import { Result } from "@/utils/result";
|
||||||
|
|
||||||
import {
|
import { PRIVATE_ROOM_PAGE_SIZE } from "../../helper/albums";
|
||||||
PRIVATE_ROOM_CHARACTER,
|
|
||||||
PRIVATE_ROOM_PAGE_SIZE,
|
|
||||||
} from "../../helper/albums";
|
|
||||||
|
|
||||||
export const loadPrivateAlbumsActor =
|
export const loadPrivateAlbumsActor =
|
||||||
fromPromise<PrivateAlbumsResponse>(async () => {
|
fromPromise<PrivateAlbumsResponse, { characterId: string }>(async ({
|
||||||
|
input,
|
||||||
|
}) => {
|
||||||
const result = await getPrivateRoomRepository().getAlbums({
|
const result = await getPrivateRoomRepository().getAlbums({
|
||||||
character: PRIVATE_ROOM_CHARACTER,
|
characterId: input.characterId,
|
||||||
limit: PRIVATE_ROOM_PAGE_SIZE,
|
limit: PRIVATE_ROOM_PAGE_SIZE,
|
||||||
});
|
});
|
||||||
if (Result.isErr(result)) throw result.error;
|
if (Result.isErr(result)) throw result.error;
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export const loadingState = albumMachineSetup.createStateConfig({
|
|||||||
entry: "clearAlbumLoadState",
|
entry: "clearAlbumLoadState",
|
||||||
invoke: {
|
invoke: {
|
||||||
src: "loadAlbums",
|
src: "loadAlbums",
|
||||||
|
input: ({ context }) => ({ characterId: context.characterId }),
|
||||||
onDone: {
|
onDone: {
|
||||||
target: "ready",
|
target: "ready",
|
||||||
actions: applyAlbumsResponseAction,
|
actions: applyAlbumsResponseAction,
|
||||||
|
|||||||
@@ -8,10 +8,15 @@ import {
|
|||||||
unlockPrivateAlbumActor,
|
unlockPrivateAlbumActor,
|
||||||
} from "./actors/albums";
|
} from "./actors/albums";
|
||||||
|
|
||||||
|
export interface PrivateRoomMachineInput {
|
||||||
|
characterId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const basePrivateRoomMachineSetup = setup({
|
export const basePrivateRoomMachineSetup = setup({
|
||||||
types: {
|
types: {
|
||||||
context: {} as PrivateRoomState,
|
context: {} as PrivateRoomState,
|
||||||
events: {} as PrivateRoomEvent,
|
events: {} as PrivateRoomEvent,
|
||||||
|
input: {} as PrivateRoomMachineInput,
|
||||||
},
|
},
|
||||||
actors: {
|
actors: {
|
||||||
loadAlbums: loadPrivateAlbumsActor,
|
loadAlbums: loadPrivateAlbumsActor,
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import type { Dispatch, ReactNode } from "react";
|
|||||||
import { createActorContext, shallowEqual } from "@xstate/react";
|
import { createActorContext, shallowEqual } from "@xstate/react";
|
||||||
import type { SnapshotFrom } from "xstate";
|
import type { SnapshotFrom } from "xstate";
|
||||||
|
|
||||||
|
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
|
||||||
|
|
||||||
import { privateRoomMachine } from "./private-room-machine";
|
import { privateRoomMachine } from "./private-room-machine";
|
||||||
import type {
|
import type {
|
||||||
PrivateRoomEvent,
|
PrivateRoomEvent,
|
||||||
@@ -11,6 +13,7 @@ import type {
|
|||||||
} from "./private-room-machine";
|
} from "./private-room-machine";
|
||||||
|
|
||||||
export interface PrivateRoomContextState {
|
export interface PrivateRoomContextState {
|
||||||
|
characterId: string;
|
||||||
status: string;
|
status: string;
|
||||||
items: MachineContext["items"];
|
items: MachineContext["items"];
|
||||||
creditBalance: number;
|
creditBalance: number;
|
||||||
@@ -31,11 +34,17 @@ const PrivateRoomActorContext = createActorContext(privateRoomMachine);
|
|||||||
|
|
||||||
export interface PrivateRoomProviderProps {
|
export interface PrivateRoomProviderProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
characterId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PrivateRoomProvider({ children }: PrivateRoomProviderProps) {
|
export function PrivateRoomProvider({
|
||||||
|
children,
|
||||||
|
characterId = DEFAULT_CHARACTER_ID,
|
||||||
|
}: PrivateRoomProviderProps) {
|
||||||
return (
|
return (
|
||||||
<PrivateRoomActorContext.Provider>{children}</PrivateRoomActorContext.Provider>
|
<PrivateRoomActorContext.Provider options={{ input: { characterId } }}>
|
||||||
|
{children}
|
||||||
|
</PrivateRoomActorContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +67,7 @@ function selectPrivateRoomState(
|
|||||||
state: PrivateRoomSnapshot,
|
state: PrivateRoomSnapshot,
|
||||||
): PrivateRoomContextState {
|
): PrivateRoomContextState {
|
||||||
return {
|
return {
|
||||||
|
characterId: state.context.characterId,
|
||||||
status: String(state.value),
|
status: String(state.value),
|
||||||
items: state.context.items,
|
items: state.context.items,
|
||||||
creditBalance: state.context.creditBalance,
|
creditBalance: state.context.creditBalance,
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import {
|
|||||||
privateRoomMachineSetup,
|
privateRoomMachineSetup,
|
||||||
privateRoomRootStateConfig,
|
privateRoomRootStateConfig,
|
||||||
} from "./machine/unlock-flow";
|
} from "./machine/unlock-flow";
|
||||||
import { initialState } from "./private-room-state";
|
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
|
||||||
|
|
||||||
|
import { createInitialPrivateRoomState } from "./private-room-state";
|
||||||
|
|
||||||
export type { PrivateRoomEvent } from "./private-room-events";
|
export type { PrivateRoomEvent } from "./private-room-events";
|
||||||
export type { PrivateRoomState } from "./private-room-state";
|
export type { PrivateRoomState } from "./private-room-state";
|
||||||
@@ -10,7 +12,10 @@ export { initialState } from "./private-room-state";
|
|||||||
|
|
||||||
export const privateRoomMachine = privateRoomMachineSetup.createMachine({
|
export const privateRoomMachine = privateRoomMachineSetup.createMachine({
|
||||||
id: "privateRoom",
|
id: "privateRoom",
|
||||||
context: initialState,
|
context: ({ input }) =>
|
||||||
|
createInitialPrivateRoomState(
|
||||||
|
input?.characterId ?? DEFAULT_CHARACTER_ID,
|
||||||
|
),
|
||||||
...privateRoomRootStateConfig,
|
...privateRoomRootStateConfig,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { PrivateAlbum } from "@/data/dto/private-room";
|
import type { PrivateAlbum } from "@/data/dto/private-room";
|
||||||
|
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
|
||||||
|
|
||||||
export interface PrivateRoomUnlockPaywallRequest {
|
export interface PrivateRoomUnlockPaywallRequest {
|
||||||
albumId: string;
|
albumId: string;
|
||||||
@@ -9,6 +10,7 @@ export interface PrivateRoomUnlockPaywallRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface PrivateRoomState {
|
export interface PrivateRoomState {
|
||||||
|
characterId: string;
|
||||||
items: PrivateAlbum[];
|
items: PrivateAlbum[];
|
||||||
creditBalance: number;
|
creditBalance: number;
|
||||||
errorMessage: string | null;
|
errorMessage: string | null;
|
||||||
@@ -19,7 +21,11 @@ export interface PrivateRoomState {
|
|||||||
unlockSuccessNonce: number;
|
unlockSuccessNonce: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const initialState: PrivateRoomState = {
|
export function createInitialPrivateRoomState(
|
||||||
|
characterId = DEFAULT_CHARACTER_ID,
|
||||||
|
): PrivateRoomState {
|
||||||
|
return {
|
||||||
|
characterId,
|
||||||
items: [],
|
items: [],
|
||||||
creditBalance: 0,
|
creditBalance: 0,
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
@@ -29,3 +35,6 @@ export const initialState: PrivateRoomState = {
|
|||||||
unlockPaywallRequest: null,
|
unlockPaywallRequest: null,
|
||||||
unlockSuccessNonce: 0,
|
unlockSuccessNonce: 0,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const initialState = createInitialPrivateRoomState();
|
||||||
|
|||||||
Reference in New Issue
Block a user