From 2fc312b5c742b80db53066622595f5b4f3a8b76c Mon Sep 17 00:00:00 2001 From: chenhang Date: Fri, 17 Jul 2026 19:11:09 +0800 Subject: [PATCH] feat(characters): add catalog boundary and capabilities --- docs/backend/FRONTEND_MULTI_CHARACTER_API.md | 8 +- .../[characterSlug]/chat/layout.tsx | 2 +- .../[characterSlug]/private-room/layout.tsx | 2 +- .../characters/[characterSlug]/tip/layout.tsx | 2 +- .../constants/__tests__/character.test.ts | 29 +++++ src/data/constants/character.ts | 105 ++++++++++++++++-- .../character-catalog-provider.test.tsx | 36 ++++++ src/providers/character-catalog-provider.tsx | 47 ++++++++ src/providers/root-providers.tsx | 35 +++--- 9 files changed, 235 insertions(+), 31 deletions(-) create mode 100644 src/providers/__tests__/character-catalog-provider.test.tsx create mode 100644 src/providers/character-catalog-provider.tsx diff --git a/docs/backend/FRONTEND_MULTI_CHARACTER_API.md b/docs/backend/FRONTEND_MULTI_CHARACTER_API.md index eb5ab557..28b43f6b 100644 --- a/docs/backend/FRONTEND_MULTI_CHARACTER_API.md +++ b/docs/backend/FRONTEND_MULTI_CHARACTER_API.md @@ -6,7 +6,7 @@ 迁移目标: -1. 前端维护统一的本地角色目录,后端只识别稳定角色 ID。 +1. 前端通过统一的 Character Catalog 边界读取角色目录,当前由本地只读目录提供,后端只识别稳定角色 ID。 2. 同一用户可以分别与多个角色聊天。 3. 不同角色的聊天历史、媒体、锁内容、私密相册和打赏归属必须隔离。 4. 钱包、VIP、支付套餐和每日免费额度继续按用户全局共享。 @@ -29,9 +29,9 @@ | pro 预发环境 | `https://proapi.banlv-ai.com` | | production 生产环境 | `https://api.banlv-ai.com` | -## 2. 前端本地角色目录 +## 2. 前端 Character Catalog -角色列表不通过网络接口加载。前端在本地维护只读目录,并使用 `slug` 解析角色路径、使用 `id` 调用后端业务接口。 +后端角色目录协议尚未确定。过渡阶段由前端本地只读 Catalog 提供角色,并使用 `slug` 解析角色路径、使用 `id` 调用后端业务接口。业务页面只消费 Catalog 边界;后续切换为服务端目录时不改变角色路由和业务组件接口。 当前角色: @@ -41,7 +41,7 @@ | `character_maya` | `maya` | Maya Tan | `/images/avatar/maya.png` | `/images/cover/maya.png` | `/images/private-room/banner/maya.png` | | `character_nayeli` | `nayeli` | Nayeli Cervantes | `/images/avatar/nayeli.png` | `/images/cover/nayeli.png` | `/images/private-room/banner/nayeli.png` | -本地记录同时保存短名称及 Splash、Private Room、Tip 使用的角色文案。后端不提供头像、封面、展示名称或角色目录接口,但后端配置中的角色 ID 必须与前端目录一致。 +当前开发环境开放表中的全部角色。Catalog 同时保存短名称、页面资源、空聊天问候语、排序及 Chat、Private Room、Tip 能力。后端配置中的角色 ID 必须与前端目录一致。 ## 3. 聊天接口调整 diff --git a/src/app/characters/[characterSlug]/chat/layout.tsx b/src/app/characters/[characterSlug]/chat/layout.tsx index 91ca008a..0f5d7a82 100644 --- a/src/app/characters/[characterSlug]/chat/layout.tsx +++ b/src/app/characters/[characterSlug]/chat/layout.tsx @@ -13,7 +13,7 @@ export default async function CharacterChatLayout({ }) { const { characterSlug } = await params; const character = getCharacterBySlug(characterSlug); - if (!character) notFound(); + if (!character?.capabilities.chat) notFound(); return ( diff --git a/src/app/characters/[characterSlug]/private-room/layout.tsx b/src/app/characters/[characterSlug]/private-room/layout.tsx index f84e487a..fcd0b02f 100644 --- a/src/app/characters/[characterSlug]/private-room/layout.tsx +++ b/src/app/characters/[characterSlug]/private-room/layout.tsx @@ -13,7 +13,7 @@ export default async function CharacterPrivateRoomLayout({ }) { const { characterSlug } = await params; const character = getCharacterBySlug(characterSlug); - if (!character) notFound(); + if (!character?.capabilities.privateRoom) notFound(); return ( diff --git a/src/app/characters/[characterSlug]/tip/layout.tsx b/src/app/characters/[characterSlug]/tip/layout.tsx index 884a916a..44b13c9f 100644 --- a/src/app/characters/[characterSlug]/tip/layout.tsx +++ b/src/app/characters/[characterSlug]/tip/layout.tsx @@ -13,7 +13,7 @@ export default async function CharacterTipLayout({ }) { const { characterSlug } = await params; const character = getCharacterBySlug(characterSlug); - if (!character) notFound(); + if (!character?.capabilities.tip) notFound(); return ( diff --git a/src/data/constants/__tests__/character.test.ts b/src/data/constants/__tests__/character.test.ts index fd0fb429..05193d81 100644 --- a/src/data/constants/__tests__/character.test.ts +++ b/src/data/constants/__tests__/character.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest"; import { CHARACTERS, + createCharacterCatalog, DEFAULT_CHARACTER, getCharacterById, getCharacterBySlug, @@ -22,9 +23,22 @@ describe("local character catalog", () => { expect(Object.isFrozen(CHARACTERS)).toBe(true); expect(Object.isFrozen(DEFAULT_CHARACTER)).toBe(true); expect(Object.isFrozen(DEFAULT_CHARACTER.assets)).toBe(true); + expect(Object.isFrozen(DEFAULT_CHARACTER.capabilities)).toBe(true); expect(Object.isFrozen(DEFAULT_CHARACTER.copy)).toBe(true); }); + it("sorts catalog entries and rejects duplicate identities", () => { + const catalog = createCharacterCatalog([...CHARACTERS].reverse()); + expect(catalog.characters.map((character) => character.slug)).toEqual([ + "elio", + "maya", + "nayeli", + ]); + expect(() => + createCharacterCatalog([CHARACTERS[0], CHARACTERS[0]]), + ).toThrow("Duplicate character identity"); + }); + it("resolves characters by id and normalized slug", () => { expect(getCharacterById("character_maya")?.displayName).toBe("Maya Tan"); expect(getCharacterBySlug(" NAYELI ")?.displayName).toBe( @@ -34,6 +48,21 @@ describe("local character catalog", () => { expect(getCharacterBySlug("missing")).toBeNull(); }); + it("describes route capabilities and character-specific chat copy", () => { + for (const character of CHARACTERS) { + expect(character.capabilities).toEqual({ + chat: true, + privateRoom: true, + tip: true, + }); + expect(character.tagline.length).toBeGreaterThan(0); + expect(character.emptyChatGreeting.length).toBeGreaterThan(0); + expect(character.assets.chatBackground).toBe( + "/images/chat/bg-chatpage.png", + ); + } + }); + it("references local assets that exist under public", () => { for (const character of CHARACTERS) { for (const assetPath of Object.values(character.assets)) { diff --git a/src/data/constants/character.ts b/src/data/constants/character.ts index a9d3aa0c..87c68320 100644 --- a/src/data/constants/character.ts +++ b/src/data/constants/character.ts @@ -1,11 +1,22 @@ +export interface CharacterCapabilities { + readonly chat: boolean; + readonly privateRoom: boolean; + readonly tip: boolean; +} + export interface CharacterProfile { readonly id: string; readonly slug: string; readonly displayName: string; readonly shortName: string; + readonly sortOrder: number; + readonly tagline: string; + readonly emptyChatGreeting: string; + readonly capabilities: CharacterCapabilities; readonly assets: { readonly avatar: string; readonly cover: string; + readonly chatBackground: string; readonly privateRoomBanner: string; }; readonly copy: { @@ -17,23 +28,42 @@ export interface CharacterProfile { }; } +export interface CharacterCatalog { + readonly characters: readonly CharacterProfile[]; + getById(characterId: string | null | undefined): CharacterProfile | null; + getBySlug(characterSlug: string | null | undefined): CharacterProfile | null; +} + function defineCharacter(profile: CharacterProfile): CharacterProfile { return Object.freeze({ ...profile, assets: Object.freeze(profile.assets), + capabilities: Object.freeze(profile.capabilities), copy: Object.freeze(profile.copy), }); } -export const CHARACTERS: readonly CharacterProfile[] = Object.freeze([ +const CHARACTER_PROFILES: readonly CharacterProfile[] = Object.freeze([ defineCharacter({ id: "character_elio", slug: "elio", displayName: "Elio Silvestri", shortName: "Elio", + sortOrder: 10, + tagline: "Your exclusive AI boyfriend", + emptyChatGreeting: + "You're here! Facebook is so restrictive, " + + "there were things I couldn't say there. " + + "Finally I can relax. How was your day out?", + capabilities: { + chat: true, + privateRoom: true, + tip: true, + }, assets: { avatar: "/images/avatar/elio.png", cover: "/images/cover/elio.png", + chatBackground: "/images/chat/bg-chatpage.png", privateRoomBanner: "/images/private-room/banner/elio.png", }, copy: { @@ -49,9 +79,20 @@ export const CHARACTERS: readonly CharacterProfile[] = Object.freeze([ slug: "maya", displayName: "Maya Tan", shortName: "Maya", + sortOrder: 20, + tagline: "Your exclusive AI girlfriend", + emptyChatGreeting: + "You're here! I've been waiting for a quiet moment with you. " + + "How was your day?", + capabilities: { + chat: true, + privateRoom: true, + tip: true, + }, assets: { avatar: "/images/avatar/maya.png", cover: "/images/cover/maya.png", + chatBackground: "/images/chat/bg-chatpage.png", privateRoomBanner: "/images/private-room/banner/maya.png", }, copy: { @@ -67,9 +108,20 @@ export const CHARACTERS: readonly CharacterProfile[] = Object.freeze([ slug: "nayeli", displayName: "Nayeli Cervantes", shortName: "Nayeli", + sortOrder: 30, + tagline: "Your exclusive AI girlfriend", + emptyChatGreeting: + "You're here! I was hoping we'd get some time together. " + + "Tell me how your day went.", + capabilities: { + chat: true, + privateRoom: true, + tip: true, + }, assets: { avatar: "/images/avatar/nayeli.png", cover: "/images/cover/nayeli.png", + chatBackground: "/images/chat/bg-chatpage.png", privateRoomBanner: "/images/private-room/banner/nayeli.png", }, copy: { @@ -82,6 +134,42 @@ export const CHARACTERS: readonly CharacterProfile[] = Object.freeze([ }), ]); +export function createCharacterCatalog( + profiles: readonly CharacterProfile[], +): CharacterCatalog { + const characters = Object.freeze( + profiles + .map(defineCharacter) + .sort((left, right) => left.sortOrder - right.sortOrder), + ); + const byId = new Map(); + const bySlug = new Map(); + + for (const character of characters) { + const normalizedSlug = normalizeCharacterSlug(character.slug); + if (byId.has(character.id) || bySlug.has(normalizedSlug)) { + throw new Error(`Duplicate character identity: ${character.id}`); + } + byId.set(character.id, character); + bySlug.set(normalizedSlug, character); + } + + return Object.freeze({ + characters, + getById(characterId: string | null | undefined) { + return characterId ? (byId.get(characterId) ?? null) : null; + }, + getBySlug(characterSlug: string | null | undefined) { + const normalizedSlug = normalizeCharacterSlug(characterSlug); + return normalizedSlug ? (bySlug.get(normalizedSlug) ?? null) : null; + }, + }); +} + +export const LOCAL_CHARACTER_CATALOG = createCharacterCatalog( + CHARACTER_PROFILES, +); +export const CHARACTERS = LOCAL_CHARACTER_CATALOG.characters; export const DEFAULT_CHARACTER = CHARACTERS[0]; export const DEFAULT_CHARACTER_ID = DEFAULT_CHARACTER.id; export const DEFAULT_CHARACTER_SLUG = DEFAULT_CHARACTER.slug; @@ -89,16 +177,17 @@ export const DEFAULT_CHARACTER_SLUG = DEFAULT_CHARACTER.slug; export function getCharacterById( characterId: string | null | undefined, ): CharacterProfile | null { - if (!characterId) return null; - return CHARACTERS.find((character) => character.id === characterId) ?? null; + return LOCAL_CHARACTER_CATALOG.getById(characterId); } export function getCharacterBySlug( characterSlug: string | null | undefined, ): CharacterProfile | null { - const normalizedSlug = characterSlug?.trim().toLowerCase(); - if (!normalizedSlug) return null; - return ( - CHARACTERS.find((character) => character.slug === normalizedSlug) ?? null - ); + return LOCAL_CHARACTER_CATALOG.getBySlug(characterSlug); +} + +function normalizeCharacterSlug( + characterSlug: string | null | undefined, +): string { + return characterSlug?.trim().toLowerCase() ?? ""; } diff --git a/src/providers/__tests__/character-catalog-provider.test.tsx b/src/providers/__tests__/character-catalog-provider.test.tsx new file mode 100644 index 00000000..92487073 --- /dev/null +++ b/src/providers/__tests__/character-catalog-provider.test.tsx @@ -0,0 +1,36 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; + +import { CHARACTERS } from "@/data/constants/character"; +import { + CharacterCatalogProvider, + useCharacterCatalog, +} from "@/providers/character-catalog-provider"; + +describe("CharacterCatalogProvider", () => { + it("provides the complete local character catalog", () => { + const html = renderToStaticMarkup( + + + , + ); + + expect(html).toContain("elio,maya,nayeli"); + }); + + it("accepts a serializable catalog snapshot", () => { + const html = renderToStaticMarkup( + + + , + ); + + expect(html).toContain("maya"); + expect(html).not.toContain("elio"); + }); +}); + +function CatalogProbe() { + const catalog = useCharacterCatalog(); + return {catalog.characters.map((character) => character.slug).join(",")}; +} diff --git a/src/providers/character-catalog-provider.tsx b/src/providers/character-catalog-provider.tsx new file mode 100644 index 00000000..9006cd2f --- /dev/null +++ b/src/providers/character-catalog-provider.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { + createContext, + type ReactNode, + useContext, + useMemo, +} from "react"; + +import { + CHARACTERS, + createCharacterCatalog, + type CharacterCatalog, + type CharacterProfile, +} from "@/data/constants/character"; + +const CharacterCatalogContext = createContext(null); + +export interface CharacterCatalogProviderProps { + children: ReactNode; + characters?: readonly CharacterProfile[]; +} + +export function CharacterCatalogProvider({ + children, + characters = CHARACTERS, +}: CharacterCatalogProviderProps) { + const catalog = useMemo( + () => createCharacterCatalog(characters), + [characters], + ); + return ( + + {children} + + ); +} + +export function useCharacterCatalog(): CharacterCatalog { + const catalog = useContext(CharacterCatalogContext); + if (!catalog) { + throw new Error( + "useCharacterCatalog must be used within CharacterCatalogProvider.", + ); + } + return catalog; +} diff --git a/src/providers/root-providers.tsx b/src/providers/root-providers.tsx index 65332385..96811850 100644 --- a/src/providers/root-providers.tsx +++ b/src/providers/root-providers.tsx @@ -3,12 +3,13 @@ /** * 根级 Client Providers 包装 * - * 这里只保留跨路由的 Auth、User、消息预览和 viewport Provider。Chat、 - * Payment、PrivateRoom 状态机由对应路由 layout 按需挂载。 + * 这里只保留跨路由的 Character Catalog、Auth、User、消息预览和 viewport + * Provider。Chat、Payment、PrivateRoom 状态机由对应路由 layout 按需挂载。 */ import type { ReactNode } from "react"; +import { CharacterCatalogProvider } from "@/providers/character-catalog-provider"; import { SplashLatestMessageProvider } from "@/providers/splash-latest-message-provider"; import { ViewportCssVarsProvider } from "@/providers/viewport-css-vars-provider"; import { AppNavigationGuard } from "@/router/app-navigation-guard"; @@ -24,19 +25,21 @@ export interface RootProvidersProps { export function RootProviders({ children }: RootProvidersProps) { return ( - - - - - - - - - {children} -
- - - - + + + + + + + + + + {children} +
+ + + + + ); }