feat: rebuild xiteng.site homelab platform

This commit is contained in:
2026-08-12 09:48:25 +08:00
parent 557b0eca33
commit 5b84988789
128 changed files with 14979 additions and 292 deletions
+173
View File
@@ -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));
}