AppStorage und IndexedDB
pi-web-ui packt alle Persistenzbedürfnisse in eine AppStorage-Fassade: provider-API-keys, Nutzereinstellungen, Konversationshistorie, eigene Provider. Darunter liegt IndexedDB, über das StorageBackend-Interface abstrahiert, sodass es durch eine Remote-Speicher-Implementierung ersetzt werden kann. Jeder Store deklariert sein eigenes IndexedDB-Schema (config + indices); AppStorage verdrahtet in seinem Konstruktor die Stores mit dem Backend und bietet das Singleton getAppStorage() für den globalen Zugriff.
Zuständigkeiten
- Fassade:
AppStoragelegt die vier Storessettings,providerKeys,sessions,customProvidersund dasbackendoffen. Siehepackages/web-ui/src/storage/app-storage.ts:11-39. - Globales Singleton:
getAppStorage()/setAppStorage()als Modul-Singleton; vor der Initialisierung wird geworfen. Siehepackages/web-ui/src/storage/app-storage.ts:42-60. - Store-Basisklasse: Die abstrakte Klasse
Storeverlangt von SubklassengetConfig()für das IndexedDB-Schema; übersetBackend/getBackendwird das Backend angesprochen. Siehepackages/web-ui/src/storage/store.ts:7-33. - IndexedDB-Backend:
IndexedDBStorageBackendimplementiert dasStorageBackend-Interface; beionupgradeneededwerden nach der config der einzelnen Stores object stores und indices angelegt. Siehepackages/web-ui/src/storage/backends/indexeddb-storage-backend.ts:7-46. - Konversations-Speicher:
SessionsStorenutzt zwei object stores —sessions(volle Nachrichten) undsessions-metadata(leichte Metadaten);saveschreibt transaktionsübergreifend atomar. Siehepackages/web-ui/src/storage/stores/sessions-store.ts:30-35. - Quota-Verwaltung:
getQuotaInforuftnavigator.storage.estimateauf,requestPersistenceruftnavigator.storage.persistauf. Siehepackages/web-ui/src/storage/backends/indexeddb-storage-backend.ts:175-192.
Designmotivation
Warum nicht localStorage? Drei Gründe. Erstens können API-Keys und Konversationshistorie die 5MB-Grenze von localStorage überschreiten. Zweitens unterstützt IndexedDB Indizes: Der lastModified-Index von sessions-metadata liefert getAllMetadata direkt zeitlich absteigend, ohne einen Vollscan. Drittens erlauben navigator.storage.estimate und persist der Anwendung, Quota wahrzunehmen und Persistenz anzufordern, damit der Browser unter Druck nicht einfach Daten wegwirft.
Warum trennt SessionsStore in die Stores sessions und sessions-metadata auf? Weil die Konversationsliste nur Titel, Zeit, Nachrichtenanzahl und Preview als leichte Felder braucht und nicht für jede Nachricht den kompletten content laden muss. Metadaten liegen separat, die Listenseite holt alles über getAllFromIndex("sessions-metadata", "lastModified", "desc") in einem Aufruf, und erst wenn der Nutzer eine konkrete Konversation öffnet, geht get("sessions", id) an die VollDaten. Das ist die klassische Listen/Detail-Trennung.
Warum ist das Backend ein Interface und nicht direkt IndexedDB? Weil pi-web-ui auch in Erweiterungen und Remote-Szenarien läuft; das StorageBackend-Interface erlaubt es dem Host, ein Backend auf Basis einer Remote-API zu übergeben, ohne dass der Store-Code angefasst wird. Der Store weiß nicht, ob unter ihm IndexedDB oder HTTP liegt, er holt über getBackend() nur ein Objekt, das zum Interface passt.
Wichtige Dateien
packages/web-ui/src/storage/app-storage.ts:11-39—class AppStorage, vier Store-Felder und backend.packages/web-ui/src/storage/app-storage.ts:48-60—getAppStorage/setAppStorageglobales Singleton.packages/web-ui/src/storage/store.ts:7-33— abstrakteStore-Basisklasse,getConfig/setBackend/getBackend.packages/web-ui/src/storage/types.ts:29-88—StorageBackend-Interface undStorageTransaction.packages/web-ui/src/storage/types.ts:94-168—SessionMetadata- undSessionData-Typen; sie legen die Felder für Liste vs. Detail fest.packages/web-ui/src/storage/backends/indexeddb-storage-backend.ts:7-46—getDBundonupgradeneeded, legt nach config store und index an.packages/web-ui/src/storage/backends/indexeddb-storage-backend.ts:99-125—getAllFromIndex, durchläuft per Cursor in Index-Reihenfolge.packages/web-ui/src/storage/stores/sessions-store.ts:30-55—save/deletetransaktionsübergreifend über Stores;getAllMetadataüber denlastModified-Index.packages/web-ui/src/storage/stores/settings-store.ts:7-34—SettingsStore, ohne keyPath, nutzt out-of-line key.packages/web-ui/src/storage/stores/provider-keys-store.ts:7-33—ProviderKeysStore, speichert key nach provider-Name.packages/web-ui/src/storage/stores/custom-providers-store.ts:28-62—CustomProvidersStore, vom Nutzer angelegte LLM-Provider.
AppStorage ist schlicht und fasst nur die Stores zusammen:
// packages/web-ui/src/storage/app-storage.ts:11-30
export class AppStorage {
readonly backend: StorageBackend;
readonly settings: SettingsStore;
readonly providerKeys: ProviderKeysStore;
readonly sessions: SessionsStore;
readonly customProviders: CustomProvidersStore;
constructor(
settings: SettingsStore,
providerKeys: ProviderKeysStore,
sessions: SessionsStore,
customProviders: CustomProvidersStore,
backend: StorageBackend,
) {
this.settings = settings;
this.providerKeys = providerKeys;
this.sessions = sessions;
this.customProviders = customProviders;
this.backend = backend;
}
}SessionsStore.save nutzt eine transaktionsübergreifende Transaktion, sodass Metadaten und Volldaten konsistent sind:
// packages/web-ui/src/storage/stores/sessions-store.ts:30-35
async save(data: SessionData, metadata: SessionMetadata): Promise<void> {
await this.getBackend().transaction(["sessions", "sessions-metadata"], "readwrite", async (tx) => {
await tx.set("sessions", data.id, data);
await tx.set("sessions-metadata", metadata.id, metadata);
});
}IndexedDBStorageBackend.getDB legt in onupgradeneeded nach der config der einzelnen Stores store und index an:
// packages/web-ui/src/storage/backends/indexeddb-storage-backend.ts:23-40
request.onupgradeneeded = (_event) => {
const db = request.result;
for (const storeConfig of this.config.stores) {
if (!db.objectStoreNames.contains(storeConfig.name)) {
const store = db.createObjectStore(storeConfig.name, {
keyPath: storeConfig.keyPath,
autoIncrement: storeConfig.autoIncrement,
});
if (storeConfig.indices) {
for (const indexConfig of storeConfig.indices) {
store.createIndex(indexConfig.name, indexConfig.keyPath, {
unique: indexConfig.unique,
});
}
}
}
}
};Datenfluss
Beim Schreiben einer Konversation schreibt AppStorage.sessions.save atomar über beide Stores; die Listenseite liest nur den Metadaten-Store:
Randbedingungen und Fehler
- Nicht initialisiert:
getAppStorage()vorsetAppStoragewirftAppStorage not initialized. Siehepackages/web-ui/src/storage/app-storage.ts:48-53. - Store ohne backend:
Store.getBackend()wirftBackend not set on <ClassName>und signalisiert, dass die Verdrahtung des Hosts fehlt. Siehepackages/web-ui/src/storage/store.ts:27-32. - Quota-Erkennung nicht verfügbar: Wenn
navigator.storage?.estimatefehlt, liefert die Funktion{usage:0, quota:0, percent:0}zurück, anstatt zu werfen. Siehepackages/web-ui/src/storage/backends/indexeddb-storage-backend.ts:175-185. - persist abgelehnt: Wenn
navigator.storage.persistfalse zurückgibt, liefertrequestPersistencefalse; die Daten können vom Browser weggeräumt werden, der Host sollte den Nutzer hinweisen. - out-of-line key vs keyPath: Beim
setgilt: Hat der Store ein keyPath, wirdstore.put(value)genutzt; sonststore.put(value, key).SettingsStoreundProviderKeysStorehaben kein keyPath und nutzen out-of-line keys. Siehepackages/web-ui/src/storage/backends/indexeddb-storage-backend.ts:68-73. - Versionssprung: Wenn
IndexedDBConfig.versionhochgesetzt wird, feuertonupgradeneeded, aber der Code legt nur noch nicht existierende Stores an und migriert keine Felder; wer das Schema ändert, muss selbst erweitern.
Zusammenfassung
AppStorage ist eine Fassade; darunter liegt das StorageBackend-Interface (default IndexedDBStorageBackend), und jede Store-Subklasse deklariert ihr eigenes IndexedDB-Schema. Konversationen nutzen sessions + sessions-metadata als Doppel-Store für die Listen/Detail-Trennung. AgentInterface liest über getAppStorage() den provider-key und die Proxy-Einstellungen; diese beiden Konsumpunkte stehen in AgentInterface Session-Host und CORS-Proxy und createStreamFn.