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. 配额管理:getQuotaInfonavigator.storage.estimate,requestPersistencenavigator.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