From 0de89a750eaf43ec42e71b905209a3a4968d57ad Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 8 Oct 2025 16:41:02 +0200 Subject: [PATCH] Refactor to Store-based architecture - Create base Store class with private backend and protected getBackend() - Add SettingsStore, ProviderKeysStore, SessionsStore - Each store defines its own schema via getConfig() - AppStorage now takes stores + backend in constructor - Remove SessionsRepository (logic moved to SessionsStore) - Update all consumers to use store API (storage.settings.get/set, storage.providerKeys.get/set) - Update example app to follow new pattern: create stores, gather configs, create backend, wire - Benefits: stores own their schema, no circular deps, cleaner separation --- packages/web-ui/example/src/main.ts | 32 +++++-- .../src/agent/transports/ProviderTransport.ts | 6 +- .../web-ui/src/components/AgentInterface.ts | 2 +- .../web-ui/src/components/ProviderKeyInput.ts | 10 +-- .../web-ui/src/dialogs/ApiKeyPromptDialog.ts | 2 +- packages/web-ui/src/dialogs/SettingsDialog.ts | 8 +- packages/web-ui/src/index.ts | 5 +- packages/web-ui/src/storage/app-storage.ts | 56 ++++-------- .../web-ui/src/storage/sessions-repository.ts | 62 ------------- packages/web-ui/src/storage/store.ts | 33 +++++++ .../src/storage/stores/provider-keys-store.ts | 33 +++++++ .../src/storage/stores/sessions-store.ts | 86 +++++++++++++++++++ .../src/storage/stores/settings-store.ts | 34 ++++++++ 13 files changed, 243 insertions(+), 126 deletions(-) delete mode 100644 packages/web-ui/src/storage/sessions-repository.ts create mode 100644 packages/web-ui/src/storage/store.ts create mode 100644 packages/web-ui/src/storage/stores/provider-keys-store.ts create mode 100644 packages/web-ui/src/storage/stores/sessions-store.ts create mode 100644 packages/web-ui/src/storage/stores/settings-store.ts diff --git a/packages/web-ui/example/src/main.ts b/packages/web-ui/example/src/main.ts index 5521cdd7..aaeb7d0e 100644 --- a/packages/web-ui/example/src/main.ts +++ b/packages/web-ui/example/src/main.ts @@ -11,11 +11,14 @@ import { ChatPanel, IndexedDBStorageBackend, // PersistentStorageDialog, // TODO: Fix - currently broken + ProviderKeysStore, ProviderTransport, ProxyTab, SessionListDialog, + SessionsStore, setAppStorage, SettingsDialog, + SettingsStore, } from "@mariozechner/pi-web-ui"; import { html, render } from "lit"; import { Bell, History, Plus, Settings } from "lucide"; @@ -25,17 +28,28 @@ import { createSystemNotification, customMessageTransformer, registerCustomMessa // Register custom message renderers registerCustomMessageRenderers(); +// Create stores +const settings = new SettingsStore(); +const providerKeys = new ProviderKeysStore(); +const sessions = new SessionsStore(); + +// Gather configs +const configs = [settings.getConfig(), SessionsStore.getMetadataConfig(), providerKeys.getConfig(), sessions.getConfig()]; + +// Create backend const backend = new IndexedDBStorageBackend({ dbName: "pi-web-ui-example", version: 1, - stores: [ - { name: "sessions-metadata", keyPath: "id", indices: [{ name: "lastModified", keyPath: "lastModified" }] }, - { name: "sessions-data", keyPath: "id" }, - { name: "settings" }, - { name: "provider-keys" }, - ], + stores: configs, }); -const storage = new AppStorage(backend); + +// Wire backend to stores +settings.setBackend(backend); +providerKeys.setBackend(backend); +sessions.setBackend(backend); + +// Create and set app storage +const storage = new AppStorage(settings, providerKeys, sessions, backend); setAppStorage(storage); let currentSessionId: string | undefined; @@ -118,7 +132,7 @@ const saveSession = async () => { preview: generateTitle(state.messages), }; - await storage.sessions.saveSession(sessionData, metadata); + await storage.sessions.save(sessionData, metadata); } catch (err) { console.error("Failed to save session:", err); } @@ -186,7 +200,7 @@ Feel free to use these tools when needed to provide accurate and helpful respons const loadSession = async (sessionId: string): Promise => { if (!storage.sessions) return false; - const sessionData = await storage.sessions.getSession(sessionId); + const sessionData = await storage.sessions.get(sessionId); if (!sessionData) { console.error("Session not found:", sessionId); return false; diff --git a/packages/web-ui/src/agent/transports/ProviderTransport.ts b/packages/web-ui/src/agent/transports/ProviderTransport.ts index 244187cb..22776fb8 100644 --- a/packages/web-ui/src/agent/transports/ProviderTransport.ts +++ b/packages/web-ui/src/agent/transports/ProviderTransport.ts @@ -9,14 +9,14 @@ import type { AgentRunConfig, AgentTransport } from "./types.js"; export class ProviderTransport implements AgentTransport { async *run(messages: Message[], userMessage: Message, cfg: AgentRunConfig, signal?: AbortSignal) { // Get API key from storage - const apiKey = await getAppStorage().getProviderKey(cfg.model.provider); + const apiKey = await getAppStorage().providerKeys.get(cfg.model.provider); if (!apiKey) { throw new Error("no-api-key"); } // Check if CORS proxy is enabled - const proxyEnabled = await getAppStorage().getSetting("proxy.enabled"); - const proxyUrl = await getAppStorage().getSetting("proxy.url"); + const proxyEnabled = await getAppStorage().settings.get("proxy.enabled"); + const proxyUrl = await getAppStorage().settings.get("proxy.url"); // Clone model and modify baseUrl if proxy is enabled let model = cfg.model; diff --git a/packages/web-ui/src/components/AgentInterface.ts b/packages/web-ui/src/components/AgentInterface.ts index 3040f085..e6b7dd83 100644 --- a/packages/web-ui/src/components/AgentInterface.ts +++ b/packages/web-ui/src/components/AgentInterface.ts @@ -173,7 +173,7 @@ export class AgentInterface extends LitElement { // Check if API key exists for the provider (only needed in direct mode) const provider = session.state.model.provider; - const apiKey = await getAppStorage().getProviderKey(provider); + const apiKey = await getAppStorage().providerKeys.get(provider); // If no API key, prompt for it if (!apiKey) { diff --git a/packages/web-ui/src/components/ProviderKeyInput.ts b/packages/web-ui/src/components/ProviderKeyInput.ts index ede6e159..e5dc3363 100644 --- a/packages/web-ui/src/components/ProviderKeyInput.ts +++ b/packages/web-ui/src/components/ProviderKeyInput.ts @@ -35,7 +35,7 @@ export class ProviderKeyInput extends LitElement { private async checkKeyStatus() { try { - const key = await getAppStorage().getProviderKey(this.provider); + const key = await getAppStorage().providerKeys.get(this.provider); this.hasKey = !!key; } catch (error) { console.error("Failed to check key status:", error); @@ -51,8 +51,8 @@ export class ProviderKeyInput extends LitElement { if (!model) return false; // Check if CORS proxy is enabled and apply it - const proxyEnabled = await getAppStorage().getSetting("proxy.enabled"); - const proxyUrl = await getAppStorage().getSetting("proxy.url"); + const proxyEnabled = await getAppStorage().settings.get("proxy.enabled"); + const proxyUrl = await getAppStorage().settings.get("proxy.url"); if (proxyEnabled && proxyUrl && model.baseUrl) { model = { @@ -89,7 +89,7 @@ export class ProviderKeyInput extends LitElement { if (success) { try { - await getAppStorage().setProviderKey(this.provider, this.keyInput); + await getAppStorage().providerKeys.set(this.provider, this.keyInput); this.hasKey = true; this.keyInput = ""; this.requestUpdate(); @@ -112,7 +112,7 @@ export class ProviderKeyInput extends LitElement { private async removeKey() { try { - await getAppStorage().deleteProviderKey(this.provider); + await getAppStorage().providerKeys.delete(this.provider); this.hasKey = false; this.keyInput = ""; this.requestUpdate(); diff --git a/packages/web-ui/src/dialogs/ApiKeyPromptDialog.ts b/packages/web-ui/src/dialogs/ApiKeyPromptDialog.ts index 0a54db9e..a61d9e44 100644 --- a/packages/web-ui/src/dialogs/ApiKeyPromptDialog.ts +++ b/packages/web-ui/src/dialogs/ApiKeyPromptDialog.ts @@ -29,7 +29,7 @@ export class ApiKeyPromptDialog extends DialogBase { // Poll for key existence - when key is added, resolve and close const checkInterval = setInterval(async () => { - const hasKey = !!(await getAppStorage().getProviderKey(this.provider)); + const hasKey = !!(await getAppStorage().providerKeys.get(this.provider)); if (hasKey) { clearInterval(checkInterval); if (this.resolvePromise) { diff --git a/packages/web-ui/src/dialogs/SettingsDialog.ts b/packages/web-ui/src/dialogs/SettingsDialog.ts index 5c29aa03..20717367 100644 --- a/packages/web-ui/src/dialogs/SettingsDialog.ts +++ b/packages/web-ui/src/dialogs/SettingsDialog.ts @@ -56,8 +56,8 @@ export class ProxyTab extends SettingsTab { // Load proxy settings when tab is connected try { const storage = getAppStorage(); - const enabled = await storage.getSetting("proxy.enabled"); - const url = await storage.getSetting("proxy.url"); + const enabled = await storage.settings.get("proxy.enabled"); + const url = await storage.settings.get("proxy.url"); if (enabled !== null) this.proxyEnabled = enabled; if (url !== null) this.proxyUrl = url; @@ -69,8 +69,8 @@ export class ProxyTab extends SettingsTab { private async saveProxySettings() { try { const storage = getAppStorage(); - await storage.setSetting("proxy.enabled", this.proxyEnabled); - await storage.setSetting("proxy.url", this.proxyUrl); + await storage.settings.set("proxy.enabled", this.proxyEnabled); + await storage.settings.set("proxy.url", this.proxyUrl); } catch (error) { console.error("Failed to save proxy settings:", error); } diff --git a/packages/web-ui/src/index.ts b/packages/web-ui/src/index.ts index 3fa97cb3..0e0f2990 100644 --- a/packages/web-ui/src/index.ts +++ b/packages/web-ui/src/index.ts @@ -45,7 +45,10 @@ export { ApiKeysTab, ProxyTab, SettingsDialog, SettingsTab } from "./dialogs/Set // Storage export { AppStorage, getAppStorage, setAppStorage } from "./storage/app-storage.js"; export { IndexedDBStorageBackend } from "./storage/backends/indexeddb-storage-backend.js"; -export { SessionsRepository } from "./storage/sessions-repository.js"; +export { Store } from "./storage/store.js"; +export { ProviderKeysStore } from "./storage/stores/provider-keys-store.js"; +export { SessionsStore } from "./storage/stores/sessions-store.js"; +export { SettingsStore } from "./storage/stores/settings-store.js"; export type { IndexConfig, IndexedDBConfig, diff --git a/packages/web-ui/src/storage/app-storage.ts b/packages/web-ui/src/storage/app-storage.ts index 5ea93e94..56e57f83 100644 --- a/packages/web-ui/src/storage/app-storage.ts +++ b/packages/web-ui/src/storage/app-storage.ts @@ -1,54 +1,30 @@ -import { SessionsRepository } from "./sessions-repository.js"; +import type { ProviderKeysStore } from "./stores/provider-keys-store.js"; +import type { SessionsStore } from "./stores/sessions-store.js"; +import type { SettingsStore } from "./stores/settings-store.js"; import type { StorageBackend } from "./types.js"; /** * High-level storage API providing access to all storage operations. - * Subclasses can extend this to add domain-specific repositories. + * Subclasses can extend this to add domain-specific stores. */ export class AppStorage { readonly backend: StorageBackend; - readonly sessions: SessionsRepository; + readonly settings: SettingsStore; + readonly providerKeys: ProviderKeysStore; + readonly sessions: SessionsStore; - constructor(backend: StorageBackend) { + constructor( + settings: SettingsStore, + providerKeys: ProviderKeysStore, + sessions: SessionsStore, + backend: StorageBackend, + ) { + this.settings = settings; + this.providerKeys = providerKeys; + this.sessions = sessions; this.backend = backend; - this.sessions = new SessionsRepository(backend); } - // Settings access (delegates to "settings" store) - async getSetting(key: string): Promise { - return this.backend.get("settings", key); - } - - async setSetting(key: string, value: T): Promise { - await this.backend.set("settings", key, value); - } - - async deleteSetting(key: string): Promise { - await this.backend.delete("settings", key); - } - - async listSettings(): Promise { - return this.backend.keys("settings"); - } - - // Provider keys access (delegates to "provider-keys" store) - async getProviderKey(provider: string): Promise { - return this.backend.get("provider-keys", provider); - } - - async setProviderKey(provider: string, key: string): Promise { - await this.backend.set("provider-keys", provider, key); - } - - async deleteProviderKey(provider: string): Promise { - await this.backend.delete("provider-keys", provider); - } - - async listProviderKeys(): Promise { - return this.backend.keys("provider-keys"); - } - - // Quota management async getQuotaInfo(): Promise<{ usage: number; quota: number; percent: number }> { return this.backend.getQuotaInfo(); } diff --git a/packages/web-ui/src/storage/sessions-repository.ts b/packages/web-ui/src/storage/sessions-repository.ts deleted file mode 100644 index 399082e1..00000000 --- a/packages/web-ui/src/storage/sessions-repository.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { SessionData, SessionMetadata, StorageBackend } from "./types.js"; - -/** - * Repository for managing sessions using a multi-store backend. - * Handles session data and metadata with atomic operations. - */ -export class SessionsRepository { - constructor(private backend: StorageBackend) {} - - async saveSession(data: SessionData, metadata: SessionMetadata): Promise { - await this.backend.transaction(["sessions-metadata", "sessions-data"], "readwrite", async (tx) => { - await tx.set("sessions-metadata", metadata.id, metadata); - await tx.set("sessions-data", data.id, data); - }); - } - - async getSession(id: string): Promise { - return this.backend.get("sessions-data", id); - } - - async getMetadata(id: string): Promise { - return this.backend.get("sessions-metadata", id); - } - - async getAllMetadata(): Promise { - const keys = await this.backend.keys("sessions-metadata"); - const metadata = await Promise.all( - keys.map((key) => this.backend.get("sessions-metadata", key)), - ); - return metadata.filter((m): m is SessionMetadata => m !== null); - } - - async deleteSession(id: string): Promise { - await this.backend.transaction(["sessions-metadata", "sessions-data"], "readwrite", async (tx) => { - await tx.delete("sessions-metadata", id); - await tx.delete("sessions-data", id); - }); - } - - async updateTitle(id: string, title: string): Promise { - const metadata = await this.getMetadata(id); - if (metadata) { - metadata.title = title; - await this.backend.set("sessions-metadata", id, metadata); - } - - // Also update in full session data - const data = await this.getSession(id); - if (data) { - data.title = title; - await this.backend.set("sessions-data", id, data); - } - } - - async getQuotaInfo(): Promise<{ usage: number; quota: number; percent: number }> { - return this.backend.getQuotaInfo(); - } - - async requestPersistence(): Promise { - return this.backend.requestPersistence(); - } -} diff --git a/packages/web-ui/src/storage/store.ts b/packages/web-ui/src/storage/store.ts new file mode 100644 index 00000000..b5ea8b08 --- /dev/null +++ b/packages/web-ui/src/storage/store.ts @@ -0,0 +1,33 @@ +import type { StorageBackend, StoreConfig } from "./types.js"; + +/** + * Base class for all storage stores. + * Each store defines its IndexedDB schema and provides domain-specific methods. + */ +export abstract class Store { + private backend: StorageBackend | null = null; + + /** + * Returns the IndexedDB configuration for this store. + * Defines store name, key path, and indices. + */ + abstract getConfig(): StoreConfig; + + /** + * Sets the storage backend. Called by AppStorage after backend creation. + */ + setBackend(backend: StorageBackend): void { + this.backend = backend; + } + + /** + * Gets the storage backend. Throws if backend not set. + * Concrete stores must use this to access the backend. + */ + protected getBackend(): StorageBackend { + if (!this.backend) { + throw new Error(`Backend not set on ${this.constructor.name}`); + } + return this.backend; + } +} diff --git a/packages/web-ui/src/storage/stores/provider-keys-store.ts b/packages/web-ui/src/storage/stores/provider-keys-store.ts new file mode 100644 index 00000000..41cf8885 --- /dev/null +++ b/packages/web-ui/src/storage/stores/provider-keys-store.ts @@ -0,0 +1,33 @@ +import { Store } from "../store.js"; +import type { StoreConfig } from "../types.js"; + +/** + * Store for LLM provider API keys (Anthropic, OpenAI, etc.). + */ +export class ProviderKeysStore extends Store { + getConfig(): StoreConfig { + return { + name: "provider-keys", + }; + } + + async get(provider: string): Promise { + return this.getBackend().get("provider-keys", provider); + } + + async set(provider: string, key: string): Promise { + await this.getBackend().set("provider-keys", provider, key); + } + + async delete(provider: string): Promise { + await this.getBackend().delete("provider-keys", provider); + } + + async list(): Promise { + return this.getBackend().keys("provider-keys"); + } + + async has(provider: string): Promise { + return this.getBackend().has("provider-keys", provider); + } +} diff --git a/packages/web-ui/src/storage/stores/sessions-store.ts b/packages/web-ui/src/storage/stores/sessions-store.ts new file mode 100644 index 00000000..eabc0577 --- /dev/null +++ b/packages/web-ui/src/storage/stores/sessions-store.ts @@ -0,0 +1,86 @@ +import { Store } from "../store.js"; +import type { SessionData, SessionMetadata, StoreConfig } from "../types.js"; + +/** + * Store for chat sessions (data and metadata). + * Uses two object stores: sessions (full data) and sessions-metadata (lightweight). + */ +export class SessionsStore extends Store { + getConfig(): StoreConfig { + return { + name: "sessions", + keyPath: "id", + indices: [{ name: "lastModified", keyPath: "lastModified" }], + }; + } + + /** + * Additional config for sessions-metadata store. + * Must be included when creating the backend. + */ + static getMetadataConfig(): StoreConfig { + return { + name: "sessions-metadata", + keyPath: "id", + indices: [{ name: "lastModified", keyPath: "lastModified" }], + }; + } + + async save(data: SessionData, metadata: SessionMetadata): Promise { + 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); + }); + } + + async get(id: string): Promise { + return this.getBackend().get("sessions", id); + } + + async getMetadata(id: string): Promise { + return this.getBackend().get("sessions-metadata", id); + } + + async getAllMetadata(): Promise { + const keys = await this.getBackend().keys("sessions-metadata"); + const metadata = await Promise.all( + keys.map((key) => this.getBackend().get("sessions-metadata", key)), + ); + return metadata.filter((m): m is SessionMetadata => m !== null); + } + + async delete(id: string): Promise { + await this.getBackend().transaction(["sessions", "sessions-metadata"], "readwrite", async (tx) => { + await tx.delete("sessions", id); + await tx.delete("sessions-metadata", id); + }); + } + + // Alias for backward compatibility + async deleteSession(id: string): Promise { + return this.delete(id); + } + + async updateTitle(id: string, title: string): Promise { + const metadata = await this.getMetadata(id); + if (metadata) { + metadata.title = title; + await this.getBackend().set("sessions-metadata", id, metadata); + } + + // Also update in full session data + const data = await this.get(id); + if (data) { + data.title = title; + await this.getBackend().set("sessions", id, data); + } + } + + async getQuotaInfo(): Promise<{ usage: number; quota: number; percent: number }> { + return this.getBackend().getQuotaInfo(); + } + + async requestPersistence(): Promise { + return this.getBackend().requestPersistence(); + } +} diff --git a/packages/web-ui/src/storage/stores/settings-store.ts b/packages/web-ui/src/storage/stores/settings-store.ts new file mode 100644 index 00000000..f2f3181e --- /dev/null +++ b/packages/web-ui/src/storage/stores/settings-store.ts @@ -0,0 +1,34 @@ +import { Store } from "../store.js"; +import type { StoreConfig } from "../types.js"; + +/** + * Store for application settings (theme, proxy config, etc.). + */ +export class SettingsStore extends Store { + getConfig(): StoreConfig { + return { + name: "settings", + // No keyPath - uses out-of-line keys + }; + } + + async get(key: string): Promise { + return this.getBackend().get("settings", key); + } + + async set(key: string, value: T): Promise { + await this.getBackend().set("settings", key, value); + } + + async delete(key: string): Promise { + await this.getBackend().delete("settings", key); + } + + async list(): Promise { + return this.getBackend().keys("settings"); + } + + async clear(): Promise { + await this.getBackend().clear("settings"); + } +}