feat: rebuild xiteng.site homelab platform
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
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));
|
||||
}
|
||||
Reference in New Issue
Block a user