feat(chat): sync multi-role backend APIs
This commit is contained in:
@@ -0,0 +1,209 @@
|
|||||||
|
# CozSweet 多角色聊天前端对接
|
||||||
|
|
||||||
|
## 3. Character IDs
|
||||||
|
|
||||||
|
前端先调用:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET <API_BASE_URL>/api/characters?capability=chat
|
||||||
|
```
|
||||||
|
|
||||||
|
聊天业务统一使用响应里的 `items[].id`。当前规范 ID 是:
|
||||||
|
|
||||||
|
```text
|
||||||
|
elio
|
||||||
|
maya-tan
|
||||||
|
nayeli-cervantes
|
||||||
|
```
|
||||||
|
|
||||||
|
只允许传递角色目录响应中的 `items[].id`,不要传展示名或 `@handle`。
|
||||||
|
|
||||||
|
角色响应中的 `capabilities` 是后端权威开关。`chat=false` 时不得开放输入框;直接调用聊天接口会得到 `CHARACTER_DISABLED`,不会返回 Elio 历史。
|
||||||
|
|
||||||
|
## 4. Send Message
|
||||||
|
|
||||||
|
### Request URL
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST <API_BASE_URL>/api/chat/send
|
||||||
|
Content-Type: application/json
|
||||||
|
Authorization: Bearer <TOKEN>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Parameters
|
||||||
|
|
||||||
|
| Field | Type | Required | Example | Meaning |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `characterId` | string | 新前端必传 | `maya-tan` | 当前聊天角色 ID |
|
||||||
|
| `message` | string | 与图片至少一项有值 | `Hello Maya` | 用户文本,最多 4000 字符 |
|
||||||
|
| `imageId` | string | 否 | `abc123` | 上传图片 ID |
|
||||||
|
| `imageThumbUrl` | string | 否 | `/images/...` | 缩略图 |
|
||||||
|
| `imageMediumUrl` | string | 否 | `/images/...` | 模型识图使用 |
|
||||||
|
| `imageOriginalUrl` | string | 否 | `/images/...` | 原图 |
|
||||||
|
| `imageWidth` | integer | 否 | `1080` | 图片宽 |
|
||||||
|
| `imageHeight` | integer | 否 | `1440` | 图片高 |
|
||||||
|
| `useWebSocket` | boolean | 否 | `false` | 是否同时通过已连接 WS 推送 |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST 'https://proapi.banlv-ai.com/api/chat/send' \
|
||||||
|
-H 'Authorization: Bearer <TOKEN>' \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"characterId":"maya-tan","message":"Hello Maya","useWebSocket":false}'
|
||||||
|
```
|
||||||
|
|
||||||
|
响应沿用现有发送结构。前端仍读取 `data.reply`、`data.messageId`、`data.audioUrl`、`data.image` 和 `data.lockDetail`,无需从响应推断角色。
|
||||||
|
|
||||||
|
## 5. Chat History
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET <API_BASE_URL>/api/chat/history?characterId=maya-tan&limit=50&offset=0
|
||||||
|
Authorization: Bearer <TOKEN>
|
||||||
|
```
|
||||||
|
|
||||||
|
| Query | Type | Required | Rule |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `characterId` | string | 新前端必传 | 只返回该角色数据 |
|
||||||
|
| `limit` | integer | 否 | `1-200`,默认 `50` |
|
||||||
|
| `offset` | integer | 否 | 最小 `0` |
|
||||||
|
|
||||||
|
`messages` 和 `total` 都只统计指定角色。切换角色时必须重新请求 history,不能复用上一个角色的本地数组。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"success": true,
|
||||||
|
"message": "success",
|
||||||
|
"data": {
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"type": "text",
|
||||||
|
"content": "Hello Maya",
|
||||||
|
"id": "<MESSAGE_ID>",
|
||||||
|
"created_at": "2026-07-17T09:00:00Z",
|
||||||
|
"audioUrl": null,
|
||||||
|
"image": {"type": null, "url": null},
|
||||||
|
"lockDetail": {"locked": false, "showContent": true, "showUpgrade": false}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 1,
|
||||||
|
"limit": 50,
|
||||||
|
"offset": 0,
|
||||||
|
"isVip": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Latest Previews
|
||||||
|
|
||||||
|
角色会话列表可一次请求所有可聊天角色的最后一条消息:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET <API_BASE_URL>/api/chat/previews
|
||||||
|
Authorization: Bearer <TOKEN>
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"items": [
|
||||||
|
{"characterId": "elio", "message": {"id": "...", "role": "assistant", "type": "text", "content": "..."}},
|
||||||
|
{"characterId": "maya-tan", "message": null}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
锁定私密文本不会泄露内容,锁定语音不会返回 `audioUrl`(文字仍正常返回)。图片沿用现有协议,URL 可以存在;前端必须以 `lockDetail.locked=true` 覆盖展示,不能把“URL 非空”当作已解锁。
|
||||||
|
|
||||||
|
## 7. Unlock One Message
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST <API_BASE_URL>/api/chat/unlock-private
|
||||||
|
Content-Type: application/json
|
||||||
|
Authorization: Bearer <TOKEN>
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"characterId": "maya-tan",
|
||||||
|
"messageId": "<MESSAGE_ID>",
|
||||||
|
"lockType": "voice_message",
|
||||||
|
"clientLockId": "maya-card-001"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`messageId` 存在时,后端会核对消息角色。拿 Elio 的 `messageId` 配 Maya 的 `characterId` 会返回 HTTP `409 / CHARACTER_MISMATCH`,且不会扣积分。
|
||||||
|
|
||||||
|
前端临时伪造锁卡片没有 `messageId` 时,仍传 `characterId + lockType + clientLockId`。`clientLockId` 的幂等查找范围已包含账号和角色,同一个 ID 可以在不同角色下分别使用。
|
||||||
|
|
||||||
|
## 8. Unlock Current Character History
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST <API_BASE_URL>/api/chat/unlock-history
|
||||||
|
Content-Type: application/json
|
||||||
|
Authorization: Bearer <TOKEN>
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"characterId":"maya-tan"}
|
||||||
|
```
|
||||||
|
|
||||||
|
费用、锁消息数量、成功解锁数量及 `messageIds` 都只计算当前角色。钱包余额仍是账号全局余额。
|
||||||
|
|
||||||
|
## 9. Guest History Sync
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST <API_BASE_URL>/api/chat/sync
|
||||||
|
Content-Type: application/json
|
||||||
|
Authorization: Bearer <LOGIN_TOKEN>
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"characterId": "maya-tan",
|
||||||
|
"messages": [
|
||||||
|
{"role": "user", "content": "Hello", "timestamp": "2026-07-17T09:00:00Z"},
|
||||||
|
{"role": "assistant", "content": "Hi", "timestamp": "2026-07-17T09:00:01Z"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
每次同步只能包含一个角色。本地若保存了多个角色,前端按角色分组分别调用。
|
||||||
|
|
||||||
|
若页面使用用户聊天统计,也必须带角色:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET <API_BASE_URL>/api/user/stats?characterId=maya-tan
|
||||||
|
```
|
||||||
|
|
||||||
|
其中 `totalMessages`、近期记忆、亲密度、情绪和关系状态都按角色返回。
|
||||||
|
响应会明确带上 `characterId`、`relationshipStage` 和 `currentMood`,前端不要把这些状态写回其他角色的本地缓存。
|
||||||
|
|
||||||
|
## 12. Tip Attribution
|
||||||
|
|
||||||
|
只有 Tip 订单使用角色归属:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"planId": "tip_coffee_usd_4_99",
|
||||||
|
"payChannel": "stripe",
|
||||||
|
"autoRenew": false,
|
||||||
|
"recipientCharacterId": "maya-tan"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Tip 订单必须携带 `recipientCharacterId`。VIP 和积分充值会忽略该字段。
|
||||||
|
|
||||||
|
## 13. Failures
|
||||||
|
|
||||||
|
| HTTP | `detail.errorCode` | Meaning | Frontend action |
|
||||||
|
| ---: | --- | --- | --- |
|
||||||
|
| 403 | `CHARACTER_DISABLED` | 角色或对应能力未开放 | 禁用输入并刷新角色目录 |
|
||||||
|
| 404 | `CHARACTER_NOT_FOUND` | 角色 ID 不存在 | 清理本地旧角色 ID,回角色列表 |
|
||||||
|
| 409 | `CHARACTER_MISMATCH` | 消息或双参数属于不同角色 | 不重试、不扣费,刷新当前角色历史 |
|
||||||
|
| 503 | `CHARACTER_STATE_UNAVAILABLE` | 角色会话状态暂不可用 | 保留输入并稍后重试 |
|
||||||
|
|
||||||
|
新前端的角色业务请求必须显式携带角色 ID。应用入口未指定角色时选择 Elio;显式错误、停用或不匹配的角色不会回退 Elio。
|
||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
CHARACTERS,
|
CHARACTERS,
|
||||||
getCharacterBySlug,
|
getCharacterBySlug,
|
||||||
} from "@/data/constants/character";
|
} from "@/data/constants/character";
|
||||||
import { CharacterProvider } from "@/providers/character-provider";
|
import { CharacterRouteBoundary } from "@/providers/character-route-boundary";
|
||||||
|
|
||||||
export const dynamicParams = false;
|
export const dynamicParams = false;
|
||||||
|
|
||||||
@@ -26,5 +26,9 @@ export default async function CharacterLayout({
|
|||||||
const character = getCharacterBySlug(characterSlug);
|
const character = getCharacterBySlug(characterSlug);
|
||||||
if (!character) notFound();
|
if (!character) notFound();
|
||||||
|
|
||||||
return <CharacterProvider character={character}>{children}</CharacterProvider>;
|
return (
|
||||||
|
<CharacterRouteBoundary character={character}>
|
||||||
|
{children}
|
||||||
|
</CharacterRouteBoundary>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const defaultScope = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const promotionSession: PendingChatPromotion = {
|
const promotionSession: PendingChatPromotion = {
|
||||||
characterId: "character_elio",
|
characterId: "elio",
|
||||||
promotionType: "image",
|
promotionType: "image",
|
||||||
lockType: "image_paywall",
|
lockType: "image_paywall",
|
||||||
clientLockId: "promotion-1",
|
clientLockId: "promotion-1",
|
||||||
@@ -182,7 +182,7 @@ function createPendingUnlock(
|
|||||||
): PendingChatUnlock {
|
): PendingChatUnlock {
|
||||||
return {
|
return {
|
||||||
reason: "single_message_unlock",
|
reason: "single_message_unlock",
|
||||||
characterId: "character_elio",
|
characterId: "elio",
|
||||||
...input,
|
...input,
|
||||||
stage: "auth",
|
stage: "auth",
|
||||||
createdAt: 1,
|
createdAt: 1,
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ import {
|
|||||||
useActiveCharacter,
|
useActiveCharacter,
|
||||||
useActiveCharacterRoutes,
|
useActiveCharacterRoutes,
|
||||||
} from "@/providers/character-provider";
|
} from "@/providers/character-provider";
|
||||||
|
import { useCharacterCatalog } from "@/providers/character-catalog-provider";
|
||||||
|
import { clearPendingChatNavigation } from "@/lib/navigation/chat_unlock_session";
|
||||||
|
import { getCharacterRoutes } from "@/router/routes";
|
||||||
|
|
||||||
import { MobileShell } from "@/app/_components/core";
|
import { MobileShell } from "@/app/_components/core";
|
||||||
|
|
||||||
@@ -50,6 +53,9 @@ const chatShellStyle = {
|
|||||||
export function ChatScreen() {
|
export function ChatScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const character = useActiveCharacter();
|
const character = useActiveCharacter();
|
||||||
|
const characterCatalog = useCharacterCatalog();
|
||||||
|
const refreshCharacterCatalog = characterCatalog.refresh;
|
||||||
|
const defaultCharacterSlug = characterCatalog.defaultCharacter.slug;
|
||||||
const characterRoutes = useActiveCharacterRoutes();
|
const characterRoutes = useActiveCharacterRoutes();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const state = useChatState();
|
const state = useChatState();
|
||||||
@@ -133,6 +139,42 @@ export function ChatScreen() {
|
|||||||
state.historyLoaded,
|
state.historyLoaded,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const errorCode = state.characterErrorCode;
|
||||||
|
if (errorCode === "CHARACTER_MISMATCH") {
|
||||||
|
chatDispatch({ type: "ChatHistoryRefreshRequested" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
errorCode !== "CHARACTER_DISABLED" &&
|
||||||
|
errorCode !== "CHARACTER_NOT_FOUND"
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
void (async () => {
|
||||||
|
if (errorCode === "CHARACTER_NOT_FOUND") {
|
||||||
|
await clearPendingChatNavigation();
|
||||||
|
}
|
||||||
|
await refreshCharacterCatalog();
|
||||||
|
if (!cancelled) {
|
||||||
|
router.replace(
|
||||||
|
getCharacterRoutes(defaultCharacterSlug).splash,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
chatDispatch,
|
||||||
|
defaultCharacterSlug,
|
||||||
|
refreshCharacterCatalog,
|
||||||
|
router,
|
||||||
|
state.characterErrorCode,
|
||||||
|
]);
|
||||||
|
|
||||||
function handleUnlockPrivateMessage(messageId: string): void {
|
function handleUnlockPrivateMessage(messageId: string): void {
|
||||||
unlockCoordinator.requestMessageUnlock(messageId, "private");
|
unlockCoordinator.requestMessageUnlock(messageId, "private");
|
||||||
}
|
}
|
||||||
@@ -223,7 +265,9 @@ export function ChatScreen() {
|
|||||||
onUnlock={messageLimitBanner.unlock}
|
onUnlock={messageLimitBanner.unlock}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ChatInputBar disabled={!state.historyLoaded} />
|
<ChatInputBar
|
||||||
|
disabled={!state.historyLoaded || !state.canSendMessage}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -235,7 +235,7 @@ function renderChatArea(
|
|||||||
act(() => {
|
act(() => {
|
||||||
root.render(
|
root.render(
|
||||||
<ChatArea
|
<ChatArea
|
||||||
characterId="character_elio"
|
characterId="elio"
|
||||||
messages={messages}
|
messages={messages}
|
||||||
isReplyingAI={false}
|
isReplyingAI={false}
|
||||||
initialScrollReady={initialScrollReady}
|
initialScrollReady={initialScrollReady}
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ describe("chat Tailwind components", () => {
|
|||||||
it("renders ImageBubble openable and paywalled states", () => {
|
it("renders ImageBubble openable and paywalled states", () => {
|
||||||
const openableHtml = renderToStaticMarkup(
|
const openableHtml = renderToStaticMarkup(
|
||||||
<ImageBubble
|
<ImageBubble
|
||||||
characterId="character_elio"
|
characterId="elio"
|
||||||
messageId="message-1"
|
messageId="message-1"
|
||||||
imageUrl="/chat-image.png"
|
imageUrl="/chat-image.png"
|
||||||
onOpenImage={() => undefined}
|
onOpenImage={() => undefined}
|
||||||
@@ -135,7 +135,7 @@ describe("chat Tailwind components", () => {
|
|||||||
);
|
);
|
||||||
const paywalledHtml = renderToStaticMarkup(
|
const paywalledHtml = renderToStaticMarkup(
|
||||||
<ImageBubble
|
<ImageBubble
|
||||||
characterId="character_elio"
|
characterId="elio"
|
||||||
imageUrl="/locked-image.png"
|
imageUrl="/locked-image.png"
|
||||||
imagePaywalled
|
imagePaywalled
|
||||||
/>,
|
/>,
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ export const ChatInputTextField = forwardRef<
|
|||||||
onBlur={() => onFocusChange?.(false)}
|
onBlur={() => onFocusChange?.(false)}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
maxLength={4000}
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
rows={1}
|
rows={1}
|
||||||
aria-label="Message"
|
aria-label="Message"
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
import type { ReactNode } from "react";
|
|
||||||
|
|
||||||
export default function LegacyChatLayout({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: ReactNode;
|
|
||||||
}) {
|
|
||||||
return children;
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import type { ReactNode } from "react";
|
|
||||||
|
|
||||||
export default function LegacyPrivateRoomLayout({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: ReactNode;
|
|
||||||
}) {
|
|
||||||
return children;
|
|
||||||
}
|
|
||||||
@@ -26,7 +26,7 @@ import { useSplashLatestMessage } from "../use-splash-latest-message";
|
|||||||
|
|
||||||
function LatestMessageHarness() {
|
function LatestMessageHarness() {
|
||||||
const state = useSplashLatestMessage({
|
const state = useSplashLatestMessage({
|
||||||
characterId: "character_maya",
|
characterId: "maya-tan",
|
||||||
hasInitialized: true,
|
hasInitialized: true,
|
||||||
isAuthLoading: false,
|
isAuthLoading: false,
|
||||||
loginStatus: "facebook",
|
loginStatus: "facebook",
|
||||||
@@ -67,7 +67,7 @@ describe("useSplashLatestMessage hydration", () => {
|
|||||||
expect(mocks.loadLatestMessage).toHaveBeenCalledOnce();
|
expect(mocks.loadLatestMessage).toHaveBeenCalledOnce();
|
||||||
expect(mocks.loadLatestMessage).toHaveBeenCalledWith({
|
expect(mocks.loadLatestMessage).toHaveBeenCalledWith({
|
||||||
cache: mocks.cache,
|
cache: mocks.cache,
|
||||||
characterId: "character_maya",
|
characterId: "maya-tan",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
import type { ReactNode } from "react";
|
|
||||||
|
|
||||||
export default function LegacyTipLayout({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: ReactNode;
|
|
||||||
}) {
|
|
||||||
return children;
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,9 @@ import {
|
|||||||
DEFAULT_CHARACTER,
|
DEFAULT_CHARACTER,
|
||||||
getCharacterById,
|
getCharacterById,
|
||||||
getCharacterBySlug,
|
getCharacterBySlug,
|
||||||
|
mergeRemoteCharacterCatalog,
|
||||||
} from "@/data/constants/character";
|
} from "@/data/constants/character";
|
||||||
|
import { CharacterListResponseSchema } from "@/data/schemas/character";
|
||||||
|
|
||||||
describe("local character catalog", () => {
|
describe("local character catalog", () => {
|
||||||
it("contains immutable and uniquely addressable character profiles", () => {
|
it("contains immutable and uniquely addressable character profiles", () => {
|
||||||
@@ -17,6 +19,11 @@ describe("local character catalog", () => {
|
|||||||
"maya",
|
"maya",
|
||||||
"nayeli",
|
"nayeli",
|
||||||
]);
|
]);
|
||||||
|
expect(CHARACTERS.map((character) => character.id)).toEqual([
|
||||||
|
"elio",
|
||||||
|
"maya-tan",
|
||||||
|
"nayeli-cervantes",
|
||||||
|
]);
|
||||||
expect(new Set(CHARACTERS.map((character) => character.id)).size).toBe(
|
expect(new Set(CHARACTERS.map((character) => character.id)).size).toBe(
|
||||||
CHARACTERS.length,
|
CHARACTERS.length,
|
||||||
);
|
);
|
||||||
@@ -27,6 +34,48 @@ describe("local character catalog", () => {
|
|||||||
expect(Object.isFrozen(DEFAULT_CHARACTER.copy)).toBe(true);
|
expect(Object.isFrozen(DEFAULT_CHARACTER.copy)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("merges backend authority with local routes and assets", () => {
|
||||||
|
const snapshot = mergeRemoteCharacterCatalog(
|
||||||
|
CharacterListResponseSchema.parse({
|
||||||
|
defaultCharacterId: "elio",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: "maya-tan",
|
||||||
|
displayName: "Maya Backend",
|
||||||
|
isActive: true,
|
||||||
|
sortOrder: 5,
|
||||||
|
capabilities: { chat: true, privateContent: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "elio",
|
||||||
|
displayName: "Elio Backend",
|
||||||
|
isActive: true,
|
||||||
|
sortOrder: 10,
|
||||||
|
capabilities: { chat: true, privateContent: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "unknown",
|
||||||
|
displayName: "Unknown",
|
||||||
|
isActive: true,
|
||||||
|
sortOrder: 1,
|
||||||
|
capabilities: { chat: true, privateContent: true },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(snapshot.catalog.characters.map((item) => item.slug)).toEqual([
|
||||||
|
"maya",
|
||||||
|
"elio",
|
||||||
|
]);
|
||||||
|
expect(snapshot.catalog.getById("maya-tan")).toMatchObject({
|
||||||
|
displayName: "Maya Backend",
|
||||||
|
assets: { avatar: "/images/avatar/maya.png" },
|
||||||
|
capabilities: { chat: true, privateRoom: false, tip: true },
|
||||||
|
});
|
||||||
|
expect(snapshot.defaultCharacter.id).toBe("elio");
|
||||||
|
});
|
||||||
|
|
||||||
it("sorts catalog entries and rejects duplicate identities", () => {
|
it("sorts catalog entries and rejects duplicate identities", () => {
|
||||||
const catalog = createCharacterCatalog([...CHARACTERS].reverse());
|
const catalog = createCharacterCatalog([...CHARACTERS].reverse());
|
||||||
expect(catalog.characters.map((character) => character.slug)).toEqual([
|
expect(catalog.characters.map((character) => character.slug)).toEqual([
|
||||||
@@ -40,7 +89,7 @@ describe("local character catalog", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("resolves characters by id and normalized slug", () => {
|
it("resolves characters by id and normalized slug", () => {
|
||||||
expect(getCharacterById("character_maya")?.displayName).toBe("Maya Tan");
|
expect(getCharacterById("maya-tan")?.displayName).toBe("Maya Tan");
|
||||||
expect(getCharacterBySlug(" NAYELI ")?.displayName).toBe(
|
expect(getCharacterBySlug(" NAYELI ")?.displayName).toBe(
|
||||||
"Nayeli Cervantes",
|
"Nayeli Cervantes",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ export interface CharacterCatalog {
|
|||||||
getBySlug(characterSlug: string | null | undefined): CharacterProfile | null;
|
getBySlug(characterSlug: string | null | undefined): CharacterProfile | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CharacterCatalogSnapshot {
|
||||||
|
readonly catalog: CharacterCatalog;
|
||||||
|
readonly defaultCharacter: CharacterProfile;
|
||||||
|
}
|
||||||
|
|
||||||
function defineCharacter(profile: CharacterProfile): CharacterProfile {
|
function defineCharacter(profile: CharacterProfile): CharacterProfile {
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
...profile,
|
...profile,
|
||||||
@@ -45,7 +50,7 @@ function defineCharacter(profile: CharacterProfile): CharacterProfile {
|
|||||||
|
|
||||||
const CHARACTER_PROFILES: readonly CharacterProfile[] = Object.freeze([
|
const CHARACTER_PROFILES: readonly CharacterProfile[] = Object.freeze([
|
||||||
defineCharacter({
|
defineCharacter({
|
||||||
id: "character_elio",
|
id: "elio",
|
||||||
slug: "elio",
|
slug: "elio",
|
||||||
displayName: "Elio Silvestri",
|
displayName: "Elio Silvestri",
|
||||||
shortName: "Elio",
|
shortName: "Elio",
|
||||||
@@ -75,7 +80,7 @@ const CHARACTER_PROFILES: readonly CharacterProfile[] = Object.freeze([
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
defineCharacter({
|
defineCharacter({
|
||||||
id: "character_maya",
|
id: "maya-tan",
|
||||||
slug: "maya",
|
slug: "maya",
|
||||||
displayName: "Maya Tan",
|
displayName: "Maya Tan",
|
||||||
shortName: "Maya",
|
shortName: "Maya",
|
||||||
@@ -104,7 +109,7 @@ const CHARACTER_PROFILES: readonly CharacterProfile[] = Object.freeze([
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
defineCharacter({
|
defineCharacter({
|
||||||
id: "character_nayeli",
|
id: "nayeli-cervantes",
|
||||||
slug: "nayeli",
|
slug: "nayeli",
|
||||||
displayName: "Nayeli Cervantes",
|
displayName: "Nayeli Cervantes",
|
||||||
shortName: "Nayeli",
|
shortName: "Nayeli",
|
||||||
@@ -166,6 +171,37 @@ export function createCharacterCatalog(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function mergeRemoteCharacterCatalog(
|
||||||
|
remote: import("@/data/schemas/character").CharacterListResponse,
|
||||||
|
localProfiles: readonly CharacterProfile[] = CHARACTERS,
|
||||||
|
): CharacterCatalogSnapshot {
|
||||||
|
const localById = new Map(localProfiles.map((profile) => [profile.id, profile]));
|
||||||
|
const profiles = remote.items.flatMap((item) => {
|
||||||
|
const local = localById.get(item.id);
|
||||||
|
if (!local || !item.isActive || !item.capabilities.chat) return [];
|
||||||
|
return [
|
||||||
|
defineCharacter({
|
||||||
|
...local,
|
||||||
|
displayName: item.displayName,
|
||||||
|
sortOrder: item.sortOrder,
|
||||||
|
capabilities: {
|
||||||
|
chat: item.capabilities.chat,
|
||||||
|
privateRoom:
|
||||||
|
local.capabilities.privateRoom && item.capabilities.privateContent,
|
||||||
|
tip: local.capabilities.tip,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
const catalog = createCharacterCatalog(profiles);
|
||||||
|
const defaultCharacter =
|
||||||
|
catalog.getById(remote.defaultCharacterId) ??
|
||||||
|
catalog.getById(DEFAULT_CHARACTER_ID) ??
|
||||||
|
catalog.characters[0] ??
|
||||||
|
DEFAULT_CHARACTER;
|
||||||
|
return Object.freeze({ catalog, defaultCharacter });
|
||||||
|
}
|
||||||
|
|
||||||
export const LOCAL_CHARACTER_CATALOG = createCharacterCatalog(
|
export const LOCAL_CHARACTER_CATALOG = createCharacterCatalog(
|
||||||
CHARACTER_PROFILES,
|
CHARACTER_PROFILES,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
{
|
{
|
||||||
"characterId": "character_elio",
|
"characterId": "elio",
|
||||||
"message": "Look at this sunset.",
|
"message": "Look at this sunset.",
|
||||||
"image": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD...",
|
|
||||||
"imageId": "img_mock_001",
|
"imageId": "img_mock_001",
|
||||||
"imageThumbUrl": "https://cdn.cozsweet.com/mock/chat/img_mock_001_thumb.jpg",
|
"imageThumbUrl": "https://cdn.cozsweet.com/mock/chat/img_mock_001_thumb.jpg",
|
||||||
"imageMediumUrl": "https://cdn.cozsweet.com/mock/chat/img_mock_001_medium.jpg",
|
"imageMediumUrl": "https://cdn.cozsweet.com/mock/chat/img_mock_001_medium.jpg",
|
||||||
|
|||||||
@@ -1,12 +1,5 @@
|
|||||||
{
|
{
|
||||||
"characterId": "character_elio",
|
"characterId": "elio",
|
||||||
"message": "I missed you today.",
|
"message": "I missed you today.",
|
||||||
"image": "",
|
|
||||||
"imageId": "",
|
|
||||||
"imageThumbUrl": "",
|
|
||||||
"imageMediumUrl": "",
|
|
||||||
"imageOriginalUrl": "",
|
|
||||||
"imageWidth": 0,
|
|
||||||
"imageHeight": 0,
|
|
||||||
"useWebSocket": false
|
"useWebSocket": false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"characterId": "elio",
|
||||||
"messages": [
|
"messages": [
|
||||||
{
|
{
|
||||||
"role": "user",
|
"role": "user",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"characterId": "character_elio",
|
"characterId": "elio",
|
||||||
"messageId": "msg_private_locked_001"
|
"messageId": "msg_private_locked_001"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,10 +26,10 @@ function createStorageState(input: {
|
|||||||
|
|
||||||
describe("chat cache identity", () => {
|
describe("chat cache identity", () => {
|
||||||
it("isolates the same owner by character", () => {
|
it("isolates the same owner by character", () => {
|
||||||
const elio = buildChatConversationKey("user:account-1", "character_elio");
|
const elio = buildChatConversationKey("user:account-1", "elio");
|
||||||
const aria = buildChatConversationKey("user:account-1", "character_aria");
|
const aria = buildChatConversationKey("user:account-1", "character_aria");
|
||||||
|
|
||||||
expect(elio).toBe("user:account-1::character:character_elio");
|
expect(elio).toBe("user:account-1::character:elio");
|
||||||
expect(aria).toBe("user:account-1::character:character_aria");
|
expect(aria).toBe("user:account-1::character:character_aria");
|
||||||
expect(elio).not.toBe(aria);
|
expect(elio).not.toBe(aria);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ describe("ChatMediaCacheCoordinator identity isolation", () => {
|
|||||||
expect(result).toEqual(Result.ok(null));
|
expect(result).toEqual(Result.ok(null));
|
||||||
expect(getMedia).toHaveBeenCalledTimes(1);
|
expect(getMedia).toHaveBeenCalledTimes(1);
|
||||||
expect(getMedia).toHaveBeenCalledWith(
|
expect(getMedia).toHaveBeenCalledWith(
|
||||||
"user:account-a::character:character_elio:message-1:image",
|
"user:account-a::character:elio:message-1:image",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ describe("ChatMediaCacheCoordinator identity isolation", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await coordinator.getCachedMedia({
|
await coordinator.getCachedMedia({
|
||||||
characterId: "character_elio",
|
characterId: "elio",
|
||||||
messageId: "shared-message-id",
|
messageId: "shared-message-id",
|
||||||
kind: "image",
|
kind: "image",
|
||||||
});
|
});
|
||||||
@@ -57,7 +57,7 @@ describe("ChatMediaCacheCoordinator identity isolation", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(getMedia.mock.calls).toEqual([
|
expect(getMedia.mock.calls).toEqual([
|
||||||
["user:account-a::character:character_elio:shared-message-id:image"],
|
["user:account-a::character:elio:shared-message-id:image"],
|
||||||
["user:account-a::character:character_aria:shared-message-id:image"],
|
["user:account-a::character:character_aria:shared-message-id:image"],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -112,7 +112,7 @@ describe("ChatMediaCacheCoordinator identity isolation", () => {
|
|||||||
kind: "image",
|
kind: "image",
|
||||||
remoteUrl: "https://media.example/expired.jpg",
|
remoteUrl: "https://media.example/expired.jpg",
|
||||||
},
|
},
|
||||||
"user:account-a::character:character_elio",
|
"user:account-a::character:elio",
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(Result.isErr(result)).toBe(true);
|
expect(Result.isErr(result)).toBe(true);
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { ICharacterRepository } from "@/data/repositories/interfaces";
|
||||||
|
import type { CharacterListResponse } from "@/data/schemas/character";
|
||||||
|
import { CharacterApi, characterApi } from "@/data/services/api";
|
||||||
|
import { Result } from "@/utils/result";
|
||||||
|
|
||||||
|
import { createLazySingleton } from "./lazy_singleton";
|
||||||
|
|
||||||
|
export class CharacterRepository implements ICharacterRepository {
|
||||||
|
constructor(private readonly api: CharacterApi) {}
|
||||||
|
|
||||||
|
async getChatCharacters(): Promise<Result<CharacterListResponse>> {
|
||||||
|
return Result.wrap(() => this.api.getChatCharacters());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getCharacterRepository = createLazySingleton<ICharacterRepository>(
|
||||||
|
() => new CharacterRepository(characterApi),
|
||||||
|
);
|
||||||
@@ -5,6 +5,9 @@ import type {
|
|||||||
} from "@/data/repositories/interfaces";
|
} from "@/data/repositories/interfaces";
|
||||||
import {
|
import {
|
||||||
ChatHistoryResponse,
|
ChatHistoryResponse,
|
||||||
|
ChatPreviewsResponse,
|
||||||
|
ChatSyncRequest,
|
||||||
|
ChatSyncRequestSchema,
|
||||||
ChatSendResponse,
|
ChatSendResponse,
|
||||||
SendMessageRequestSchema,
|
SendMessageRequestSchema,
|
||||||
UnlockHistoryRequestSchema,
|
UnlockHistoryRequestSchema,
|
||||||
@@ -27,13 +30,43 @@ export class ChatRemoteDataSource {
|
|||||||
const request = SendMessageRequestSchema.parse({
|
const request = SendMessageRequestSchema.parse({
|
||||||
characterId,
|
characterId,
|
||||||
message,
|
message,
|
||||||
image: options?.image ?? "",
|
...(options?.imageId ? { imageId: options.imageId } : {}),
|
||||||
|
...(options?.imageThumbUrl
|
||||||
|
? { imageThumbUrl: options.imageThumbUrl }
|
||||||
|
: {}),
|
||||||
|
...(options?.imageMediumUrl
|
||||||
|
? { imageMediumUrl: options.imageMediumUrl }
|
||||||
|
: {}),
|
||||||
|
...(options?.imageOriginalUrl
|
||||||
|
? { imageOriginalUrl: options.imageOriginalUrl }
|
||||||
|
: {}),
|
||||||
|
...(options?.imageWidth !== undefined
|
||||||
|
? { imageWidth: options.imageWidth }
|
||||||
|
: {}),
|
||||||
|
...(options?.imageHeight !== undefined
|
||||||
|
? { imageHeight: options.imageHeight }
|
||||||
|
: {}),
|
||||||
useWebSocket: options?.useWebSocket ?? false,
|
useWebSocket: options?.useWebSocket ?? false,
|
||||||
});
|
});
|
||||||
return await this.api.sendMessage(request, options);
|
return await this.api.sendMessage(request, options);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getPreviews(
|
||||||
|
options?: ChatRequestOptions,
|
||||||
|
): Promise<Result<ChatPreviewsResponse>> {
|
||||||
|
return Result.wrap(() => this.api.getPreviews(options));
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncGuestHistory(
|
||||||
|
request: ChatSyncRequest,
|
||||||
|
options?: ChatRequestOptions,
|
||||||
|
): Promise<Result<void>> {
|
||||||
|
return Result.wrap(() =>
|
||||||
|
this.api.syncGuestHistory(ChatSyncRequestSchema.parse(request), options),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async getHistory(
|
async getHistory(
|
||||||
characterId: string,
|
characterId: string,
|
||||||
limit = 50,
|
limit = 50,
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import type {
|
|||||||
} from "@/data/repositories/interfaces";
|
} from "@/data/repositories/interfaces";
|
||||||
import type {
|
import type {
|
||||||
ChatHistoryResponse,
|
ChatHistoryResponse,
|
||||||
|
ChatPreviewsResponse,
|
||||||
|
ChatSyncRequest,
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
ChatSendResponse,
|
ChatSendResponse,
|
||||||
UnlockHistoryResponse,
|
UnlockHistoryResponse,
|
||||||
@@ -55,6 +57,19 @@ export class ChatRepository implements IChatRepository {
|
|||||||
return this.remote.getHistory(characterId, limit, offset, options);
|
return this.remote.getHistory(characterId, limit, offset, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getPreviews(
|
||||||
|
options?: ChatRequestOptions,
|
||||||
|
): Promise<Result<ChatPreviewsResponse>> {
|
||||||
|
return this.remote.getPreviews(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncGuestHistory(
|
||||||
|
request: ChatSyncRequest,
|
||||||
|
options?: ChatRequestOptions,
|
||||||
|
): Promise<Result<void>> {
|
||||||
|
return this.remote.syncGuestHistory(request, options);
|
||||||
|
}
|
||||||
|
|
||||||
/** 解锁单条历史付费 / 私密消息。 */
|
/** 解锁单条历史付费 / 私密消息。 */
|
||||||
async unlockPrivateMessage(
|
async unlockPrivateMessage(
|
||||||
input: UnlockPrivateMessageInput,
|
input: UnlockPrivateMessageInput,
|
||||||
|
|||||||
@@ -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 { CharacterListResponse } from "@/data/schemas/character";
|
||||||
|
import type { Result } from "@/utils/result";
|
||||||
|
|
||||||
|
export interface ICharacterRepository {
|
||||||
|
getChatCharacters(): Promise<Result<CharacterListResponse>>;
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
ChatHistoryResponse,
|
ChatHistoryResponse,
|
||||||
|
ChatPreviewsResponse,
|
||||||
|
ChatSyncRequest,
|
||||||
ChatImageData,
|
ChatImageData,
|
||||||
ChatLockDetailData,
|
ChatLockDetailData,
|
||||||
ChatLockType,
|
ChatLockType,
|
||||||
@@ -47,7 +49,12 @@ export interface ChatRequestOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatSendOptions extends ChatRequestOptions {
|
export interface ChatSendOptions extends ChatRequestOptions {
|
||||||
image?: string;
|
imageId?: string;
|
||||||
|
imageThumbUrl?: string;
|
||||||
|
imageMediumUrl?: string;
|
||||||
|
imageOriginalUrl?: string;
|
||||||
|
imageWidth?: number;
|
||||||
|
imageHeight?: number;
|
||||||
useWebSocket?: boolean;
|
useWebSocket?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +74,17 @@ export interface IChatRepository {
|
|||||||
options?: ChatRequestOptions,
|
options?: ChatRequestOptions,
|
||||||
): Promise<Result<ChatHistoryResponse>>;
|
): Promise<Result<ChatHistoryResponse>>;
|
||||||
|
|
||||||
|
/** 批量获取所有可聊天角色的最新消息。 */
|
||||||
|
getPreviews(
|
||||||
|
options?: ChatRequestOptions,
|
||||||
|
): Promise<Result<ChatPreviewsResponse>>;
|
||||||
|
|
||||||
|
/** 把一个角色的游客历史同步到正式账号。 */
|
||||||
|
syncGuestHistory(
|
||||||
|
request: ChatSyncRequest,
|
||||||
|
options?: ChatRequestOptions,
|
||||||
|
): Promise<Result<void>>;
|
||||||
|
|
||||||
/** 解锁单条历史付费 / 私密消息。 */
|
/** 解锁单条历史付费 / 私密消息。 */
|
||||||
unlockPrivateMessage(
|
unlockPrivateMessage(
|
||||||
input: UnlockPrivateMessageInput,
|
input: UnlockPrivateMessageInput,
|
||||||
|
|||||||
@@ -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,13 +5,13 @@ import type {
|
|||||||
import type { Result } from "@/utils/result";
|
import type { Result } from "@/utils/result";
|
||||||
|
|
||||||
export interface GetPrivateAlbumsInput {
|
export interface GetPrivateAlbumsInput {
|
||||||
characterId?: string;
|
characterId: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IPrivateRoomRepository {
|
export interface IPrivateRoomRepository {
|
||||||
getAlbums(
|
getAlbums(
|
||||||
input?: GetPrivateAlbumsInput,
|
input: GetPrivateAlbumsInput,
|
||||||
): Promise<Result<PrivateAlbumsResponse>>;
|
): Promise<Result<PrivateAlbumsResponse>>;
|
||||||
|
|
||||||
unlockAlbum(
|
unlockAlbum(
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export class PrivateRoomRepository implements IPrivateRoomRepository {
|
|||||||
constructor(private readonly api: PrivateRoomApi) {}
|
constructor(private readonly api: PrivateRoomApi) {}
|
||||||
|
|
||||||
getAlbums(
|
getAlbums(
|
||||||
input: GetPrivateAlbumsInput = {},
|
input: GetPrivateAlbumsInput,
|
||||||
): Promise<Result<PrivateAlbumsResponse>> {
|
): Promise<Result<PrivateAlbumsResponse>> {
|
||||||
return Result.wrap(() => this.api.getAlbums(input));
|
return Result.wrap(() => this.api.getAlbums(input));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { arrayOrEmpty, booleanOrFalse, numberOrZero } from "../nullable-defaults";
|
||||||
|
|
||||||
|
export const CharacterCapabilitiesResponseSchema = z
|
||||||
|
.object({
|
||||||
|
chat: booleanOrFalse,
|
||||||
|
privateContent: booleanOrFalse,
|
||||||
|
})
|
||||||
|
.readonly();
|
||||||
|
|
||||||
|
export const CharacterListItemSchema = z
|
||||||
|
.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
displayName: z.string().min(1),
|
||||||
|
isActive: booleanOrFalse,
|
||||||
|
capabilities: CharacterCapabilitiesResponseSchema,
|
||||||
|
sortOrder: numberOrZero,
|
||||||
|
})
|
||||||
|
.readonly();
|
||||||
|
|
||||||
|
export const CharacterListResponseSchema = z
|
||||||
|
.object({
|
||||||
|
items: arrayOrEmpty(CharacterListItemSchema),
|
||||||
|
defaultCharacterId: z.string().min(1),
|
||||||
|
})
|
||||||
|
.readonly();
|
||||||
|
|
||||||
|
export type CharacterListItem = z.output<typeof CharacterListItemSchema>;
|
||||||
|
export type CharacterListResponseInput = z.input<typeof CharacterListResponseSchema>;
|
||||||
|
export type CharacterListResponse = z.output<typeof CharacterListResponseSchema>;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./character_list_response";
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ChatPreviewsResponseSchema,
|
||||||
|
ChatSyncRequestSchema,
|
||||||
|
SendMessageRequestSchema,
|
||||||
|
} from "@/data/schemas/chat";
|
||||||
|
|
||||||
|
describe("multi-role chat schemas", () => {
|
||||||
|
it("accepts text or image sends and rejects empty or oversized text", () => {
|
||||||
|
expect(
|
||||||
|
SendMessageRequestSchema.parse({ characterId: "elio", message: "Hi" }),
|
||||||
|
).toMatchObject({ characterId: "elio", message: "Hi" });
|
||||||
|
expect(
|
||||||
|
SendMessageRequestSchema.parse({
|
||||||
|
characterId: "maya-tan",
|
||||||
|
imageId: "image-1",
|
||||||
|
}),
|
||||||
|
).toMatchObject({ characterId: "maya-tan", imageId: "image-1" });
|
||||||
|
expect(() => SendMessageRequestSchema.parse({ characterId: "elio" })).toThrow();
|
||||||
|
expect(() =>
|
||||||
|
SendMessageRequestSchema.parse({
|
||||||
|
characterId: "elio",
|
||||||
|
message: "x".repeat(4001),
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses nullable previews and immutable sync messages", () => {
|
||||||
|
const previews = ChatPreviewsResponseSchema.parse({
|
||||||
|
items: [{ characterId: "elio", message: null }],
|
||||||
|
});
|
||||||
|
const sync = ChatSyncRequestSchema.parse({
|
||||||
|
characterId: "elio",
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: "Welcome back",
|
||||||
|
timestamp: "2026-07-20T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(previews.items[0]?.message).toBeNull();
|
||||||
|
expect(Object.isFrozen(previews.items)).toBe(true);
|
||||||
|
expect(Object.isFrozen(sync.messages)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,9 +7,11 @@ export * from "./chat_media";
|
|||||||
export * from "./chat_message";
|
export * from "./chat_message";
|
||||||
export * from "./chat_payloads";
|
export * from "./chat_payloads";
|
||||||
export * from "./request/send_message_request";
|
export * from "./request/send_message_request";
|
||||||
|
export * from "./request/chat_sync_request";
|
||||||
export * from "./request/unlock_history_request";
|
export * from "./request/unlock_history_request";
|
||||||
export * from "./request/unlock_private_request";
|
export * from "./request/unlock_private_request";
|
||||||
export * from "./response/chat_history_response";
|
export * from "./response/chat_history_response";
|
||||||
|
export * from "./response/chat_previews_response";
|
||||||
export * from "./response/chat_send_response";
|
export * from "./response/chat_send_response";
|
||||||
export * from "./response/unlock_history_response";
|
export * from "./response/unlock_history_response";
|
||||||
export * from "./response/unlock_private_response";
|
export * from "./response/unlock_private_response";
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const ChatSyncMessageSchema = z
|
||||||
|
.object({
|
||||||
|
role: z.enum(["user", "assistant"]),
|
||||||
|
content: z.string(),
|
||||||
|
timestamp: z.string().min(1),
|
||||||
|
})
|
||||||
|
.readonly();
|
||||||
|
|
||||||
|
export const ChatSyncRequestSchema = z
|
||||||
|
.object({
|
||||||
|
characterId: z.string().min(1),
|
||||||
|
messages: z.array(ChatSyncMessageSchema).min(1).readonly(),
|
||||||
|
})
|
||||||
|
.readonly();
|
||||||
|
|
||||||
|
export type ChatSyncRequestInput = z.input<typeof ChatSyncRequestSchema>;
|
||||||
|
export type ChatSyncRequest = z.output<typeof ChatSyncRequestSchema>;
|
||||||
@@ -3,5 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export * from "./send_message_request";
|
export * from "./send_message_request";
|
||||||
|
export * from "./chat_sync_request";
|
||||||
export * from "./unlock_private_request";
|
export * from "./unlock_private_request";
|
||||||
export * from "./unlock_history_request";
|
export * from "./unlock_history_request";
|
||||||
|
|||||||
@@ -4,25 +4,31 @@
|
|||||||
*/
|
*/
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import {
|
import { booleanOrFalse } from "../../nullable-defaults";
|
||||||
booleanOrFalse,
|
|
||||||
numberOrZero,
|
|
||||||
stringOrEmpty,
|
|
||||||
} from "../../nullable-defaults";
|
|
||||||
|
|
||||||
export const SendMessageRequestSchema = z
|
export const SendMessageRequestSchema = z
|
||||||
.object({
|
.object({
|
||||||
characterId: z.string().min(1),
|
characterId: z.string().min(1),
|
||||||
message: stringOrEmpty,
|
message: z.string().max(4000).optional(),
|
||||||
image: stringOrEmpty,
|
imageId: z.string().min(1).optional(),
|
||||||
imageId: stringOrEmpty,
|
imageThumbUrl: z.string().min(1).optional(),
|
||||||
imageThumbUrl: stringOrEmpty,
|
imageMediumUrl: z.string().min(1).optional(),
|
||||||
imageMediumUrl: stringOrEmpty,
|
imageOriginalUrl: z.string().min(1).optional(),
|
||||||
imageOriginalUrl: stringOrEmpty,
|
imageWidth: z.number().int().nonnegative().optional(),
|
||||||
imageWidth: numberOrZero,
|
imageHeight: z.number().int().nonnegative().optional(),
|
||||||
imageHeight: numberOrZero,
|
|
||||||
useWebSocket: booleanOrFalse,
|
useWebSocket: booleanOrFalse,
|
||||||
})
|
})
|
||||||
|
.refine(
|
||||||
|
(value) =>
|
||||||
|
Boolean(value.message?.trim()) ||
|
||||||
|
Boolean(
|
||||||
|
value.imageId ||
|
||||||
|
value.imageThumbUrl ||
|
||||||
|
value.imageMediumUrl ||
|
||||||
|
value.imageOriginalUrl,
|
||||||
|
),
|
||||||
|
{ message: "message or image is required" },
|
||||||
|
)
|
||||||
.readonly();
|
.readonly();
|
||||||
|
|
||||||
export type SendMessageRequestInput = z.input<typeof SendMessageRequestSchema>;
|
export type SendMessageRequestInput = z.input<typeof SendMessageRequestSchema>;
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { arrayOrEmpty } from "../../nullable-defaults";
|
||||||
|
import { ChatMessageSchema } from "../chat_message";
|
||||||
|
|
||||||
|
export const ChatPreviewItemSchema = z
|
||||||
|
.object({
|
||||||
|
characterId: z.string().min(1),
|
||||||
|
message: ChatMessageSchema.nullable().default(null),
|
||||||
|
})
|
||||||
|
.readonly();
|
||||||
|
|
||||||
|
export const ChatPreviewsResponseSchema = z
|
||||||
|
.object({
|
||||||
|
items: arrayOrEmpty(ChatPreviewItemSchema),
|
||||||
|
})
|
||||||
|
.readonly();
|
||||||
|
|
||||||
|
export type ChatPreviewItem = z.output<typeof ChatPreviewItemSchema>;
|
||||||
|
export type ChatPreviewsResponseInput = z.input<typeof ChatPreviewsResponseSchema>;
|
||||||
|
export type ChatPreviewsResponse = z.output<typeof ChatPreviewsResponseSchema>;
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export * from "./chat_history_response";
|
export * from "./chat_history_response";
|
||||||
|
export * from "./chat_previews_response";
|
||||||
export * from "./chat_send_response";
|
export * from "./chat_send_response";
|
||||||
export * from "./unlock_history_response";
|
export * from "./unlock_history_response";
|
||||||
export * from "./unlock_private_response";
|
export * from "./unlock_private_response";
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { ExceptionHandler } from "@/core/errors";
|
||||||
|
|
||||||
|
import { ApiError } from "../api_result";
|
||||||
|
import { getCharacterErrorCode } from "../character_error_code";
|
||||||
|
|
||||||
|
describe("getCharacterErrorCode", () => {
|
||||||
|
it("reads nested backend detail through AppException causes", () => {
|
||||||
|
const error = ExceptionHandler.toError(
|
||||||
|
new ApiError("HTTP_ERROR", "Mismatch", 409, {
|
||||||
|
detail: { errorCode: "CHARACTER_MISMATCH" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getCharacterErrorCode(error)).toBe("CHARACTER_MISMATCH");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores unrelated backend codes", () => {
|
||||||
|
expect(
|
||||||
|
getCharacterErrorCode(
|
||||||
|
new ApiError("HTTP_ERROR", "No", 400, {
|
||||||
|
detail: { errorCode: "OTHER_ERROR" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
ChatSyncRequestSchema,
|
||||||
SendMessageRequestSchema,
|
SendMessageRequestSchema,
|
||||||
UnlockHistoryRequestSchema,
|
UnlockHistoryRequestSchema,
|
||||||
UnlockPrivateRequestSchema,
|
UnlockPrivateRequestSchema,
|
||||||
@@ -13,9 +14,10 @@ vi.mock("../http_client", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { ChatApi } from "../chat_api";
|
import { ChatApi } from "../chat_api";
|
||||||
|
import { CharacterApi } from "../character_api";
|
||||||
import { PrivateRoomApi } from "../private_room_api";
|
import { PrivateRoomApi } from "../private_room_api";
|
||||||
|
|
||||||
const CHARACTER_ID = "character_elio";
|
const CHARACTER_ID = "elio";
|
||||||
|
|
||||||
describe("multi-character API contract", () => {
|
describe("multi-character API contract", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -59,6 +61,45 @@ describe("multi-character API contract", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("loads the chat character catalog", async () => {
|
||||||
|
httpClientMock.mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
data: { items: [], defaultCharacterId: "elio" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await new CharacterApi().getChatCharacters();
|
||||||
|
|
||||||
|
expect(httpClientMock).toHaveBeenCalledWith("/api/characters", {
|
||||||
|
query: { capability: "chat" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads previews and syncs guest history", async () => {
|
||||||
|
const api = new ChatApi();
|
||||||
|
httpClientMock
|
||||||
|
.mockResolvedValueOnce({ success: true, data: { items: [] } })
|
||||||
|
.mockResolvedValueOnce({ success: true, data: {} });
|
||||||
|
|
||||||
|
await api.getPreviews();
|
||||||
|
const request = ChatSyncRequestSchema.parse({
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: "Hello",
|
||||||
|
timestamp: "2026-07-20T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await api.syncGuestHistory(request);
|
||||||
|
|
||||||
|
expect(httpClientMock).toHaveBeenNthCalledWith(1, "/api/chat/previews", {});
|
||||||
|
expect(httpClientMock).toHaveBeenNthCalledWith(2, "/api/chat/sync", {
|
||||||
|
method: "POST",
|
||||||
|
body: request,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("forwards request cancellation to the HTTP client", async () => {
|
it("forwards request cancellation to the HTTP client", async () => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
httpClientMock.mockResolvedValue({
|
httpClientMock.mockResolvedValue({
|
||||||
|
|||||||
@@ -24,5 +24,8 @@
|
|||||||
"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" },
|
||||||
|
"chatPreviews": { "method": "get", "path": "/api/chat/previews" },
|
||||||
|
"chatSync": { "method": "post", "path": "/api/chat/sync" }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,4 +99,11 @@ export class ApiPath {
|
|||||||
// ============ 用户反馈相关 ============
|
// ============ 用户反馈相关 ============
|
||||||
static readonly feedback = apiContract.feedback.path;
|
static readonly feedback = apiContract.feedback.path;
|
||||||
|
|
||||||
|
// ============ 角色目录相关 ============
|
||||||
|
static readonly characters = apiContract.characters.path;
|
||||||
|
|
||||||
|
static readonly chatPreviews = apiContract.chatPreviews.path;
|
||||||
|
|
||||||
|
static readonly chatSync = apiContract.chatSync.path;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import {
|
||||||
|
CharacterListResponse,
|
||||||
|
CharacterListResponseSchema,
|
||||||
|
} from "@/data/schemas/character";
|
||||||
|
|
||||||
|
import { ApiPath } from "./api_path";
|
||||||
|
import { httpClient } from "./http_client";
|
||||||
|
import { ApiEnvelope, unwrap } from "./response_helper";
|
||||||
|
|
||||||
|
export class CharacterApi {
|
||||||
|
async getChatCharacters(): Promise<CharacterListResponse> {
|
||||||
|
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.characters, {
|
||||||
|
query: { capability: "chat" },
|
||||||
|
});
|
||||||
|
return CharacterListResponseSchema.parse(unwrap(env));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const characterApi = new CharacterApi();
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { ApiError } from "./api_result";
|
||||||
|
|
||||||
|
export const CHARACTER_ERROR_CODES = [
|
||||||
|
"CHARACTER_DISABLED",
|
||||||
|
"CHARACTER_NOT_FOUND",
|
||||||
|
"CHARACTER_MISMATCH",
|
||||||
|
"CHARACTER_STATE_UNAVAILABLE",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type CharacterErrorCode = (typeof CHARACTER_ERROR_CODES)[number];
|
||||||
|
|
||||||
|
export function getCharacterErrorCode(error: unknown): CharacterErrorCode | null {
|
||||||
|
let current = error;
|
||||||
|
for (let depth = 0; depth < 5 && current; depth += 1) {
|
||||||
|
if (current instanceof ApiError) {
|
||||||
|
const code = readErrorCode(current.details);
|
||||||
|
if (isCharacterErrorCode(code)) return code;
|
||||||
|
}
|
||||||
|
current = current instanceof Error ? current.cause : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readErrorCode(value: unknown): unknown {
|
||||||
|
if (!value || typeof value !== "object") return null;
|
||||||
|
const data = value as Record<string, unknown>;
|
||||||
|
if (typeof data.errorCode === "string") return data.errorCode;
|
||||||
|
return readErrorCode(data.detail) ?? readErrorCode(data.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCharacterErrorCode(value: unknown): value is CharacterErrorCode {
|
||||||
|
return (
|
||||||
|
typeof value === "string" &&
|
||||||
|
(CHARACTER_ERROR_CODES as readonly string[]).includes(value)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,8 +7,11 @@
|
|||||||
import {
|
import {
|
||||||
ChatHistoryResponse,
|
ChatHistoryResponse,
|
||||||
ChatHistoryResponseSchema,
|
ChatHistoryResponseSchema,
|
||||||
|
ChatPreviewsResponse,
|
||||||
|
ChatPreviewsResponseSchema,
|
||||||
ChatSendResponse,
|
ChatSendResponse,
|
||||||
ChatSendResponseSchema,
|
ChatSendResponseSchema,
|
||||||
|
ChatSyncRequest,
|
||||||
SendMessageRequest,
|
SendMessageRequest,
|
||||||
UnlockHistoryRequest,
|
UnlockHistoryRequest,
|
||||||
UnlockHistoryResponse,
|
UnlockHistoryResponse,
|
||||||
@@ -55,6 +58,26 @@ export class ChatApi {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getPreviews(
|
||||||
|
options?: { signal?: AbortSignal },
|
||||||
|
): Promise<ChatPreviewsResponse> {
|
||||||
|
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.chatPreviews, {
|
||||||
|
...(options?.signal ? { signal: options.signal } : {}),
|
||||||
|
});
|
||||||
|
return ChatPreviewsResponseSchema.parse(unwrap(env));
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncGuestHistory(
|
||||||
|
body: ChatSyncRequest,
|
||||||
|
options?: { signal?: AbortSignal },
|
||||||
|
): Promise<void> {
|
||||||
|
await httpClient<ApiEnvelope<unknown>>(ApiPath.chatSync, {
|
||||||
|
method: "POST",
|
||||||
|
body,
|
||||||
|
...(options?.signal ? { signal: options.signal } : {}),
|
||||||
|
}).then(unwrap);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解锁单条历史付费 / 私密消息
|
* 解锁单条历史付费 / 私密消息
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ 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 "./character_error_code";
|
||||||
export * from "./feedback_api";
|
export * from "./feedback_api";
|
||||||
export * from "./http_client";
|
export * from "./http_client";
|
||||||
export * from "./metrics_api";
|
export * from "./metrics_api";
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
|
|
||||||
import {
|
import {
|
||||||
PrivateAlbumsResponse,
|
PrivateAlbumsResponse,
|
||||||
PrivateAlbumsResponseSchema,
|
PrivateAlbumsResponseSchema,
|
||||||
@@ -12,19 +11,19 @@ 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 {
|
||||||
characterId?: string;
|
characterId: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PrivateRoomApi {
|
export class PrivateRoomApi {
|
||||||
async getAlbums(
|
async getAlbums(
|
||||||
input: GetPrivateAlbumsInput = {},
|
input: GetPrivateAlbumsInput,
|
||||||
): Promise<PrivateAlbumsResponse> {
|
): Promise<PrivateAlbumsResponse> {
|
||||||
const env = await httpClient<ApiEnvelope<unknown>>(
|
const env = await httpClient<ApiEnvelope<unknown>>(
|
||||||
ApiPath.privateRoomAlbums,
|
ApiPath.privateRoomAlbums,
|
||||||
{
|
{
|
||||||
query: {
|
query: {
|
||||||
characterId: input.characterId ?? DEFAULT_CHARACTER_ID,
|
characterId: input.characterId,
|
||||||
limit: input.limit ?? 20,
|
limit: input.limit ?? 20,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,213 +0,0 @@
|
|||||||
import "fake-indexeddb/auto";
|
|
||||||
|
|
||||||
import Dexie from "dexie";
|
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
|
|
||||||
import {
|
|
||||||
buildChatConversationKey,
|
|
||||||
buildChatMediaCacheKey,
|
|
||||||
} from "@/lib/chat/chat_cache_keys";
|
|
||||||
|
|
||||||
import {
|
|
||||||
LocalChatDB,
|
|
||||||
type LocalChatMediaRow,
|
|
||||||
type LocalMessageRow,
|
|
||||||
} from "../local_chat_db";
|
|
||||||
|
|
||||||
const DATABASE_SCHEMA = {
|
|
||||||
messages: "++dbId, sessionId",
|
|
||||||
media:
|
|
||||||
"cacheKey, ownerKey, messageId, kind, remoteUrl, updatedAt, lastAccessedAt",
|
|
||||||
};
|
|
||||||
|
|
||||||
const openDatabases: Dexie[] = [];
|
|
||||||
const databaseNames = new Set<string>();
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
for (const database of openDatabases.splice(0)) database.close();
|
|
||||||
await Promise.all([...databaseNames].map((name) => Dexie.delete(name)));
|
|
||||||
databaseNames.clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("LocalChatDB v4 migration", () => {
|
|
||||||
it("moves legacy user and guest messages into the Elio conversation", async () => {
|
|
||||||
const legacy = await createLegacyV3Database();
|
|
||||||
const mayaConversation = buildChatConversationKey(
|
|
||||||
"user:maya-owner",
|
|
||||||
"character_maya",
|
|
||||||
);
|
|
||||||
await legacy.table<LocalMessageRow, number>("messages").bulkAdd([
|
|
||||||
messageRow("message-user", "user:account-1"),
|
|
||||||
messageRow("message-guest", "device:guest-1"),
|
|
||||||
messageRow("message-maya", mayaConversation),
|
|
||||||
]);
|
|
||||||
legacy.close();
|
|
||||||
|
|
||||||
const upgraded = track(new LocalChatDB(legacy.name));
|
|
||||||
await upgraded.open();
|
|
||||||
|
|
||||||
const rows = await upgraded.messages.orderBy("dbId").toArray();
|
|
||||||
expect(rows.map(({ id, sessionId }) => ({ id, sessionId }))).toEqual([
|
|
||||||
{
|
|
||||||
id: "message-user",
|
|
||||||
sessionId: buildChatConversationKey(
|
|
||||||
"user:account-1",
|
|
||||||
DEFAULT_CHARACTER_ID,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "message-guest",
|
|
||||||
sessionId: buildChatConversationKey(
|
|
||||||
"device:guest-1",
|
|
||||||
DEFAULT_CHARACTER_ID,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ id: "message-maya", sessionId: mayaConversation },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rekeys legacy media without overwriting existing scoped media", async () => {
|
|
||||||
const legacy = await createLegacyV3Database();
|
|
||||||
const legacyOwner = "user:account-1";
|
|
||||||
const elioConversation = buildChatConversationKey(
|
|
||||||
legacyOwner,
|
|
||||||
DEFAULT_CHARACTER_ID,
|
|
||||||
);
|
|
||||||
const elioCacheKey = buildChatMediaCacheKey({
|
|
||||||
ownerKey: elioConversation,
|
|
||||||
messageId: "shared-message",
|
|
||||||
kind: "image",
|
|
||||||
});
|
|
||||||
const mayaConversation = buildChatConversationKey(
|
|
||||||
legacyOwner,
|
|
||||||
"character_maya",
|
|
||||||
);
|
|
||||||
const mayaCacheKey = buildChatMediaCacheKey({
|
|
||||||
ownerKey: mayaConversation,
|
|
||||||
messageId: "maya-message",
|
|
||||||
kind: "audio",
|
|
||||||
});
|
|
||||||
await legacy.table<LocalChatMediaRow, string>("media").bulkAdd([
|
|
||||||
mediaRow({
|
|
||||||
cacheKey: `${legacyOwner}:shared-message:image`,
|
|
||||||
ownerKey: legacyOwner,
|
|
||||||
messageId: "shared-message",
|
|
||||||
kind: "image",
|
|
||||||
remoteUrl: "https://example.com/legacy.jpg",
|
|
||||||
}),
|
|
||||||
mediaRow({
|
|
||||||
cacheKey: elioCacheKey,
|
|
||||||
ownerKey: elioConversation,
|
|
||||||
messageId: "shared-message",
|
|
||||||
kind: "image",
|
|
||||||
remoteUrl: "https://example.com/current.jpg",
|
|
||||||
}),
|
|
||||||
mediaRow({
|
|
||||||
cacheKey: "device:guest-1:guest-message:audio",
|
|
||||||
ownerKey: "device:guest-1",
|
|
||||||
messageId: "guest-message",
|
|
||||||
kind: "audio",
|
|
||||||
remoteUrl: "https://example.com/guest.mp3",
|
|
||||||
}),
|
|
||||||
mediaRow({
|
|
||||||
cacheKey: mayaCacheKey,
|
|
||||||
ownerKey: mayaConversation,
|
|
||||||
messageId: "maya-message",
|
|
||||||
kind: "audio",
|
|
||||||
remoteUrl: "https://example.com/maya.mp3",
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
legacy.close();
|
|
||||||
|
|
||||||
const upgraded = track(new LocalChatDB(legacy.name));
|
|
||||||
await upgraded.open();
|
|
||||||
|
|
||||||
const rows = await upgraded.media.toArray();
|
|
||||||
expect(rows).toHaveLength(3);
|
|
||||||
expect(await upgraded.media.get(`${legacyOwner}:shared-message:image`)).toBe(
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
expect(await upgraded.media.get(elioCacheKey)).toMatchObject({
|
|
||||||
ownerKey: elioConversation,
|
|
||||||
remoteUrl: "https://example.com/current.jpg",
|
|
||||||
});
|
|
||||||
|
|
||||||
const guestConversation = buildChatConversationKey(
|
|
||||||
"device:guest-1",
|
|
||||||
DEFAULT_CHARACTER_ID,
|
|
||||||
);
|
|
||||||
const guestCacheKey = buildChatMediaCacheKey({
|
|
||||||
ownerKey: guestConversation,
|
|
||||||
messageId: "guest-message",
|
|
||||||
kind: "audio",
|
|
||||||
});
|
|
||||||
expect(await upgraded.media.get(guestCacheKey)).toMatchObject({
|
|
||||||
cacheKey: guestCacheKey,
|
|
||||||
ownerKey: guestConversation,
|
|
||||||
remoteUrl: "https://example.com/guest.mp3",
|
|
||||||
});
|
|
||||||
expect(await upgraded.media.get(mayaCacheKey)).toMatchObject({
|
|
||||||
ownerKey: mayaConversation,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not run the migration again after the database reaches v4", async () => {
|
|
||||||
const legacy = await createLegacyV3Database();
|
|
||||||
await legacy
|
|
||||||
.table<LocalMessageRow, number>("messages")
|
|
||||||
.add(messageRow("message-user", "user:account-1"));
|
|
||||||
legacy.close();
|
|
||||||
|
|
||||||
const upgraded = track(new LocalChatDB(legacy.name));
|
|
||||||
await upgraded.open();
|
|
||||||
const firstRows = await upgraded.messages.toArray();
|
|
||||||
upgraded.close();
|
|
||||||
|
|
||||||
const reopened = track(new LocalChatDB(legacy.name));
|
|
||||||
await reopened.open();
|
|
||||||
expect(await reopened.messages.toArray()).toEqual(firstRows);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
async function createLegacyV3Database(): Promise<Dexie> {
|
|
||||||
const name = `cozsweet-chat-v3-${crypto.randomUUID()}`;
|
|
||||||
databaseNames.add(name);
|
|
||||||
const database = track(new Dexie(name));
|
|
||||||
database.version(3).stores(DATABASE_SCHEMA);
|
|
||||||
await database.open();
|
|
||||||
return database;
|
|
||||||
}
|
|
||||||
|
|
||||||
function track<T extends Dexie>(database: T): T {
|
|
||||||
openDatabases.push(database);
|
|
||||||
return database;
|
|
||||||
}
|
|
||||||
|
|
||||||
function messageRow(id: string, sessionId: string): LocalMessageRow {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
role: "assistant",
|
|
||||||
type: "text",
|
|
||||||
content: id,
|
|
||||||
createdAt: "2026-07-17T00:00:00.000Z",
|
|
||||||
sessionId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function mediaRow(
|
|
||||||
input: Pick<
|
|
||||||
LocalChatMediaRow,
|
|
||||||
"cacheKey" | "ownerKey" | "messageId" | "kind" | "remoteUrl"
|
|
||||||
>,
|
|
||||||
): LocalChatMediaRow {
|
|
||||||
return {
|
|
||||||
...input,
|
|
||||||
bytes: new Uint8Array([1, 2, 3]).buffer,
|
|
||||||
mimeType: input.kind === "image" ? "image/jpeg" : "audio/mpeg",
|
|
||||||
byteSize: 3,
|
|
||||||
createdAt: 1,
|
|
||||||
updatedAt: 1,
|
|
||||||
lastAccessedAt: 1,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -9,18 +9,12 @@
|
|||||||
* 构造时 `dbName` 可注入,便于测试时每个用例用独立 DB 互不污染。
|
* 构造时 `dbName` 可注入,便于测试时每个用例用独立 DB 互不污染。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import Dexie, { type Table, type Transaction } from "dexie";
|
import Dexie, { type Table } from "dexie";
|
||||||
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
|
|
||||||
import type { ChatMediaKind } from "@/data/schemas/chat";
|
import type { ChatMediaKind } from "@/data/schemas/chat";
|
||||||
import type {
|
import type {
|
||||||
ChatImageData,
|
ChatImageData,
|
||||||
ChatLockDetailData,
|
ChatLockDetailData,
|
||||||
} from "@/data/schemas/chat";
|
} from "@/data/schemas/chat";
|
||||||
import {
|
|
||||||
buildChatConversationKey,
|
|
||||||
buildChatMediaCacheKey,
|
|
||||||
isLegacyChatCacheOwnerKey,
|
|
||||||
} from "@/lib/chat/chat_cache_keys";
|
|
||||||
|
|
||||||
export interface LocalMessageRow {
|
export interface LocalMessageRow {
|
||||||
/** Dexie 自增主键(数据库内部使用,不暴露给 LocalMessage 类)。 */
|
/** Dexie 自增主键(数据库内部使用,不暴露给 LocalMessage 类)。 */
|
||||||
@@ -81,45 +75,6 @@ export class LocalChatDB extends Dexie {
|
|||||||
.stores({
|
.stores({
|
||||||
messages: "++dbId, sessionId",
|
messages: "++dbId, sessionId",
|
||||||
media: "cacheKey, ownerKey, messageId, kind, remoteUrl, updatedAt, lastAccessedAt",
|
media: "cacheKey, ownerKey, messageId, kind, remoteUrl, updatedAt, lastAccessedAt",
|
||||||
})
|
|
||||||
.upgrade(migrateLegacyChatCacheToElio);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function migrateLegacyChatCacheToElio(
|
|
||||||
transaction: Transaction,
|
|
||||||
): Promise<void> {
|
|
||||||
const messages = transaction.table<LocalMessageRow, number>("messages");
|
|
||||||
await messages.toCollection().modify((message) => {
|
|
||||||
const conversationKey = getElioConversationKey(message.sessionId);
|
|
||||||
if (conversationKey) message.sessionId = conversationKey;
|
|
||||||
});
|
|
||||||
|
|
||||||
const media = transaction.table<LocalChatMediaRow, string>("media");
|
|
||||||
const rows = await media.toArray();
|
|
||||||
for (const row of rows) {
|
|
||||||
const conversationKey = getElioConversationKey(row.ownerKey);
|
|
||||||
if (!conversationKey) continue;
|
|
||||||
|
|
||||||
const cacheKey = buildChatMediaCacheKey({
|
|
||||||
ownerKey: conversationKey,
|
|
||||||
messageId: row.messageId,
|
|
||||||
kind: row.kind,
|
|
||||||
});
|
|
||||||
const existing = await media.get(cacheKey);
|
|
||||||
await media.delete(row.cacheKey);
|
|
||||||
if (existing) continue;
|
|
||||||
|
|
||||||
await media.put({
|
|
||||||
...row,
|
|
||||||
cacheKey,
|
|
||||||
ownerKey: conversationKey,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getElioConversationKey(ownerKey: string): string | null {
|
|
||||||
return isLegacyChatCacheOwnerKey(ownerKey)
|
|
||||||
? buildChatConversationKey(ownerKey, DEFAULT_CHARACTER_ID)
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { SessionAsyncUtil } from "@/utils/session-storage";
|
|||||||
|
|
||||||
import { NavigationStorage } from "../navigation_storage";
|
import { NavigationStorage } from "../navigation_storage";
|
||||||
|
|
||||||
const CHARACTER_ID = "character_elio";
|
const CHARACTER_ID = "elio";
|
||||||
const OTHER_CHARACTER_ID = "character_maya";
|
const OTHER_CHARACTER_ID = "maya-tan";
|
||||||
|
|
||||||
describe("NavigationStorage", () => {
|
describe("NavigationStorage", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -164,4 +164,5 @@ describe("NavigationStorage", () => {
|
|||||||
NavigationStorage.consumePendingChatImageReturn(CHARACTER_ID),
|
NavigationStorage.consumePendingChatImageReturn(CHARACTER_ID),
|
||||||
).resolves.toMatchObject({ characterId: CHARACTER_ID });
|
).resolves.toMatchObject({ characterId: CHARACTER_ID });
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { z } from "zod";
|
|||||||
|
|
||||||
import { StorageKeys } from "@/data/storage/storage_keys";
|
import { StorageKeys } from "@/data/storage/storage_keys";
|
||||||
import { ChatLockTypeSchema } from "@/data/schemas/chat";
|
import { ChatLockTypeSchema } from "@/data/schemas/chat";
|
||||||
|
import { getCharacterById } from "@/data/constants/character";
|
||||||
import { Result } from "@/utils/result";
|
import { Result } from "@/utils/result";
|
||||||
import { SessionAsyncUtil } from "@/utils/session-storage";
|
import { SessionAsyncUtil } from "@/utils/session-storage";
|
||||||
|
|
||||||
@@ -254,28 +255,53 @@ export class NavigationStorage {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async clearPendingChatImageReturn(): Promise<void> {
|
||||||
|
await SessionAsyncUtil.remove(StorageKeys.pendingChatImageReturn);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async saveGuestChatOwnerKey(ownerKey: string): Promise<void> {
|
||||||
|
await SessionAsyncUtil.setJson(
|
||||||
|
StorageKeys.guestChatOwnerKey,
|
||||||
|
ownerKey,
|
||||||
|
z.string().min(1),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getGuestChatOwnerKey(): Promise<string | null> {
|
||||||
|
const result = await SessionAsyncUtil.getJson(
|
||||||
|
StorageKeys.guestChatOwnerKey,
|
||||||
|
z.string().min(1),
|
||||||
|
);
|
||||||
|
return Result.isOk(result) ? result.data : null;
|
||||||
|
}
|
||||||
|
|
||||||
private static parsePendingChatUnlock(
|
private static parsePendingChatUnlock(
|
||||||
value: PendingChatUnlock | null,
|
value: PendingChatUnlock | null,
|
||||||
): PendingChatUnlock | null {
|
): PendingChatUnlock | null {
|
||||||
if (!value) return null;
|
if (!value || Date.now() - value.createdAt > MAX_AGE_MS) return null;
|
||||||
if (Date.now() - value.createdAt > MAX_AGE_MS) return null;
|
if (!getCharacterById(value.characterId)) return null;
|
||||||
return value;
|
const promotion = value.promotion
|
||||||
|
? NavigationStorage.parsePendingChatPromotion(value.promotion)
|
||||||
|
: undefined;
|
||||||
|
if (value.promotion && !promotion) return null;
|
||||||
|
return PendingChatUnlockSchema.parse({
|
||||||
|
...value,
|
||||||
|
...(promotion ? { promotion } : {}),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private static parsePendingChatImageReturn(
|
private static parsePendingChatImageReturn(
|
||||||
value: PendingChatImageReturn | null,
|
value: PendingChatImageReturn | null,
|
||||||
): PendingChatImageReturn | null {
|
): PendingChatImageReturn | null {
|
||||||
if (!value) return null;
|
if (!value || Date.now() - value.createdAt > MAX_AGE_MS) return null;
|
||||||
if (Date.now() - value.createdAt > MAX_AGE_MS) return null;
|
return getCharacterById(value.characterId) ? value : null;
|
||||||
return value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static parsePendingChatPromotion(
|
private static parsePendingChatPromotion(
|
||||||
value: PendingChatPromotion | null,
|
value: PendingChatPromotion | null,
|
||||||
): PendingChatPromotion | null {
|
): PendingChatPromotion | null {
|
||||||
if (!value) return null;
|
if (!value || Date.now() - value.createdAt > MAX_AGE_MS) return null;
|
||||||
if (Date.now() - value.createdAt > MAX_AGE_MS) return null;
|
return getCharacterById(value.characterId) ? value : null;
|
||||||
return value;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export const StorageKeys = {
|
|||||||
pendingChatImageReturn: "pending_chat_image_return",
|
pendingChatImageReturn: "pending_chat_image_return",
|
||||||
pendingChatUnlock: "pending_chat_unlock",
|
pendingChatUnlock: "pending_chat_unlock",
|
||||||
pendingChatPromotion: "pending_chat_promotion",
|
pendingChatPromotion: "pending_chat_promotion",
|
||||||
|
guestChatOwnerKey: "guest_chat_owner_key",
|
||||||
|
|
||||||
// pwa / app info
|
// pwa / app info
|
||||||
pwaDialogShown: "pwa_dialog_shown",
|
pwaDialogShown: "pwa_dialog_shown",
|
||||||
|
|||||||
@@ -3,9 +3,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
import { Result } from "@/utils/result";
|
import { Result } from "@/utils/result";
|
||||||
|
|
||||||
const getHistoryMock = vi.hoisted(() => vi.fn());
|
const getHistoryMock = vi.hoisted(() => vi.fn());
|
||||||
|
const getPreviewsMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
vi.mock("@/data/repositories/chat_repository_loader", () => ({
|
vi.mock("@/data/repositories/chat_repository_loader", () => ({
|
||||||
loadChatRepository: async () => ({ getHistory: getHistoryMock }),
|
loadChatRepository: async () => ({
|
||||||
|
getHistory: getHistoryMock,
|
||||||
|
getPreviews: getPreviewsMock,
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -17,6 +21,7 @@ import { createSplashLatestMessageCache } from "../splash_latest_message_cache";
|
|||||||
describe("fetchSplashLatestMessagePreview", () => {
|
describe("fetchSplashLatestMessagePreview", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
getHistoryMock.mockReset();
|
getHistoryMock.mockReset();
|
||||||
|
getPreviewsMock.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses the last message returned by history", async () => {
|
it("uses the last message returned by history", async () => {
|
||||||
@@ -29,10 +34,10 @@ describe("fetchSplashLatestMessagePreview", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await fetchSplashLatestMessagePreview();
|
const result = await fetchSplashLatestMessagePreview("elio");
|
||||||
|
|
||||||
expect(getHistoryMock).toHaveBeenCalledWith(
|
expect(getHistoryMock).toHaveBeenCalledWith(
|
||||||
"character_elio",
|
"elio",
|
||||||
1,
|
1,
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
@@ -50,13 +55,44 @@ describe("fetchSplashLatestMessagePreview", () => {
|
|||||||
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: [] }));
|
||||||
|
|
||||||
const result = await fetchSplashLatestMessagePreview();
|
const result = await fetchSplashLatestMessagePreview("elio");
|
||||||
|
|
||||||
expect(Result.isOk(result) && result.data).toBeNull();
|
expect(Result.isOk(result) && result.data).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("loadSplashLatestMessagePreview", () => {
|
describe("loadSplashLatestMessagePreview", () => {
|
||||||
|
it("loads all character previews once and caches each conversation", async () => {
|
||||||
|
const cache = createSplashLatestMessageCache();
|
||||||
|
getPreviewsMock.mockResolvedValue(
|
||||||
|
Result.ok({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
characterId: "elio",
|
||||||
|
message: { type: "text", content: "Hello Elio" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
characterId: "maya-tan",
|
||||||
|
message: { type: "text", content: "Hello Maya" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await loadSplashLatestMessagePreview({
|
||||||
|
cache,
|
||||||
|
characterId: "maya-tan",
|
||||||
|
resolveIdentity: vi.fn().mockResolvedValue(Result.ok("user:maya")),
|
||||||
|
resolvePreviewIdentity: async (characterId) =>
|
||||||
|
Result.ok(`user:${characterId}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(Result.isOk(result) && result.data).toBe("Hello Maya");
|
||||||
|
expect(getPreviewsMock).toHaveBeenCalledOnce();
|
||||||
|
expect(cache.get("user:elio")?.message).toBe("Hello Elio");
|
||||||
|
expect(cache.get("user:maya-tan")?.message).toBe("Hello Maya");
|
||||||
|
});
|
||||||
|
|
||||||
it("reuses a successful result for the same identity", async () => {
|
it("reuses a successful result for the same identity", async () => {
|
||||||
const cache = createSplashLatestMessageCache();
|
const cache = createSplashLatestMessageCache();
|
||||||
const fetchPreview = vi.fn().mockResolvedValue(Result.ok("Hello again"));
|
const fetchPreview = vi.fn().mockResolvedValue(Result.ok("Hello again"));
|
||||||
@@ -64,11 +100,13 @@ describe("loadSplashLatestMessagePreview", () => {
|
|||||||
|
|
||||||
const first = await loadSplashLatestMessagePreview({
|
const first = await loadSplashLatestMessagePreview({
|
||||||
cache,
|
cache,
|
||||||
|
characterId: "elio",
|
||||||
fetchPreview,
|
fetchPreview,
|
||||||
resolveIdentity,
|
resolveIdentity,
|
||||||
});
|
});
|
||||||
const second = await loadSplashLatestMessagePreview({
|
const second = await loadSplashLatestMessagePreview({
|
||||||
cache,
|
cache,
|
||||||
|
characterId: "elio",
|
||||||
fetchPreview,
|
fetchPreview,
|
||||||
resolveIdentity,
|
resolveIdentity,
|
||||||
});
|
});
|
||||||
@@ -85,6 +123,7 @@ describe("loadSplashLatestMessagePreview", () => {
|
|||||||
|
|
||||||
const result = await loadSplashLatestMessagePreview({
|
const result = await loadSplashLatestMessagePreview({
|
||||||
cache,
|
cache,
|
||||||
|
characterId: "elio",
|
||||||
fetchPreview,
|
fetchPreview,
|
||||||
resolveIdentity: vi
|
resolveIdentity: vi
|
||||||
.fn()
|
.fn()
|
||||||
@@ -105,11 +144,13 @@ describe("loadSplashLatestMessagePreview", () => {
|
|||||||
|
|
||||||
await loadSplashLatestMessagePreview({
|
await loadSplashLatestMessagePreview({
|
||||||
cache,
|
cache,
|
||||||
|
characterId: "elio",
|
||||||
fetchPreview,
|
fetchPreview,
|
||||||
resolveIdentity,
|
resolveIdentity,
|
||||||
});
|
});
|
||||||
const retry = await loadSplashLatestMessagePreview({
|
const retry = await loadSplashLatestMessagePreview({
|
||||||
cache,
|
cache,
|
||||||
|
characterId: "elio",
|
||||||
fetchPreview,
|
fetchPreview,
|
||||||
resolveIdentity,
|
resolveIdentity,
|
||||||
});
|
});
|
||||||
@@ -127,11 +168,13 @@ describe("loadSplashLatestMessagePreview", () => {
|
|||||||
|
|
||||||
await loadSplashLatestMessagePreview({
|
await loadSplashLatestMessagePreview({
|
||||||
cache,
|
cache,
|
||||||
|
characterId: "elio",
|
||||||
fetchPreview,
|
fetchPreview,
|
||||||
resolveIdentity,
|
resolveIdentity,
|
||||||
});
|
});
|
||||||
await loadSplashLatestMessagePreview({
|
await loadSplashLatestMessagePreview({
|
||||||
cache,
|
cache,
|
||||||
|
characterId: "elio",
|
||||||
fetchPreview,
|
fetchPreview,
|
||||||
resolveIdentity,
|
resolveIdentity,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { ChatMediaKind } from "@/data/schemas/chat";
|
import type { ChatMediaKind } from "@/data/schemas/chat";
|
||||||
|
|
||||||
const CHARACTER_NAMESPACE = "::character:";
|
const CHARACTER_NAMESPACE = "::character:";
|
||||||
const LEGACY_OWNER_KEY_PATTERN = /^(?:user|device):.+$/u;
|
|
||||||
|
|
||||||
export function buildChatConversationKey(
|
export function buildChatConversationKey(
|
||||||
ownerKey: string,
|
ownerKey: string,
|
||||||
@@ -20,10 +19,3 @@ export function buildChatMediaCacheKey(input: {
|
|||||||
}): string {
|
}): string {
|
||||||
return `${input.ownerKey}:${input.messageId}:${input.kind}`;
|
return `${input.ownerKey}:${input.messageId}:${input.kind}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isLegacyChatCacheOwnerKey(ownerKey: string): boolean {
|
|
||||||
return (
|
|
||||||
!ownerKey.includes(CHARACTER_NAMESPACE) &&
|
|
||||||
LEGACY_OWNER_KEY_PATTERN.test(ownerKey)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
resolveChatConversationKey,
|
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";
|
||||||
@@ -11,13 +10,26 @@ import { getLatestSplashMessagePreview } from "./splash_latest_message_preview";
|
|||||||
|
|
||||||
export interface LoadSplashLatestMessagePreviewInput {
|
export interface LoadSplashLatestMessagePreviewInput {
|
||||||
cache: SplashLatestMessageCache;
|
cache: SplashLatestMessageCache;
|
||||||
characterId?: string;
|
characterId: string;
|
||||||
fetchPreview?: (characterId: string) => Promise<ResultT<string | null>>;
|
fetchPreview?: (characterId: string) => Promise<ResultT<string | null>>;
|
||||||
|
fetchPreviews?: () => Promise<
|
||||||
|
ResultT<
|
||||||
|
readonly {
|
||||||
|
characterId: string;
|
||||||
|
message: import("@/data/schemas/chat").ChatMessage | null;
|
||||||
|
}[]
|
||||||
|
>
|
||||||
|
>;
|
||||||
resolveIdentity?: ChatCacheIdentityResolver;
|
resolveIdentity?: ChatCacheIdentityResolver;
|
||||||
|
resolvePreviewIdentity?: ChatConversationIdentityResolver;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ChatConversationIdentityResolver = (
|
||||||
|
characterId: string,
|
||||||
|
) => Promise<ResultT<string>>;
|
||||||
|
|
||||||
export async function resolveSplashLatestMessageCacheIdentity(
|
export async function resolveSplashLatestMessageCacheIdentity(
|
||||||
characterId = DEFAULT_CHARACTER_ID,
|
characterId: string,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const result = await resolveChatConversationKey(characterId);
|
const result = await resolveChatConversationKey(characterId);
|
||||||
return Result.isOk(result) ? result.data : null;
|
return Result.isOk(result) ? result.data : null;
|
||||||
@@ -25,9 +37,11 @@ export async function resolveSplashLatestMessageCacheIdentity(
|
|||||||
|
|
||||||
export async function loadSplashLatestMessagePreview({
|
export async function loadSplashLatestMessagePreview({
|
||||||
cache,
|
cache,
|
||||||
characterId = DEFAULT_CHARACTER_ID,
|
characterId,
|
||||||
fetchPreview = fetchSplashLatestMessagePreview,
|
fetchPreview = fetchSplashLatestMessagePreview,
|
||||||
|
fetchPreviews = fetchSplashLatestMessagePreviews,
|
||||||
resolveIdentity = () => resolveChatConversationKey(characterId),
|
resolveIdentity = () => resolveChatConversationKey(characterId),
|
||||||
|
resolvePreviewIdentity = resolveChatConversationKey,
|
||||||
}: 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;
|
||||||
@@ -37,15 +51,67 @@ export async function loadSplashLatestMessagePreview({
|
|||||||
if (cached) return Result.ok(cached.message);
|
if (cached) return Result.ok(cached.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await fetchPreview(characterId);
|
const result =
|
||||||
|
fetchPreview !== fetchSplashLatestMessagePreview
|
||||||
|
? await fetchPreview(characterId)
|
||||||
|
: await loadBatchPreview({
|
||||||
|
cache,
|
||||||
|
characterId,
|
||||||
|
fetchPreviews,
|
||||||
|
resolvePreviewIdentity,
|
||||||
|
});
|
||||||
if (identity && Result.isOk(result)) {
|
if (identity && Result.isOk(result)) {
|
||||||
cache.set(identity, result.data);
|
cache.set(identity, result.data);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadBatchPreview(input: {
|
||||||
|
cache: SplashLatestMessageCache;
|
||||||
|
characterId: string;
|
||||||
|
fetchPreviews: NonNullable<LoadSplashLatestMessagePreviewInput["fetchPreviews"]>;
|
||||||
|
resolvePreviewIdentity: ChatConversationIdentityResolver;
|
||||||
|
}): Promise<ResultT<string | null>> {
|
||||||
|
const previews = await input.fetchPreviews();
|
||||||
|
if (Result.isErr(previews)) {
|
||||||
|
return fetchSplashLatestMessagePreview(input.characterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
previews.data.map(async (item) => {
|
||||||
|
const identity = await input.resolvePreviewIdentity(item.characterId);
|
||||||
|
if (Result.isErr(identity)) return;
|
||||||
|
input.cache.set(
|
||||||
|
identity.data,
|
||||||
|
item.message ? getLatestSplashMessagePreview([item.message]) : null,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const selected = previews.data.find(
|
||||||
|
(item) => item.characterId === input.characterId,
|
||||||
|
);
|
||||||
|
return Result.ok(
|
||||||
|
selected?.message
|
||||||
|
? getLatestSplashMessagePreview([selected.message])
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSplashLatestMessagePreviews(): Promise<
|
||||||
|
ResultT<
|
||||||
|
readonly {
|
||||||
|
characterId: string;
|
||||||
|
message: import("@/data/schemas/chat").ChatMessage | null;
|
||||||
|
}[]
|
||||||
|
>
|
||||||
|
> {
|
||||||
|
const repository = await loadChatRepository();
|
||||||
|
const result = await repository.getPreviews();
|
||||||
|
return Result.isErr(result) ? Result.err(result.error) : Result.ok(result.data.items);
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchSplashLatestMessagePreview(
|
export async function fetchSplashLatestMessagePreview(
|
||||||
characterId = DEFAULT_CHARACTER_ID,
|
characterId: string,
|
||||||
): Promise<ResultT<string | null>> {
|
): Promise<ResultT<string | null>> {
|
||||||
const repository = await loadChatRepository();
|
const repository = await loadChatRepository();
|
||||||
const result = await repository.getHistory(characterId, 1, 0);
|
const result = await repository.getHistory(characterId, 1, 0);
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ describe("subscription exit helpers", () => {
|
|||||||
it("uses explicit chat image return urls first", async () => {
|
it("uses explicit chat image return urls first", async () => {
|
||||||
consumePendingChatImageReturnMock.mockResolvedValue({
|
consumePendingChatImageReturnMock.mockResolvedValue({
|
||||||
reason: "image_paywall",
|
reason: "image_paywall",
|
||||||
characterId: "character_elio",
|
characterId: "elio",
|
||||||
messageId: "msg_1",
|
messageId: "msg_1",
|
||||||
returnUrl: "/chat?image=msg_1",
|
returnUrl: "/chat?image=msg_1",
|
||||||
createdAt: 1,
|
createdAt: 1,
|
||||||
@@ -74,14 +74,14 @@ describe("subscription exit helpers", () => {
|
|||||||
it("returns directly to private room without consuming chat state", async () => {
|
it("returns directly to private room without consuming chat state", async () => {
|
||||||
consumePendingChatImageReturnMock.mockResolvedValue({
|
consumePendingChatImageReturnMock.mockResolvedValue({
|
||||||
reason: "image_paywall",
|
reason: "image_paywall",
|
||||||
characterId: "character_elio",
|
characterId: "elio",
|
||||||
messageId: "msg_1",
|
messageId: "msg_1",
|
||||||
returnUrl: "/chat?image=msg_1",
|
returnUrl: "/chat?image=msg_1",
|
||||||
createdAt: 1,
|
createdAt: 1,
|
||||||
});
|
});
|
||||||
peekPendingChatUnlockMock.mockResolvedValue({
|
peekPendingChatUnlockMock.mockResolvedValue({
|
||||||
reason: "single_message_unlock",
|
reason: "single_message_unlock",
|
||||||
characterId: "character_elio",
|
characterId: "elio",
|
||||||
displayMessageId: "msg_1",
|
displayMessageId: "msg_1",
|
||||||
messageId: "msg_1",
|
messageId: "msg_1",
|
||||||
kind: "image",
|
kind: "image",
|
||||||
@@ -104,7 +104,7 @@ describe("subscription exit helpers", () => {
|
|||||||
it("keeps private room ahead of stale chat state after payment", async () => {
|
it("keeps private room ahead of stale chat state after payment", async () => {
|
||||||
peekPendingChatUnlockMock.mockResolvedValue({
|
peekPendingChatUnlockMock.mockResolvedValue({
|
||||||
reason: "single_message_unlock",
|
reason: "single_message_unlock",
|
||||||
characterId: "character_elio",
|
characterId: "elio",
|
||||||
displayMessageId: "msg_1",
|
displayMessageId: "msg_1",
|
||||||
messageId: "msg_1",
|
messageId: "msg_1",
|
||||||
kind: "image",
|
kind: "image",
|
||||||
@@ -123,7 +123,7 @@ describe("subscription exit helpers", () => {
|
|||||||
it("keeps pending chat unlocks ahead of chat fallback after payment", async () => {
|
it("keeps pending chat unlocks ahead of chat fallback after payment", async () => {
|
||||||
peekPendingChatUnlockMock.mockResolvedValue({
|
peekPendingChatUnlockMock.mockResolvedValue({
|
||||||
reason: "single_message_unlock",
|
reason: "single_message_unlock",
|
||||||
characterId: "character_elio",
|
characterId: "elio",
|
||||||
displayMessageId: "msg_1",
|
displayMessageId: "msg_1",
|
||||||
messageId: "msg_1",
|
messageId: "msg_1",
|
||||||
kind: "image",
|
kind: "image",
|
||||||
@@ -141,7 +141,7 @@ describe("subscription exit helpers", () => {
|
|||||||
it("peeks chat unlock return urls without clearing them", async () => {
|
it("peeks chat unlock return urls without clearing them", async () => {
|
||||||
peekPendingChatUnlockMock.mockResolvedValue({
|
peekPendingChatUnlockMock.mockResolvedValue({
|
||||||
reason: "single_message_unlock",
|
reason: "single_message_unlock",
|
||||||
characterId: "character_elio",
|
characterId: "elio",
|
||||||
displayMessageId: "msg_1",
|
displayMessageId: "msg_1",
|
||||||
messageId: "msg_1",
|
messageId: "msg_1",
|
||||||
kind: "image",
|
kind: "image",
|
||||||
@@ -160,7 +160,7 @@ describe("subscription exit helpers", () => {
|
|||||||
consumePendingChatImageReturnMock.mockResolvedValue(null);
|
consumePendingChatImageReturnMock.mockResolvedValue(null);
|
||||||
peekPendingChatUnlockMock.mockResolvedValue({
|
peekPendingChatUnlockMock.mockResolvedValue({
|
||||||
reason: "single_message_unlock",
|
reason: "single_message_unlock",
|
||||||
characterId: "character_elio",
|
characterId: "elio",
|
||||||
displayMessageId: "msg_1",
|
displayMessageId: "msg_1",
|
||||||
messageId: "msg_1",
|
messageId: "msg_1",
|
||||||
kind: "image",
|
kind: "image",
|
||||||
|
|||||||
@@ -67,3 +67,11 @@ export async function consumePendingChatPromotion(
|
|||||||
export async function clearPendingChatPromotion(): Promise<void> {
|
export async function clearPendingChatPromotion(): Promise<void> {
|
||||||
await NavigationStorage.clearPendingChatPromotion();
|
await NavigationStorage.clearPendingChatPromotion();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function clearPendingChatNavigation(): Promise<void> {
|
||||||
|
await Promise.all([
|
||||||
|
NavigationStorage.clearPendingChatUnlock(),
|
||||||
|
NavigationStorage.clearPendingChatPromotion(),
|
||||||
|
NavigationStorage.clearPendingChatImageReturn(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|||||||
@@ -108,36 +108,36 @@ describe("character actor route providers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("recreates chat and chat payment actors when the character changes", () => {
|
it("recreates chat and chat payment actors when the character changes", () => {
|
||||||
renderChat("character_elio");
|
renderChat("elio");
|
||||||
const firstChatInstance = instanceId("chat-provider");
|
const firstChatInstance = instanceId("chat-provider");
|
||||||
const firstPaymentInstance = instanceId("payment-provider");
|
const firstPaymentInstance = instanceId("payment-provider");
|
||||||
|
|
||||||
renderChat("character_maya");
|
renderChat("maya-tan");
|
||||||
|
|
||||||
expect(instanceId("chat-provider")).not.toBe(firstChatInstance);
|
expect(instanceId("chat-provider")).not.toBe(firstChatInstance);
|
||||||
expect(instanceId("payment-provider")).not.toBe(firstPaymentInstance);
|
expect(instanceId("payment-provider")).not.toBe(firstPaymentInstance);
|
||||||
expect(testNode("chat-provider").dataset.characterId).toBe(
|
expect(testNode("chat-provider").dataset.characterId).toBe(
|
||||||
"character_maya",
|
"maya-tan",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("recreates the private-room actor when the character changes", () => {
|
it("recreates the private-room actor when the character changes", () => {
|
||||||
renderPrivateRoom("character_elio");
|
renderPrivateRoom("elio");
|
||||||
const firstInstance = instanceId("private-room-provider");
|
const firstInstance = instanceId("private-room-provider");
|
||||||
|
|
||||||
renderPrivateRoom("character_maya");
|
renderPrivateRoom("maya-tan");
|
||||||
|
|
||||||
expect(instanceId("private-room-provider")).not.toBe(firstInstance);
|
expect(instanceId("private-room-provider")).not.toBe(firstInstance);
|
||||||
expect(testNode("private-room-provider").dataset.characterId).toBe(
|
expect(testNode("private-room-provider").dataset.characterId).toBe(
|
||||||
"character_maya",
|
"maya-tan",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("recreates the route payment actor when its character scope changes", () => {
|
it("recreates the route payment actor when its character scope changes", () => {
|
||||||
renderPayment("character_elio");
|
renderPayment("elio");
|
||||||
const firstInstance = instanceId("payment-provider");
|
const firstInstance = instanceId("payment-provider");
|
||||||
|
|
||||||
renderPayment("character_maya");
|
renderPayment("maya-tan");
|
||||||
|
|
||||||
expect(instanceId("payment-provider")).not.toBe(firstInstance);
|
expect(instanceId("payment-provider")).not.toBe(firstInstance);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { CHARACTERS, createCharacterCatalog } from "@/data/constants/character";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
replace: vi.fn(),
|
||||||
|
catalog: null as unknown as ReturnType<
|
||||||
|
typeof import("@/providers/character-catalog-provider").useCharacterCatalog
|
||||||
|
>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
useRouter: () => ({ replace: mocks.replace }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/providers/character-catalog-provider", () => ({
|
||||||
|
useCharacterCatalog: () => mocks.catalog,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { CharacterRouteBoundary } from "../character-route-boundary";
|
||||||
|
|
||||||
|
describe("CharacterRouteBoundary", () => {
|
||||||
|
let root: Root;
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.append(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
mocks.replace.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
act(() => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("waits for the catalog before mounting character content", () => {
|
||||||
|
mocks.catalog = catalogValue("loading", CHARACTERS);
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<CharacterRouteBoundary character={CHARACTERS[0]}>
|
||||||
|
<span>Chat actor mounted</span>
|
||||||
|
</CharacterRouteBoundary>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.textContent).not.toContain("Chat actor mounted");
|
||||||
|
expect(container.querySelector('[role="status"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects an unavailable character to the default splash", () => {
|
||||||
|
mocks.catalog = catalogValue("ready", [CHARACTERS[0]]);
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<CharacterRouteBoundary character={CHARACTERS[1]}>
|
||||||
|
<span>Maya</span>
|
||||||
|
</CharacterRouteBoundary>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.replace).toHaveBeenCalledWith("/characters/elio/splash");
|
||||||
|
expect(container.textContent).not.toContain("Maya");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function catalogValue(
|
||||||
|
status: "loading" | "ready" | "refreshing",
|
||||||
|
characters: typeof CHARACTERS,
|
||||||
|
) {
|
||||||
|
const catalog = createCharacterCatalog(characters);
|
||||||
|
return {
|
||||||
|
...catalog,
|
||||||
|
status,
|
||||||
|
defaultCharacter: CHARACTERS[0],
|
||||||
|
isFallback: false,
|
||||||
|
refresh: async () => undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,19 +2,36 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
useContext,
|
useContext,
|
||||||
useMemo,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CHARACTERS,
|
CHARACTERS,
|
||||||
createCharacterCatalog,
|
createCharacterCatalog,
|
||||||
|
DEFAULT_CHARACTER,
|
||||||
|
mergeRemoteCharacterCatalog,
|
||||||
type CharacterCatalog,
|
type CharacterCatalog,
|
||||||
type CharacterProfile,
|
type CharacterProfile,
|
||||||
} from "@/data/constants/character";
|
} from "@/data/constants/character";
|
||||||
|
import { getCharacterRepository } from "@/data/repositories/character_repository";
|
||||||
|
import { AppEnvUtil } from "@/utils/app-env";
|
||||||
|
import { Result } from "@/utils/result";
|
||||||
|
|
||||||
const CharacterCatalogContext = createContext<CharacterCatalog | null>(null);
|
export type CharacterCatalogStatus = "loading" | "ready" | "refreshing";
|
||||||
|
|
||||||
|
export interface CharacterCatalogContextValue extends CharacterCatalog {
|
||||||
|
readonly status: CharacterCatalogStatus;
|
||||||
|
readonly defaultCharacter: CharacterProfile;
|
||||||
|
readonly isFallback: boolean;
|
||||||
|
refresh(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CharacterCatalogContext =
|
||||||
|
createContext<CharacterCatalogContextValue | null>(null);
|
||||||
|
|
||||||
export interface CharacterCatalogProviderProps {
|
export interface CharacterCatalogProviderProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -25,18 +42,74 @@ export function CharacterCatalogProvider({
|
|||||||
children,
|
children,
|
||||||
characters = CHARACTERS,
|
characters = CHARACTERS,
|
||||||
}: CharacterCatalogProviderProps) {
|
}: CharacterCatalogProviderProps) {
|
||||||
const catalog = useMemo(
|
const fallbackCharacters = AppEnvUtil.isProduction()
|
||||||
() => createCharacterCatalog(characters),
|
? characters.filter((character) => character.id === DEFAULT_CHARACTER.id)
|
||||||
[characters],
|
: characters;
|
||||||
);
|
const [state, setState] = useState(() => ({
|
||||||
|
catalog: createCharacterCatalog(fallbackCharacters),
|
||||||
|
defaultCharacter: DEFAULT_CHARACTER,
|
||||||
|
status: "loading" as CharacterCatalogStatus,
|
||||||
|
isFallback: true,
|
||||||
|
hasRemoteSnapshot: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
setState((current) => ({
|
||||||
|
...current,
|
||||||
|
status: current.hasRemoteSnapshot ? "refreshing" : "loading",
|
||||||
|
}));
|
||||||
|
const result = await getCharacterRepository().getChatCharacters();
|
||||||
|
if (Result.isErr(result)) {
|
||||||
|
setState((current) => ({ ...current, status: "ready" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const snapshot = mergeRemoteCharacterCatalog(result.data, characters);
|
||||||
|
setState({
|
||||||
|
...snapshot,
|
||||||
|
status: "ready",
|
||||||
|
isFallback: false,
|
||||||
|
hasRemoteSnapshot: true,
|
||||||
|
});
|
||||||
|
}, [characters]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
void getCharacterRepository()
|
||||||
|
.getChatCharacters()
|
||||||
|
.then((result) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (Result.isErr(result)) {
|
||||||
|
setState((current) => ({ ...current, status: "ready" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const snapshot = mergeRemoteCharacterCatalog(result.data, characters);
|
||||||
|
setState({
|
||||||
|
...snapshot,
|
||||||
|
status: "ready",
|
||||||
|
isFallback: false,
|
||||||
|
hasRemoteSnapshot: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [characters]);
|
||||||
|
|
||||||
|
const value: CharacterCatalogContextValue = {
|
||||||
|
...state.catalog,
|
||||||
|
status: state.status,
|
||||||
|
defaultCharacter: state.defaultCharacter,
|
||||||
|
isFallback: state.isFallback,
|
||||||
|
refresh,
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<CharacterCatalogContext.Provider value={catalog}>
|
<CharacterCatalogContext.Provider value={value}>
|
||||||
{children}
|
{children}
|
||||||
</CharacterCatalogContext.Provider>
|
</CharacterCatalogContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCharacterCatalog(): CharacterCatalog {
|
export function useCharacterCatalog(): CharacterCatalogContextValue {
|
||||||
const catalog = useContext(CharacterCatalogContext);
|
const catalog = useContext(CharacterCatalogContext);
|
||||||
if (!catalog) {
|
if (!catalog) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { type ReactNode, useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
import type { CharacterProfile } from "@/data/constants/character";
|
||||||
|
import { getCharacterRoutes } from "@/router/routes";
|
||||||
|
|
||||||
|
import { useCharacterCatalog } from "./character-catalog-provider";
|
||||||
|
import { CharacterProvider } from "./character-provider";
|
||||||
|
|
||||||
|
export function CharacterRouteBoundary({
|
||||||
|
character,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
character: CharacterProfile;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const catalog = useCharacterCatalog();
|
||||||
|
const router = useRouter();
|
||||||
|
const activeCharacter = catalog.getById(character.id);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (catalog.status === "loading" || activeCharacter) return;
|
||||||
|
router.replace(getCharacterRoutes(catalog.defaultCharacter.slug).splash);
|
||||||
|
}, [activeCharacter, catalog.defaultCharacter.slug, catalog.status, router]);
|
||||||
|
|
||||||
|
if (catalog.status === "loading" || !activeCharacter) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex min-h-dvh flex-1 items-center justify-center bg-[#fbf1f2]"
|
||||||
|
aria-label="Loading character"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
<div className="size-9 animate-spin rounded-full border-4 border-[#f657a0] border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CharacterProvider character={activeCharacter}>
|
||||||
|
{children}
|
||||||
|
</CharacterProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@ vi.mock("@/data/repositories/chat_repository_loader", () => ({
|
|||||||
vi.mock("@/data/repositories/chat_cache_identity", () => ({
|
vi.mock("@/data/repositories/chat_cache_identity", () => ({
|
||||||
resolveChatConversationKey: vi.fn(async () => ({
|
resolveChatConversationKey: vi.fn(async () => ({
|
||||||
success: true,
|
success: true,
|
||||||
data: "user:test::character:character_elio",
|
data: "user:test::character:elio",
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ describe("chat actor request cancellation", () => {
|
|||||||
it("aborts an in-flight send request when its actor stops", async () => {
|
it("aborts an in-flight send request when its actor stops", async () => {
|
||||||
repository.sendMessage.mockImplementation(() => new Promise(() => undefined));
|
repository.sendMessage.mockImplementation(() => new Promise(() => undefined));
|
||||||
const actor = createActor(sendMessageHttpActor, {
|
const actor = createActor(sendMessageHttpActor, {
|
||||||
input: { characterId: "character_elio", content: "hello" },
|
input: { characterId: "elio", content: "hello" },
|
||||||
}).start();
|
}).start();
|
||||||
await vi.waitFor(() => expect(repository.sendMessage).toHaveBeenCalled());
|
await vi.waitFor(() => expect(repository.sendMessage).toHaveBeenCalled());
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ describe("chat actor request cancellation", () => {
|
|||||||
);
|
);
|
||||||
const actor = createActor(loadHistoryActor, {
|
const actor = createActor(loadHistoryActor, {
|
||||||
input: {
|
input: {
|
||||||
characterId: "character_elio",
|
characterId: "elio",
|
||||||
emptyChatGreeting: "Hello from Elio",
|
emptyChatGreeting: "Hello from Elio",
|
||||||
},
|
},
|
||||||
}).start();
|
}).start();
|
||||||
@@ -85,7 +85,7 @@ describe("chat actor request cancellation", () => {
|
|||||||
);
|
);
|
||||||
const actor = createActor(unlockHistoryActor, {
|
const actor = createActor(unlockHistoryActor, {
|
||||||
input: {
|
input: {
|
||||||
characterId: "character_elio",
|
characterId: "elio",
|
||||||
emptyChatGreeting: "Hello from Elio",
|
emptyChatGreeting: "Hello from Elio",
|
||||||
},
|
},
|
||||||
}).start();
|
}).start();
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ function makeResponse(
|
|||||||
function makeChatState(overrides: Partial<ChatState> = {}): ChatState {
|
function makeChatState(overrides: Partial<ChatState> = {}): ChatState {
|
||||||
return {
|
return {
|
||||||
characterId: DEFAULT_CHARACTER_ID,
|
characterId: DEFAULT_CHARACTER_ID,
|
||||||
|
characterErrorCode: null,
|
||||||
emptyChatGreeting: DEFAULT_CHARACTER.emptyChatGreeting,
|
emptyChatGreeting: DEFAULT_CHARACTER.emptyChatGreeting,
|
||||||
messages: [],
|
messages: [],
|
||||||
promotion: null,
|
promotion: null,
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ export function createTestChatMachine(
|
|||||||
unlockHistoryOutput?: UnlockHistoryOutput;
|
unlockHistoryOutput?: UnlockHistoryOutput;
|
||||||
unlockHistoryError?: Error;
|
unlockHistoryError?: Error;
|
||||||
unlockMessageOutput?: TestUnlockMessageOutput;
|
unlockMessageOutput?: TestUnlockMessageOutput;
|
||||||
|
unlockMessageError?: Error;
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
return chatMachine.provide({
|
return chatMachine.provide({
|
||||||
@@ -134,6 +135,7 @@ export function createTestChatMachine(
|
|||||||
MachineUnlockMessageOutput,
|
MachineUnlockMessageOutput,
|
||||||
UnlockMessageRequest & { characterId: string }
|
UnlockMessageRequest & { characterId: string }
|
||||||
>(async ({ input }) => {
|
>(async ({ input }) => {
|
||||||
|
if (options.unlockMessageError) throw options.unlockMessageError;
|
||||||
const output = options.unlockMessageOutput ?? {
|
const output = options.unlockMessageOutput ?? {
|
||||||
messageId: input.messageId ?? input.displayMessageId,
|
messageId: input.messageId ?? input.displayMessageId,
|
||||||
response: makeUnlockPrivateResponse(),
|
response: makeUnlockPrivateResponse(),
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
} from "@/stores/chat/helper/promotion";
|
} from "@/stores/chat/helper/promotion";
|
||||||
|
|
||||||
const promotion: PendingChatPromotion = {
|
const promotion: PendingChatPromotion = {
|
||||||
characterId: "character_elio",
|
characterId: "elio",
|
||||||
promotionType: "image",
|
promotionType: "image",
|
||||||
lockType: "image_paywall",
|
lockType: "image_paywall",
|
||||||
clientLockId: "promotion-1",
|
clientLockId: "promotion-1",
|
||||||
|
|||||||
@@ -10,13 +10,13 @@ describe("chat session flow", () => {
|
|||||||
it("initializes the conversation from the supplied character", () => {
|
it("initializes the conversation from the supplied character", () => {
|
||||||
const actor = createActor(createTestChatMachine(), {
|
const actor = createActor(createTestChatMachine(), {
|
||||||
input: {
|
input: {
|
||||||
characterId: "character_maya",
|
characterId: "maya-tan",
|
||||||
emptyChatGreeting: "Hello from Maya",
|
emptyChatGreeting: "Hello from Maya",
|
||||||
},
|
},
|
||||||
}).start();
|
}).start();
|
||||||
|
|
||||||
expect(actor.getSnapshot().context).toMatchObject({
|
expect(actor.getSnapshot().context).toMatchObject({
|
||||||
characterId: "character_maya",
|
characterId: "maya-tan",
|
||||||
emptyChatGreeting: "Hello from Maya",
|
emptyChatGreeting: "Hello from Maya",
|
||||||
});
|
});
|
||||||
actor.stop();
|
actor.stop();
|
||||||
@@ -25,7 +25,7 @@ describe("chat session flow", () => {
|
|||||||
it("keeps the active character greeting across login state changes", async () => {
|
it("keeps the active character greeting across login state changes", async () => {
|
||||||
const actor = createActor(createTestChatMachine(), {
|
const actor = createActor(createTestChatMachine(), {
|
||||||
input: {
|
input: {
|
||||||
characterId: "character_maya",
|
characterId: "maya-tan",
|
||||||
emptyChatGreeting: "Hello from Maya",
|
emptyChatGreeting: "Hello from Maya",
|
||||||
},
|
},
|
||||||
}).start();
|
}).start();
|
||||||
@@ -113,7 +113,7 @@ describe("chat session flow", () => {
|
|||||||
actor.send({
|
actor.send({
|
||||||
type: "ChatPromotionInjected",
|
type: "ChatPromotionInjected",
|
||||||
promotion: {
|
promotion: {
|
||||||
characterId: "character_elio",
|
characterId: "elio",
|
||||||
promotionType: "voice",
|
promotionType: "voice",
|
||||||
lockType: "voice_message",
|
lockType: "voice_message",
|
||||||
clientLockId: "promotion-1",
|
clientLockId: "promotion-1",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { createActor, fromPromise, waitFor } from "xstate";
|
import { createActor, fromPromise, waitFor } from "xstate";
|
||||||
|
|
||||||
import { ChatSendResponseSchema } from "@/data/schemas/chat";
|
import { ChatSendResponseSchema } from "@/data/schemas/chat";
|
||||||
|
import { ApiError } from "@/data/services/api";
|
||||||
import type {
|
import type {
|
||||||
UnlockMessageOutput,
|
UnlockMessageOutput,
|
||||||
UnlockMessageRequest,
|
UnlockMessageRequest,
|
||||||
@@ -565,6 +566,50 @@ describe("chat unlock flow", () => {
|
|||||||
actor.stop();
|
actor.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("refreshes history instead of opening payment on character mismatch", async () => {
|
||||||
|
const actor = createActor(
|
||||||
|
createTestChatMachine({
|
||||||
|
historyMessages: [
|
||||||
|
{
|
||||||
|
id: "msg-mismatch",
|
||||||
|
content: "",
|
||||||
|
isFromAI: true,
|
||||||
|
date: "2026-07-20",
|
||||||
|
locked: true,
|
||||||
|
lockReason: "voice_message",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
unlockMessageError: new ApiError("HTTP_ERROR", "Mismatch", 409, {
|
||||||
|
detail: { errorCode: "CHARACTER_MISMATCH" },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
{ input: TEST_CHAT_MACHINE_INPUT },
|
||||||
|
).start();
|
||||||
|
|
||||||
|
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||||
|
await waitFor(actor, (snapshot) =>
|
||||||
|
snapshot.matches({ userSession: "ready" }),
|
||||||
|
);
|
||||||
|
actor.send({
|
||||||
|
type: "ChatUnlockMessageRequested",
|
||||||
|
messageId: "msg-mismatch",
|
||||||
|
kind: "voice",
|
||||||
|
});
|
||||||
|
await waitFor(
|
||||||
|
actor,
|
||||||
|
(snapshot) =>
|
||||||
|
snapshot.context.characterErrorCode === "CHARACTER_MISMATCH",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(actor.getSnapshot().context.unlockPaywallRequest).toBeNull();
|
||||||
|
actor.send({ type: "ChatHistoryRefreshRequested" });
|
||||||
|
await waitFor(
|
||||||
|
actor,
|
||||||
|
(snapshot) => snapshot.context.characterErrorCode === null,
|
||||||
|
);
|
||||||
|
actor.stop();
|
||||||
|
});
|
||||||
|
|
||||||
it("owns the active request in one object and ignores history refreshes while unlocking", async () => {
|
it("owns the active request in one object and ignores history refreshes while unlocking", async () => {
|
||||||
let capturedRequest: UnlockMessageRequest | null = null;
|
let capturedRequest: UnlockMessageRequest | null = null;
|
||||||
let resolveUnlock!: (output: UnlockMessageOutput) => void;
|
let resolveUnlock!: (output: UnlockMessageOutput) => void;
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { Result } from "@/utils/result";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
getDeviceId: vi.fn(),
|
||||||
|
getLocalMessages: vi.fn(),
|
||||||
|
syncGuestHistory: vi.fn(),
|
||||||
|
clearLocalMessages: vi.fn(),
|
||||||
|
getGuestChatOwnerKey: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/data/storage/auth", () => ({
|
||||||
|
AuthStorage: {
|
||||||
|
getInstance: () => ({ getDeviceId: mocks.getDeviceId }),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/data/repositories/chat_repository_loader", () => ({
|
||||||
|
loadChatRepository: async () => ({
|
||||||
|
getLocalMessages: mocks.getLocalMessages,
|
||||||
|
syncGuestHistory: mocks.syncGuestHistory,
|
||||||
|
clearLocalMessages: mocks.clearLocalMessages,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/data/storage/navigation", () => ({
|
||||||
|
NavigationStorage: {
|
||||||
|
getGuestChatOwnerKey: mocks.getGuestChatOwnerKey,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { syncGuestHistoriesToUser } from "../guest-history-sync";
|
||||||
|
|
||||||
|
describe("syncGuestHistoriesToUser", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mocks.getDeviceId.mockResolvedValue(Result.ok("device-1"));
|
||||||
|
mocks.getGuestChatOwnerKey.mockResolvedValue(null);
|
||||||
|
mocks.clearLocalMessages.mockResolvedValue(Result.ok(undefined));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("groups guest history by character and clears only successful caches", async () => {
|
||||||
|
mocks.getLocalMessages.mockImplementation(async (identity: string) =>
|
||||||
|
Result.ok([
|
||||||
|
{
|
||||||
|
id: `${identity}:message`,
|
||||||
|
role: "user",
|
||||||
|
type: "text",
|
||||||
|
content: identity,
|
||||||
|
createdAt: "2026-07-20T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
mocks.syncGuestHistory.mockImplementation(
|
||||||
|
async (request: { characterId: string }) =>
|
||||||
|
request.characterId === "elio"
|
||||||
|
? Result.ok(undefined)
|
||||||
|
: Result.err(new Error("offline")),
|
||||||
|
);
|
||||||
|
|
||||||
|
await syncGuestHistoriesToUser(["elio", "maya-tan"]);
|
||||||
|
|
||||||
|
expect(mocks.syncGuestHistory).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mocks.syncGuestHistory).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ characterId: "elio" }),
|
||||||
|
);
|
||||||
|
expect(mocks.syncGuestHistory).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ characterId: "maya-tan" }),
|
||||||
|
);
|
||||||
|
expect(mocks.clearLocalMessages).toHaveBeenCalledOnce();
|
||||||
|
expect(mocks.clearLocalMessages).toHaveBeenCalledWith(
|
||||||
|
"device:device-1::character:elio",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips characters without guest messages", async () => {
|
||||||
|
mocks.getLocalMessages.mockResolvedValue(Result.ok([]));
|
||||||
|
|
||||||
|
await syncGuestHistoriesToUser(["elio"]);
|
||||||
|
|
||||||
|
expect(mocks.syncGuestHistory).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,11 +16,13 @@ import { appendPromotionMessage } from "./helper/promotion";
|
|||||||
*/
|
*/
|
||||||
interface ChatState {
|
interface ChatState {
|
||||||
characterId: string;
|
characterId: string;
|
||||||
|
characterErrorCode: MachineContext["characterErrorCode"];
|
||||||
messages: MachineContext["messages"];
|
messages: MachineContext["messages"];
|
||||||
historyMessages: MachineContext["messages"];
|
historyMessages: MachineContext["messages"];
|
||||||
promotion: MachineContext["promotion"];
|
promotion: MachineContext["promotion"];
|
||||||
outgoingMessageRevision: number;
|
outgoingMessageRevision: number;
|
||||||
isReplyingAI: boolean;
|
isReplyingAI: boolean;
|
||||||
|
canSendMessage: boolean;
|
||||||
upgradePromptVisible: boolean;
|
upgradePromptVisible: boolean;
|
||||||
upgradeReason: MachineContext["upgradeReason"];
|
upgradeReason: MachineContext["upgradeReason"];
|
||||||
/** history 初始加载完成标志(local → network → save 跑完) —— UI 可用此显示 loading */
|
/** history 初始加载完成标志(local → network → save 跑完) —— UI 可用此显示 loading */
|
||||||
@@ -87,10 +89,12 @@ type SelectedChatState = Omit<ChatState, "messages">;
|
|||||||
function selectChatState(state: ChatSnapshot): SelectedChatState {
|
function selectChatState(state: ChatSnapshot): SelectedChatState {
|
||||||
return {
|
return {
|
||||||
characterId: state.context.characterId,
|
characterId: state.context.characterId,
|
||||||
|
characterErrorCode: state.context.characterErrorCode,
|
||||||
historyMessages: state.context.messages,
|
historyMessages: state.context.messages,
|
||||||
promotion: state.context.promotion,
|
promotion: state.context.promotion,
|
||||||
outgoingMessageRevision: state.context.outgoingMessageRevision,
|
outgoingMessageRevision: state.context.outgoingMessageRevision,
|
||||||
isReplyingAI: state.context.isReplyingAI,
|
isReplyingAI: state.context.isReplyingAI,
|
||||||
|
canSendMessage: state.context.canSendMessage,
|
||||||
upgradePromptVisible: state.context.upgradePromptVisible,
|
upgradePromptVisible: state.context.upgradePromptVisible,
|
||||||
upgradeReason: state.context.upgradeReason,
|
upgradeReason: state.context.upgradeReason,
|
||||||
historyLoaded: state.context.historyLoaded,
|
historyLoaded: state.context.historyLoaded,
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export type ChatEvent =
|
|||||||
output: import("./machine/actors/history").LoadMoreHistoryOutput;
|
output: import("./machine/actors/history").LoadMoreHistoryOutput;
|
||||||
}
|
}
|
||||||
| { type: "ChatOlderHistoryLoadFailed"; error: unknown }
|
| { type: "ChatOlderHistoryLoadFailed"; error: unknown }
|
||||||
|
| { type: "ChatHistoryRefreshRequested" }
|
||||||
// 业务事件
|
// 业务事件
|
||||||
| { type: "ChatSendMessage"; content: string }
|
| { type: "ChatSendMessage"; content: string }
|
||||||
| { type: "ChatSendImage"; imageBase64: string }
|
| { type: "ChatSendImage"; imageBase64: string }
|
||||||
@@ -65,4 +66,9 @@ export type ChatEvent =
|
|||||||
type: "ChatQueuedHttpDone";
|
type: "ChatQueuedHttpDone";
|
||||||
output: import("./helper/send-state").HttpSendOutput;
|
output: import("./helper/send-state").HttpSendOutput;
|
||||||
}
|
}
|
||||||
| { type: "ChatQueuedSendError"; content: string; errorMessage: string };
|
| {
|
||||||
|
type: "ChatQueuedSendError";
|
||||||
|
content: string;
|
||||||
|
errorMessage: string;
|
||||||
|
characterErrorCode?: import("@/data/services/api").CharacterErrorCode;
|
||||||
|
};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Logger } from "@/utils/logger";
|
|||||||
import { Result } from "@/utils/result";
|
import { Result } from "@/utils/result";
|
||||||
import { todayString } from "@/utils/date";
|
import { todayString } from "@/utils/date";
|
||||||
import { isAbortError } from "@/utils/abort";
|
import { isAbortError } from "@/utils/abort";
|
||||||
|
import { getCharacterErrorCode } from "@/data/services/api";
|
||||||
|
|
||||||
import { CHAT_HISTORY_LIMIT } from "./helper/history";
|
import { CHAT_HISTORY_LIMIT } from "./helper/history";
|
||||||
import { localMessagesToUi } from "./helper/message-mappers";
|
import { localMessagesToUi } from "./helper/message-mappers";
|
||||||
@@ -94,6 +95,7 @@ export async function syncNetworkHistory(
|
|||||||
);
|
);
|
||||||
if (Result.isErr(networkResult)) {
|
if (Result.isErr(networkResult)) {
|
||||||
if (isAbortError(networkResult.error)) throw networkResult.error;
|
if (isAbortError(networkResult.error)) throw networkResult.error;
|
||||||
|
if (getCharacterErrorCode(networkResult.error)) throw networkResult.error;
|
||||||
log.error("[chat-machine] loadHistory NETWORK FAILED", {
|
log.error("[chat-machine] loadHistory NETWORK FAILED", {
|
||||||
error: networkResult.error,
|
error: networkResult.error,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,11 +2,6 @@ import {
|
|||||||
chatMachineSetup,
|
chatMachineSetup,
|
||||||
chatRootStateConfig,
|
chatRootStateConfig,
|
||||||
} from "./machine/session-flow";
|
} from "./machine/session-flow";
|
||||||
import {
|
|
||||||
DEFAULT_CHARACTER,
|
|
||||||
DEFAULT_CHARACTER_ID,
|
|
||||||
} from "@/data/constants/character";
|
|
||||||
|
|
||||||
import { createInitialChatState } from "./chat-state";
|
import { createInitialChatState } from "./chat-state";
|
||||||
|
|
||||||
export type { ChatState } from "./chat-state";
|
export type { ChatState } from "./chat-state";
|
||||||
@@ -17,8 +12,8 @@ export const chatMachine = chatMachineSetup.createMachine({
|
|||||||
id: "chat",
|
id: "chat",
|
||||||
context: ({ input }) =>
|
context: ({ input }) =>
|
||||||
createInitialChatState(
|
createInitialChatState(
|
||||||
input?.characterId ?? DEFAULT_CHARACTER_ID,
|
input.characterId,
|
||||||
input?.emptyChatGreeting ?? DEFAULT_CHARACTER.emptyChatGreeting,
|
input.emptyChatGreeting,
|
||||||
),
|
),
|
||||||
...chatRootStateConfig,
|
...chatRootStateConfig,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
DEFAULT_CHARACTER_ID,
|
DEFAULT_CHARACTER_ID,
|
||||||
} from "@/data/constants/character";
|
} from "@/data/constants/character";
|
||||||
import type { ChatPromotionState } from "./helper/promotion";
|
import type { ChatPromotionState } from "./helper/promotion";
|
||||||
|
import type { CharacterErrorCode } from "@/data/services/api";
|
||||||
|
|
||||||
export type ChatUpgradeReason = "insufficient_credits";
|
export type ChatUpgradeReason = "insufficient_credits";
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ export interface ChatUnlockPaywallRequest
|
|||||||
|
|
||||||
export interface ChatState {
|
export interface ChatState {
|
||||||
characterId: string;
|
characterId: string;
|
||||||
|
characterErrorCode: CharacterErrorCode | null;
|
||||||
emptyChatGreeting: string;
|
emptyChatGreeting: string;
|
||||||
messages: UiMessage[];
|
messages: UiMessage[];
|
||||||
promotion: ChatPromotionState | null;
|
promotion: ChatPromotionState | null;
|
||||||
@@ -58,11 +60,12 @@ export interface ChatState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createInitialChatState(
|
export function createInitialChatState(
|
||||||
characterId: string = DEFAULT_CHARACTER_ID,
|
characterId: string,
|
||||||
emptyChatGreeting: string = DEFAULT_CHARACTER.emptyChatGreeting,
|
emptyChatGreeting: string,
|
||||||
): ChatState {
|
): ChatState {
|
||||||
return {
|
return {
|
||||||
characterId,
|
characterId,
|
||||||
|
characterErrorCode: null,
|
||||||
emptyChatGreeting,
|
emptyChatGreeting,
|
||||||
messages: [],
|
messages: [],
|
||||||
promotion: null,
|
promotion: null,
|
||||||
@@ -91,4 +94,7 @@ export function createInitialChatState(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const initialState: ChatState = createInitialChatState();
|
export const initialState: ChatState = createInitialChatState(
|
||||||
|
DEFAULT_CHARACTER_ID,
|
||||||
|
DEFAULT_CHARACTER.emptyChatGreeting,
|
||||||
|
);
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { AuthStorage } from "@/data/storage/auth";
|
||||||
|
import { NavigationStorage } from "@/data/storage/navigation";
|
||||||
|
import { loadChatRepository } from "@/data/repositories/chat_repository_loader";
|
||||||
|
import { ChatSyncRequestSchema } from "@/data/schemas/chat";
|
||||||
|
import { buildChatConversationKey } from "@/lib/chat/chat_cache_keys";
|
||||||
|
import { Logger } from "@/utils/logger";
|
||||||
|
import { Result } from "@/utils/result";
|
||||||
|
|
||||||
|
const log = new Logger("StoresChatGuestHistorySync");
|
||||||
|
|
||||||
|
export async function syncGuestHistoriesToUser(
|
||||||
|
characterIds: readonly string[],
|
||||||
|
): Promise<void> {
|
||||||
|
const deviceResult = await AuthStorage.getInstance().getDeviceId();
|
||||||
|
const savedOwnerKey = await NavigationStorage.getGuestChatOwnerKey();
|
||||||
|
const ownerKey =
|
||||||
|
savedOwnerKey ??
|
||||||
|
(Result.isOk(deviceResult) && deviceResult.data
|
||||||
|
? `device:${deviceResult.data}`
|
||||||
|
: null);
|
||||||
|
if (!ownerKey) return;
|
||||||
|
|
||||||
|
const repository = await loadChatRepository();
|
||||||
|
await Promise.allSettled(
|
||||||
|
[...new Set(characterIds)].map(async (characterId) => {
|
||||||
|
const cacheIdentity = buildChatConversationKey(ownerKey, characterId);
|
||||||
|
const messagesResult = await repository.getLocalMessages(cacheIdentity);
|
||||||
|
if (Result.isErr(messagesResult) || messagesResult.data.length === 0) return;
|
||||||
|
|
||||||
|
const messages = messagesResult.data.flatMap((message) => {
|
||||||
|
if (message.role !== "user" && message.role !== "assistant") return [];
|
||||||
|
return [{
|
||||||
|
role: message.role,
|
||||||
|
content: message.content,
|
||||||
|
timestamp: normalizeTimestamp(message.createdAt),
|
||||||
|
}];
|
||||||
|
});
|
||||||
|
if (messages.length === 0) return;
|
||||||
|
|
||||||
|
const request = ChatSyncRequestSchema.parse({ characterId, messages });
|
||||||
|
const syncResult = await repository.syncGuestHistory(request);
|
||||||
|
if (Result.isErr(syncResult)) {
|
||||||
|
log.warn("[chat-sync] guest history sync failed", {
|
||||||
|
characterId,
|
||||||
|
error: syncResult.error,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const clearResult = await repository.clearLocalMessages(cacheIdentity);
|
||||||
|
if (Result.isErr(clearResult)) {
|
||||||
|
log.warn("[chat-sync] guest history cleanup failed", {
|
||||||
|
characterId,
|
||||||
|
error: clearResult.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTimestamp(value: string): string {
|
||||||
|
const parsed = new Date(value);
|
||||||
|
return Number.isNaN(parsed.getTime())
|
||||||
|
? new Date().toISOString()
|
||||||
|
: parsed.toISOString();
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { resolveChatConversationKey } from "@/data/repositories/chat_cache_ident
|
|||||||
import { Logger } from "@/utils/logger";
|
import { Logger } from "@/utils/logger";
|
||||||
import { Result } from "@/utils/result";
|
import { Result } from "@/utils/result";
|
||||||
import { isAbortError } from "@/utils/abort";
|
import { isAbortError } from "@/utils/abort";
|
||||||
|
import { getCharacterErrorCode } from "@/data/services/api";
|
||||||
|
|
||||||
import type { ChatEvent } from "../../chat-events";
|
import type { ChatEvent } from "../../chat-events";
|
||||||
import { sendResponseToUiMessage } from "../../helper/message-mappers";
|
import { sendResponseToUiMessage } from "../../helper/message-mappers";
|
||||||
@@ -61,6 +62,9 @@ function createMessageQueueActor(
|
|||||||
type: "ChatQueuedSendError",
|
type: "ChatQueuedSendError",
|
||||||
content,
|
content,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
|
...(getCharacterErrorCode(error)
|
||||||
|
? { characterErrorCode: getCharacterErrorCode(error) ?? undefined }
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
if (activeController === controller) activeController = null;
|
if (activeController === controller) activeController = null;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from "../helper/unlock";
|
} from "../helper/unlock";
|
||||||
import type { ChatState } from "../chat-state";
|
import type { ChatState } from "../chat-state";
|
||||||
import { baseChatMachineSetup } from "./setup";
|
import { baseChatMachineSetup } from "./setup";
|
||||||
|
import { getCharacterErrorCode } from "@/data/services/api";
|
||||||
|
|
||||||
const applyLocalHistoryLoadedAction = baseChatMachineSetup.assign(
|
const applyLocalHistoryLoadedAction = baseChatMachineSetup.assign(
|
||||||
({ event }) => {
|
({ event }) => {
|
||||||
@@ -48,9 +49,21 @@ const showUnlockHistoryPromptFromNetworkAction = baseChatMachineSetup.assign(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const markHistoryLoadFailedAction = baseChatMachineSetup.assign({
|
const markHistoryLoadFailedAction = baseChatMachineSetup.assign(
|
||||||
|
({ context, event }) => {
|
||||||
|
if (event.type !== "ChatHistoryLoadFailed") return {};
|
||||||
|
const characterErrorCode = getCharacterErrorCode(event.error);
|
||||||
|
return {
|
||||||
historyLoaded: true,
|
historyLoaded: true,
|
||||||
});
|
characterErrorCode,
|
||||||
|
canSendMessage:
|
||||||
|
characterErrorCode === "CHARACTER_DISABLED" ||
|
||||||
|
characterErrorCode === "CHARACTER_NOT_FOUND"
|
||||||
|
? false
|
||||||
|
: context.canSendMessage,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const markLoadMoreHistoryStartedAction = baseChatMachineSetup.assign({
|
const markLoadMoreHistoryStartedAction = baseChatMachineSetup.assign({
|
||||||
isLoadingMoreHistory: true,
|
isLoadingMoreHistory: true,
|
||||||
|
|||||||
@@ -76,16 +76,30 @@ const appendUserMessageAction = historyMachineSetup.assign(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const appendQueuedSendErrorMessageAction = historyMachineSetup.assign(
|
const appendQueuedSendErrorMessageAction = historyMachineSetup.assign(
|
||||||
({ context }) => {
|
({ context, event }) => {
|
||||||
|
if (event.type !== "ChatQueuedSendError") return {};
|
||||||
|
const characterErrorCode = event.characterErrorCode ?? null;
|
||||||
|
const unavailable = characterErrorCode === "CHARACTER_STATE_UNAVAILABLE";
|
||||||
const messages = [
|
const messages = [
|
||||||
...context.messages,
|
...context.messages,
|
||||||
{
|
{
|
||||||
content: "Something went wrong. Try sending again?",
|
content: unavailable
|
||||||
|
? "This character is temporarily unavailable. Please try again shortly."
|
||||||
|
: "Something went wrong. Try sending again?",
|
||||||
isFromAI: true,
|
isFromAI: true,
|
||||||
date: todayString(),
|
date: todayString(),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
return { messages, ...finishPendingReply(context) };
|
return {
|
||||||
|
messages,
|
||||||
|
...finishPendingReply(context),
|
||||||
|
characterErrorCode,
|
||||||
|
canSendMessage:
|
||||||
|
characterErrorCode === "CHARACTER_DISABLED" ||
|
||||||
|
characterErrorCode === "CHARACTER_NOT_FOUND"
|
||||||
|
? false
|
||||||
|
: context.canSendMessage,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -189,6 +189,11 @@ const userSessionState = chatMachineSetup.createStateConfig({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
on: {
|
on: {
|
||||||
|
ChatHistoryRefreshRequested: {
|
||||||
|
target: "#chat.userSession",
|
||||||
|
reenter: true,
|
||||||
|
actions: "startUserSession",
|
||||||
|
},
|
||||||
...historyPaginationTransitions,
|
...historyPaginationTransitions,
|
||||||
ChatGuestLogin: {
|
ChatGuestLogin: {
|
||||||
target: "#chat.guestSession",
|
target: "#chat.guestSession",
|
||||||
|
|||||||
@@ -16,15 +16,15 @@ import {
|
|||||||
import type { ChatState } from "../chat-state";
|
import type { ChatState } from "../chat-state";
|
||||||
|
|
||||||
export interface ChatMachineInput {
|
export interface ChatMachineInput {
|
||||||
characterId?: string;
|
characterId: string;
|
||||||
emptyChatGreeting?: string;
|
emptyChatGreeting: 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,
|
input: {} as ChatMachineInput,
|
||||||
},
|
},
|
||||||
actors: {
|
actors: {
|
||||||
loadHistory: loadHistoryActor,
|
loadHistory: loadHistoryActor,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import type { UnlockHistoryOutput } from "./actors/unlock";
|
import type { UnlockHistoryOutput } from "./actors/unlock";
|
||||||
import { sendMachineSetup } from "./send-flow";
|
import { sendMachineSetup } from "./send-flow";
|
||||||
import { createChatActorActionSetup } from "./setup";
|
import { createChatActorActionSetup } from "./setup";
|
||||||
|
import { getCharacterErrorCode } from "@/data/services/api";
|
||||||
|
|
||||||
const markPaymentUnlockPendingAction = sendMachineSetup.assign(() => ({
|
const markPaymentUnlockPendingAction = sendMachineSetup.assign(() => ({
|
||||||
paymentUnlockPending: true,
|
paymentUnlockPending: true,
|
||||||
@@ -146,6 +147,13 @@ const clearUnlockPaywallRequestAction = sendMachineSetup.assign(() => ({
|
|||||||
unlockPaywallRequest: null,
|
unlockPaywallRequest: null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const handleCharacterMismatchAction = sendMachineSetup.assign(() => ({
|
||||||
|
unlockingMessage: null,
|
||||||
|
unlockMessageError: "character_mismatch",
|
||||||
|
unlockPaywallRequest: null,
|
||||||
|
characterErrorCode: "CHARACTER_MISMATCH" as const,
|
||||||
|
}));
|
||||||
|
|
||||||
export const unlockMachineSetup = sendMachineSetup.extend({
|
export const unlockMachineSetup = sendMachineSetup.extend({
|
||||||
actions: {
|
actions: {
|
||||||
markPaymentUnlockPending: markPaymentUnlockPendingAction,
|
markPaymentUnlockPending: markPaymentUnlockPendingAction,
|
||||||
@@ -158,6 +166,7 @@ export const unlockMachineSetup = sendMachineSetup.extend({
|
|||||||
requestUnlockPaymentFromOutput: requestUnlockPaymentFromOutputAction,
|
requestUnlockPaymentFromOutput: requestUnlockPaymentFromOutputAction,
|
||||||
requestUnlockPaymentFromError: requestUnlockPaymentFromErrorAction,
|
requestUnlockPaymentFromError: requestUnlockPaymentFromErrorAction,
|
||||||
clearUnlockPaywallRequest: clearUnlockPaywallRequestAction,
|
clearUnlockPaywallRequest: clearUnlockPaywallRequestAction,
|
||||||
|
handleCharacterMismatch: handleCharacterMismatchAction,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -225,9 +234,17 @@ export const unlockingMessageState = unlockMachineSetup.createStateConfig({
|
|||||||
actions: "requestUnlockPaymentFromOutput",
|
actions: "requestUnlockPaymentFromOutput",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
onError: {
|
onError: [
|
||||||
|
{
|
||||||
|
guard: ({ event }) =>
|
||||||
|
getCharacterErrorCode(event.error) === "CHARACTER_MISMATCH",
|
||||||
|
target: "ready",
|
||||||
|
actions: "handleCharacterMismatch",
|
||||||
|
},
|
||||||
|
{
|
||||||
target: "ready",
|
target: "ready",
|
||||||
actions: "requestUnlockPaymentFromError",
|
actions: "requestUnlockPaymentFromError",
|
||||||
},
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ describe("payment order flow", () => {
|
|||||||
await initialize(actor);
|
await initialize(actor);
|
||||||
actor.send({
|
actor.send({
|
||||||
type: "PaymentCreateOrderSubmitted",
|
type: "PaymentCreateOrderSubmitted",
|
||||||
recipientCharacterId: "character_maya",
|
recipientCharacterId: "maya-tan",
|
||||||
});
|
});
|
||||||
await waitFor(actor, (snapshot) => snapshot.matches("paid"));
|
await waitFor(actor, (snapshot) => snapshot.matches("paid"));
|
||||||
|
|
||||||
@@ -95,7 +95,7 @@ describe("payment order flow", () => {
|
|||||||
planId: "vip_monthly",
|
planId: "vip_monthly",
|
||||||
payChannel: "stripe",
|
payChannel: "stripe",
|
||||||
autoRenew: true,
|
autoRenew: true,
|
||||||
recipientCharacterId: "character_maya",
|
recipientCharacterId: "maya-tan",
|
||||||
});
|
});
|
||||||
actor.stop();
|
actor.stop();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
} from "./actors/albums";
|
} from "./actors/albums";
|
||||||
|
|
||||||
export interface PrivateRoomMachineInput {
|
export interface PrivateRoomMachineInput {
|
||||||
characterId?: string;
|
characterId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const basePrivateRoomMachineSetup = setup({
|
export const basePrivateRoomMachineSetup = setup({
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import {
|
|||||||
privateRoomMachineSetup,
|
privateRoomMachineSetup,
|
||||||
privateRoomRootStateConfig,
|
privateRoomRootStateConfig,
|
||||||
} from "./machine/unlock-flow";
|
} from "./machine/unlock-flow";
|
||||||
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
|
|
||||||
|
|
||||||
import { createInitialPrivateRoomState } from "./private-room-state";
|
import { createInitialPrivateRoomState } from "./private-room-state";
|
||||||
|
|
||||||
export type { PrivateRoomEvent } from "./private-room-events";
|
export type { PrivateRoomEvent } from "./private-room-events";
|
||||||
@@ -13,9 +11,7 @@ export { initialState } from "./private-room-state";
|
|||||||
export const privateRoomMachine = privateRoomMachineSetup.createMachine({
|
export const privateRoomMachine = privateRoomMachineSetup.createMachine({
|
||||||
id: "privateRoom",
|
id: "privateRoom",
|
||||||
context: ({ input }) =>
|
context: ({ input }) =>
|
||||||
createInitialPrivateRoomState(
|
createInitialPrivateRoomState(input.characterId),
|
||||||
input?.characterId ?? DEFAULT_CHARACTER_ID,
|
|
||||||
),
|
|
||||||
...privateRoomRootStateConfig,
|
...privateRoomRootStateConfig,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export interface PrivateRoomState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createInitialPrivateRoomState(
|
export function createInitialPrivateRoomState(
|
||||||
characterId = DEFAULT_CHARACTER_ID,
|
characterId: string,
|
||||||
): PrivateRoomState {
|
): PrivateRoomState {
|
||||||
return {
|
return {
|
||||||
characterId,
|
characterId,
|
||||||
@@ -37,4 +37,4 @@ export function createInitialPrivateRoomState(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const initialState = createInitialPrivateRoomState();
|
export const initialState = createInitialPrivateRoomState(DEFAULT_CHARACTER_ID);
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ import {
|
|||||||
useAuthSelector,
|
useAuthSelector,
|
||||||
} from "@/stores/auth/auth-context";
|
} from "@/stores/auth/auth-context";
|
||||||
import { useChatDispatch } from "@/stores/chat/chat-context";
|
import { useChatDispatch } from "@/stores/chat/chat-context";
|
||||||
|
import { syncGuestHistoriesToUser } from "@/stores/chat/guest-history-sync";
|
||||||
|
import { useCharacterCatalog } from "@/providers/character-catalog-provider";
|
||||||
|
import { resolveChatCacheOwnerKey } from "@/data/repositories/chat_cache_identity";
|
||||||
|
import { NavigationStorage } from "@/data/storage/navigation";
|
||||||
|
import { Result } from "@/utils/result";
|
||||||
|
|
||||||
export function ChatAuthSync() {
|
export function ChatAuthSync() {
|
||||||
const authState = useAuthSelector(
|
const authState = useAuthSelector(
|
||||||
@@ -27,6 +32,7 @@ export function ChatAuthSync() {
|
|||||||
shallowEqual,
|
shallowEqual,
|
||||||
);
|
);
|
||||||
const chatDispatch = useChatDispatch();
|
const chatDispatch = useChatDispatch();
|
||||||
|
const characterCatalog = useCharacterCatalog();
|
||||||
const prevSessionKeyRef = useRef<string | null>(null);
|
const prevSessionKeyRef = useRef<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -42,14 +48,27 @@ export function ChatAuthSync() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (authState.loginStatus === "guest") {
|
if (authState.loginStatus === "guest") {
|
||||||
chatDispatch({ type: "ChatGuestLogin" });
|
let cancelled = false;
|
||||||
return;
|
void (async () => {
|
||||||
|
const ownerResult = await resolveChatCacheOwnerKey();
|
||||||
|
if (Result.isOk(ownerResult)) {
|
||||||
|
await NavigationStorage.saveGuestChatOwnerKey(ownerResult.data);
|
||||||
|
}
|
||||||
|
if (!cancelled) chatDispatch({ type: "ChatGuestLogin" });
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const tokenR = await AuthStorage.getInstance().getLoginToken();
|
const tokenR = await AuthStorage.getInstance().getLoginToken();
|
||||||
if (!cancelled && tokenR.success && tokenR.data) {
|
if (!cancelled && tokenR.success && tokenR.data) {
|
||||||
|
await syncGuestHistoriesToUser(
|
||||||
|
characterCatalog.characters.map((character) => character.id),
|
||||||
|
);
|
||||||
|
if (cancelled) return;
|
||||||
chatDispatch({
|
chatDispatch({
|
||||||
type: "ChatUserLogin",
|
type: "ChatUserLogin",
|
||||||
token: tokenR.data,
|
token: tokenR.data,
|
||||||
@@ -64,6 +83,7 @@ export function ChatAuthSync() {
|
|||||||
authState.hasInitialized,
|
authState.hasInitialized,
|
||||||
authState.isLoading,
|
authState.isLoading,
|
||||||
authState.loginStatus,
|
authState.loginStatus,
|
||||||
|
characterCatalog.characters,
|
||||||
chatDispatch,
|
chatDispatch,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user