feat(chat): sync multi-role backend APIs

This commit is contained in:
2026-07-20 11:29:54 +08:00
parent 16b5c16e76
commit b6fdc912ae
84 changed files with 1488 additions and 439 deletions
@@ -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,
+45 -1
View File
@@ -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"
-9
View File
@@ -1,9 +0,0 @@
import type { ReactNode } from "react";
export default function LegacyChatLayout({
children,
}: {
children: ReactNode;
}) {
return children;
}
-9
View File
@@ -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",
}); });
}); });
}); });
-9
View File
@@ -1,9 +0,0 @@
import type { ReactNode } from "react";
export default function LegacyTipLayout({
children,
}: {
children: ReactNode;
}) {
return children;
}
+50 -1
View File
@@ -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",
); );
+39 -3
View File
@@ -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,
+15
View File
@@ -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,
+2
View File
@@ -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>;
+1
View File
@@ -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);
});
});
+2
View File
@@ -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>;
+1
View File
@@ -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>;
+1
View File
@@ -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({
+4 -1
View File
@@ -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" }
} }
+7
View File
@@ -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;
} }
+19
View File
@@ -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)
);
}
+23
View File
@@ -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);
}
/** /**
* 解锁单条历史付费 / 私密消息 * 解锁单条历史付费 / 私密消息
*/ */
+2
View File
@@ -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";
+3 -4
View File
@@ -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,
};
}
+1 -46
View File
@@ -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;
} }
} }
+1
View File
@@ -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,
}); });
-8
View File
@@ -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)
);
}
+72 -6
View File
@@ -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,
};
}
+81 -8
View File
@@ -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();
});
});
+4
View File
@@ -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,
+7 -1
View File
@@ -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;
};
+2
View File
@@ -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 -7
View File
@@ -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,
}); });
+9 -3
View File
@@ -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,
);
+65
View File
@@ -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();
}
+4
View File
@@ -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;
+15 -2
View File
@@ -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,
+17 -3
View File
@@ -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,
};
}, },
); );
+5
View File
@@ -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",
+3 -3
View File
@@ -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,
+18 -1
View File
@@ -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();
}); });
+1 -1
View File
@@ -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);
+22 -2
View File
@@ -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,
]); ]);