"use client"; /** * Dexie 数据库定义 * * `messages.sessionId` 是当前聊天身份的缓存命名空间。所有消息读写必须通过 * 该索引限定身份,不能退化为整表操作。 * * 构造时 `dbName` 可注入,便于测试时每个用例用独立 DB 互不污染。 */ import Dexie, { type Table, type Transaction } from "dexie"; import { DEFAULT_CHARACTER_ID } from "@/data/constants/character"; 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 类)。 */ dbId?: number; /** 消息 id(来自 ChatMessage.id)。 */ id: string; role: string; type: string; content: string; createdAt: string; audioUrl?: string | null; image?: ChatImageData; lockDetail?: ChatLockDetailData; sessionId: string; } export interface LocalChatMediaRow { cacheKey: string; ownerKey: string; messageId: string; kind: ChatMediaKind; remoteUrl: string; /** v3+ stores raw bytes instead of Blob to avoid iOS WebKit Blob URL issues. */ bytes?: ArrayBuffer; /** Legacy v2 field. Read-only fallback for users who already have cached media. */ blob?: Blob; mimeType: string; byteSize: number; createdAt: number; updatedAt: number; lastAccessedAt: number; } export class LocalChatDB extends Dexie { messages!: Table; media!: Table; constructor(dbName: string = "cozsweet-chat") { super(dbName); this.version(1).stores({ messages: "++dbId", }); this.version(2).stores({ messages: "++dbId", media: "cacheKey, ownerKey, messageId, kind, remoteUrl, updatedAt, lastAccessedAt", }); this.version(3) .stores({ messages: "++dbId, sessionId", media: "cacheKey, ownerKey, messageId, kind, remoteUrl, updatedAt, lastAccessedAt", }) .upgrade(async (transaction) => { // v1/v2 always wrote an empty sessionId. Its owner cannot be recovered, // so retaining it would expose one identity's history to another. await transaction.table("messages").clear(); }); this.version(4) .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; }