232 lines
7.1 KiB
TypeScript
232 lines
7.1 KiB
TypeScript
export interface CharacterCapabilities {
|
|
readonly chat: boolean;
|
|
readonly privateZone: 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 splashCover?: string;
|
|
readonly chatBackground: string;
|
|
readonly privateZoneBanner: string;
|
|
};
|
|
readonly copy: {
|
|
readonly splashRelationship: string;
|
|
readonly privateZoneTitle: string;
|
|
readonly privateZoneSubtitle: string;
|
|
readonly tipHeader: string;
|
|
readonly tipTitle: string;
|
|
};
|
|
}
|
|
|
|
export interface CharacterCatalog {
|
|
readonly characters: readonly CharacterProfile[];
|
|
getById(characterId: string | null | undefined): CharacterProfile | null;
|
|
getBySlug(characterSlug: string | null | undefined): CharacterProfile | null;
|
|
}
|
|
|
|
export interface CharacterCatalogSnapshot {
|
|
readonly catalog: CharacterCatalog;
|
|
readonly defaultCharacter: CharacterProfile;
|
|
}
|
|
|
|
function defineCharacter(profile: CharacterProfile): CharacterProfile {
|
|
return Object.freeze({
|
|
...profile,
|
|
assets: Object.freeze(profile.assets),
|
|
capabilities: Object.freeze(profile.capabilities),
|
|
copy: Object.freeze(profile.copy),
|
|
});
|
|
}
|
|
|
|
const CHARACTER_PROFILES: readonly CharacterProfile[] = Object.freeze([
|
|
defineCharacter({
|
|
id: "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,
|
|
privateZone: true,
|
|
tip: true,
|
|
},
|
|
assets: {
|
|
avatar: "/images/avatar/elio.png",
|
|
cover: "/images/cover/elio.png",
|
|
chatBackground: "/images/chat/bg-chatpage.png",
|
|
privateZoneBanner: "/images/private-zone/banner/elio.png",
|
|
},
|
|
copy: {
|
|
splashRelationship: "Your exclusive AI boyfriend",
|
|
privateZoneTitle: "Elio Private Zone",
|
|
privateZoneSubtitle: "Join me, unlock my private zone",
|
|
tipHeader: "Tip Elio",
|
|
tipTitle: "Buy Elio a coffee",
|
|
},
|
|
}),
|
|
defineCharacter({
|
|
id: "maya-tan",
|
|
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,
|
|
privateZone: true,
|
|
tip: true,
|
|
},
|
|
assets: {
|
|
avatar: "/images/avatar/maya.png",
|
|
cover: "/images/cover/maya.webp",
|
|
splashCover: "/images/cover/maya-home.webp",
|
|
chatBackground: "/images/chat/bg-chatpage.png",
|
|
privateZoneBanner: "/images/private-zone/banner/maya.png",
|
|
},
|
|
copy: {
|
|
splashRelationship: "Your exclusive AI girlfriend",
|
|
privateZoneTitle: "Maya Private Zone",
|
|
privateZoneSubtitle: "Join me, unlock my private zone",
|
|
tipHeader: "Tip Maya",
|
|
tipTitle: "Buy Maya a coffee",
|
|
},
|
|
}),
|
|
defineCharacter({
|
|
id: "nayeli-cervantes",
|
|
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,
|
|
privateZone: true,
|
|
tip: true,
|
|
},
|
|
assets: {
|
|
avatar: "/images/avatar/nayeli.png",
|
|
cover: "/images/cover/nayeli.webp",
|
|
chatBackground: "/images/chat/bg-chatpage.png",
|
|
privateZoneBanner: "/images/private-zone/banner/nayeli.png",
|
|
},
|
|
copy: {
|
|
splashRelationship: "Your exclusive AI girlfriend",
|
|
privateZoneTitle: "Nayeli Private Zone",
|
|
privateZoneSubtitle: "Join me, unlock my private zone",
|
|
tipHeader: "Tip Nayeli",
|
|
tipTitle: "Buy Nayeli a coffee",
|
|
},
|
|
}),
|
|
]);
|
|
|
|
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 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,
|
|
privateZone:
|
|
local.capabilities.privateZone && 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,
|
|
);
|
|
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;
|
|
|
|
export function getCharacterById(
|
|
characterId: string | null | undefined,
|
|
): CharacterProfile | null {
|
|
return LOCAL_CHARACTER_CATALOG.getById(characterId);
|
|
}
|
|
|
|
export function getCharacterBySlug(
|
|
characterSlug: string | null | undefined,
|
|
): CharacterProfile | null {
|
|
return LOCAL_CHARACTER_CATALOG.getBySlug(characterSlug);
|
|
}
|
|
|
|
function normalizeCharacterSlug(
|
|
characterSlug: string | null | undefined,
|
|
): string {
|
|
return characterSlug?.trim().toLowerCase() ?? "";
|
|
}
|