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。