feat(characters): add catalog boundary and capabilities

This commit is contained in:
2026-07-17 19:11:09 +08:00
parent a30e9937b8
commit 2fc312b5c7
9 changed files with 235 additions and 31 deletions
+4 -4
View File
@@ -6,7 +6,7 @@
迁移目标: 迁移目标:
1. 前端维护统一的本地角色目录,后端只识别稳定角色 ID。 1. 前端通过统一的 Character Catalog 边界读取角色目录,当前由本地只读目录提供,后端只识别稳定角色 ID。
2. 同一用户可以分别与多个角色聊天。 2. 同一用户可以分别与多个角色聊天。
3. 不同角色的聊天历史、媒体、锁内容、私密相册和打赏归属必须隔离。 3. 不同角色的聊天历史、媒体、锁内容、私密相册和打赏归属必须隔离。
4. 钱包、VIP、支付套餐和每日免费额度继续按用户全局共享。 4. 钱包、VIP、支付套餐和每日免费额度继续按用户全局共享。
@@ -29,9 +29,9 @@
| pro 预发环境 | `https://proapi.banlv-ai.com` | | pro 预发环境 | `https://proapi.banlv-ai.com` |
| production 生产环境 | `https://api.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_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` | | `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. 聊天接口调整 ## 3. 聊天接口调整
@@ -13,7 +13,7 @@ export default async function CharacterChatLayout({
}) { }) {
const { characterSlug } = await params; const { characterSlug } = await params;
const character = getCharacterBySlug(characterSlug); const character = getCharacterBySlug(characterSlug);
if (!character) notFound(); if (!character?.capabilities.chat) notFound();
return ( return (
<ChatRouteProviders characterId={character.id}> <ChatRouteProviders characterId={character.id}>
@@ -13,7 +13,7 @@ export default async function CharacterPrivateRoomLayout({
}) { }) {
const { characterSlug } = await params; const { characterSlug } = await params;
const character = getCharacterBySlug(characterSlug); const character = getCharacterBySlug(characterSlug);
if (!character) notFound(); if (!character?.capabilities.privateRoom) notFound();
return ( return (
<PrivateRoomRouteProvider characterId={character.id}> <PrivateRoomRouteProvider characterId={character.id}>
@@ -13,7 +13,7 @@ export default async function CharacterTipLayout({
}) { }) {
const { characterSlug } = await params; const { characterSlug } = await params;
const character = getCharacterBySlug(characterSlug); const character = getCharacterBySlug(characterSlug);
if (!character) notFound(); if (!character?.capabilities.tip) notFound();
return ( return (
<PaymentRouteProvider scopeKey={character.id}> <PaymentRouteProvider scopeKey={character.id}>
@@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest";
import { import {
CHARACTERS, CHARACTERS,
createCharacterCatalog,
DEFAULT_CHARACTER, DEFAULT_CHARACTER,
getCharacterById, getCharacterById,
getCharacterBySlug, getCharacterBySlug,
@@ -22,9 +23,22 @@ describe("local character catalog", () => {
expect(Object.isFrozen(CHARACTERS)).toBe(true); expect(Object.isFrozen(CHARACTERS)).toBe(true);
expect(Object.isFrozen(DEFAULT_CHARACTER)).toBe(true); expect(Object.isFrozen(DEFAULT_CHARACTER)).toBe(true);
expect(Object.isFrozen(DEFAULT_CHARACTER.assets)).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); 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", () => { it("resolves characters by id and normalized slug", () => {
expect(getCharacterById("character_maya")?.displayName).toBe("Maya Tan"); expect(getCharacterById("character_maya")?.displayName).toBe("Maya Tan");
expect(getCharacterBySlug(" NAYELI ")?.displayName).toBe( expect(getCharacterBySlug(" NAYELI ")?.displayName).toBe(
@@ -34,6 +48,21 @@ describe("local character catalog", () => {
expect(getCharacterBySlug("missing")).toBeNull(); 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", () => { it("references local assets that exist under public", () => {
for (const character of CHARACTERS) { for (const character of CHARACTERS) {
for (const assetPath of Object.values(character.assets)) { for (const assetPath of Object.values(character.assets)) {
+97 -8
View File
@@ -1,11 +1,22 @@
export interface CharacterCapabilities {
readonly chat: boolean;
readonly privateRoom: boolean;
readonly tip: boolean;
}
export interface CharacterProfile { export interface CharacterProfile {
readonly id: string; readonly id: string;
readonly slug: string; readonly slug: string;
readonly displayName: string; readonly displayName: string;
readonly shortName: string; readonly shortName: string;
readonly sortOrder: number;
readonly tagline: string;
readonly emptyChatGreeting: string;
readonly capabilities: CharacterCapabilities;
readonly assets: { readonly assets: {
readonly avatar: string; readonly avatar: string;
readonly cover: string; readonly cover: string;
readonly chatBackground: string;
readonly privateRoomBanner: string; readonly privateRoomBanner: string;
}; };
readonly copy: { 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 { function defineCharacter(profile: CharacterProfile): CharacterProfile {
return Object.freeze({ return Object.freeze({
...profile, ...profile,
assets: Object.freeze(profile.assets), assets: Object.freeze(profile.assets),
capabilities: Object.freeze(profile.capabilities),
copy: Object.freeze(profile.copy), copy: Object.freeze(profile.copy),
}); });
} }
export const CHARACTERS: readonly CharacterProfile[] = Object.freeze([ const CHARACTER_PROFILES: readonly CharacterProfile[] = Object.freeze([
defineCharacter({ defineCharacter({
id: "character_elio", id: "character_elio",
slug: "elio", slug: "elio",
displayName: "Elio Silvestri", displayName: "Elio Silvestri",
shortName: "Elio", 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: { assets: {
avatar: "/images/avatar/elio.png", avatar: "/images/avatar/elio.png",
cover: "/images/cover/elio.png", cover: "/images/cover/elio.png",
chatBackground: "/images/chat/bg-chatpage.png",
privateRoomBanner: "/images/private-room/banner/elio.png", privateRoomBanner: "/images/private-room/banner/elio.png",
}, },
copy: { copy: {
@@ -49,9 +79,20 @@ export const CHARACTERS: readonly CharacterProfile[] = Object.freeze([
slug: "maya", slug: "maya",
displayName: "Maya Tan", displayName: "Maya Tan",
shortName: "Maya", 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: { assets: {
avatar: "/images/avatar/maya.png", avatar: "/images/avatar/maya.png",
cover: "/images/cover/maya.png", cover: "/images/cover/maya.png",
chatBackground: "/images/chat/bg-chatpage.png",
privateRoomBanner: "/images/private-room/banner/maya.png", privateRoomBanner: "/images/private-room/banner/maya.png",
}, },
copy: { copy: {
@@ -67,9 +108,20 @@ export const CHARACTERS: readonly CharacterProfile[] = Object.freeze([
slug: "nayeli", slug: "nayeli",
displayName: "Nayeli Cervantes", displayName: "Nayeli Cervantes",
shortName: "Nayeli", 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: { assets: {
avatar: "/images/avatar/nayeli.png", avatar: "/images/avatar/nayeli.png",
cover: "/images/cover/nayeli.png", cover: "/images/cover/nayeli.png",
chatBackground: "/images/chat/bg-chatpage.png",
privateRoomBanner: "/images/private-room/banner/nayeli.png", privateRoomBanner: "/images/private-room/banner/nayeli.png",
}, },
copy: { 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<string, CharacterProfile>();
const bySlug = new Map<string, CharacterProfile>();
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 = CHARACTERS[0];
export const DEFAULT_CHARACTER_ID = DEFAULT_CHARACTER.id; export const DEFAULT_CHARACTER_ID = DEFAULT_CHARACTER.id;
export const DEFAULT_CHARACTER_SLUG = DEFAULT_CHARACTER.slug; export const DEFAULT_CHARACTER_SLUG = DEFAULT_CHARACTER.slug;
@@ -89,16 +177,17 @@ export const DEFAULT_CHARACTER_SLUG = DEFAULT_CHARACTER.slug;
export function getCharacterById( export function getCharacterById(
characterId: string | null | undefined, characterId: string | null | undefined,
): CharacterProfile | null { ): CharacterProfile | null {
if (!characterId) return null; return LOCAL_CHARACTER_CATALOG.getById(characterId);
return CHARACTERS.find((character) => character.id === characterId) ?? null;
} }
export function getCharacterBySlug( export function getCharacterBySlug(
characterSlug: string | null | undefined, characterSlug: string | null | undefined,
): CharacterProfile | null { ): CharacterProfile | null {
const normalizedSlug = characterSlug?.trim().toLowerCase(); return LOCAL_CHARACTER_CATALOG.getBySlug(characterSlug);
if (!normalizedSlug) return null; }
return (
CHARACTERS.find((character) => character.slug === normalizedSlug) ?? null function normalizeCharacterSlug(
); characterSlug: string | null | undefined,
): string {
return characterSlug?.trim().toLowerCase() ?? "";
} }
@@ -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(
<CharacterCatalogProvider>
<CatalogProbe />
</CharacterCatalogProvider>,
);
expect(html).toContain("elio,maya,nayeli");
});
it("accepts a serializable catalog snapshot", () => {
const html = renderToStaticMarkup(
<CharacterCatalogProvider characters={[CHARACTERS[1]]}>
<CatalogProbe />
</CharacterCatalogProvider>,
);
expect(html).toContain("maya");
expect(html).not.toContain("elio");
});
});
function CatalogProbe() {
const catalog = useCharacterCatalog();
return <span>{catalog.characters.map((character) => character.slug).join(",")}</span>;
}
@@ -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<CharacterCatalog | null>(null);
export interface CharacterCatalogProviderProps {
children: ReactNode;
characters?: readonly CharacterProfile[];
}
export function CharacterCatalogProvider({
children,
characters = CHARACTERS,
}: CharacterCatalogProviderProps) {
const catalog = useMemo(
() => createCharacterCatalog(characters),
[characters],
);
return (
<CharacterCatalogContext.Provider value={catalog}>
{children}
</CharacterCatalogContext.Provider>
);
}
export function useCharacterCatalog(): CharacterCatalog {
const catalog = useContext(CharacterCatalogContext);
if (!catalog) {
throw new Error(
"useCharacterCatalog must be used within CharacterCatalogProvider.",
);
}
return catalog;
}
+19 -16
View File
@@ -3,12 +3,13 @@
/** /**
* 根级 Client Providers 包装 * 根级 Client Providers 包装
* *
* 这里只保留跨路由的 Auth、User、消息预览和 viewport Provider。Chat、 * 这里只保留跨路由的 Character Catalog、Auth、User、消息预览和 viewport
* Payment、PrivateRoom 状态机由对应路由 layout 按需挂载。 * Provider。Chat、Payment、PrivateRoom 状态机由对应路由 layout 按需挂载。
*/ */
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { CharacterCatalogProvider } from "@/providers/character-catalog-provider";
import { SplashLatestMessageProvider } from "@/providers/splash-latest-message-provider"; import { SplashLatestMessageProvider } from "@/providers/splash-latest-message-provider";
import { ViewportCssVarsProvider } from "@/providers/viewport-css-vars-provider"; import { ViewportCssVarsProvider } from "@/providers/viewport-css-vars-provider";
import { AppNavigationGuard } from "@/router/app-navigation-guard"; import { AppNavigationGuard } from "@/router/app-navigation-guard";
@@ -24,19 +25,21 @@ export interface RootProvidersProps {
export function RootProviders({ children }: RootProvidersProps) { export function RootProviders({ children }: RootProvidersProps) {
return ( return (
<ViewportCssVarsProvider> <CharacterCatalogProvider>
<AuthProvider> <ViewportCssVarsProvider>
<AuthStatusChecker /> <AuthProvider>
<OAuthSessionSync /> <AuthStatusChecker />
<AppNavigationGuard /> <OAuthSessionSync />
<SplashLatestMessageProvider> <AppNavigationGuard />
<UserProvider> <SplashLatestMessageProvider>
<UserAuthSync /> <UserProvider>
{children} <UserAuthSync />
<div id="toast-portal" /> {children}
</UserProvider> <div id="toast-portal" />
</SplashLatestMessageProvider> </UserProvider>
</AuthProvider> </SplashLatestMessageProvider>
</ViewportCssVarsProvider> </AuthProvider>
</ViewportCssVarsProvider>
</CharacterCatalogProvider>
); );
} }