Skip to content

AppStorage und IndexedDB

源码版本v0.73.1

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

  1. Fassade: AppStorage legt die vier Stores settings, providerKeys, sessions, customProviders und das backend offen. Siehe packages/web-ui/src/storage/app-storage.ts:11-39.
  2. Globales Singleton: getAppStorage()/setAppStorage() als Modul-Singleton; vor der Initialisierung wird geworfen. Siehe packages/web-ui/src/storage/app-storage.ts:42-60.
  3. Store-Basisklasse: Die abstrakte Klasse Store verlangt von Subklassen getConfig() für das IndexedDB-Schema; über setBackend/getBackend wird das Backend angesprochen. Siehe packages/web-ui/src/storage/store.ts:7-33.
  4. IndexedDB-Backend: IndexedDBStorageBackend implementiert das StorageBackend-Interface; bei onupgradeneeded werden nach der config der einzelnen Stores object stores und indices angelegt. Siehe packages/web-ui/src/storage/backends/indexeddb-storage-backend.ts:7-46.
  5. Konversations-Speicher: SessionsStore nutzt zwei object stores — sessions (volle Nachrichten) und sessions-metadata (leichte Metadaten); save schreibt transaktionsübergreifend atomar. Siehe packages/web-ui/src/storage/stores/sessions-store.ts:30-35.
  6. Quota-Verwaltung: getQuotaInfo ruft navigator.storage.estimate auf, requestPersistence ruft navigator.storage.persist auf. Siehe packages/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

AppStorage ist schlicht und fasst nur die Stores zusammen:

typescript
// 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:

typescript
// 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:

typescript
// 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() vor setAppStorage wirft AppStorage not initialized. Siehe packages/web-ui/src/storage/app-storage.ts:48-53.
  • Store ohne backend: Store.getBackend() wirft Backend not set on <ClassName> und signalisiert, dass die Verdrahtung des Hosts fehlt. Siehe packages/web-ui/src/storage/store.ts:27-32.
  • Quota-Erkennung nicht verfügbar: Wenn navigator.storage?.estimate fehlt, liefert die Funktion {usage:0, quota:0, percent:0} zurück, anstatt zu werfen. Siehe packages/web-ui/src/storage/backends/indexeddb-storage-backend.ts:175-185.
  • persist abgelehnt: Wenn navigator.storage.persist false zurückgibt, liefert requestPersistence false; die Daten können vom Browser weggeräumt werden, der Host sollte den Nutzer hinweisen.
  • out-of-line key vs keyPath: Beim set gilt: Hat der Store ein keyPath, wird store.put(value) genutzt; sonst store.put(value, key). SettingsStore und ProviderKeysStore haben kein keyPath und nutzen out-of-line keys. Siehe packages/web-ui/src/storage/backends/indexeddb-storage-backend.ts:68-73.
  • Versionssprung: Wenn IndexedDBConfig.version hochgesetzt wird, feuert onupgradeneeded, 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.