Skip to content

AppStorage 與 IndexedDB

源码版本v0.73.1

pi-web-ui 把所有持久化需求塞進一個 AppStorage 門面:provider API key、使用者設定、會話歷史、自訂 provider。底層是 IndexedDB,透過 StorageBackend 介面抽象,可以替換成遠端儲存。每個 store 宣告自己的 IndexedDB schema(config + indices),AppStorage 在建構時把這些 store 與 backend 連起來,提供單例 getAppStorage() 給全域存取。

職責

  1. 門面:AppStorage 暴露 settingsproviderKeyssessionscustomProviders 四個 store 與 backend,見 packages/web-ui/src/storage/app-storage.ts:11-39
  2. 全域單例:getAppStorage()/setAppStorage() 模組級單例,未初始化拋錯,見 packages/web-ui/src/storage/app-storage.ts:42-60
  3. Store 基類:Store 抽象類別要求子類實作 getConfig() 回傳 IndexedDB schema,透過 setBackend/getBackend 存取底層,見 packages/web-ui/src/storage/store.ts:7-33
  4. IndexedDB backend:IndexedDBStorageBackend 實作 StorageBackend 介面,onupgradeneeded 時按各 store 的 config 建立 object store 與 index,見 packages/web-ui/src/storage/backends/indexeddb-storage-backend.ts:7-46
  5. 會話儲存:SessionsStore 用兩個 object store:sessions(全量訊息)與 sessions-metadata(輕量元資料),save 時跨 store 事務原子寫,見 packages/web-ui/src/storage/stores/sessions-store.ts:30-35
  6. 配額管理:getQuotaInfo 調 navigator.storage.estimate,requestPersistence 調 navigator.storage.persist,見 packages/web-ui/src/storage/backends/indexeddb-storage-backend.ts:175-192

設計動機

為什麼不用 localStorage?三個原因:一是 API key、會話歷史這種資料可能超過 localStorage 的 5MB 上限;二是 IndexedDB 支援索引,sessions-metadatalastModified 索引讓 getAllMetadata 直接按時間倒序回傳,不用全量掃;三是 navigator.storage.estimatepersist 讓應用能感知配額並請求持久化,避免瀏覽器在壓力下清掉資料。

為什麼 SessionsStoresessionssessions-metadata 兩個 store?因為會話列表頁只需要標題、時間、訊息數、preview 這些輕量欄位,不需要把每條訊息的全量 content 都拉出來。metadata 單獨存,列表頁用 getAllFromIndex("sessions-metadata", "lastModified", "desc") 一次拿全,只有使用者點開具體會話時才 get("sessions", id) 讀全量。這是典型的列表/詳情分離。

為什麼 backend 是介面而不是直接用 IndexedDB?因為 pi-web-ui 也用在擴充與遠端場景,StorageBackend 抽象出來後,host 可以傳一個遠端 API 實作的 backend,store 程式碼完全不用改。Store 基類不知道底層是 IndexedDB 還是 HTTP,只透過 getBackend() 拿到一個符合介面的物件。

關鍵檔案

AppStorage 的結構簡單,只是把各 store 聚合:

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 用跨 store 事務保證 metadata 與全量資料一致:

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.getDBonupgradeneeded 時按各 store config 建立 store 和 index:

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,
                    });
                }
            }
        }
    }
};

資料流

寫會話時,AppStorage.sessions.save 跨兩個 store 原子寫;讀會話列表只走 metadata store:

邊界與失敗

小結

AppStorage 是個門面,底下是 StorageBackend 介面(預設 IndexedDBStorageBackend),每個 Store 子類宣告自己的 IndexedDB schema。會話用 sessions + sessions-metadata 雙 store 做列表/詳情分離。AgentInterface 透過 getAppStorage() 讀 provider key 與 proxy 設定,這兩個消費點看 AgentInterface 會話宿主CORS 代理與 createStreamFn