515 lines
15 KiB
TypeScript
515 lines
15 KiB
TypeScript
import { useSearchParams } from '@remix-run/react';
|
||
import { getChatId } from '~/.client/stores/ai-state';
|
||
import type { Page, Section } from '~/types/actions';
|
||
import { createScopedLogger } from '~/utils/logger';
|
||
|
||
/**
|
||
* 序列化标记常量
|
||
*/
|
||
export const SERIALIZATION_MARKERS = {
|
||
FUNCTION_PREFIX: '__FUNCTION__:',
|
||
ABORT_SIGNAL_PREFIX: '__ABORT_SIGNAL__',
|
||
ABORT_CONTROLLER_PREFIX: '__ABORT_CONTROLLER__',
|
||
};
|
||
|
||
/**
|
||
* 将对象序列化为可存储在 IndexedDB 中的格式
|
||
* 将函数转换为特殊格式的字符串
|
||
* @param data 需要序列化的数据
|
||
* @returns 序列化后的数据(可存储在 IndexedDB 中)
|
||
*/
|
||
function serializeForIndexedDB<T>(data: T): any {
|
||
if (data === null || data === undefined) {
|
||
return data;
|
||
}
|
||
|
||
if (typeof data === 'function') {
|
||
const funcStr = data.toString();
|
||
if (funcStr.includes('abortController.abort()')) {
|
||
return `${SERIALIZATION_MARKERS.FUNCTION_PREFIX}abort`;
|
||
}
|
||
return `${SERIALIZATION_MARKERS.FUNCTION_PREFIX}${funcStr}`;
|
||
}
|
||
|
||
if (data && typeof data === 'object' && 'aborted' in data && 'onabort' in data) {
|
||
return SERIALIZATION_MARKERS.ABORT_SIGNAL_PREFIX;
|
||
}
|
||
|
||
if (data && typeof data === 'object' && 'signal' in data && 'abort' in data) {
|
||
return SERIALIZATION_MARKERS.ABORT_CONTROLLER_PREFIX;
|
||
}
|
||
|
||
if (data instanceof Date) {
|
||
return {
|
||
__type: 'Date',
|
||
value: data.toISOString(),
|
||
};
|
||
}
|
||
|
||
if (Array.isArray(data)) {
|
||
return data.map((item) => serializeForIndexedDB(item));
|
||
}
|
||
|
||
if (typeof data === 'object') {
|
||
const serializedObject: Record<string, any> = {};
|
||
|
||
for (const key in data) {
|
||
if (Object.prototype.hasOwnProperty.call(data, key)) {
|
||
serializedObject[key] = serializeForIndexedDB((data as Record<string, any>)[key]);
|
||
}
|
||
}
|
||
|
||
return serializedObject;
|
||
}
|
||
|
||
return data;
|
||
}
|
||
|
||
/**
|
||
* 将从 IndexedDB 中读取的数据反序列化
|
||
* 将特殊格式的字符串转换回函数
|
||
* @param data 需要反序列化的数据
|
||
* @returns 反序列化后的数据
|
||
*/
|
||
function deserializeFromIndexedDB<T>(data: any): T {
|
||
if (data === null || data === undefined) {
|
||
return data as T;
|
||
}
|
||
|
||
if (typeof data === 'string') {
|
||
if (data.startsWith(SERIALIZATION_MARKERS.FUNCTION_PREFIX)) {
|
||
const funcBody = data.substring(SERIALIZATION_MARKERS.FUNCTION_PREFIX.length);
|
||
|
||
if (funcBody === 'abort') {
|
||
const abortController = new AbortController();
|
||
return function () {
|
||
abortController.abort();
|
||
} as unknown as T;
|
||
}
|
||
|
||
try {
|
||
return new Function(`return ${funcBody}`)() as T;
|
||
} catch (error) {
|
||
console.error('Failed to deserialize function:', error);
|
||
return (() => {
|
||
// ignore error
|
||
return undefined;
|
||
}) as unknown as T;
|
||
}
|
||
}
|
||
|
||
if (data === SERIALIZATION_MARKERS.ABORT_SIGNAL_PREFIX) {
|
||
return new AbortController().signal as unknown as T;
|
||
}
|
||
|
||
if (data === SERIALIZATION_MARKERS.ABORT_CONTROLLER_PREFIX) {
|
||
return new AbortController() as unknown as T;
|
||
}
|
||
}
|
||
|
||
if (data && typeof data === 'object') {
|
||
if (data.__type === 'Date' && data.value) {
|
||
return new Date(data.value) as unknown as T;
|
||
}
|
||
|
||
if (Array.isArray(data)) {
|
||
return data.map((item) => deserializeFromIndexedDB(item)) as unknown as T;
|
||
}
|
||
|
||
const deserializedObject: Record<string, any> = {};
|
||
|
||
for (const key in data) {
|
||
if (Object.prototype.hasOwnProperty.call(data, key)) {
|
||
deserializedObject[key] = deserializeFromIndexedDB(data[key]);
|
||
}
|
||
}
|
||
|
||
return deserializedObject as T;
|
||
}
|
||
|
||
return data as T;
|
||
}
|
||
|
||
export interface IEditorMessageProject {
|
||
messageId: string;
|
||
pages: Page[];
|
||
sections: Section[];
|
||
}
|
||
|
||
export interface IProject {
|
||
id: string;
|
||
messageProjects: IEditorMessageProject[];
|
||
timestamp: string;
|
||
}
|
||
|
||
const logger = createScopedLogger('EditorProjects');
|
||
|
||
/**
|
||
* 打开 editor 本地数据库。
|
||
* @returns editor 本地数据库。
|
||
*/
|
||
export async function openEditorDatabase(): Promise<IDBDatabase | undefined> {
|
||
if (typeof indexedDB === 'undefined') {
|
||
logger.debug('indexedDB 在当前环境中不可用');
|
||
return undefined;
|
||
}
|
||
|
||
return new Promise((resolve) => {
|
||
const request = indexedDB.open('editorProjects', 1);
|
||
|
||
request.onupgradeneeded = (event: IDBVersionChangeEvent) => {
|
||
const db = (event.target as IDBOpenDBRequest).result;
|
||
const oldVersion = event.oldVersion;
|
||
|
||
if (oldVersion < 1) {
|
||
if (!db.objectStoreNames.contains('projects')) {
|
||
const store = db.createObjectStore('projects', { keyPath: 'id' });
|
||
store.createIndex('id', 'id', { unique: true });
|
||
}
|
||
}
|
||
};
|
||
|
||
request.onsuccess = (event: Event) => {
|
||
resolve((event.target as IDBOpenDBRequest).result);
|
||
};
|
||
|
||
request.onerror = (event: Event) => {
|
||
resolve(undefined);
|
||
logger.error((event.target as IDBOpenDBRequest).error);
|
||
};
|
||
});
|
||
}
|
||
|
||
// 保存项目数据
|
||
export async function saveProject(
|
||
db: IDBDatabase,
|
||
messageId: string,
|
||
pages: Page[],
|
||
sections: Section[],
|
||
): Promise<void> {
|
||
return new Promise((resolve, reject) => {
|
||
// 序列化数据,处理不可序列化的内容
|
||
const serializedPages = serializeForIndexedDB(pages);
|
||
const serializedSections = serializeForIndexedDB(sections);
|
||
|
||
const transaction = db.transaction('projects', 'readwrite');
|
||
const store = transaction.objectStore('projects');
|
||
|
||
// 首先尝试获取现有记录
|
||
const getRequest = store.get(getChatId()!);
|
||
|
||
getRequest.onsuccess = () => {
|
||
const existingData = getRequest.result as IProject | undefined;
|
||
const timestamp = new Date().toISOString();
|
||
|
||
if (existingData) {
|
||
/*
|
||
* 如果记录存在
|
||
* 检查是否已存在相同 messageId 的项目
|
||
*/
|
||
const existingIndex = existingData.messageProjects.findIndex((p) => p.messageId === messageId);
|
||
|
||
let messageProjects;
|
||
|
||
if (existingIndex !== -1) {
|
||
// 如果找到了相同 messageId 的项目,则更新它
|
||
messageProjects = existingData.messageProjects.map((p, index) =>
|
||
index === existingIndex ? { ...p, pages: serializedPages, sections: serializedSections } : p,
|
||
);
|
||
} else {
|
||
// 如果没有找到相同 messageId 的项目,则添加新项目
|
||
messageProjects = [
|
||
...existingData.messageProjects,
|
||
{ messageId, pages: serializedPages, sections: serializedSections },
|
||
];
|
||
}
|
||
|
||
const updatedData = {
|
||
...existingData,
|
||
messageProjects,
|
||
timestamp,
|
||
};
|
||
|
||
const putRequest = store.put(updatedData);
|
||
putRequest.onsuccess = () => resolve();
|
||
putRequest.onerror = () => reject(putRequest.error);
|
||
} else {
|
||
// 创建新记录
|
||
const newData: IProject = {
|
||
id: getChatId()!,
|
||
messageProjects: [{ messageId, pages: serializedPages, sections: serializedSections }],
|
||
timestamp,
|
||
};
|
||
|
||
const putRequest = store.put(newData);
|
||
putRequest.onsuccess = () => resolve();
|
||
putRequest.onerror = () => reject(putRequest.error);
|
||
}
|
||
};
|
||
|
||
getRequest.onerror = () => reject(getRequest.error);
|
||
});
|
||
}
|
||
|
||
export async function setEditorProjects(db: IDBDatabase, id: string, projects: IEditorMessageProject[]): Promise<void> {
|
||
return new Promise((resolve, reject) => {
|
||
// 序列化项目数据,处理不可序列化的内容
|
||
const serializedProjects = serializeForIndexedDB(projects);
|
||
|
||
const transaction = db.transaction('projects', 'readwrite');
|
||
const store = transaction.objectStore('projects');
|
||
const request = store.put({
|
||
id,
|
||
messageProjects: serializedProjects,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
|
||
request.onsuccess = () => resolve();
|
||
request.onerror = () => reject(request.error);
|
||
});
|
||
}
|
||
|
||
export async function getEditorProjects(db: IDBDatabase, chatId: string): Promise<IProject> {
|
||
return new Promise((resolve, reject) => {
|
||
const transaction = db.transaction('projects', 'readonly');
|
||
const store = transaction.objectStore('projects');
|
||
const request = store.get(chatId);
|
||
|
||
request.onsuccess = () => {
|
||
const project = request.result as IProject;
|
||
|
||
if (project && project.messageProjects) {
|
||
const deserializedProject = {
|
||
...project,
|
||
messageProjects: project.messageProjects.map((mp) => ({
|
||
...mp,
|
||
pages: mp.pages ? deserializeFromIndexedDB<Page[]>(mp.pages) : [],
|
||
sections: mp.sections ? deserializeFromIndexedDB<Section[]>(mp.sections) : [],
|
||
})),
|
||
};
|
||
resolve(deserializedProject);
|
||
} else {
|
||
resolve(project);
|
||
}
|
||
};
|
||
|
||
request.onerror = () => reject(request.error);
|
||
});
|
||
}
|
||
|
||
// 获取项目数据
|
||
export async function getEditorProject(
|
||
db: IDBDatabase,
|
||
messageId?: string,
|
||
): Promise<{ pages: Page[]; sections: Section[] | undefined; project?: IProject } | undefined> {
|
||
return new Promise((resolve, reject) => {
|
||
const transaction = db.transaction('projects', 'readonly');
|
||
const store = transaction.objectStore('projects');
|
||
const request = store.get(getChatId()!);
|
||
|
||
request.onsuccess = () => {
|
||
const project = request.result as IProject;
|
||
|
||
if (!project) {
|
||
resolve(undefined);
|
||
return;
|
||
}
|
||
|
||
if (messageId) {
|
||
// 返回特定消息 ID 的项目数据
|
||
const data = project.messageProjects?.find((p) => p.messageId === messageId);
|
||
|
||
const deserializedPages = data?.pages ? deserializeFromIndexedDB<Page[]>(data.pages) : [];
|
||
const deserializedSections = data?.sections ? deserializeFromIndexedDB<Section[]>(data.sections) : undefined;
|
||
|
||
resolve({
|
||
pages: deserializedPages,
|
||
sections: deserializedSections,
|
||
project,
|
||
});
|
||
} else {
|
||
// 没有指定消息 ID,返回最新的项目数据
|
||
const messageIds = project.messageProjects.map((p) => p.messageId);
|
||
|
||
if (messageIds.length === 0) {
|
||
resolve({ pages: [], sections: undefined, project });
|
||
} else {
|
||
// 按时间戳排序(如果有时间戳),或者取最后一个
|
||
const lastMessageId = messageIds[messageIds.length - 1];
|
||
const lastMessageProject = project.messageProjects.find((p) => p.messageId === lastMessageId);
|
||
|
||
const deserializedPages = lastMessageProject?.pages
|
||
? deserializeFromIndexedDB<Page[]>(lastMessageProject.pages)
|
||
: [];
|
||
const deserializedSections = lastMessageProject?.sections
|
||
? deserializeFromIndexedDB<Section[]>(lastMessageProject.sections)
|
||
: undefined;
|
||
|
||
resolve({
|
||
pages: deserializedPages,
|
||
sections: deserializedSections,
|
||
project,
|
||
});
|
||
}
|
||
}
|
||
};
|
||
|
||
request.onerror = () => reject(request.error);
|
||
});
|
||
}
|
||
|
||
// 删除项目数据
|
||
export async function deleteEditorProject(db: IDBDatabase, chatId: string, messageId?: string): Promise<void> {
|
||
return new Promise((resolve, reject) => {
|
||
const transaction = db.transaction('projects', 'readwrite');
|
||
const store = transaction.objectStore('projects');
|
||
|
||
if (messageId) {
|
||
// 只删除特定消息 ID 的项目数据
|
||
const getRequest = store.get(chatId);
|
||
|
||
getRequest.onsuccess = () => {
|
||
const project = getRequest.result as IProject;
|
||
|
||
if (project && project.messageProjects && project.messageProjects.find((p) => p.messageId === messageId)) {
|
||
// 删除特定消息的项目数据
|
||
project.messageProjects = project.messageProjects.filter((p) => p.messageId !== messageId);
|
||
|
||
if (project.messageProjects.length === 0) {
|
||
// 如果没有剩余项目,删除整个记录
|
||
const deleteRequest = store.delete(chatId);
|
||
deleteRequest.onsuccess = () => resolve();
|
||
deleteRequest.onerror = () => reject(deleteRequest.error);
|
||
} else {
|
||
// 更新记录
|
||
project.timestamp = new Date().toISOString();
|
||
|
||
const putRequest = store.put(project);
|
||
putRequest.onsuccess = () => resolve();
|
||
putRequest.onerror = () => reject(putRequest.error);
|
||
}
|
||
} else {
|
||
resolve(); // 项目不存在或消息 ID 不存在,视为删除成功
|
||
}
|
||
};
|
||
|
||
getRequest.onerror = () => reject(getRequest.error);
|
||
} else {
|
||
// 删除整个聊天 ID 的所有项目数据
|
||
const request = store.delete(chatId);
|
||
request.onsuccess = () => resolve();
|
||
request.onerror = () => reject(request.error);
|
||
}
|
||
});
|
||
}
|
||
|
||
// 获取所有项目数据
|
||
export async function getAllEditorProjects(db: IDBDatabase): Promise<IProject[]> {
|
||
return new Promise((resolve, reject) => {
|
||
const transaction = db.transaction('projects', 'readonly');
|
||
const store = transaction.objectStore('projects');
|
||
const request = store.getAll();
|
||
|
||
request.onsuccess = () => {
|
||
const projects = request.result as IProject[];
|
||
resolve(projects);
|
||
};
|
||
|
||
request.onerror = () => reject(request.error);
|
||
});
|
||
}
|
||
|
||
export async function createEditorProjectFromMessages(
|
||
db: IDBDatabase,
|
||
chatId: string,
|
||
messageProjects: IEditorMessageProject[],
|
||
): Promise<void> {
|
||
await setEditorProjects(db, chatId, messageProjects);
|
||
}
|
||
|
||
export async function forkEditorProject(
|
||
db: IDBDatabase,
|
||
chatId: string,
|
||
messageId: string,
|
||
newChatId: string,
|
||
): Promise<void> {
|
||
const project = await getEditorProjects(db, chatId);
|
||
if (!project) {
|
||
console.warn('editor project not found, It may be old project data.');
|
||
return;
|
||
}
|
||
|
||
const messageIndex = project.messageProjects.findIndex((msg) => msg.messageId === messageId);
|
||
|
||
if (messageIndex === -1) {
|
||
throw new Error('Message not found');
|
||
}
|
||
|
||
const messages = project.messageProjects.slice(0, messageIndex + 1);
|
||
|
||
await createEditorProjectFromMessages(db, newChatId, messages);
|
||
}
|
||
|
||
export async function duplicateEditorProject(db: IDBDatabase, id: string, newChatId: string): Promise<void> {
|
||
const project = await getEditorProjects(db, id);
|
||
if (!project) {
|
||
console.warn('editor project not found, It may be old project data.');
|
||
return;
|
||
}
|
||
|
||
createEditorProjectFromMessages(db, newChatId, project.messageProjects);
|
||
}
|
||
|
||
// 在 GrapesEditor 中使用的Hook
|
||
export function useEditorStorage() {
|
||
const [searchParams] = useSearchParams();
|
||
const currentMessageId = searchParams.get('rewindTo');
|
||
|
||
// 保存项目至本地数据库
|
||
const saveEditorProject = async (messageId: string | undefined, pages: Page[], sections: Section[]) => {
|
||
const db = await openEditorDatabase();
|
||
|
||
if (!db || !messageId) {
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
await saveProject(db, messageId, pages, sections);
|
||
return true;
|
||
} catch (error) {
|
||
logger.error('保存 editor 项目失败', error);
|
||
return false;
|
||
} finally {
|
||
db.close();
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 加载 editor 项目。
|
||
* @returns editor 项目数据。
|
||
*/
|
||
const loadEditorProject = async (): Promise<Page[] | undefined> => {
|
||
const db = await openEditorDatabase();
|
||
|
||
if (!db) {
|
||
return undefined;
|
||
}
|
||
|
||
const messageId = currentMessageId || undefined;
|
||
try {
|
||
const result = await getEditorProject(db, messageId);
|
||
return result?.pages;
|
||
} catch (error) {
|
||
logger.error('加载 editor 项目失败', error);
|
||
return undefined;
|
||
} finally {
|
||
db.close();
|
||
}
|
||
};
|
||
|
||
return {
|
||
saveEditorProject,
|
||
loadEditorProject,
|
||
};
|
||
}
|