174 lines
7.0 KiB
TypeScript
174 lines
7.0 KiB
TypeScript
import type {Conversation, ConversationSummary} from "./conversation-types";
|
|
|
|
const databaseName = "xiteng-chat-offline";
|
|
const databaseVersion = 1;
|
|
const activeProfileKey = "xiteng-chat-offline-profile";
|
|
|
|
type CachedProfile<T = unknown> = {
|
|
id: string;
|
|
config: T;
|
|
summaries: ConversationSummary[];
|
|
updatedAt: string;
|
|
};
|
|
|
|
type CachedConversation = Conversation & {cacheKey: string; profileId: string};
|
|
|
|
export type PendingConversationChange = {
|
|
cacheKey: string;
|
|
profileId: string;
|
|
conversationId: string;
|
|
method: "PUT" | "PATCH" | "DELETE";
|
|
body?: string;
|
|
createdAt: string;
|
|
};
|
|
|
|
function openDatabase() {
|
|
return new Promise<IDBDatabase>((resolve, reject) => {
|
|
const request = indexedDB.open(databaseName, databaseVersion);
|
|
request.onupgradeneeded = () => {
|
|
const database = request.result;
|
|
if (!database.objectStoreNames.contains("profiles")) database.createObjectStore("profiles", {keyPath: "id"});
|
|
if (!database.objectStoreNames.contains("conversations")) {
|
|
const conversations = database.createObjectStore("conversations", {keyPath: "cacheKey"});
|
|
conversations.createIndex("profileId", "profileId");
|
|
}
|
|
if (!database.objectStoreNames.contains("pending")) {
|
|
const pending = database.createObjectStore("pending", {keyPath: "cacheKey"});
|
|
pending.createIndex("profileId", "profileId");
|
|
}
|
|
};
|
|
request.onsuccess = () => resolve(request.result);
|
|
request.onerror = () => reject(request.error || new Error("Unable to open offline history"));
|
|
});
|
|
}
|
|
|
|
async function transaction<T>(storeName: string, mode: IDBTransactionMode, run: (store: IDBObjectStore) => IDBRequest<T>) {
|
|
const database = await openDatabase();
|
|
return new Promise<T>((resolve, reject) => {
|
|
const current = database.transaction(storeName, mode);
|
|
const request = run(current.objectStore(storeName));
|
|
request.onsuccess = () => resolve(request.result);
|
|
request.onerror = () => reject(request.error || new Error(`Offline ${storeName} operation failed`));
|
|
current.oncomplete = () => database.close();
|
|
current.onerror = () => reject(current.error || new Error(`Offline ${storeName} transaction failed`));
|
|
});
|
|
}
|
|
|
|
function activeProfileId() {
|
|
return window.localStorage.getItem(activeProfileKey) || "";
|
|
}
|
|
|
|
function conversationCacheKey(profileId: string, conversationId: string) {
|
|
return `${profileId}:${conversationId}`;
|
|
}
|
|
|
|
export function activateOfflineProfile(profileId: string) {
|
|
window.localStorage.setItem(activeProfileKey, profileId);
|
|
}
|
|
|
|
export async function cacheChatConfig<T>(profileId: string, config: T) {
|
|
activateOfflineProfile(profileId);
|
|
const current = await transaction<CachedProfile<T> | undefined>("profiles", "readonly", (store) => store.get(profileId));
|
|
const profile: CachedProfile<T> = {
|
|
id: profileId,
|
|
config,
|
|
summaries: current?.summaries || [],
|
|
updatedAt: new Date().toISOString()
|
|
};
|
|
await transaction<IDBValidKey>("profiles", "readwrite", (store) => store.put(profile));
|
|
}
|
|
|
|
export async function loadCachedChatConfig<T>() {
|
|
const profileId = activeProfileId();
|
|
if (!profileId) return null;
|
|
const profile = await transaction<CachedProfile<T> | undefined>("profiles", "readonly", (store) => store.get(profileId));
|
|
return profile ? {profileId, config: profile.config, updatedAt: profile.updatedAt} : null;
|
|
}
|
|
|
|
export async function cacheConversationSummaries(summaries: ConversationSummary[]) {
|
|
const profileId = activeProfileId();
|
|
if (!profileId) return;
|
|
const current = await transaction<CachedProfile | undefined>("profiles", "readonly", (store) => store.get(profileId));
|
|
if (!current) return;
|
|
await transaction<IDBValidKey>("profiles", "readwrite", (store) => store.put({
|
|
...current,
|
|
summaries,
|
|
updatedAt: new Date().toISOString()
|
|
}));
|
|
}
|
|
|
|
export async function loadCachedConversationSummaries() {
|
|
const profileId = activeProfileId();
|
|
if (!profileId) return [];
|
|
const profile = await transaction<CachedProfile | undefined>("profiles", "readonly", (store) => store.get(profileId));
|
|
return profile?.summaries || [];
|
|
}
|
|
|
|
export async function cacheConversation(conversation: Conversation) {
|
|
const profileId = activeProfileId();
|
|
if (!profileId) return;
|
|
const record: CachedConversation = {
|
|
...conversation,
|
|
cacheKey: conversationCacheKey(profileId, conversation.id),
|
|
profileId
|
|
};
|
|
await transaction<IDBValidKey>("conversations", "readwrite", (store) => store.put(record));
|
|
const summaries = await loadCachedConversationSummaries();
|
|
const summary: ConversationSummary = {
|
|
id: conversation.id,
|
|
title: conversation.title,
|
|
providerId: conversation.providerId,
|
|
model: conversation.model,
|
|
messageCount: conversation.messageCount,
|
|
createdAt: conversation.createdAt,
|
|
updatedAt: conversation.updatedAt
|
|
};
|
|
await cacheConversationSummaries([summary, ...summaries.filter((item) => item.id !== conversation.id)].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)));
|
|
}
|
|
|
|
export async function loadCachedConversation(id: string) {
|
|
const profileId = activeProfileId();
|
|
if (!profileId) return null;
|
|
const record = await transaction<CachedConversation | undefined>("conversations", "readonly", (store) => store.get(conversationCacheKey(profileId, id)));
|
|
if (!record) return null;
|
|
const {cacheKey: _cacheKey, profileId: _profileId, ...conversation} = record;
|
|
return conversation;
|
|
}
|
|
|
|
export async function removeCachedConversation(id: string) {
|
|
const profileId = activeProfileId();
|
|
if (!profileId) return;
|
|
await transaction<undefined>("conversations", "readwrite", (store) => store.delete(conversationCacheKey(profileId, id)));
|
|
const summaries = await loadCachedConversationSummaries();
|
|
await cacheConversationSummaries(summaries.filter((conversation) => conversation.id !== id));
|
|
}
|
|
|
|
export async function queueConversationChange(change: Omit<PendingConversationChange, "cacheKey" | "profileId" | "createdAt">) {
|
|
const profileId = activeProfileId();
|
|
if (!profileId) return;
|
|
const record: PendingConversationChange = {
|
|
...change,
|
|
cacheKey: `${profileId}:${change.conversationId}:${change.method}`,
|
|
profileId,
|
|
createdAt: new Date().toISOString()
|
|
};
|
|
await transaction<IDBValidKey>("pending", "readwrite", (store) => store.put(record));
|
|
}
|
|
|
|
export async function listPendingConversationChanges() {
|
|
const profileId = activeProfileId();
|
|
if (!profileId) return [];
|
|
const database = await openDatabase();
|
|
return new Promise<PendingConversationChange[]>((resolve, reject) => {
|
|
const current = database.transaction("pending", "readonly");
|
|
const request = current.objectStore("pending").index("profileId").getAll(profileId);
|
|
request.onsuccess = () => resolve(request.result.sort((left, right) => left.createdAt.localeCompare(right.createdAt)));
|
|
request.onerror = () => reject(request.error || new Error("Unable to read pending history changes"));
|
|
current.oncomplete = () => database.close();
|
|
});
|
|
}
|
|
|
|
export async function removePendingConversationChange(cacheKey: string) {
|
|
await transaction<undefined>("pending", "readwrite", (store) => store.delete(cacheKey));
|
|
}
|