diff --git a/docs/backend/FRONTEND_MULTI_ROLE_CHAT_API.md b/docs/backend/FRONTEND_MULTI_ROLE_CHAT_API.md new file mode 100644 index 00000000..afb62dde --- /dev/null +++ b/docs/backend/FRONTEND_MULTI_ROLE_CHAT_API.md @@ -0,0 +1,209 @@ +# CozSweet 多角色聊天前端对接 + +## 3. Character IDs + +前端先调用: + +```http +GET /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/chat/send +Content-Type: application/json +Authorization: Bearer +``` + +### Parameters + +| Field | Type | Required | Example | Meaning | +| --- | --- | --- | --- | --- | +| `characterId` | string | 新前端必传 | `maya-tan` | 当前聊天角色 ID | +| `message` | string | 与图片至少一项有值 | `Hello Maya` | 用户文本,最多 4000 字符 | +| `imageId` | string | 否 | `abc123` | 上传图片 ID | +| `imageThumbUrl` | string | 否 | `/images/...` | 缩略图 | +| `imageMediumUrl` | string | 否 | `/images/...` | 模型识图使用 | +| `imageOriginalUrl` | string | 否 | `/images/...` | 原图 | +| `imageWidth` | integer | 否 | `1080` | 图片宽 | +| `imageHeight` | integer | 否 | `1440` | 图片高 | +| `useWebSocket` | boolean | 否 | `false` | 是否同时通过已连接 WS 推送 | + +```bash +curl -X POST 'https://proapi.banlv-ai.com/api/chat/send' \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -d '{"characterId":"maya-tan","message":"Hello Maya","useWebSocket":false}' +``` + +响应沿用现有发送结构。前端仍读取 `data.reply`、`data.messageId`、`data.audioUrl`、`data.image` 和 `data.lockDetail`,无需从响应推断角色。 + +## 5. Chat History + +```http +GET /api/chat/history?characterId=maya-tan&limit=50&offset=0 +Authorization: Bearer +``` + +| Query | Type | Required | Rule | +| --- | --- | --- | --- | +| `characterId` | string | 新前端必传 | 只返回该角色数据 | +| `limit` | integer | 否 | `1-200`,默认 `50` | +| `offset` | integer | 否 | 最小 `0` | + +`messages` 和 `total` 都只统计指定角色。切换角色时必须重新请求 history,不能复用上一个角色的本地数组。 + +```json +{ + "code": 200, + "success": true, + "message": "success", + "data": { + "messages": [ + { + "role": "user", + "type": "text", + "content": "Hello Maya", + "id": "", + "created_at": "2026-07-17T09:00:00Z", + "audioUrl": null, + "image": {"type": null, "url": null}, + "lockDetail": {"locked": false, "showContent": true, "showUpgrade": false} + } + ], + "total": 1, + "limit": 50, + "offset": 0, + "isVip": false + } +} +``` + +## 6. Latest Previews + +角色会话列表可一次请求所有可聊天角色的最后一条消息: + +```http +GET /api/chat/previews +Authorization: Bearer +``` + +```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/chat/unlock-private +Content-Type: application/json +Authorization: Bearer +``` + +```json +{ + "characterId": "maya-tan", + "messageId": "", + "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/chat/unlock-history +Content-Type: application/json +Authorization: Bearer +``` + +```json +{"characterId":"maya-tan"} +``` + +费用、锁消息数量、成功解锁数量及 `messageIds` 都只计算当前角色。钱包余额仍是账号全局余额。 + +## 9. Guest History Sync + +```http +POST /api/chat/sync +Content-Type: application/json +Authorization: Bearer +``` + +```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/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。 diff --git a/src/app/characters/[characterSlug]/layout.tsx b/src/app/characters/[characterSlug]/layout.tsx index df45a85b..3a7f384a 100644 --- a/src/app/characters/[characterSlug]/layout.tsx +++ b/src/app/characters/[characterSlug]/layout.tsx @@ -5,7 +5,7 @@ import { CHARACTERS, getCharacterBySlug, } from "@/data/constants/character"; -import { CharacterProvider } from "@/providers/character-provider"; +import { CharacterRouteBoundary } from "@/providers/character-route-boundary"; export const dynamicParams = false; @@ -26,5 +26,9 @@ export default async function CharacterLayout({ const character = getCharacterBySlug(characterSlug); if (!character) notFound(); - return {children}; + return ( + + {children} + + ); } diff --git a/src/app/chat/__tests__/chat-unlock-coordinator.test.ts b/src/app/chat/__tests__/chat-unlock-coordinator.test.ts index 11cbbe1c..4b5ab8a8 100644 --- a/src/app/chat/__tests__/chat-unlock-coordinator.test.ts +++ b/src/app/chat/__tests__/chat-unlock-coordinator.test.ts @@ -20,7 +20,7 @@ const defaultScope = { }; const promotionSession: PendingChatPromotion = { - characterId: "character_elio", + characterId: "elio", promotionType: "image", lockType: "image_paywall", clientLockId: "promotion-1", @@ -182,7 +182,7 @@ function createPendingUnlock( ): PendingChatUnlock { return { reason: "single_message_unlock", - characterId: "character_elio", + characterId: "elio", ...input, stage: "auth", createdAt: 1, diff --git a/src/app/chat/chat-screen.tsx b/src/app/chat/chat-screen.tsx index 3c3dd4a9..196d59e3 100644 --- a/src/app/chat/chat-screen.tsx +++ b/src/app/chat/chat-screen.tsx @@ -13,6 +13,9 @@ import { useActiveCharacter, useActiveCharacterRoutes, } 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"; @@ -50,6 +53,9 @@ const chatShellStyle = { export function ChatScreen() { const router = useRouter(); const character = useActiveCharacter(); + const characterCatalog = useCharacterCatalog(); + const refreshCharacterCatalog = characterCatalog.refresh; + const defaultCharacterSlug = characterCatalog.defaultCharacter.slug; const characterRoutes = useActiveCharacterRoutes(); const searchParams = useSearchParams(); const state = useChatState(); @@ -133,6 +139,42 @@ export function ChatScreen() { 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 { unlockCoordinator.requestMessageUnlock(messageId, "private"); } @@ -223,7 +265,9 @@ export function ChatScreen() { onUnlock={messageLimitBanner.unlock} /> ) : ( - + )} diff --git a/src/app/chat/components/__tests__/chat-area-scroll.test.tsx b/src/app/chat/components/__tests__/chat-area-scroll.test.tsx index 2d2e5a8d..ec26c8f3 100644 --- a/src/app/chat/components/__tests__/chat-area-scroll.test.tsx +++ b/src/app/chat/components/__tests__/chat-area-scroll.test.tsx @@ -235,7 +235,7 @@ function renderChatArea( act(() => { root.render( { it("renders ImageBubble openable and paywalled states", () => { const openableHtml = renderToStaticMarkup( undefined} @@ -135,7 +135,7 @@ describe("chat Tailwind components", () => { ); const paywalledHtml = renderToStaticMarkup( , diff --git a/src/app/chat/components/chat-input-text-field.tsx b/src/app/chat/components/chat-input-text-field.tsx index 55d2c1fb..e6d8f026 100644 --- a/src/app/chat/components/chat-input-text-field.tsx +++ b/src/app/chat/components/chat-input-text-field.tsx @@ -73,6 +73,7 @@ export const ChatInputTextField = forwardRef< onBlur={() => onFocusChange?.(false)} placeholder={placeholder} disabled={disabled} + maxLength={4000} autoFocus={autoFocus} rows={1} aria-label="Message" diff --git a/src/app/chat/layout.tsx b/src/app/chat/layout.tsx deleted file mode 100644 index cd93f4f2..00000000 --- a/src/app/chat/layout.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import type { ReactNode } from "react"; - -export default function LegacyChatLayout({ - children, -}: { - children: ReactNode; -}) { - return children; -} diff --git a/src/app/private-room/layout.tsx b/src/app/private-room/layout.tsx deleted file mode 100644 index c26c6fa4..00000000 --- a/src/app/private-room/layout.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import type { ReactNode } from "react"; - -export default function LegacyPrivateRoomLayout({ - children, -}: { - children: ReactNode; -}) { - return children; -} diff --git a/src/app/splash/hooks/__tests__/use-splash-latest-message.test.tsx b/src/app/splash/hooks/__tests__/use-splash-latest-message.test.tsx index 6c4b13ae..5a7c454e 100644 --- a/src/app/splash/hooks/__tests__/use-splash-latest-message.test.tsx +++ b/src/app/splash/hooks/__tests__/use-splash-latest-message.test.tsx @@ -26,7 +26,7 @@ import { useSplashLatestMessage } from "../use-splash-latest-message"; function LatestMessageHarness() { const state = useSplashLatestMessage({ - characterId: "character_maya", + characterId: "maya-tan", hasInitialized: true, isAuthLoading: false, loginStatus: "facebook", @@ -67,7 +67,7 @@ describe("useSplashLatestMessage hydration", () => { expect(mocks.loadLatestMessage).toHaveBeenCalledOnce(); expect(mocks.loadLatestMessage).toHaveBeenCalledWith({ cache: mocks.cache, - characterId: "character_maya", + characterId: "maya-tan", }); }); }); diff --git a/src/app/tip/layout.tsx b/src/app/tip/layout.tsx deleted file mode 100644 index 50fcc63c..00000000 --- a/src/app/tip/layout.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import type { ReactNode } from "react"; - -export default function LegacyTipLayout({ - children, -}: { - children: ReactNode; -}) { - return children; -} diff --git a/src/data/constants/__tests__/character.test.ts b/src/data/constants/__tests__/character.test.ts index 05193d81..781facae 100644 --- a/src/data/constants/__tests__/character.test.ts +++ b/src/data/constants/__tests__/character.test.ts @@ -8,7 +8,9 @@ import { DEFAULT_CHARACTER, getCharacterById, getCharacterBySlug, + mergeRemoteCharacterCatalog, } from "@/data/constants/character"; +import { CharacterListResponseSchema } from "@/data/schemas/character"; describe("local character catalog", () => { it("contains immutable and uniquely addressable character profiles", () => { @@ -17,6 +19,11 @@ describe("local character catalog", () => { "maya", "nayeli", ]); + expect(CHARACTERS.map((character) => character.id)).toEqual([ + "elio", + "maya-tan", + "nayeli-cervantes", + ]); expect(new Set(CHARACTERS.map((character) => character.id)).size).toBe( CHARACTERS.length, ); @@ -27,6 +34,48 @@ describe("local character catalog", () => { 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", () => { const catalog = createCharacterCatalog([...CHARACTERS].reverse()); expect(catalog.characters.map((character) => character.slug)).toEqual([ @@ -40,7 +89,7 @@ describe("local character catalog", () => { }); 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( "Nayeli Cervantes", ); diff --git a/src/data/constants/character.ts b/src/data/constants/character.ts index 87c68320..7ebdc881 100644 --- a/src/data/constants/character.ts +++ b/src/data/constants/character.ts @@ -34,6 +34,11 @@ export interface CharacterCatalog { getBySlug(characterSlug: string | null | undefined): CharacterProfile | null; } +export interface CharacterCatalogSnapshot { + readonly catalog: CharacterCatalog; + readonly defaultCharacter: CharacterProfile; +} + function defineCharacter(profile: CharacterProfile): CharacterProfile { return Object.freeze({ ...profile, @@ -45,7 +50,7 @@ function defineCharacter(profile: CharacterProfile): CharacterProfile { const CHARACTER_PROFILES: readonly CharacterProfile[] = Object.freeze([ defineCharacter({ - id: "character_elio", + id: "elio", slug: "elio", displayName: "Elio Silvestri", shortName: "Elio", @@ -75,7 +80,7 @@ const CHARACTER_PROFILES: readonly CharacterProfile[] = Object.freeze([ }, }), defineCharacter({ - id: "character_maya", + id: "maya-tan", slug: "maya", displayName: "Maya Tan", shortName: "Maya", @@ -104,7 +109,7 @@ const CHARACTER_PROFILES: readonly CharacterProfile[] = Object.freeze([ }, }), defineCharacter({ - id: "character_nayeli", + id: "nayeli-cervantes", slug: "nayeli", displayName: "Nayeli Cervantes", 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( CHARACTER_PROFILES, ); diff --git a/src/data/mock/chat/requests/send-image-message.json b/src/data/mock/chat/requests/send-image-message.json index 06fc66bb..735d5dcd 100644 --- a/src/data/mock/chat/requests/send-image-message.json +++ b/src/data/mock/chat/requests/send-image-message.json @@ -1,7 +1,6 @@ { - "characterId": "character_elio", + "characterId": "elio", "message": "Look at this sunset.", - "image": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD...", "imageId": "img_mock_001", "imageThumbUrl": "https://cdn.cozsweet.com/mock/chat/img_mock_001_thumb.jpg", "imageMediumUrl": "https://cdn.cozsweet.com/mock/chat/img_mock_001_medium.jpg", diff --git a/src/data/mock/chat/requests/send-text-message.json b/src/data/mock/chat/requests/send-text-message.json index 97752fd2..bd2ba0f2 100644 --- a/src/data/mock/chat/requests/send-text-message.json +++ b/src/data/mock/chat/requests/send-text-message.json @@ -1,12 +1,5 @@ { - "characterId": "character_elio", + "characterId": "elio", "message": "I missed you today.", - "image": "", - "imageId": "", - "imageThumbUrl": "", - "imageMediumUrl": "", - "imageOriginalUrl": "", - "imageWidth": 0, - "imageHeight": 0, "useWebSocket": false } diff --git a/src/data/mock/chat/requests/sync-guest-messages.json b/src/data/mock/chat/requests/sync-guest-messages.json index d1e30c7a..5df17fc0 100644 --- a/src/data/mock/chat/requests/sync-guest-messages.json +++ b/src/data/mock/chat/requests/sync-guest-messages.json @@ -1,4 +1,5 @@ { + "characterId": "elio", "messages": [ { "role": "user", diff --git a/src/data/mock/chat/requests/unlock-private-message.json b/src/data/mock/chat/requests/unlock-private-message.json index ba5bdbe3..8b8dae8a 100644 --- a/src/data/mock/chat/requests/unlock-private-message.json +++ b/src/data/mock/chat/requests/unlock-private-message.json @@ -1,4 +1,4 @@ { - "characterId": "character_elio", + "characterId": "elio", "messageId": "msg_private_locked_001" } diff --git a/src/data/repositories/__tests__/chat_cache_identity.test.ts b/src/data/repositories/__tests__/chat_cache_identity.test.ts index 624a1fce..3661398c 100644 --- a/src/data/repositories/__tests__/chat_cache_identity.test.ts +++ b/src/data/repositories/__tests__/chat_cache_identity.test.ts @@ -26,10 +26,10 @@ function createStorageState(input: { describe("chat cache identity", () => { 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"); - 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(elio).not.toBe(aria); }); diff --git a/src/data/repositories/__tests__/chat_media_cache_coordinator.test.ts b/src/data/repositories/__tests__/chat_media_cache_coordinator.test.ts index b046cff7..aa9d6c62 100644 --- a/src/data/repositories/__tests__/chat_media_cache_coordinator.test.ts +++ b/src/data/repositories/__tests__/chat_media_cache_coordinator.test.ts @@ -31,7 +31,7 @@ describe("ChatMediaCacheCoordinator identity isolation", () => { expect(result).toEqual(Result.ok(null)); expect(getMedia).toHaveBeenCalledTimes(1); 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({ - characterId: "character_elio", + characterId: "elio", messageId: "shared-message-id", kind: "image", }); @@ -57,7 +57,7 @@ describe("ChatMediaCacheCoordinator identity isolation", () => { }); 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"], ]); }); @@ -112,7 +112,7 @@ describe("ChatMediaCacheCoordinator identity isolation", () => { kind: "image", remoteUrl: "https://media.example/expired.jpg", }, - "user:account-a::character:character_elio", + "user:account-a::character:elio", ); expect(Result.isErr(result)).toBe(true); diff --git a/src/data/repositories/character_repository.ts b/src/data/repositories/character_repository.ts new file mode 100644 index 00000000..2e9b5287 --- /dev/null +++ b/src/data/repositories/character_repository.ts @@ -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> { + return Result.wrap(() => this.api.getChatCharacters()); + } +} + +export const getCharacterRepository = createLazySingleton( + () => new CharacterRepository(characterApi), +); diff --git a/src/data/repositories/chat_remote_data_source.ts b/src/data/repositories/chat_remote_data_source.ts index 181ac5e8..d1b39541 100644 --- a/src/data/repositories/chat_remote_data_source.ts +++ b/src/data/repositories/chat_remote_data_source.ts @@ -5,6 +5,9 @@ import type { } from "@/data/repositories/interfaces"; import { ChatHistoryResponse, + ChatPreviewsResponse, + ChatSyncRequest, + ChatSyncRequestSchema, ChatSendResponse, SendMessageRequestSchema, UnlockHistoryRequestSchema, @@ -27,13 +30,43 @@ export class ChatRemoteDataSource { const request = SendMessageRequestSchema.parse({ characterId, 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, }); return await this.api.sendMessage(request, options); }); } + async getPreviews( + options?: ChatRequestOptions, + ): Promise> { + return Result.wrap(() => this.api.getPreviews(options)); + } + + async syncGuestHistory( + request: ChatSyncRequest, + options?: ChatRequestOptions, + ): Promise> { + return Result.wrap(() => + this.api.syncGuestHistory(ChatSyncRequestSchema.parse(request), options), + ); + } + async getHistory( characterId: string, limit = 50, diff --git a/src/data/repositories/chat_repository.ts b/src/data/repositories/chat_repository.ts index e57240ba..e900c3fb 100644 --- a/src/data/repositories/chat_repository.ts +++ b/src/data/repositories/chat_repository.ts @@ -9,6 +9,8 @@ import type { } from "@/data/repositories/interfaces"; import type { ChatHistoryResponse, + ChatPreviewsResponse, + ChatSyncRequest, ChatMessage, ChatSendResponse, UnlockHistoryResponse, @@ -55,6 +57,19 @@ export class ChatRepository implements IChatRepository { return this.remote.getHistory(characterId, limit, offset, options); } + async getPreviews( + options?: ChatRequestOptions, + ): Promise> { + return this.remote.getPreviews(options); + } + + async syncGuestHistory( + request: ChatSyncRequest, + options?: ChatRequestOptions, + ): Promise> { + return this.remote.syncGuestHistory(request, options); + } + /** 解锁单条历史付费 / 私密消息。 */ async unlockPrivateMessage( input: UnlockPrivateMessageInput, diff --git a/src/data/repositories/index.ts b/src/data/repositories/index.ts index 63e6d15a..0e4ff6f5 100644 --- a/src/data/repositories/index.ts +++ b/src/data/repositories/index.ts @@ -4,6 +4,7 @@ export * from "./auth_repository"; export * from "./chat_repository"; +export * from "./character_repository"; export * from "./feedback_repository"; export * from "./metrics_repository"; export * from "./payment_repository"; @@ -11,6 +12,7 @@ export * from "./private_room_repository"; export * from "./user_repository"; export * from "./interfaces/iauth_repository"; export * from "./interfaces/ichat_repository"; +export * from "./interfaces/icharacter_repository"; export * from "./interfaces/ifeedback_repository"; export * from "./interfaces/imetrics_repository"; export * from "./interfaces/ipayment_repository"; diff --git a/src/data/repositories/interfaces/icharacter_repository.ts b/src/data/repositories/interfaces/icharacter_repository.ts new file mode 100644 index 00000000..c1f75870 --- /dev/null +++ b/src/data/repositories/interfaces/icharacter_repository.ts @@ -0,0 +1,6 @@ +import type { CharacterListResponse } from "@/data/schemas/character"; +import type { Result } from "@/utils/result"; + +export interface ICharacterRepository { + getChatCharacters(): Promise>; +} diff --git a/src/data/repositories/interfaces/ichat_repository.ts b/src/data/repositories/interfaces/ichat_repository.ts index 4f0f205b..b318e1d8 100644 --- a/src/data/repositories/interfaces/ichat_repository.ts +++ b/src/data/repositories/interfaces/ichat_repository.ts @@ -6,6 +6,8 @@ import type { ChatHistoryResponse, + ChatPreviewsResponse, + ChatSyncRequest, ChatImageData, ChatLockDetailData, ChatLockType, @@ -47,7 +49,12 @@ export interface ChatRequestOptions { } export interface ChatSendOptions extends ChatRequestOptions { - image?: string; + imageId?: string; + imageThumbUrl?: string; + imageMediumUrl?: string; + imageOriginalUrl?: string; + imageWidth?: number; + imageHeight?: number; useWebSocket?: boolean; } @@ -67,6 +74,17 @@ export interface IChatRepository { options?: ChatRequestOptions, ): Promise>; + /** 批量获取所有可聊天角色的最新消息。 */ + getPreviews( + options?: ChatRequestOptions, + ): Promise>; + + /** 把一个角色的游客历史同步到正式账号。 */ + syncGuestHistory( + request: ChatSyncRequest, + options?: ChatRequestOptions, + ): Promise>; + /** 解锁单条历史付费 / 私密消息。 */ unlockPrivateMessage( input: UnlockPrivateMessageInput, diff --git a/src/data/repositories/interfaces/index.ts b/src/data/repositories/interfaces/index.ts index c109eec7..321042ed 100644 --- a/src/data/repositories/interfaces/index.ts +++ b/src/data/repositories/interfaces/index.ts @@ -4,6 +4,7 @@ export * from "./iauth_repository"; export * from "./ichat_repository"; +export * from "./icharacter_repository"; export * from "./ifeedback_repository"; export * from "./imetrics_repository"; export * from "./ipayment_repository"; diff --git a/src/data/repositories/interfaces/iprivate_room_repository.ts b/src/data/repositories/interfaces/iprivate_room_repository.ts index e929db89..a45fa29f 100644 --- a/src/data/repositories/interfaces/iprivate_room_repository.ts +++ b/src/data/repositories/interfaces/iprivate_room_repository.ts @@ -5,13 +5,13 @@ import type { import type { Result } from "@/utils/result"; export interface GetPrivateAlbumsInput { - characterId?: string; + characterId: string; limit?: number; } export interface IPrivateRoomRepository { getAlbums( - input?: GetPrivateAlbumsInput, + input: GetPrivateAlbumsInput, ): Promise>; unlockAlbum( diff --git a/src/data/repositories/private_room_repository.ts b/src/data/repositories/private_room_repository.ts index 228e4d93..24cfd6b6 100644 --- a/src/data/repositories/private_room_repository.ts +++ b/src/data/repositories/private_room_repository.ts @@ -19,7 +19,7 @@ export class PrivateRoomRepository implements IPrivateRoomRepository { constructor(private readonly api: PrivateRoomApi) {} getAlbums( - input: GetPrivateAlbumsInput = {}, + input: GetPrivateAlbumsInput, ): Promise> { return Result.wrap(() => this.api.getAlbums(input)); } diff --git a/src/data/schemas/character/character_list_response.ts b/src/data/schemas/character/character_list_response.ts new file mode 100644 index 00000000..1f294163 --- /dev/null +++ b/src/data/schemas/character/character_list_response.ts @@ -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; +export type CharacterListResponseInput = z.input; +export type CharacterListResponse = z.output; diff --git a/src/data/schemas/character/index.ts b/src/data/schemas/character/index.ts new file mode 100644 index 00000000..d76e0d0a --- /dev/null +++ b/src/data/schemas/character/index.ts @@ -0,0 +1 @@ +export * from "./character_list_response"; diff --git a/src/data/schemas/chat/__tests__/multi_role_contract.test.ts b/src/data/schemas/chat/__tests__/multi_role_contract.test.ts new file mode 100644 index 00000000..ba5862b0 --- /dev/null +++ b/src/data/schemas/chat/__tests__/multi_role_contract.test.ts @@ -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); + }); +}); diff --git a/src/data/schemas/chat/index.ts b/src/data/schemas/chat/index.ts index 729d569a..a4613d0f 100644 --- a/src/data/schemas/chat/index.ts +++ b/src/data/schemas/chat/index.ts @@ -7,9 +7,11 @@ export * from "./chat_media"; export * from "./chat_message"; export * from "./chat_payloads"; export * from "./request/send_message_request"; +export * from "./request/chat_sync_request"; export * from "./request/unlock_history_request"; export * from "./request/unlock_private_request"; export * from "./response/chat_history_response"; +export * from "./response/chat_previews_response"; export * from "./response/chat_send_response"; export * from "./response/unlock_history_response"; export * from "./response/unlock_private_response"; diff --git a/src/data/schemas/chat/request/chat_sync_request.ts b/src/data/schemas/chat/request/chat_sync_request.ts new file mode 100644 index 00000000..c5e5566f --- /dev/null +++ b/src/data/schemas/chat/request/chat_sync_request.ts @@ -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; +export type ChatSyncRequest = z.output; diff --git a/src/data/schemas/chat/request/index.ts b/src/data/schemas/chat/request/index.ts index c7462863..1a375418 100644 --- a/src/data/schemas/chat/request/index.ts +++ b/src/data/schemas/chat/request/index.ts @@ -3,5 +3,6 @@ */ export * from "./send_message_request"; +export * from "./chat_sync_request"; export * from "./unlock_private_request"; export * from "./unlock_history_request"; diff --git a/src/data/schemas/chat/request/send_message_request.ts b/src/data/schemas/chat/request/send_message_request.ts index c0510aac..1695596b 100644 --- a/src/data/schemas/chat/request/send_message_request.ts +++ b/src/data/schemas/chat/request/send_message_request.ts @@ -4,25 +4,31 @@ */ import { z } from "zod"; -import { - booleanOrFalse, - numberOrZero, - stringOrEmpty, -} from "../../nullable-defaults"; +import { booleanOrFalse } from "../../nullable-defaults"; export const SendMessageRequestSchema = z .object({ characterId: z.string().min(1), - message: stringOrEmpty, - image: stringOrEmpty, - imageId: stringOrEmpty, - imageThumbUrl: stringOrEmpty, - imageMediumUrl: stringOrEmpty, - imageOriginalUrl: stringOrEmpty, - imageWidth: numberOrZero, - imageHeight: numberOrZero, + message: z.string().max(4000).optional(), + imageId: z.string().min(1).optional(), + imageThumbUrl: z.string().min(1).optional(), + imageMediumUrl: z.string().min(1).optional(), + imageOriginalUrl: z.string().min(1).optional(), + imageWidth: z.number().int().nonnegative().optional(), + imageHeight: z.number().int().nonnegative().optional(), useWebSocket: booleanOrFalse, }) + .refine( + (value) => + Boolean(value.message?.trim()) || + Boolean( + value.imageId || + value.imageThumbUrl || + value.imageMediumUrl || + value.imageOriginalUrl, + ), + { message: "message or image is required" }, + ) .readonly(); export type SendMessageRequestInput = z.input; diff --git a/src/data/schemas/chat/response/chat_previews_response.ts b/src/data/schemas/chat/response/chat_previews_response.ts new file mode 100644 index 00000000..3ed5317d --- /dev/null +++ b/src/data/schemas/chat/response/chat_previews_response.ts @@ -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; +export type ChatPreviewsResponseInput = z.input; +export type ChatPreviewsResponse = z.output; diff --git a/src/data/schemas/chat/response/index.ts b/src/data/schemas/chat/response/index.ts index 52c0c0bf..8061a249 100644 --- a/src/data/schemas/chat/response/index.ts +++ b/src/data/schemas/chat/response/index.ts @@ -3,6 +3,7 @@ */ export * from "./chat_history_response"; +export * from "./chat_previews_response"; export * from "./chat_send_response"; export * from "./unlock_history_response"; export * from "./unlock_private_response"; diff --git a/src/data/services/api/__tests__/character_error_code.test.ts b/src/data/services/api/__tests__/character_error_code.test.ts new file mode 100644 index 00000000..28230c56 --- /dev/null +++ b/src/data/services/api/__tests__/character_error_code.test.ts @@ -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(); + }); +}); diff --git a/src/data/services/api/__tests__/multi_character_api.test.ts b/src/data/services/api/__tests__/multi_character_api.test.ts index 9f2b8d42..ca76475a 100644 --- a/src/data/services/api/__tests__/multi_character_api.test.ts +++ b/src/data/services/api/__tests__/multi_character_api.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { + ChatSyncRequestSchema, SendMessageRequestSchema, UnlockHistoryRequestSchema, UnlockPrivateRequestSchema, @@ -13,9 +14,10 @@ vi.mock("../http_client", () => ({ })); import { ChatApi } from "../chat_api"; +import { CharacterApi } from "../character_api"; import { PrivateRoomApi } from "../private_room_api"; -const CHARACTER_ID = "character_elio"; +const CHARACTER_ID = "elio"; describe("multi-character API contract", () => { 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 () => { const controller = new AbortController(); httpClientMock.mockResolvedValue({ diff --git a/src/data/services/api/api_contract.json b/src/data/services/api/api_contract.json index fcc96f42..da1e9855 100644 --- a/src/data/services/api/api_contract.json +++ b/src/data/services/api/api_contract.json @@ -24,5 +24,8 @@ "privateRoomAlbumUnlock": { "method": "post", "path": "/api/private-room/albums/{albumId}/unlock" }, "metricsPwaEvent": { "method": "post", "path": "/api/metrics/pwa/event" }, "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" } } diff --git a/src/data/services/api/api_path.ts b/src/data/services/api/api_path.ts index 401bb340..81d79584 100644 --- a/src/data/services/api/api_path.ts +++ b/src/data/services/api/api_path.ts @@ -99,4 +99,11 @@ export class ApiPath { // ============ 用户反馈相关 ============ static readonly feedback = apiContract.feedback.path; + // ============ 角色目录相关 ============ + static readonly characters = apiContract.characters.path; + + static readonly chatPreviews = apiContract.chatPreviews.path; + + static readonly chatSync = apiContract.chatSync.path; + } diff --git a/src/data/services/api/character_api.ts b/src/data/services/api/character_api.ts new file mode 100644 index 00000000..64399738 --- /dev/null +++ b/src/data/services/api/character_api.ts @@ -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 { + const env = await httpClient>(ApiPath.characters, { + query: { capability: "chat" }, + }); + return CharacterListResponseSchema.parse(unwrap(env)); + } +} + +export const characterApi = new CharacterApi(); diff --git a/src/data/services/api/character_error_code.ts b/src/data/services/api/character_error_code.ts new file mode 100644 index 00000000..ca6acb8f --- /dev/null +++ b/src/data/services/api/character_error_code.ts @@ -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; + 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) + ); +} diff --git a/src/data/services/api/chat_api.ts b/src/data/services/api/chat_api.ts index 657f2a30..2929b391 100644 --- a/src/data/services/api/chat_api.ts +++ b/src/data/services/api/chat_api.ts @@ -7,8 +7,11 @@ import { ChatHistoryResponse, ChatHistoryResponseSchema, + ChatPreviewsResponse, + ChatPreviewsResponseSchema, ChatSendResponse, ChatSendResponseSchema, + ChatSyncRequest, SendMessageRequest, UnlockHistoryRequest, UnlockHistoryResponse, @@ -55,6 +58,26 @@ export class ChatApi { ); } + async getPreviews( + options?: { signal?: AbortSignal }, + ): Promise { + const env = await httpClient>(ApiPath.chatPreviews, { + ...(options?.signal ? { signal: options.signal } : {}), + }); + return ChatPreviewsResponseSchema.parse(unwrap(env)); + } + + async syncGuestHistory( + body: ChatSyncRequest, + options?: { signal?: AbortSignal }, + ): Promise { + await httpClient>(ApiPath.chatSync, { + method: "POST", + body, + ...(options?.signal ? { signal: options.signal } : {}), + }).then(unwrap); + } + /** * 解锁单条历史付费 / 私密消息 */ diff --git a/src/data/services/api/index.ts b/src/data/services/api/index.ts index 56903766..7ada7295 100644 --- a/src/data/services/api/index.ts +++ b/src/data/services/api/index.ts @@ -6,6 +6,8 @@ export * from "./api_path"; export * from "./api_result"; export * from "./auth_api"; export * from "./chat_api"; +export * from "./character_api"; +export * from "./character_error_code"; export * from "./feedback_api"; export * from "./http_client"; export * from "./metrics_api"; diff --git a/src/data/services/api/private_room_api.ts b/src/data/services/api/private_room_api.ts index ee2b4823..d8f3b33e 100644 --- a/src/data/services/api/private_room_api.ts +++ b/src/data/services/api/private_room_api.ts @@ -1,4 +1,3 @@ -import { DEFAULT_CHARACTER_ID } from "@/data/constants/character"; import { PrivateAlbumsResponse, PrivateAlbumsResponseSchema, @@ -12,19 +11,19 @@ import { httpClient } from "./http_client"; import { type ApiEnvelope, unwrap } from "./response_helper"; export interface GetPrivateAlbumsInput { - characterId?: string; + characterId: string; limit?: number; } export class PrivateRoomApi { async getAlbums( - input: GetPrivateAlbumsInput = {}, + input: GetPrivateAlbumsInput, ): Promise { const env = await httpClient>( ApiPath.privateRoomAlbums, { query: { - characterId: input.characterId ?? DEFAULT_CHARACTER_ID, + characterId: input.characterId, limit: input.limit ?? 20, }, }, diff --git a/src/data/storage/chat/__tests__/local_chat_db_migration.test.ts b/src/data/storage/chat/__tests__/local_chat_db_migration.test.ts deleted file mode 100644 index 0c9d6eff..00000000 --- a/src/data/storage/chat/__tests__/local_chat_db_migration.test.ts +++ /dev/null @@ -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(); - -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("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("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("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 { - 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(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, - }; -} diff --git a/src/data/storage/chat/local_chat_db.ts b/src/data/storage/chat/local_chat_db.ts index a3364577..17e2d740 100644 --- a/src/data/storage/chat/local_chat_db.ts +++ b/src/data/storage/chat/local_chat_db.ts @@ -9,18 +9,12 @@ * 构造时 `dbName` 可注入,便于测试时每个用例用独立 DB 互不污染。 */ -import Dexie, { type Table, type Transaction } from "dexie"; -import { DEFAULT_CHARACTER_ID } from "@/data/constants/character"; +import Dexie, { type Table } from "dexie"; import type { ChatMediaKind } from "@/data/schemas/chat"; import type { ChatImageData, ChatLockDetailData, } from "@/data/schemas/chat"; -import { - buildChatConversationKey, - buildChatMediaCacheKey, - isLegacyChatCacheOwnerKey, -} from "@/lib/chat/chat_cache_keys"; export interface LocalMessageRow { /** Dexie 自增主键(数据库内部使用,不暴露给 LocalMessage 类)。 */ @@ -81,45 +75,6 @@ export class LocalChatDB extends Dexie { .stores({ messages: "++dbId, sessionId", media: "cacheKey, ownerKey, messageId, kind, remoteUrl, updatedAt, lastAccessedAt", - }) - .upgrade(migrateLegacyChatCacheToElio); + }); } } - -async function migrateLegacyChatCacheToElio( - transaction: Transaction, -): Promise { - const messages = transaction.table("messages"); - await messages.toCollection().modify((message) => { - const conversationKey = getElioConversationKey(message.sessionId); - if (conversationKey) message.sessionId = conversationKey; - }); - - const media = transaction.table("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; -} diff --git a/src/data/storage/navigation/__tests__/navigation_storage.test.ts b/src/data/storage/navigation/__tests__/navigation_storage.test.ts index 2aab3fe4..817a5fd4 100644 --- a/src/data/storage/navigation/__tests__/navigation_storage.test.ts +++ b/src/data/storage/navigation/__tests__/navigation_storage.test.ts @@ -6,8 +6,8 @@ import { SessionAsyncUtil } from "@/utils/session-storage"; import { NavigationStorage } from "../navigation_storage"; -const CHARACTER_ID = "character_elio"; -const OTHER_CHARACTER_ID = "character_maya"; +const CHARACTER_ID = "elio"; +const OTHER_CHARACTER_ID = "maya-tan"; describe("NavigationStorage", () => { beforeEach(() => { @@ -164,4 +164,5 @@ describe("NavigationStorage", () => { NavigationStorage.consumePendingChatImageReturn(CHARACTER_ID), ).resolves.toMatchObject({ characterId: CHARACTER_ID }); }); + }); diff --git a/src/data/storage/navigation/navigation_storage.ts b/src/data/storage/navigation/navigation_storage.ts index 3cd72c20..8d1904b1 100644 --- a/src/data/storage/navigation/navigation_storage.ts +++ b/src/data/storage/navigation/navigation_storage.ts @@ -4,6 +4,7 @@ import { z } from "zod"; import { StorageKeys } from "@/data/storage/storage_keys"; import { ChatLockTypeSchema } from "@/data/schemas/chat"; +import { getCharacterById } from "@/data/constants/character"; import { Result } from "@/utils/result"; import { SessionAsyncUtil } from "@/utils/session-storage"; @@ -254,28 +255,53 @@ export class NavigationStorage { return value; } + static async clearPendingChatImageReturn(): Promise { + await SessionAsyncUtil.remove(StorageKeys.pendingChatImageReturn); + } + + static async saveGuestChatOwnerKey(ownerKey: string): Promise { + await SessionAsyncUtil.setJson( + StorageKeys.guestChatOwnerKey, + ownerKey, + z.string().min(1), + ); + } + + static async getGuestChatOwnerKey(): Promise { + const result = await SessionAsyncUtil.getJson( + StorageKeys.guestChatOwnerKey, + z.string().min(1), + ); + return Result.isOk(result) ? result.data : null; + } + private static parsePendingChatUnlock( value: PendingChatUnlock | null, ): PendingChatUnlock | null { - if (!value) return null; - if (Date.now() - value.createdAt > MAX_AGE_MS) return null; - return value; + if (!value || Date.now() - value.createdAt > MAX_AGE_MS) return null; + if (!getCharacterById(value.characterId)) return null; + const promotion = value.promotion + ? NavigationStorage.parsePendingChatPromotion(value.promotion) + : undefined; + if (value.promotion && !promotion) return null; + return PendingChatUnlockSchema.parse({ + ...value, + ...(promotion ? { promotion } : {}), + }); } private static parsePendingChatImageReturn( value: PendingChatImageReturn | null, ): PendingChatImageReturn | null { - if (!value) return null; - if (Date.now() - value.createdAt > MAX_AGE_MS) return null; - return value; + if (!value || Date.now() - value.createdAt > MAX_AGE_MS) return null; + return getCharacterById(value.characterId) ? value : null; } private static parsePendingChatPromotion( value: PendingChatPromotion | null, ): PendingChatPromotion | null { - if (!value) return null; - if (Date.now() - value.createdAt > MAX_AGE_MS) return null; - return value; + if (!value || Date.now() - value.createdAt > MAX_AGE_MS) return null; + return getCharacterById(value.characterId) ? value : null; } } diff --git a/src/data/storage/storage_keys.ts b/src/data/storage/storage_keys.ts index ad13632c..7ac1963b 100644 --- a/src/data/storage/storage_keys.ts +++ b/src/data/storage/storage_keys.ts @@ -28,6 +28,7 @@ export const StorageKeys = { pendingChatImageReturn: "pending_chat_image_return", pendingChatUnlock: "pending_chat_unlock", pendingChatPromotion: "pending_chat_promotion", + guestChatOwnerKey: "guest_chat_owner_key", // pwa / app info pwaDialogShown: "pwa_dialog_shown", diff --git a/src/lib/chat/__tests__/splash_latest_message.test.ts b/src/lib/chat/__tests__/splash_latest_message.test.ts index e6d34bf8..a68a405e 100644 --- a/src/lib/chat/__tests__/splash_latest_message.test.ts +++ b/src/lib/chat/__tests__/splash_latest_message.test.ts @@ -3,9 +3,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { Result } from "@/utils/result"; const getHistoryMock = vi.hoisted(() => vi.fn()); +const getPreviewsMock = vi.hoisted(() => vi.fn()); vi.mock("@/data/repositories/chat_repository_loader", () => ({ - loadChatRepository: async () => ({ getHistory: getHistoryMock }), + loadChatRepository: async () => ({ + getHistory: getHistoryMock, + getPreviews: getPreviewsMock, + }), })); import { @@ -17,6 +21,7 @@ import { createSplashLatestMessageCache } from "../splash_latest_message_cache"; describe("fetchSplashLatestMessagePreview", () => { beforeEach(() => { getHistoryMock.mockReset(); + getPreviewsMock.mockReset(); }); 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( - "character_elio", + "elio", 1, 0, ); @@ -50,13 +55,44 @@ describe("fetchSplashLatestMessagePreview", () => { it("returns null when history is empty", async () => { getHistoryMock.mockResolvedValue(Result.ok({ messages: [] })); - const result = await fetchSplashLatestMessagePreview(); + const result = await fetchSplashLatestMessagePreview("elio"); expect(Result.isOk(result) && result.data).toBeNull(); }); }); 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 () => { const cache = createSplashLatestMessageCache(); const fetchPreview = vi.fn().mockResolvedValue(Result.ok("Hello again")); @@ -64,11 +100,13 @@ describe("loadSplashLatestMessagePreview", () => { const first = await loadSplashLatestMessagePreview({ cache, + characterId: "elio", fetchPreview, resolveIdentity, }); const second = await loadSplashLatestMessagePreview({ cache, + characterId: "elio", fetchPreview, resolveIdentity, }); @@ -85,6 +123,7 @@ describe("loadSplashLatestMessagePreview", () => { const result = await loadSplashLatestMessagePreview({ cache, + characterId: "elio", fetchPreview, resolveIdentity: vi .fn() @@ -105,11 +144,13 @@ describe("loadSplashLatestMessagePreview", () => { await loadSplashLatestMessagePreview({ cache, + characterId: "elio", fetchPreview, resolveIdentity, }); const retry = await loadSplashLatestMessagePreview({ cache, + characterId: "elio", fetchPreview, resolveIdentity, }); @@ -127,11 +168,13 @@ describe("loadSplashLatestMessagePreview", () => { await loadSplashLatestMessagePreview({ cache, + characterId: "elio", fetchPreview, resolveIdentity, }); await loadSplashLatestMessagePreview({ cache, + characterId: "elio", fetchPreview, resolveIdentity, }); diff --git a/src/lib/chat/chat_cache_keys.ts b/src/lib/chat/chat_cache_keys.ts index f9e4291b..1d740466 100644 --- a/src/lib/chat/chat_cache_keys.ts +++ b/src/lib/chat/chat_cache_keys.ts @@ -1,7 +1,6 @@ import type { ChatMediaKind } from "@/data/schemas/chat"; const CHARACTER_NAMESPACE = "::character:"; -const LEGACY_OWNER_KEY_PATTERN = /^(?:user|device):.+$/u; export function buildChatConversationKey( ownerKey: string, @@ -20,10 +19,3 @@ export function buildChatMediaCacheKey(input: { }): string { return `${input.ownerKey}:${input.messageId}:${input.kind}`; } - -export function isLegacyChatCacheOwnerKey(ownerKey: string): boolean { - return ( - !ownerKey.includes(CHARACTER_NAMESPACE) && - LEGACY_OWNER_KEY_PATTERN.test(ownerKey) - ); -} diff --git a/src/lib/chat/splash_latest_message.ts b/src/lib/chat/splash_latest_message.ts index c9634d64..75309783 100644 --- a/src/lib/chat/splash_latest_message.ts +++ b/src/lib/chat/splash_latest_message.ts @@ -3,7 +3,6 @@ import { resolveChatConversationKey, type ChatCacheIdentityResolver, } from "@/data/repositories/chat_cache_identity"; -import { DEFAULT_CHARACTER_ID } from "@/data/constants/character"; import { Result, type Result as ResultT } from "@/utils/result"; import type { SplashLatestMessageCache } from "./splash_latest_message_cache"; @@ -11,13 +10,26 @@ import { getLatestSplashMessagePreview } from "./splash_latest_message_preview"; export interface LoadSplashLatestMessagePreviewInput { cache: SplashLatestMessageCache; - characterId?: string; + characterId: string; fetchPreview?: (characterId: string) => Promise>; + fetchPreviews?: () => Promise< + ResultT< + readonly { + characterId: string; + message: import("@/data/schemas/chat").ChatMessage | null; + }[] + > + >; resolveIdentity?: ChatCacheIdentityResolver; + resolvePreviewIdentity?: ChatConversationIdentityResolver; } +type ChatConversationIdentityResolver = ( + characterId: string, +) => Promise>; + export async function resolveSplashLatestMessageCacheIdentity( - characterId = DEFAULT_CHARACTER_ID, + characterId: string, ): Promise { const result = await resolveChatConversationKey(characterId); return Result.isOk(result) ? result.data : null; @@ -25,9 +37,11 @@ export async function resolveSplashLatestMessageCacheIdentity( export async function loadSplashLatestMessagePreview({ cache, - characterId = DEFAULT_CHARACTER_ID, + characterId, fetchPreview = fetchSplashLatestMessagePreview, + fetchPreviews = fetchSplashLatestMessagePreviews, resolveIdentity = () => resolveChatConversationKey(characterId), + resolvePreviewIdentity = resolveChatConversationKey, }: LoadSplashLatestMessagePreviewInput): Promise> { const identityResult = await resolveIdentity(); const identity = Result.isOk(identityResult) ? identityResult.data : null; @@ -37,15 +51,67 @@ export async function loadSplashLatestMessagePreview({ 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)) { cache.set(identity, result.data); } return result; } +async function loadBatchPreview(input: { + cache: SplashLatestMessageCache; + characterId: string; + fetchPreviews: NonNullable; + resolvePreviewIdentity: ChatConversationIdentityResolver; +}): Promise> { + 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( - characterId = DEFAULT_CHARACTER_ID, + characterId: string, ): Promise> { const repository = await loadChatRepository(); const result = await repository.getHistory(characterId, 1, 0); diff --git a/src/lib/navigation/__tests__/subscription_exit.test.ts b/src/lib/navigation/__tests__/subscription_exit.test.ts index bd7ed528..6da32fa5 100644 --- a/src/lib/navigation/__tests__/subscription_exit.test.ts +++ b/src/lib/navigation/__tests__/subscription_exit.test.ts @@ -39,7 +39,7 @@ describe("subscription exit helpers", () => { it("uses explicit chat image return urls first", async () => { consumePendingChatImageReturnMock.mockResolvedValue({ reason: "image_paywall", - characterId: "character_elio", + characterId: "elio", messageId: "msg_1", returnUrl: "/chat?image=msg_1", createdAt: 1, @@ -74,14 +74,14 @@ describe("subscription exit helpers", () => { it("returns directly to private room without consuming chat state", async () => { consumePendingChatImageReturnMock.mockResolvedValue({ reason: "image_paywall", - characterId: "character_elio", + characterId: "elio", messageId: "msg_1", returnUrl: "/chat?image=msg_1", createdAt: 1, }); peekPendingChatUnlockMock.mockResolvedValue({ reason: "single_message_unlock", - characterId: "character_elio", + characterId: "elio", displayMessageId: "msg_1", messageId: "msg_1", kind: "image", @@ -104,7 +104,7 @@ describe("subscription exit helpers", () => { it("keeps private room ahead of stale chat state after payment", async () => { peekPendingChatUnlockMock.mockResolvedValue({ reason: "single_message_unlock", - characterId: "character_elio", + characterId: "elio", displayMessageId: "msg_1", messageId: "msg_1", kind: "image", @@ -123,7 +123,7 @@ describe("subscription exit helpers", () => { it("keeps pending chat unlocks ahead of chat fallback after payment", async () => { peekPendingChatUnlockMock.mockResolvedValue({ reason: "single_message_unlock", - characterId: "character_elio", + characterId: "elio", displayMessageId: "msg_1", messageId: "msg_1", kind: "image", @@ -141,7 +141,7 @@ describe("subscription exit helpers", () => { it("peeks chat unlock return urls without clearing them", async () => { peekPendingChatUnlockMock.mockResolvedValue({ reason: "single_message_unlock", - characterId: "character_elio", + characterId: "elio", displayMessageId: "msg_1", messageId: "msg_1", kind: "image", @@ -160,7 +160,7 @@ describe("subscription exit helpers", () => { consumePendingChatImageReturnMock.mockResolvedValue(null); peekPendingChatUnlockMock.mockResolvedValue({ reason: "single_message_unlock", - characterId: "character_elio", + characterId: "elio", displayMessageId: "msg_1", messageId: "msg_1", kind: "image", diff --git a/src/lib/navigation/chat_unlock_session.ts b/src/lib/navigation/chat_unlock_session.ts index ac3393fb..f653d672 100644 --- a/src/lib/navigation/chat_unlock_session.ts +++ b/src/lib/navigation/chat_unlock_session.ts @@ -67,3 +67,11 @@ export async function consumePendingChatPromotion( export async function clearPendingChatPromotion(): Promise { await NavigationStorage.clearPendingChatPromotion(); } + +export async function clearPendingChatNavigation(): Promise { + await Promise.all([ + NavigationStorage.clearPendingChatUnlock(), + NavigationStorage.clearPendingChatPromotion(), + NavigationStorage.clearPendingChatImageReturn(), + ]); +} diff --git a/src/providers/__tests__/character-actor-scope-providers.test.tsx b/src/providers/__tests__/character-actor-scope-providers.test.tsx index 4cae9214..fd9eb283 100644 --- a/src/providers/__tests__/character-actor-scope-providers.test.tsx +++ b/src/providers/__tests__/character-actor-scope-providers.test.tsx @@ -108,36 +108,36 @@ describe("character actor route providers", () => { }); it("recreates chat and chat payment actors when the character changes", () => { - renderChat("character_elio"); + renderChat("elio"); const firstChatInstance = instanceId("chat-provider"); const firstPaymentInstance = instanceId("payment-provider"); - renderChat("character_maya"); + renderChat("maya-tan"); expect(instanceId("chat-provider")).not.toBe(firstChatInstance); expect(instanceId("payment-provider")).not.toBe(firstPaymentInstance); expect(testNode("chat-provider").dataset.characterId).toBe( - "character_maya", + "maya-tan", ); }); it("recreates the private-room actor when the character changes", () => { - renderPrivateRoom("character_elio"); + renderPrivateRoom("elio"); const firstInstance = instanceId("private-room-provider"); - renderPrivateRoom("character_maya"); + renderPrivateRoom("maya-tan"); expect(instanceId("private-room-provider")).not.toBe(firstInstance); expect(testNode("private-room-provider").dataset.characterId).toBe( - "character_maya", + "maya-tan", ); }); it("recreates the route payment actor when its character scope changes", () => { - renderPayment("character_elio"); + renderPayment("elio"); const firstInstance = instanceId("payment-provider"); - renderPayment("character_maya"); + renderPayment("maya-tan"); expect(instanceId("payment-provider")).not.toBe(firstInstance); }); diff --git a/src/providers/__tests__/character-route-boundary.test.tsx b/src/providers/__tests__/character-route-boundary.test.tsx new file mode 100644 index 00000000..48959955 --- /dev/null +++ b/src/providers/__tests__/character-route-boundary.test.tsx @@ -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( + + Chat actor mounted + , + ); + }); + + 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( + + Maya + , + ); + }); + + 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, + }; +} diff --git a/src/providers/character-catalog-provider.tsx b/src/providers/character-catalog-provider.tsx index 9006cd2f..1fc0ae35 100644 --- a/src/providers/character-catalog-provider.tsx +++ b/src/providers/character-catalog-provider.tsx @@ -2,19 +2,36 @@ import { createContext, + useCallback, + useEffect, type ReactNode, useContext, - useMemo, + useState, } from "react"; import { CHARACTERS, createCharacterCatalog, + DEFAULT_CHARACTER, + mergeRemoteCharacterCatalog, type CharacterCatalog, type CharacterProfile, } 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(null); +export type CharacterCatalogStatus = "loading" | "ready" | "refreshing"; + +export interface CharacterCatalogContextValue extends CharacterCatalog { + readonly status: CharacterCatalogStatus; + readonly defaultCharacter: CharacterProfile; + readonly isFallback: boolean; + refresh(): Promise; +} + +const CharacterCatalogContext = + createContext(null); export interface CharacterCatalogProviderProps { children: ReactNode; @@ -25,18 +42,74 @@ export function CharacterCatalogProvider({ children, characters = CHARACTERS, }: CharacterCatalogProviderProps) { - const catalog = useMemo( - () => createCharacterCatalog(characters), - [characters], - ); + const fallbackCharacters = AppEnvUtil.isProduction() + ? characters.filter((character) => character.id === DEFAULT_CHARACTER.id) + : 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 ( - + {children} ); } -export function useCharacterCatalog(): CharacterCatalog { +export function useCharacterCatalog(): CharacterCatalogContextValue { const catalog = useContext(CharacterCatalogContext); if (!catalog) { throw new Error( diff --git a/src/providers/character-route-boundary.tsx b/src/providers/character-route-boundary.tsx new file mode 100644 index 00000000..cef8a8a0 --- /dev/null +++ b/src/providers/character-route-boundary.tsx @@ -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 ( +
+
+
+ ); + } + + return ( + + {children} + + ); +} diff --git a/src/stores/chat/__tests__/chat-actor-cancellation.test.ts b/src/stores/chat/__tests__/chat-actor-cancellation.test.ts index 268c47ca..953cfd07 100644 --- a/src/stores/chat/__tests__/chat-actor-cancellation.test.ts +++ b/src/stores/chat/__tests__/chat-actor-cancellation.test.ts @@ -27,7 +27,7 @@ vi.mock("@/data/repositories/chat_repository_loader", () => ({ vi.mock("@/data/repositories/chat_cache_identity", () => ({ resolveChatConversationKey: vi.fn(async () => ({ 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 () => { repository.sendMessage.mockImplementation(() => new Promise(() => undefined)); const actor = createActor(sendMessageHttpActor, { - input: { characterId: "character_elio", content: "hello" }, + input: { characterId: "elio", content: "hello" }, }).start(); await vi.waitFor(() => expect(repository.sendMessage).toHaveBeenCalled()); @@ -63,7 +63,7 @@ describe("chat actor request cancellation", () => { ); const actor = createActor(loadHistoryActor, { input: { - characterId: "character_elio", + characterId: "elio", emptyChatGreeting: "Hello from Elio", }, }).start(); @@ -85,7 +85,7 @@ describe("chat actor request cancellation", () => { ); const actor = createActor(unlockHistoryActor, { input: { - characterId: "character_elio", + characterId: "elio", emptyChatGreeting: "Hello from Elio", }, }).start(); diff --git a/src/stores/chat/__tests__/chat-helpers.test.ts b/src/stores/chat/__tests__/chat-helpers.test.ts index e219d0df..561d901f 100644 --- a/src/stores/chat/__tests__/chat-helpers.test.ts +++ b/src/stores/chat/__tests__/chat-helpers.test.ts @@ -38,6 +38,7 @@ function makeResponse( function makeChatState(overrides: Partial = {}): ChatState { return { characterId: DEFAULT_CHARACTER_ID, + characterErrorCode: null, emptyChatGreeting: DEFAULT_CHARACTER.emptyChatGreeting, messages: [], promotion: null, diff --git a/src/stores/chat/__tests__/chat-machine.test-utils.ts b/src/stores/chat/__tests__/chat-machine.test-utils.ts index 6553f5f7..26182c9c 100644 --- a/src/stores/chat/__tests__/chat-machine.test-utils.ts +++ b/src/stores/chat/__tests__/chat-machine.test-utils.ts @@ -80,6 +80,7 @@ export function createTestChatMachine( unlockHistoryOutput?: UnlockHistoryOutput; unlockHistoryError?: Error; unlockMessageOutput?: TestUnlockMessageOutput; + unlockMessageError?: Error; } = {}, ) { return chatMachine.provide({ @@ -134,6 +135,7 @@ export function createTestChatMachine( MachineUnlockMessageOutput, UnlockMessageRequest & { characterId: string } >(async ({ input }) => { + if (options.unlockMessageError) throw options.unlockMessageError; const output = options.unlockMessageOutput ?? { messageId: input.messageId ?? input.displayMessageId, response: makeUnlockPrivateResponse(), diff --git a/src/stores/chat/__tests__/chat-promotion.test.ts b/src/stores/chat/__tests__/chat-promotion.test.ts index cf287f77..f3f368b0 100644 --- a/src/stores/chat/__tests__/chat-promotion.test.ts +++ b/src/stores/chat/__tests__/chat-promotion.test.ts @@ -9,7 +9,7 @@ import { } from "@/stores/chat/helper/promotion"; const promotion: PendingChatPromotion = { - characterId: "character_elio", + characterId: "elio", promotionType: "image", lockType: "image_paywall", clientLockId: "promotion-1", diff --git a/src/stores/chat/__tests__/chat-session-flow.test.ts b/src/stores/chat/__tests__/chat-session-flow.test.ts index 56b48bc3..74cb46fd 100644 --- a/src/stores/chat/__tests__/chat-session-flow.test.ts +++ b/src/stores/chat/__tests__/chat-session-flow.test.ts @@ -10,13 +10,13 @@ describe("chat session flow", () => { it("initializes the conversation from the supplied character", () => { const actor = createActor(createTestChatMachine(), { input: { - characterId: "character_maya", + characterId: "maya-tan", emptyChatGreeting: "Hello from Maya", }, }).start(); expect(actor.getSnapshot().context).toMatchObject({ - characterId: "character_maya", + characterId: "maya-tan", emptyChatGreeting: "Hello from Maya", }); actor.stop(); @@ -25,7 +25,7 @@ describe("chat session flow", () => { it("keeps the active character greeting across login state changes", async () => { const actor = createActor(createTestChatMachine(), { input: { - characterId: "character_maya", + characterId: "maya-tan", emptyChatGreeting: "Hello from Maya", }, }).start(); @@ -113,7 +113,7 @@ describe("chat session flow", () => { actor.send({ type: "ChatPromotionInjected", promotion: { - characterId: "character_elio", + characterId: "elio", promotionType: "voice", lockType: "voice_message", clientLockId: "promotion-1", diff --git a/src/stores/chat/__tests__/chat-unlock-flow.test.ts b/src/stores/chat/__tests__/chat-unlock-flow.test.ts index abe30431..f24c3b72 100644 --- a/src/stores/chat/__tests__/chat-unlock-flow.test.ts +++ b/src/stores/chat/__tests__/chat-unlock-flow.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { createActor, fromPromise, waitFor } from "xstate"; import { ChatSendResponseSchema } from "@/data/schemas/chat"; +import { ApiError } from "@/data/services/api"; import type { UnlockMessageOutput, UnlockMessageRequest, @@ -565,6 +566,50 @@ describe("chat unlock flow", () => { 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 () => { let capturedRequest: UnlockMessageRequest | null = null; let resolveUnlock!: (output: UnlockMessageOutput) => void; diff --git a/src/stores/chat/__tests__/guest-history-sync.test.ts b/src/stores/chat/__tests__/guest-history-sync.test.ts new file mode 100644 index 00000000..d2850c3e --- /dev/null +++ b/src/stores/chat/__tests__/guest-history-sync.test.ts @@ -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(); + }); +}); diff --git a/src/stores/chat/chat-context.tsx b/src/stores/chat/chat-context.tsx index 7dd233ba..adebe37e 100644 --- a/src/stores/chat/chat-context.tsx +++ b/src/stores/chat/chat-context.tsx @@ -16,11 +16,13 @@ import { appendPromotionMessage } from "./helper/promotion"; */ interface ChatState { characterId: string; + characterErrorCode: MachineContext["characterErrorCode"]; messages: MachineContext["messages"]; historyMessages: MachineContext["messages"]; promotion: MachineContext["promotion"]; outgoingMessageRevision: number; isReplyingAI: boolean; + canSendMessage: boolean; upgradePromptVisible: boolean; upgradeReason: MachineContext["upgradeReason"]; /** history 初始加载完成标志(local → network → save 跑完) —— UI 可用此显示 loading */ @@ -87,10 +89,12 @@ type SelectedChatState = Omit; function selectChatState(state: ChatSnapshot): SelectedChatState { return { characterId: state.context.characterId, + characterErrorCode: state.context.characterErrorCode, historyMessages: state.context.messages, promotion: state.context.promotion, outgoingMessageRevision: state.context.outgoingMessageRevision, isReplyingAI: state.context.isReplyingAI, + canSendMessage: state.context.canSendMessage, upgradePromptVisible: state.context.upgradePromptVisible, upgradeReason: state.context.upgradeReason, historyLoaded: state.context.historyLoaded, diff --git a/src/stores/chat/chat-events.ts b/src/stores/chat/chat-events.ts index 12603c33..ff960b44 100644 --- a/src/stores/chat/chat-events.ts +++ b/src/stores/chat/chat-events.ts @@ -39,6 +39,7 @@ export type ChatEvent = output: import("./machine/actors/history").LoadMoreHistoryOutput; } | { type: "ChatOlderHistoryLoadFailed"; error: unknown } + | { type: "ChatHistoryRefreshRequested" } // 业务事件 | { type: "ChatSendMessage"; content: string } | { type: "ChatSendImage"; imageBase64: string } @@ -65,4 +66,9 @@ export type ChatEvent = type: "ChatQueuedHttpDone"; output: import("./helper/send-state").HttpSendOutput; } - | { type: "ChatQueuedSendError"; content: string; errorMessage: string }; + | { + type: "ChatQueuedSendError"; + content: string; + errorMessage: string; + characterErrorCode?: import("@/data/services/api").CharacterErrorCode; + }; diff --git a/src/stores/chat/chat-history-sync.ts b/src/stores/chat/chat-history-sync.ts index d221c6a7..0a78cfde 100644 --- a/src/stores/chat/chat-history-sync.ts +++ b/src/stores/chat/chat-history-sync.ts @@ -5,6 +5,7 @@ import { Logger } from "@/utils/logger"; import { Result } from "@/utils/result"; import { todayString } from "@/utils/date"; import { isAbortError } from "@/utils/abort"; +import { getCharacterErrorCode } from "@/data/services/api"; import { CHAT_HISTORY_LIMIT } from "./helper/history"; import { localMessagesToUi } from "./helper/message-mappers"; @@ -94,6 +95,7 @@ export async function syncNetworkHistory( ); if (Result.isErr(networkResult)) { if (isAbortError(networkResult.error)) throw networkResult.error; + if (getCharacterErrorCode(networkResult.error)) throw networkResult.error; log.error("[chat-machine] loadHistory NETWORK FAILED", { error: networkResult.error, }); diff --git a/src/stores/chat/chat-machine.ts b/src/stores/chat/chat-machine.ts index 77b18203..09b24542 100644 --- a/src/stores/chat/chat-machine.ts +++ b/src/stores/chat/chat-machine.ts @@ -2,11 +2,6 @@ import { chatMachineSetup, chatRootStateConfig, } from "./machine/session-flow"; -import { - DEFAULT_CHARACTER, - DEFAULT_CHARACTER_ID, -} from "@/data/constants/character"; - import { createInitialChatState } from "./chat-state"; export type { ChatState } from "./chat-state"; @@ -17,8 +12,8 @@ export const chatMachine = chatMachineSetup.createMachine({ id: "chat", context: ({ input }) => createInitialChatState( - input?.characterId ?? DEFAULT_CHARACTER_ID, - input?.emptyChatGreeting ?? DEFAULT_CHARACTER.emptyChatGreeting, + input.characterId, + input.emptyChatGreeting, ), ...chatRootStateConfig, }); diff --git a/src/stores/chat/chat-state.ts b/src/stores/chat/chat-state.ts index 79c679e0..359f8d51 100644 --- a/src/stores/chat/chat-state.ts +++ b/src/stores/chat/chat-state.ts @@ -7,6 +7,7 @@ import { DEFAULT_CHARACTER_ID, } from "@/data/constants/character"; import type { ChatPromotionState } from "./helper/promotion"; +import type { CharacterErrorCode } from "@/data/services/api"; export type ChatUpgradeReason = "insufficient_credits"; @@ -29,6 +30,7 @@ export interface ChatUnlockPaywallRequest export interface ChatState { characterId: string; + characterErrorCode: CharacterErrorCode | null; emptyChatGreeting: string; messages: UiMessage[]; promotion: ChatPromotionState | null; @@ -58,11 +60,12 @@ export interface ChatState { } export function createInitialChatState( - characterId: string = DEFAULT_CHARACTER_ID, - emptyChatGreeting: string = DEFAULT_CHARACTER.emptyChatGreeting, + characterId: string, + emptyChatGreeting: string, ): ChatState { return { characterId, + characterErrorCode: null, emptyChatGreeting, messages: [], 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, +); diff --git a/src/stores/chat/guest-history-sync.ts b/src/stores/chat/guest-history-sync.ts new file mode 100644 index 00000000..4022735e --- /dev/null +++ b/src/stores/chat/guest-history-sync.ts @@ -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 { + 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(); +} diff --git a/src/stores/chat/machine/actors/send.ts b/src/stores/chat/machine/actors/send.ts index 9c45d110..eaaa5f59 100644 --- a/src/stores/chat/machine/actors/send.ts +++ b/src/stores/chat/machine/actors/send.ts @@ -8,6 +8,7 @@ import { resolveChatConversationKey } from "@/data/repositories/chat_cache_ident import { Logger } from "@/utils/logger"; import { Result } from "@/utils/result"; import { isAbortError } from "@/utils/abort"; +import { getCharacterErrorCode } from "@/data/services/api"; import type { ChatEvent } from "../../chat-events"; import { sendResponseToUiMessage } from "../../helper/message-mappers"; @@ -61,6 +62,9 @@ function createMessageQueueActor( type: "ChatQueuedSendError", content, errorMessage, + ...(getCharacterErrorCode(error) + ? { characterErrorCode: getCharacterErrorCode(error) ?? undefined } + : {}), }); } finally { if (activeController === controller) activeController = null; diff --git a/src/stores/chat/machine/history-flow.ts b/src/stores/chat/machine/history-flow.ts index 9f82a737..4e5db9e5 100644 --- a/src/stores/chat/machine/history-flow.ts +++ b/src/stores/chat/machine/history-flow.ts @@ -10,6 +10,7 @@ import { } from "../helper/unlock"; import type { ChatState } from "../chat-state"; import { baseChatMachineSetup } from "./setup"; +import { getCharacterErrorCode } from "@/data/services/api"; const applyLocalHistoryLoadedAction = baseChatMachineSetup.assign( ({ event }) => { @@ -48,9 +49,21 @@ const showUnlockHistoryPromptFromNetworkAction = baseChatMachineSetup.assign( }, ); -const markHistoryLoadFailedAction = baseChatMachineSetup.assign({ - historyLoaded: true, -}); +const markHistoryLoadFailedAction = baseChatMachineSetup.assign( + ({ context, event }) => { + if (event.type !== "ChatHistoryLoadFailed") return {}; + const characterErrorCode = getCharacterErrorCode(event.error); + return { + historyLoaded: true, + characterErrorCode, + canSendMessage: + characterErrorCode === "CHARACTER_DISABLED" || + characterErrorCode === "CHARACTER_NOT_FOUND" + ? false + : context.canSendMessage, + }; + }, +); const markLoadMoreHistoryStartedAction = baseChatMachineSetup.assign({ isLoadingMoreHistory: true, diff --git a/src/stores/chat/machine/send-flow.ts b/src/stores/chat/machine/send-flow.ts index 7cb07d4e..1ae81a23 100644 --- a/src/stores/chat/machine/send-flow.ts +++ b/src/stores/chat/machine/send-flow.ts @@ -76,16 +76,30 @@ const appendUserMessageAction = 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 = [ ...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, date: todayString(), }, ]; - return { messages, ...finishPendingReply(context) }; + return { + messages, + ...finishPendingReply(context), + characterErrorCode, + canSendMessage: + characterErrorCode === "CHARACTER_DISABLED" || + characterErrorCode === "CHARACTER_NOT_FOUND" + ? false + : context.canSendMessage, + }; }, ); diff --git a/src/stores/chat/machine/session-flow.ts b/src/stores/chat/machine/session-flow.ts index 1bd60522..1036247c 100644 --- a/src/stores/chat/machine/session-flow.ts +++ b/src/stores/chat/machine/session-flow.ts @@ -189,6 +189,11 @@ const userSessionState = chatMachineSetup.createStateConfig({ }, ], on: { + ChatHistoryRefreshRequested: { + target: "#chat.userSession", + reenter: true, + actions: "startUserSession", + }, ...historyPaginationTransitions, ChatGuestLogin: { target: "#chat.guestSession", diff --git a/src/stores/chat/machine/setup.ts b/src/stores/chat/machine/setup.ts index bfc1fefd..598c6613 100644 --- a/src/stores/chat/machine/setup.ts +++ b/src/stores/chat/machine/setup.ts @@ -16,15 +16,15 @@ import { import type { ChatState } from "../chat-state"; export interface ChatMachineInput { - characterId?: string; - emptyChatGreeting?: string; + characterId: string; + emptyChatGreeting: string; } export const baseChatMachineSetup = setup({ types: { context: {} as ChatState, events: {} as ChatEvent, - input: {} as ChatMachineInput | undefined, + input: {} as ChatMachineInput, }, actors: { loadHistory: loadHistoryActor, diff --git a/src/stores/chat/machine/unlock-flow.ts b/src/stores/chat/machine/unlock-flow.ts index 5d46cfce..216f9a36 100644 --- a/src/stores/chat/machine/unlock-flow.ts +++ b/src/stores/chat/machine/unlock-flow.ts @@ -9,6 +9,7 @@ import { import type { UnlockHistoryOutput } from "./actors/unlock"; import { sendMachineSetup } from "./send-flow"; import { createChatActorActionSetup } from "./setup"; +import { getCharacterErrorCode } from "@/data/services/api"; const markPaymentUnlockPendingAction = sendMachineSetup.assign(() => ({ paymentUnlockPending: true, @@ -146,6 +147,13 @@ const clearUnlockPaywallRequestAction = sendMachineSetup.assign(() => ({ unlockPaywallRequest: null, })); +const handleCharacterMismatchAction = sendMachineSetup.assign(() => ({ + unlockingMessage: null, + unlockMessageError: "character_mismatch", + unlockPaywallRequest: null, + characterErrorCode: "CHARACTER_MISMATCH" as const, +})); + export const unlockMachineSetup = sendMachineSetup.extend({ actions: { markPaymentUnlockPending: markPaymentUnlockPendingAction, @@ -158,6 +166,7 @@ export const unlockMachineSetup = sendMachineSetup.extend({ requestUnlockPaymentFromOutput: requestUnlockPaymentFromOutputAction, requestUnlockPaymentFromError: requestUnlockPaymentFromErrorAction, clearUnlockPaywallRequest: clearUnlockPaywallRequestAction, + handleCharacterMismatch: handleCharacterMismatchAction, }, }); @@ -225,9 +234,17 @@ export const unlockingMessageState = unlockMachineSetup.createStateConfig({ actions: "requestUnlockPaymentFromOutput", }, ], - onError: { - target: "ready", - actions: "requestUnlockPaymentFromError", - }, + onError: [ + { + guard: ({ event }) => + getCharacterErrorCode(event.error) === "CHARACTER_MISMATCH", + target: "ready", + actions: "handleCharacterMismatch", + }, + { + target: "ready", + actions: "requestUnlockPaymentFromError", + }, + ], }, }); diff --git a/src/stores/payment/__tests__/payment-order-flow.test.ts b/src/stores/payment/__tests__/payment-order-flow.test.ts index 15dbde36..36e95e16 100644 --- a/src/stores/payment/__tests__/payment-order-flow.test.ts +++ b/src/stores/payment/__tests__/payment-order-flow.test.ts @@ -87,7 +87,7 @@ describe("payment order flow", () => { await initialize(actor); actor.send({ type: "PaymentCreateOrderSubmitted", - recipientCharacterId: "character_maya", + recipientCharacterId: "maya-tan", }); await waitFor(actor, (snapshot) => snapshot.matches("paid")); @@ -95,7 +95,7 @@ describe("payment order flow", () => { planId: "vip_monthly", payChannel: "stripe", autoRenew: true, - recipientCharacterId: "character_maya", + recipientCharacterId: "maya-tan", }); actor.stop(); }); diff --git a/src/stores/private-room/machine/setup.ts b/src/stores/private-room/machine/setup.ts index 43084551..b594f5c3 100644 --- a/src/stores/private-room/machine/setup.ts +++ b/src/stores/private-room/machine/setup.ts @@ -9,7 +9,7 @@ import { } from "./actors/albums"; export interface PrivateRoomMachineInput { - characterId?: string; + characterId: string; } export const basePrivateRoomMachineSetup = setup({ diff --git a/src/stores/private-room/private-room-machine.ts b/src/stores/private-room/private-room-machine.ts index f5565a1c..ff07cfab 100644 --- a/src/stores/private-room/private-room-machine.ts +++ b/src/stores/private-room/private-room-machine.ts @@ -2,8 +2,6 @@ import { privateRoomMachineSetup, privateRoomRootStateConfig, } from "./machine/unlock-flow"; -import { DEFAULT_CHARACTER_ID } from "@/data/constants/character"; - import { createInitialPrivateRoomState } from "./private-room-state"; export type { PrivateRoomEvent } from "./private-room-events"; @@ -13,9 +11,7 @@ export { initialState } from "./private-room-state"; export const privateRoomMachine = privateRoomMachineSetup.createMachine({ id: "privateRoom", context: ({ input }) => - createInitialPrivateRoomState( - input?.characterId ?? DEFAULT_CHARACTER_ID, - ), + createInitialPrivateRoomState(input.characterId), ...privateRoomRootStateConfig, }); diff --git a/src/stores/private-room/private-room-state.ts b/src/stores/private-room/private-room-state.ts index 93308105..4de50b05 100644 --- a/src/stores/private-room/private-room-state.ts +++ b/src/stores/private-room/private-room-state.ts @@ -22,7 +22,7 @@ export interface PrivateRoomState { } export function createInitialPrivateRoomState( - characterId = DEFAULT_CHARACTER_ID, + characterId: string, ): PrivateRoomState { return { characterId, @@ -37,4 +37,4 @@ export function createInitialPrivateRoomState( }; } -export const initialState = createInitialPrivateRoomState(); +export const initialState = createInitialPrivateRoomState(DEFAULT_CHARACTER_ID); diff --git a/src/stores/sync/chat-auth-sync.tsx b/src/stores/sync/chat-auth-sync.tsx index 93e57cb6..ce3a94b2 100644 --- a/src/stores/sync/chat-auth-sync.tsx +++ b/src/stores/sync/chat-auth-sync.tsx @@ -16,6 +16,11 @@ import { useAuthSelector, } from "@/stores/auth/auth-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() { const authState = useAuthSelector( @@ -27,6 +32,7 @@ export function ChatAuthSync() { shallowEqual, ); const chatDispatch = useChatDispatch(); + const characterCatalog = useCharacterCatalog(); const prevSessionKeyRef = useRef(null); useEffect(() => { @@ -42,14 +48,27 @@ export function ChatAuthSync() { } if (authState.loginStatus === "guest") { - chatDispatch({ type: "ChatGuestLogin" }); - return; + let cancelled = false; + 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; void (async () => { const tokenR = await AuthStorage.getInstance().getLoginToken(); if (!cancelled && tokenR.success && tokenR.data) { + await syncGuestHistoriesToUser( + characterCatalog.characters.map((character) => character.id), + ); + if (cancelled) return; chatDispatch({ type: "ChatUserLogin", token: tokenR.data, @@ -64,6 +83,7 @@ export function ChatAuthSync() { authState.hasInitialized, authState.isLoading, authState.loginStatus, + characterCatalog.characters, chatDispatch, ]);