Compare commits

...

3 Commits

Author SHA1 Message Date
admin 96a2accde4 fix(splash): hide experimental splash components
Docker Image / Quality and Bundle Budgets (push) Successful in 3m33s
Docker Image / Build and Push Docker Image (push) Successful in 1m53s
2026-07-20 17:49:19 +08:00
admin 26ab790078 chore(icons): 使用生产环境的图标 2026-07-20 17:49:19 +08:00
admin 159e8bfd59 fix(chat): migrate legacy character cache identities 2026-07-20 17:44:22 +08:00
11 changed files with 449 additions and 16 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 19 KiB

@@ -31,10 +31,7 @@
padding: padding:
calc(var(--page-padding-y, 20px) + var(--app-safe-top, 0px)) calc(var(--page-padding-y, 20px) + var(--app-safe-top, 0px))
calc(var(--page-padding-x, 26px) + var(--app-safe-right, 0px)) calc(var(--page-padding-x, 26px) + var(--app-safe-right, 0px))
calc( calc(var(--page-padding-y, 20px) + var(--app-safe-bottom, 0px))
var(--spacing-md, 12px) + var(--app-safe-bottom, 0px) +
var(--app-bottom-nav-height, 64px)
)
calc(var(--page-padding-x, 26px) + var(--app-safe-left, 0px)); calc(var(--page-padding-x, 26px) + var(--app-safe-left, 0px));
} }
+13 -4
View File
@@ -2,29 +2,31 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { AppBottomNav, MobileShell } from "@/app/_components/core"; import { MobileShell } from "@/app/_components/core";
// import { AppBottomNav } from "@/app/_components/core";
import { useAppNavigator } from "@/router/use-app-navigator"; import { useAppNavigator } from "@/router/use-app-navigator";
import { import {
useActiveCharacter, useActiveCharacter,
useActiveCharacterRoutes, useActiveCharacterRoutes,
} from "@/providers/character-provider"; } from "@/providers/character-provider";
import { useAuthState } from "@/stores/auth/auth-context"; // import { useAuthState } from "@/stores/auth/auth-context";
import { pwaUtil } from "@/utils/pwa"; import { pwaUtil } from "@/utils/pwa";
import { import {
SplashBackground, SplashBackground,
SplashButton, SplashButton,
SplashContent, SplashContent,
SplashLatestMessage, // SplashLatestMessage,
SplashLogo, SplashLogo,
} from "./components"; } from "./components";
import { useSplashLatestMessage } from "./hooks/use-splash-latest-message"; // import { useSplashLatestMessage } from "./hooks/use-splash-latest-message";
import styles from "./components/splash-screen.module.css"; import styles from "./components/splash-screen.module.css";
export function SplashScreen() { export function SplashScreen() {
const navigator = useAppNavigator(); const navigator = useAppNavigator();
const character = useActiveCharacter(); const character = useActiveCharacter();
const characterRoutes = useActiveCharacterRoutes(); const characterRoutes = useActiveCharacterRoutes();
/*
const authState = useAuthState(); const authState = useAuthState();
const latestMessage = useSplashLatestMessage({ const latestMessage = useSplashLatestMessage({
characterId: character.id, characterId: character.id,
@@ -32,14 +34,17 @@ export function SplashScreen() {
isAuthLoading: authState.isLoading, isAuthLoading: authState.isLoading,
loginStatus: authState.loginStatus, loginStatus: authState.loginStatus,
}); });
*/
const handleStartChat = () => { const handleStartChat = () => {
navigator.openChat({ replace: true }); navigator.openChat({ replace: true });
}; };
/*
const handleOpenPrivateRoom = () => { const handleOpenPrivateRoom = () => {
navigator.push(characterRoutes.privateRoom, { scroll: false }); navigator.push(characterRoutes.privateRoom, { scroll: false });
}; };
*/
const handleOpenSplash = () => { const handleOpenSplash = () => {
navigator.push(characterRoutes.splash, { scroll: false }); navigator.push(characterRoutes.splash, { scroll: false });
@@ -58,6 +63,7 @@ export function SplashScreen() {
<div className={styles.content}> <div className={styles.content}>
<SplashLogo /> <SplashLogo />
<div className={styles.spacer} /> <div className={styles.spacer} />
{/*
<SplashLatestMessage <SplashLatestMessage
message={latestMessage.message} message={latestMessage.message}
characterName={character.shortName} characterName={character.shortName}
@@ -65,6 +71,7 @@ export function SplashScreen() {
isLoading={latestMessage.isLoading} isLoading={latestMessage.isLoading}
onOpenChat={handleStartChat} onOpenChat={handleStartChat}
/> />
*/}
<SplashContent characterName={character.shortName} /> <SplashContent characterName={character.shortName} />
<div className={styles.buttonArea}> <div className={styles.buttonArea}>
<SplashButton onStartChat={handleStartChat} /> <SplashButton onStartChat={handleStartChat} />
@@ -75,12 +82,14 @@ export function SplashScreen() {
24/7 online | Chat | Companion | Heal | Sweet moments 24/7 online | Chat | Companion | Heal | Sweet moments
</p> </p>
</div> </div>
{/*
<AppBottomNav <AppBottomNav
activeItem="chat" activeItem="chat"
privateRoomLabel={character.copy.privateRoomTitle} privateRoomLabel={character.copy.privateRoomTitle}
onChatClick={handleOpenSplash} onChatClick={handleOpenSplash}
onPrivateRoomClick={handleOpenPrivateRoom} onPrivateRoomClick={handleOpenPrivateRoom}
/> />
*/}
</div> </div>
</MobileShell> </MobileShell>
); );
@@ -0,0 +1,14 @@
const LEGACY_CHARACTER_ID_ALIASES: Readonly<Record<string, string>> =
Object.freeze({
character_elio: "elio",
character_maya: "maya-tan",
character_nayeli: "nayeli-cervantes",
});
/**
* Converts character IDs persisted by pre-catalog releases to the current
* backend IDs. Unknown and already-current IDs pass through unchanged.
*/
export function normalizeLegacyCharacterId(characterId: string): string {
return LEGACY_CHARACTER_ID_ALIASES[characterId] ?? characterId;
}
@@ -0,0 +1,241 @@
import "fake-indexeddb/auto";
import Dexie from "dexie";
import { afterEach, describe, expect, it } from "vitest";
import {
buildChatConversationKey,
buildChatMediaCacheKey,
} from "@/lib/chat/chat_cache_keys";
import {
LocalChatDB,
type LocalChatMediaRow,
type LocalMessageRow,
} from "../local_chat_db";
const DATABASE_SCHEMA = {
messages: "++dbId, sessionId",
media:
"cacheKey, ownerKey, messageId, kind, remoteUrl, updatedAt, lastAccessedAt",
};
const openDatabases: Dexie[] = [];
const databaseNames = new Set<string>();
afterEach(async () => {
for (const database of openDatabases.splice(0)) database.close();
await Promise.all([...databaseNames].map((name) => Dexie.delete(name)));
databaseNames.clear();
});
describe("LocalChatDB v5 migration", () => {
it("moves owner-only and old character messages to current conversations", async () => {
const legacy = await createLegacyDatabase(3);
await legacy.table<LocalMessageRow, number>("messages").bulkAdd([
messageRow("owner-only", "user:account-1"),
messageRow(
"old-elio",
buildChatConversationKey("user:account-1", "character_elio"),
),
messageRow(
"old-maya",
buildChatConversationKey("user:account-1", "character_maya"),
),
messageRow(
"old-nayeli",
buildChatConversationKey("device:guest-1", "character_nayeli"),
),
messageRow(
"current-elio",
buildChatConversationKey("user:account-2", "elio"),
),
messageRow(
"unknown-character",
buildChatConversationKey("user:account-2", "character_aria"),
),
]);
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: "owner-only",
sessionId: buildChatConversationKey("user:account-1", "elio"),
},
{
id: "old-elio",
sessionId: buildChatConversationKey("user:account-1", "elio"),
},
{
id: "old-maya",
sessionId: buildChatConversationKey("user:account-1", "maya-tan"),
},
{
id: "old-nayeli",
sessionId: buildChatConversationKey(
"device:guest-1",
"nayeli-cervantes",
),
},
{
id: "current-elio",
sessionId: buildChatConversationKey("user:account-2", "elio"),
},
{
id: "unknown-character",
sessionId: buildChatConversationKey(
"user:account-2",
"character_aria",
),
},
]);
});
it("rekeys media while preserving current data and the newest legacy row", async () => {
const legacy = await createLegacyDatabase(4);
const owner = "user:account-1";
const currentElioOwner = buildChatConversationKey(owner, "elio");
const currentElioKey = buildChatMediaCacheKey({
ownerKey: currentElioOwner,
messageId: "current-wins",
kind: "image",
});
await legacy.table<LocalChatMediaRow, string>("media").bulkAdd([
mediaRow({
ownerKey: buildChatConversationKey(owner, "character_elio"),
messageId: "current-wins",
kind: "image",
remoteUrl: "https://example.com/legacy-newer.jpg",
updatedAt: 20,
}),
mediaRow({
ownerKey: currentElioOwner,
messageId: "current-wins",
kind: "image",
remoteUrl: "https://example.com/current.jpg",
updatedAt: 10,
}),
mediaRow({
ownerKey: owner,
messageId: "newest-legacy-wins",
kind: "audio",
remoteUrl: "https://example.com/older.mp3",
updatedAt: 5,
}),
mediaRow({
ownerKey: buildChatConversationKey(owner, "character_elio"),
messageId: "newest-legacy-wins",
kind: "audio",
remoteUrl: "https://example.com/newer.mp3",
updatedAt: 15,
}),
mediaRow({
ownerKey: buildChatConversationKey(owner, "character_maya"),
messageId: "maya-media",
kind: "image",
remoteUrl: "https://example.com/maya.jpg",
updatedAt: 8,
}),
]);
legacy.close();
const upgraded = track(new LocalChatDB(legacy.name));
await upgraded.open();
expect(await upgraded.media.get(currentElioKey)).toMatchObject({
ownerKey: currentElioOwner,
remoteUrl: "https://example.com/current.jpg",
});
const legacyCollisionKey = buildChatMediaCacheKey({
ownerKey: currentElioOwner,
messageId: "newest-legacy-wins",
kind: "audio",
});
expect(await upgraded.media.get(legacyCollisionKey)).toMatchObject({
ownerKey: currentElioOwner,
remoteUrl: "https://example.com/newer.mp3",
});
const mayaOwner = buildChatConversationKey(owner, "maya-tan");
expect(
await upgraded.media.get(
buildChatMediaCacheKey({
ownerKey: mayaOwner,
messageId: "maya-media",
kind: "image",
}),
),
).toMatchObject({ ownerKey: mayaOwner });
expect(await upgraded.media.count()).toBe(3);
});
it("upgrades an existing v4 database and remains idempotent", async () => {
const legacy = await createLegacyDatabase(4);
await legacy.table<LocalMessageRow, number>("messages").add(
messageRow(
"old-elio",
buildChatConversationKey("device:guest-1", "character_elio"),
),
);
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);
expect(firstRows[0]?.sessionId).toBe(
buildChatConversationKey("device:guest-1", "elio"),
);
});
});
async function createLegacyDatabase(version: 3 | 4): Promise<Dexie> {
const name = `cozsweet-chat-v${version}-${crypto.randomUUID()}`;
databaseNames.add(name);
const database = track(new Dexie(name));
database.version(version).stores(DATABASE_SCHEMA);
await database.open();
return database;
}
function track<T extends Dexie>(database: T): T {
openDatabases.push(database);
return database;
}
function messageRow(id: string, sessionId: string): LocalMessageRow {
return {
id,
role: "assistant",
type: "text",
content: id,
createdAt: "2026-07-17T00:00:00.000Z",
sessionId,
};
}
function mediaRow(
input: Pick<
LocalChatMediaRow,
"ownerKey" | "messageId" | "kind" | "remoteUrl" | "updatedAt"
>,
): LocalChatMediaRow {
return {
...input,
cacheKey: buildChatMediaCacheKey(input),
bytes: new Uint8Array([1, 2, 3]).buffer,
mimeType: input.kind === "image" ? "image/jpeg" : "audio/mpeg",
byteSize: 3,
createdAt: input.updatedAt,
lastAccessedAt: input.updatedAt,
};
}
+71 -1
View File
@@ -9,12 +9,16 @@
* 构造时 `dbName` 可注入,便于测试时每个用例用独立 DB 互不污染。 * 构造时 `dbName` 可注入,便于测试时每个用例用独立 DB 互不污染。
*/ */
import Dexie, { type Table } from "dexie"; import Dexie, { type Table, type Transaction } from "dexie";
import type { ChatMediaKind } from "@/data/schemas/chat"; import type { ChatMediaKind } from "@/data/schemas/chat";
import type { import type {
ChatImageData, ChatImageData,
ChatLockDetailData, ChatLockDetailData,
} from "@/data/schemas/chat"; } from "@/data/schemas/chat";
import {
buildChatMediaCacheKey,
migrateLegacyChatConversationKey,
} from "@/lib/chat/chat_cache_keys";
export interface LocalMessageRow { export interface LocalMessageRow {
/** Dexie 自增主键(数据库内部使用,不暴露给 LocalMessage 类)。 */ /** Dexie 自增主键(数据库内部使用,不暴露给 LocalMessage 类)。 */
@@ -76,5 +80,71 @@ export class LocalChatDB extends Dexie {
messages: "++dbId, sessionId", messages: "++dbId, sessionId",
media: "cacheKey, ownerKey, messageId, kind, remoteUrl, updatedAt, lastAccessedAt", media: "cacheKey, ownerKey, messageId, kind, remoteUrl, updatedAt, lastAccessedAt",
}); });
this.version(5)
.stores({
messages: "++dbId, sessionId",
media: "cacheKey, ownerKey, messageId, kind, remoteUrl, updatedAt, lastAccessedAt",
})
.upgrade(migrateLegacyChatCache);
} }
} }
async function migrateLegacyChatCache(
transaction: Transaction,
): Promise<void> {
const messages = transaction.table<LocalMessageRow, number>("messages");
await messages.toCollection().modify((message) => {
message.sessionId = migrateLegacyChatConversationKey(message.sessionId);
});
const media = transaction.table<LocalChatMediaRow, string>("media");
const rows = await media.toArray();
const canonicalCacheKeys = new Set(
rows.flatMap((row) => {
const ownerKey = migrateLegacyChatConversationKey(row.ownerKey);
const cacheKey = buildChatMediaCacheKey({
ownerKey,
messageId: row.messageId,
kind: row.kind,
});
return ownerKey === row.ownerKey && cacheKey === row.cacheKey
? [cacheKey]
: [];
}),
);
for (const row of rows) {
const ownerKey = migrateLegacyChatConversationKey(row.ownerKey);
const cacheKey = buildChatMediaCacheKey({
ownerKey,
messageId: row.messageId,
kind: row.kind,
});
if (ownerKey === row.ownerKey && cacheKey === row.cacheKey) continue;
const existing = await media.get(cacheKey);
await media.delete(row.cacheKey);
if (existing && canonicalCacheKeys.has(cacheKey)) continue;
if (existing && !isNewerMediaRow(row, existing)) continue;
await media.put({
...row,
cacheKey,
ownerKey,
});
}
}
function isNewerMediaRow(
candidate: LocalChatMediaRow,
existing: LocalChatMediaRow,
): boolean {
return (
candidate.updatedAt > existing.updatedAt ||
(candidate.updatedAt === existing.updatedAt &&
candidate.lastAccessedAt > existing.lastAccessedAt) ||
(candidate.updatedAt === existing.updatedAt &&
candidate.lastAccessedAt === existing.lastAccessedAt &&
candidate.createdAt > existing.createdAt)
);
}
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { createStorage } from "unstorage"; import { createStorage } from "unstorage";
import memoryDriver from "unstorage/drivers/memory"; import memoryDriver from "unstorage/drivers/memory";
import { StorageKeys } from "@/data/storage/storage_keys";
import { SessionAsyncUtil } from "@/utils/session-storage"; import { SessionAsyncUtil } from "@/utils/session-storage";
import { NavigationStorage } from "../navigation_storage"; import { NavigationStorage } from "../navigation_storage";
@@ -165,4 +166,51 @@ describe("NavigationStorage", () => {
).resolves.toMatchObject({ characterId: CHARACTER_ID }); ).resolves.toMatchObject({ characterId: CHARACTER_ID });
}); });
it("normalizes old character ids in pending navigation sessions", async () => {
const createdAt = Date.now();
const legacyPromotion = {
characterId: "character_elio",
promotionType: "image" as const,
lockType: "image_paywall" as const,
clientLockId: "promotion_legacy",
createdAt,
};
await SessionAsyncUtil.setJson(
StorageKeys.pendingChatPromotion,
legacyPromotion,
);
await SessionAsyncUtil.setJson(StorageKeys.pendingChatUnlock, {
reason: "single_message_unlock",
characterId: "character_elio",
displayMessageId: "promotion:promotion_legacy",
kind: "image",
lockType: "image_paywall",
clientLockId: "promotion_legacy",
promotion: legacyPromotion,
returnUrl: "/characters/elio/chat",
stage: "payment",
createdAt,
});
await SessionAsyncUtil.setJson(StorageKeys.pendingChatImageReturn, {
reason: "image_paywall",
characterId: "character_elio",
messageId: "message_legacy",
returnUrl: "/characters/elio/chat?image=message_legacy",
createdAt,
});
await expect(
NavigationStorage.consumePendingChatPromotion(CHARACTER_ID),
).resolves.toMatchObject({ characterId: CHARACTER_ID });
await expect(
NavigationStorage.peekPendingChatUnlock(CHARACTER_ID),
).resolves.toMatchObject({
characterId: CHARACTER_ID,
promotion: { characterId: CHARACTER_ID },
});
await expect(
NavigationStorage.consumePendingChatImageReturn(CHARACTER_ID),
).resolves.toMatchObject({ characterId: CHARACTER_ID });
});
}); });
@@ -5,6 +5,7 @@ import { z } from "zod";
import { StorageKeys } from "@/data/storage/storage_keys"; import { StorageKeys } from "@/data/storage/storage_keys";
import { ChatLockTypeSchema } from "@/data/schemas/chat"; import { ChatLockTypeSchema } from "@/data/schemas/chat";
import { getCharacterById } from "@/data/constants/character"; import { getCharacterById } from "@/data/constants/character";
import { normalizeLegacyCharacterId } from "@/data/constants/character_id_aliases";
import { Result } from "@/utils/result"; import { Result } from "@/utils/result";
import { SessionAsyncUtil } from "@/utils/session-storage"; import { SessionAsyncUtil } from "@/utils/session-storage";
@@ -138,7 +139,9 @@ export class NavigationStorage {
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock); await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
return null; return null;
} }
if (value.characterId !== characterId) return null; if (value.characterId !== normalizeLegacyCharacterId(characterId)) {
return null;
}
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock); await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
return value; return value;
} }
@@ -158,7 +161,9 @@ export class NavigationStorage {
if (!value) { if (!value) {
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock); await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
} }
return value?.characterId === characterId ? value : null; return value?.characterId === normalizeLegacyCharacterId(characterId)
? value
: null;
} }
static async hasPendingChatUnlock(characterId: string): Promise<boolean> { static async hasPendingChatUnlock(characterId: string): Promise<boolean> {
@@ -206,7 +211,9 @@ export class NavigationStorage {
await SessionAsyncUtil.remove(StorageKeys.pendingChatPromotion); await SessionAsyncUtil.remove(StorageKeys.pendingChatPromotion);
return null; return null;
} }
if (value.characterId !== characterId) return null; if (value.characterId !== normalizeLegacyCharacterId(characterId)) {
return null;
}
await SessionAsyncUtil.remove(StorageKeys.pendingChatPromotion); await SessionAsyncUtil.remove(StorageKeys.pendingChatPromotion);
return value; return value;
} }
@@ -250,7 +257,9 @@ export class NavigationStorage {
await SessionAsyncUtil.remove(StorageKeys.pendingChatImageReturn); await SessionAsyncUtil.remove(StorageKeys.pendingChatImageReturn);
return null; return null;
} }
if (value.characterId !== characterId) return null; if (value.characterId !== normalizeLegacyCharacterId(characterId)) {
return null;
}
await SessionAsyncUtil.remove(StorageKeys.pendingChatImageReturn); await SessionAsyncUtil.remove(StorageKeys.pendingChatImageReturn);
return value; return value;
} }
@@ -263,13 +272,15 @@ export class NavigationStorage {
value: PendingChatUnlock | null, value: PendingChatUnlock | null,
): PendingChatUnlock | null { ): PendingChatUnlock | null {
if (!value || Date.now() - value.createdAt > MAX_AGE_MS) return null; if (!value || Date.now() - value.createdAt > MAX_AGE_MS) return null;
if (!getCharacterById(value.characterId)) return null; const characterId = normalizeLegacyCharacterId(value.characterId);
if (!getCharacterById(characterId)) return null;
const promotion = value.promotion const promotion = value.promotion
? NavigationStorage.parsePendingChatPromotion(value.promotion) ? NavigationStorage.parsePendingChatPromotion(value.promotion)
: undefined; : undefined;
if (value.promotion && !promotion) return null; if (value.promotion && !promotion) return null;
return PendingChatUnlockSchema.parse({ return PendingChatUnlockSchema.parse({
...value, ...value,
characterId,
...(promotion ? { promotion } : {}), ...(promotion ? { promotion } : {}),
}); });
} }
@@ -278,14 +289,20 @@ export class NavigationStorage {
value: PendingChatImageReturn | null, value: PendingChatImageReturn | null,
): PendingChatImageReturn | null { ): PendingChatImageReturn | null {
if (!value || Date.now() - value.createdAt > MAX_AGE_MS) return null; if (!value || Date.now() - value.createdAt > MAX_AGE_MS) return null;
return getCharacterById(value.characterId) ? value : null; const characterId = normalizeLegacyCharacterId(value.characterId);
return getCharacterById(characterId)
? PendingChatImageReturnSchema.parse({ ...value, characterId })
: null;
} }
private static parsePendingChatPromotion( private static parsePendingChatPromotion(
value: PendingChatPromotion | null, value: PendingChatPromotion | null,
): PendingChatPromotion | null { ): PendingChatPromotion | null {
if (!value || Date.now() - value.createdAt > MAX_AGE_MS) return null; if (!value || Date.now() - value.createdAt > MAX_AGE_MS) return null;
return getCharacterById(value.characterId) ? value : null; const characterId = normalizeLegacyCharacterId(value.characterId);
return getCharacterById(characterId)
? PendingChatPromotionSchema.parse({ ...value, characterId })
: null;
} }
} }
+37
View File
@@ -1,6 +1,9 @@
import type { ChatMediaKind } from "@/data/schemas/chat"; import type { ChatMediaKind } from "@/data/schemas/chat";
import { normalizeLegacyCharacterId } from "@/data/constants/character_id_aliases";
const CHARACTER_NAMESPACE = "::character:"; const CHARACTER_NAMESPACE = "::character:";
const LEGACY_OWNER_KEY_PATTERN = /^(?:user|device):.+$/u;
const LEGACY_DEFAULT_CHARACTER_ID = "elio";
export function buildChatConversationKey( export function buildChatConversationKey(
ownerKey: string, ownerKey: string,
@@ -19,3 +22,37 @@ export function buildChatMediaCacheKey(input: {
}): string { }): string {
return `${input.ownerKey}:${input.messageId}:${input.kind}`; return `${input.ownerKey}:${input.messageId}:${input.kind}`;
} }
/**
* Migrates both pre-character owner keys and keys containing the first
* frontend-only character IDs. This is intentionally idempotent so it can be
* used by database upgrades and compatibility reads.
*/
export function migrateLegacyChatConversationKey(ownerKey: string): string {
const namespaceIndex = ownerKey.lastIndexOf(CHARACTER_NAMESPACE);
if (namespaceIndex < 0) {
return LEGACY_OWNER_KEY_PATTERN.test(ownerKey)
? buildChatConversationKey(ownerKey, LEGACY_DEFAULT_CHARACTER_ID)
: ownerKey;
}
const encodedCharacterId = ownerKey.slice(
namespaceIndex + CHARACTER_NAMESPACE.length,
);
if (encodedCharacterId.length === 0) return ownerKey;
let characterId: string;
try {
characterId = decodeURIComponent(encodedCharacterId);
} catch {
return ownerKey;
}
const normalizedCharacterId = normalizeLegacyCharacterId(characterId);
if (normalizedCharacterId === characterId) return ownerKey;
return buildChatConversationKey(
ownerKey.slice(0, namespaceIndex),
normalizedCharacterId,
);
}