AppStorage 与 IndexedDB
pi-web-ui 把所有持久化需求塞进一个 AppStorage 门面:provider API key、用户设置、会话历史、自定义 provider。底层是 IndexedDB,通过 StorageBackend 接口抽象,可以替换成远程存储。每个 store 声明自己的 IndexedDB schema(config + indices),AppStorage 在构造时把这些 store 与 backend 连起来,提供单例 getAppStorage() 给全局访问。
职责
- 门面:
AppStorage暴露settings、providerKeys、sessions、customProviders四个 store 与backend,见packages/web-ui/src/storage/app-storage.ts:11-39。 - 全局单例:
getAppStorage()/setAppStorage()模块级单例,未初始化抛错,见packages/web-ui/src/storage/app-storage.ts:42-60。 - Store 基类:
Store抽象类要求子类实现getConfig()返回 IndexedDB schema,通过setBackend/getBackend访问底层,见packages/web-ui/src/storage/store.ts:7-33。 - IndexedDB backend:
IndexedDBStorageBackend实现StorageBackend接口,onupgradeneeded时按各 store 的 config 创建 object store 与 index,见packages/web-ui/src/storage/backends/indexeddb-storage-backend.ts:7-46。 - 会话存储:
SessionsStore用两个 object store:sessions(全量消息)与sessions-metadata(轻量元数据),save时跨 store 事务原子写,见packages/web-ui/src/storage/stores/sessions-store.ts:30-35。 - 配额管理:
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-metadata 的 lastModified 索引让 getAllMetadata 直接按时间倒序返回,不用全量扫;三是 navigator.storage.estimate 与 persist 让应用能感知配额并请求持久化,避免浏览器在压力下清掉数据。
为什么 SessionsStore 拆 sessions 和 sessions-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() 拿到一个符合接口的对象。
关键文件
packages/web-ui/src/storage/app-storage.ts:11-39—class AppStorage,四个 store 字段加 backend。packages/web-ui/src/storage/app-storage.ts:48-60—getAppStorage/setAppStorage全局单例。packages/web-ui/src/storage/store.ts:7-33—Store抽象基类,getConfig/setBackend/getBackend。packages/web-ui/src/storage/types.ts:29-88—StorageBackend接口与StorageTransaction。packages/web-ui/src/storage/types.ts:94-168—SessionMetadata与SessionData类型,决定列表 vs 详情的字段。packages/web-ui/src/storage/backends/indexeddb-storage-backend.ts:7-46—getDB与onupgradeneeded,按 config 创建 store 与 index。packages/web-ui/src/storage/backends/indexeddb-storage-backend.ts:99-125—getAllFromIndex,用 cursor 按索引顺序遍历。packages/web-ui/src/storage/stores/sessions-store.ts:30-55—save/delete跨 store 事务,getAllMetadata用lastModified索引。packages/web-ui/src/storage/stores/settings-store.ts:7-34—SettingsStore,无 keyPath 用 out-of-line key。packages/web-ui/src/storage/stores/provider-keys-store.ts:7-33—ProviderKeysStore,按 provider 名存 key。packages/web-ui/src/storage/stores/custom-providers-store.ts:28-62—CustomProvidersStore,用户自定义 LLM provider。
AppStorage 的结构简单,只是把各 store 聚合:
// 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 与全量数据一致:
// 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 在 onupgradeneeded 时按各 store config 创建 store 和 index:
// 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:
边界与失败
- 未初始化:
getAppStorage()在setAppStorage之前调用,抛AppStorage not initialized,见packages/web-ui/src/storage/app-storage.ts:48-53。 - store 未设 backend:
Store.getBackend()抛Backend not set on <ClassName>,提示 host 装配流程出错,见packages/web-ui/src/storage/store.ts:27-32。 - 配额检测不可用:
navigator.storage?.estimate不存在时返回{usage:0, quota:0, percent:0},不报错,见packages/web-ui/src/storage/backends/indexeddb-storage-backend.ts:175-185。 - persist 请求拒绝:
navigator.storage.persist返回 false 时requestPersistence返回 false,数据可能被浏览器清理,host 应该提示用户。 - out-of-line key 与 keyPath 区分:
set时若 store 有 keyPath,store.put(value);否则store.put(value, key)。SettingsStore、ProviderKeysStore都是无 keyPath 用 out-of-line key,见packages/web-ui/src/storage/backends/indexeddb-storage-backend.ts:68-73。 - 版本升级:
IndexedDBConfig.version升级时onupgradeneeded触发,但代码只创建不存在的 store,不处理字段迁移,host 改 schema 需自己扩。
小结
AppStorage 是个门面,底下是 StorageBackend 接口(默认 IndexedDBStorageBackend),每个 Store 子类声明自己的 IndexedDB schema。会话用 sessions + sessions-metadata 双 store 做列表/详情分离。AgentInterface 通过 getAppStorage() 读 provider key 与 proxy 设置,这两个消费点看 AgentInterface 会话宿主 和 CORS 代理与 createStreamFn。