126 lines
3.7 KiB
TypeScript
126 lines
3.7 KiB
TypeScript
"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<LocalMessageRow, number>;
|
|
media!: Table<LocalChatMediaRow, string>;
|
|
|
|
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<void> {
|
|
const messages = transaction.table<LocalMessageRow, number>("messages");
|
|
await messages.toCollection().modify((message) => {
|
|
const conversationKey = getElioConversationKey(message.sessionId);
|
|
if (conversationKey) message.sessionId = conversationKey;
|
|
});
|
|
|
|
const media = transaction.table<LocalChatMediaRow, string>("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;
|
|
}
|