diff --git a/README.md b/README.md index d740dab3..e548502a 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,10 @@ A collection of tools for managing LLM deployments and building AI agents. ## Packages - **[@mariozechner/pi-ai](packages/ai)** - Unified multi-provider LLM API +- **[@mariozechner/pi-web-ui](packages/web-ui)** - Web components for building AI chat interfaces +- **[@mariozechner/pi-browser-extension](packages/browser-extension)** - Browser extension for AI assistance +- **[@mariozechner/pi-proxy](packages/proxy)** - CORS proxy for browser-based LLM API calls - **[@mariozechner/pi-tui](packages/tui)** - Terminal UI library with differential rendering -- **[@mariozechner/pi](packages/browser-extension)** - CLI for managing vLLM deployments on GPU pods - **[@mariozechner/pi-agent](packages/agent)** - General-purpose agent with tool calling and session persistence - **[@mariozechner/pi](packages/pods)** - CLI for managing vLLM deployments on GPU pods diff --git a/package-lock.json b/package-lock.json index 9c1ac524..01708f5b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,8 @@ "packages/*" ], "dependencies": { - "@mariozechner/jailjs": "^0.1.1" + "@mariozechner/jailjs": "^0.1.1", + "@mariozechner/mini-lit": "^0.1.8" }, "devDependencies": { "@biomejs/biome": "^2.1.4", @@ -816,9 +817,9 @@ } }, "node_modules/@mariozechner/mini-lit": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/@mariozechner/mini-lit/-/mini-lit-0.1.7.tgz", - "integrity": "sha512-P77V7xTaJrc3mocG7wWtxFrhidkBGirD0hRq0aFWpOf+lpjFinkMCnzgY3uB2HcngajDDJpZk5OlvNl+SoiHaw==", + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@mariozechner/mini-lit/-/mini-lit-0.1.8.tgz", + "integrity": "sha512-7jG0ESWq7emYgyp2xj7osN+DLXGfFkepPZE0nRCoIc7idLXWAGumSz/k7cZxoNyb50iVUGdVzT3rZPr8HtGClw==", "dependencies": { "@preact/signals-core": "^1.12.1", "class-variance-authority": "^0.7.1", @@ -5516,7 +5517,7 @@ "version": "0.5.43", "license": "MIT", "dependencies": { - "@mariozechner/mini-lit": "^0.1.7", + "@mariozechner/mini-lit": "^0.1.8", "@mariozechner/pi-ai": "^0.5.43", "docx-preview": "^0.3.7", "jszip": "^3.10.1", diff --git a/package.json b/package.json index 0b73ab7f..4318a19a 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ }, "version": "0.0.1", "dependencies": { - "@mariozechner/jailjs": "^0.1.1" + "@mariozechner/jailjs": "^0.1.1", + "@mariozechner/mini-lit": "^0.1.8" } } diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index ba808876..15ceb04c 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -291,7 +291,16 @@ function buildParams( }); } } else if (context.systemPrompt) { - params.system = context.systemPrompt; + // Add cache control to system prompt for non-OAuth tokens + params.system = [ + { + type: "text", + text: context.systemPrompt, + cache_control: { + type: "ephemeral", + }, + }, + ]; } if (options?.temperature !== undefined) { @@ -440,6 +449,24 @@ function convertMessages(messages: Message[], model: Model<"anthropic-messages"> }); } } + + // Add cache_control to the last user message to cache conversation history + if (params.length > 0) { + const lastMessage = params[params.length - 1]; + if (lastMessage.role === "user") { + // Add cache control to the last content block + if (Array.isArray(lastMessage.content)) { + const lastBlock = lastMessage.content[lastMessage.content.length - 1]; + if ( + lastBlock && + (lastBlock.type === "text" || lastBlock.type === "image" || lastBlock.type === "tool_result") + ) { + (lastBlock as any).cache_control = { type: "ephemeral" }; + } + } + } + } + return params; } diff --git a/packages/browser-extension/src/sidepanel.ts b/packages/browser-extension/src/sidepanel.ts index 90ab28f0..6abb72d8 100644 --- a/packages/browser-extension/src/sidepanel.ts +++ b/packages/browser-extension/src/sidepanel.ts @@ -1,6 +1,14 @@ import { Button, icon } from "@mariozechner/mini-lit"; import "@mariozechner/mini-lit/dist/ThemeToggle.js"; -import { ApiKeysDialog, ChromeStorageAdapter, LocalStorageKeyStore, setKeyStore } from "@mariozechner/pi-web-ui"; +import { + ApiKeyPromptDialog, + ApiKeysTab, + AppStorage, + ChromeStorageBackend, + ProxyTab, + SettingsDialog, + setAppStorage, +} from "@mariozechner/pi-web-ui"; import "@mariozechner/pi-web-ui"; // Import all web-ui components import { html, LitElement, render } from "lit"; import { customElement, state } from "lit/decorators.js"; @@ -10,8 +18,12 @@ import "./utils/live-reload.js"; declare const browser: any; -// Initialize browser extension storage -setKeyStore(new LocalStorageKeyStore(new ChromeStorageAdapter())); +// Initialize browser extension storage using chrome.storage +const storage = new AppStorage({ + settings: new ChromeStorageBackend("settings"), + providerKeys: new ChromeStorageBackend("providerKeys"), +}); +setAppStorage(storage); // Get sandbox URL for extension CSP restrictions const getSandboxUrl = () => { @@ -68,7 +80,7 @@ export class Header extends LitElement { size: "sm", children: html`${icon(Settings, "sm")}`, onClick: async () => { - ApiKeysDialog.open(); + SettingsDialog.open([new ApiKeysTab(), new ProxyTab()]); }, })} @@ -98,6 +110,10 @@ class App extends LitElement { return this; } + private async handleApiKeyRequired(provider: string): Promise { + return await ApiKeyPromptDialog.prompt(provider); + } + private handleNewSession() { // Remove the old chat panel const oldPanel = this.querySelector("pi-chat-panel"); @@ -111,6 +127,7 @@ class App extends LitElement { newPanel.systemPrompt = systemPrompt; newPanel.additionalTools = [browserJavaScriptTool]; newPanel.sandboxUrlProvider = getSandboxUrl; + newPanel.onApiKeyRequired = (provider: string) => this.handleApiKeyRequired(provider); const container = this.querySelector(".w-full"); if (container) { @@ -127,6 +144,7 @@ class App extends LitElement { .systemPrompt=${systemPrompt} .additionalTools=${[browserJavaScriptTool]} .sandboxUrlProvider=${getSandboxUrl} + .onApiKeyRequired=${(provider: string) => this.handleApiKeyRequired(provider)} > `; diff --git a/packages/web-ui/example/index.html b/packages/web-ui/example/index.html index 5e844470..e462448d 100644 --- a/packages/web-ui/example/index.html +++ b/packages/web-ui/example/index.html @@ -6,7 +6,7 @@ Pi Web UI - Example - +
diff --git a/packages/web-ui/example/src/main.ts b/packages/web-ui/example/src/main.ts index 4ba2ee7d..e60c0452 100644 --- a/packages/web-ui/example/src/main.ts +++ b/packages/web-ui/example/src/main.ts @@ -1,10 +1,13 @@ import { Button, icon } from "@mariozechner/mini-lit"; import "@mariozechner/mini-lit/dist/ThemeToggle.js"; -import { ChatPanel, ApiKeysDialog } from "@mariozechner/pi-web-ui"; +import { ApiKeyPromptDialog, ApiKeysTab, ChatPanel, initAppStorage, ProxyTab, SettingsDialog } from "@mariozechner/pi-web-ui"; import { html, render } from "lit"; import { Settings } from "lucide"; import "./app.css"; +// Initialize storage with default configuration (localStorage) +initAppStorage(); + const systemPrompt = `You are a helpful AI assistant with access to various tools. Available tools: @@ -17,6 +20,9 @@ Feel free to use these tools when needed to provide accurate and helpful respons const chatPanel = new ChatPanel(); chatPanel.systemPrompt = systemPrompt; chatPanel.additionalTools = []; +chatPanel.onApiKeyRequired = async (provider: string) => { + return await ApiKeyPromptDialog.prompt(provider); +}; // Render the app structure const appHtml = html` @@ -32,8 +38,8 @@ const appHtml = html` variant: "ghost", size: "sm", children: icon(Settings, "sm"), - onClick: () => ApiKeysDialog.open(), - title: "API Keys Settings", + onClick: () => SettingsDialog.open([new ApiKeysTab(), new ProxyTab()]), + title: "Settings", })} diff --git a/packages/web-ui/package.json b/packages/web-ui/package.json index 4f91c3a0..1b7a7160 100644 --- a/packages/web-ui/package.json +++ b/packages/web-ui/package.json @@ -17,7 +17,7 @@ "check": "npm run typecheck" }, "dependencies": { - "@mariozechner/mini-lit": "^0.1.7", + "@mariozechner/mini-lit": "^0.1.8", "@mariozechner/pi-ai": "^0.5.43", "docx-preview": "^0.3.7", "jszip": "^3.10.1", diff --git a/packages/web-ui/src/ChatPanel.ts b/packages/web-ui/src/ChatPanel.ts index 0b16b2c6..a0870c3c 100644 --- a/packages/web-ui/src/ChatPanel.ts +++ b/packages/web-ui/src/ChatPanel.ts @@ -23,6 +23,7 @@ export class ChatPanel extends LitElement { @property({ type: String }) systemPrompt = "You are a helpful AI assistant."; @property({ type: Array }) additionalTools: AgentTool[] = []; @property({ attribute: false }) sandboxUrlProvider?: () => string; + @property({ attribute: false }) onApiKeyRequired?: (provider: string) => Promise; private resizeHandler = () => { this.windowWidth = window.innerWidth; @@ -121,7 +122,7 @@ export class ChatPanel extends LitElement { this.session = new AgentSession({ initialState, authTokenProvider: async () => getAuthToken(), - transportMode: "direct", // Use direct mode by default (API keys from KeyStore) + transportMode: "provider", // Use provider mode by default (API keys from storage, optional CORS proxy) }); // Reconstruct artifacts panel from initial messages (session must exist first) @@ -170,6 +171,7 @@ export class ChatPanel extends LitElement { .enableThinking=${true} .showThemeToggle=${false} .showDebugToggle=${false} + .onApiKeyRequired=${this.onApiKeyRequired} > diff --git a/packages/web-ui/src/app.css b/packages/web-ui/src/app.css index dfa82b33..540964d5 100644 --- a/packages/web-ui/src/app.css +++ b/packages/web-ui/src/app.css @@ -36,3 +36,9 @@ body { *::-webkit-scrollbar-thumb:hover { background-color: rgba(0, 0, 0, 0); } + +/* Fix cursor for dialog close buttons */ +.fixed.inset-0 button[aria-label*="Close"], +.fixed.inset-0 button[type="button"] { + cursor: pointer; +} diff --git a/packages/web-ui/src/components/AgentInterface.ts b/packages/web-ui/src/components/AgentInterface.ts index d1b29707..d179cb25 100644 --- a/packages/web-ui/src/components/AgentInterface.ts +++ b/packages/web-ui/src/components/AgentInterface.ts @@ -2,14 +2,13 @@ import { html } from "@mariozechner/mini-lit"; import type { ToolResultMessage, Usage } from "@mariozechner/pi-ai"; import { LitElement } from "lit"; import { customElement, property, query } from "lit/decorators.js"; -import { ApiKeysDialog } from "../dialogs/ApiKeysDialog.js"; import { ModelSelector } from "../dialogs/ModelSelector.js"; import type { MessageEditor } from "./MessageEditor.js"; import "./MessageEditor.js"; import "./MessageList.js"; import "./Messages.js"; // Import for side effects to register the custom elements import type { AgentSession, AgentSessionEvent } from "../state/agent-session.js"; -import { getKeyStore } from "../state/key-store.js"; +import { getAppStorage } from "../storage/app-storage.js"; import "./StreamingMessageContainer.js"; import type { Attachment } from "../utils/attachment-utils.js"; import { formatUsage } from "../utils/format.js"; @@ -25,6 +24,8 @@ export class AgentInterface extends LitElement { @property() enableThinking = true; @property() showThemeToggle = false; @property() showDebugToggle = false; + // Optional custom API key prompt handler - if not provided, uses default dialog + @property({ attribute: false }) onApiKeyRequired?: (provider: string) => Promise; // References @query("message-editor") private _messageEditor!: MessageEditor; @@ -126,8 +127,7 @@ export class AgentInterface extends LitElement { } else if (ev.type === "error-no-model") { // TODO show some UI feedback } else if (ev.type === "error-no-api-key") { - // Open API keys dialog to configure the missing key - ApiKeysDialog.open(); + // Handled by onApiKeyRequired callback } }); } @@ -166,15 +166,19 @@ export class AgentInterface extends LitElement { // Check if API key exists for the provider (only needed in direct mode) const provider = session.state.model.provider; - let apiKey = await getKeyStore().getKey(provider); + const apiKey = await getAppStorage().providerKeys.getKey(provider); - // If no API key, open the API keys dialog + // If no API key, prompt for it if (!apiKey) { - await ApiKeysDialog.open(); - // Check again after dialog closes - apiKey = await getKeyStore().getKey(provider); + if (!this.onApiKeyRequired) { + console.error("No API key configured and no onApiKeyRequired handler set"); + return; + } + + const success = await this.onApiKeyRequired(provider); + // If still no API key, abort the send - if (!apiKey) { + if (!success) { return; } } diff --git a/packages/web-ui/src/components/ProviderKeyInput.ts b/packages/web-ui/src/components/ProviderKeyInput.ts new file mode 100644 index 00000000..d4720e23 --- /dev/null +++ b/packages/web-ui/src/components/ProviderKeyInput.ts @@ -0,0 +1,170 @@ +import { Badge, Button, html, Input, i18n } from "@mariozechner/mini-lit"; +import { type Context, complete, getModel } from "@mariozechner/pi-ai"; +import { LitElement } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { getAppStorage } from "../storage/app-storage.js"; + +// Test models for each provider +const TEST_MODELS: Record = { + anthropic: "claude-3-5-haiku-20241022", + openai: "gpt-4o-mini", + google: "gemini-2.5-flash", + groq: "openai/gpt-oss-20b", + openrouter: "z-ai/glm-4.6", + cerebras: "gpt-oss-120b", + xai: "grok-4-fast-non-reasoning", + zai: "glm-4.5-air", +}; + +@customElement("provider-key-input") +export class ProviderKeyInput extends LitElement { + @property() provider = ""; + @state() private keyInput = ""; + @state() private testing = false; + @state() private failed = false; + @state() private hasKey = false; + + protected createRenderRoot() { + return this; + } + + override async connectedCallback() { + super.connectedCallback(); + await this.checkKeyStatus(); + } + + private async checkKeyStatus() { + try { + const key = await getAppStorage().providerKeys.getKey(this.provider); + this.hasKey = !!key; + } catch (error) { + console.error("Failed to check key status:", error); + } + } + + private async testApiKey(provider: string, apiKey: string): Promise { + try { + const modelId = TEST_MODELS[provider]; + if (!modelId) return false; + + let model = getModel(provider as any, modelId); + if (!model) return false; + + // Check if CORS proxy is enabled and apply it + const proxyEnabled = await getAppStorage().settings.get("proxy.enabled"); + const proxyUrl = await getAppStorage().settings.get("proxy.url"); + + if (proxyEnabled && proxyUrl && model.baseUrl) { + model = { + ...model, + baseUrl: `${proxyUrl}/?url=${encodeURIComponent(model.baseUrl)}`, + }; + } + + const context: Context = { + messages: [{ role: "user", content: "Reply with: ok" }], + }; + + const result = await complete(model, context, { + apiKey, + maxTokens: 10, + } as any); + + return result.stopReason === "stop"; + } catch (error) { + console.error(`API key test failed for ${provider}:`, error); + return false; + } + } + + private async saveKey() { + if (!this.keyInput) return; + + this.testing = true; + this.failed = false; + + const success = await this.testApiKey(this.provider, this.keyInput); + + this.testing = false; + + if (success) { + try { + await getAppStorage().providerKeys.setKey(this.provider, this.keyInput); + this.hasKey = true; + this.keyInput = ""; + this.requestUpdate(); + } catch (error) { + console.error("Failed to save API key:", error); + this.failed = true; + setTimeout(() => { + this.failed = false; + this.requestUpdate(); + }, 5000); + } + } else { + this.failed = true; + setTimeout(() => { + this.failed = false; + this.requestUpdate(); + }, 5000); + } + } + + private async removeKey() { + try { + await getAppStorage().providerKeys.removeKey(this.provider); + this.hasKey = false; + this.keyInput = ""; + this.requestUpdate(); + } catch (error) { + console.error("Failed to remove API key:", error); + } + } + + render() { + return html` +
+
+ ${this.provider} + ${ + this.testing + ? Badge({ children: i18n("Testing..."), variant: "secondary" }) + : this.hasKey + ? html`` + : "" + } + ${this.failed ? Badge({ children: i18n("✗ Invalid"), variant: "destructive" }) : ""} +
+
+ ${Input({ + type: "password", + placeholder: this.hasKey ? "••••••••••••" : i18n("Enter API key"), + value: this.keyInput, + onInput: (e: Event) => { + this.keyInput = (e.target as HTMLInputElement).value; + this.requestUpdate(); + }, + className: "flex-1", + })} + ${ + this.hasKey + ? Button({ + onClick: () => this.removeKey(), + variant: "ghost", + size: "sm", + children: i18n("Clear"), + className: "!text-destructive", + }) + : Button({ + onClick: () => this.saveKey(), + variant: "default", + size: "sm", + disabled: !this.keyInput || this.testing, + children: i18n("Save"), + }) + } +
+
+ `; + } +} diff --git a/packages/web-ui/src/dialogs/ApiKeyPromptDialog.ts b/packages/web-ui/src/dialogs/ApiKeyPromptDialog.ts new file mode 100644 index 00000000..d41e717d --- /dev/null +++ b/packages/web-ui/src/dialogs/ApiKeyPromptDialog.ts @@ -0,0 +1,76 @@ +import { DialogBase, DialogContent, DialogHeader, html } from "@mariozechner/mini-lit"; +import { customElement, state } from "lit/decorators.js"; +import "../components/ProviderKeyInput.js"; +import { getAppStorage } from "../storage/app-storage.js"; +import { i18n } from "../utils/i18n.js"; + +@customElement("api-key-prompt-dialog") +export class ApiKeyPromptDialog extends DialogBase { + @state() private provider = ""; + + private resolvePromise?: (success: boolean) => void; + private unsubscribe?: () => void; + + protected modalWidth = "min(500px, 90vw)"; + protected modalHeight = "auto"; + + static async prompt(provider: string): Promise { + const dialog = new ApiKeyPromptDialog(); + dialog.provider = provider; + dialog.open(); + + return new Promise((resolve) => { + dialog.resolvePromise = resolve; + }); + } + + override async connectedCallback() { + super.connectedCallback(); + + // Poll for key existence - when key is added, resolve and close + const checkInterval = setInterval(async () => { + const hasKey = await getAppStorage().providerKeys.hasKey(this.provider); + if (hasKey) { + clearInterval(checkInterval); + if (this.resolvePromise) { + this.resolvePromise(true); + this.resolvePromise = undefined; + } + this.close(); + } + }, 500); + + this.unsubscribe = () => clearInterval(checkInterval); + } + + override disconnectedCallback() { + super.disconnectedCallback(); + if (this.unsubscribe) { + this.unsubscribe(); + this.unsubscribe = undefined; + } + } + + override close() { + super.close(); + if (this.resolvePromise) { + this.resolvePromise(false); + } + } + + protected override renderContent() { + return html` + ${DialogContent({ + children: html` + ${DialogHeader({ + title: i18n("API Key Required"), + description: i18n("Enter your API key for {provider}").replace("{provider}", this.provider), + })} +
+ +
+ `, + })} + `; + } +} diff --git a/packages/web-ui/src/dialogs/ApiKeysDialog.ts b/packages/web-ui/src/dialogs/ApiKeysDialog.ts deleted file mode 100644 index 7b6d0de3..00000000 --- a/packages/web-ui/src/dialogs/ApiKeysDialog.ts +++ /dev/null @@ -1,267 +0,0 @@ -import { Alert, Badge, Button, DialogBase, DialogHeader, html, type TemplateResult } from "@mariozechner/mini-lit"; -import { type Context, complete, getModel, getProviders } from "@mariozechner/pi-ai"; -import type { PropertyValues } from "lit"; -import { customElement, state } from "lit/decorators.js"; -import { Input } from "../components/Input.js"; -import { getKeyStore } from "../state/key-store.js"; -import { i18n } from "../utils/i18n.js"; - -// Test models for each provider - known to be reliable and cheap -const TEST_MODELS: Record = { - anthropic: "claude-3-5-haiku-20241022", - openai: "gpt-4o-mini", - google: "gemini-2.5-flash", - groq: "openai/gpt-oss-20b", - openrouter: "z-ai/glm-4.6", - cerebras: "gpt-oss-120b", - xai: "grok-4-fast-non-reasoning", - zai: "glm-4.5-air", -}; - -@customElement("api-keys-dialog") -export class ApiKeysDialog extends DialogBase { - @state() apiKeys: Record = {}; // provider -> configured - @state() apiKeyInputs: Record = {}; - @state() testResults: Record = {}; - @state() savingProvider = ""; - @state() testingProvider = ""; - @state() error = ""; - - protected override modalWidth = "min(600px, 90vw)"; - protected override modalHeight = "min(600px, 80vh)"; - - static async open() { - const dialog = new ApiKeysDialog(); - dialog.open(); - await dialog.loadKeys(); - } - - override async firstUpdated(changedProperties: PropertyValues): Promise { - super.firstUpdated(changedProperties); - await this.loadKeys(); - } - - private async loadKeys() { - this.apiKeys = await getKeyStore().getAllKeys(); - } - - private async testApiKey(provider: string, apiKey: string): Promise { - try { - // Get the test model for this provider - const modelId = TEST_MODELS[provider]; - if (!modelId) { - this.error = `No test model configured for ${provider}`; - return false; - } - - const model = getModel(provider as any, modelId); - if (!model) { - this.error = `Test model ${modelId} not found for ${provider}`; - return false; - } - - // Simple test prompt - const context: Context = { - messages: [{ role: "user", content: "Reply with exactly: test successful" }], - }; - const response = await complete(model, context, { - apiKey, - maxTokens: 10, // Keep it minimal for testing - } as any); - - return true; - } catch (error) { - console.error(`API key test failed for ${provider}:`, error); - return false; - } - } - - private async saveKey(provider: string) { - const key = this.apiKeyInputs[provider]; - if (!key) return; - - this.savingProvider = provider; - this.testResults[provider] = "testing"; - this.error = ""; - - try { - // Test the key first - const isValid = await this.testApiKey(provider, key); - - if (isValid) { - await getKeyStore().setKey(provider, key); - this.apiKeyInputs[provider] = ""; // Clear input - await this.loadKeys(); - this.testResults[provider] = "success"; - } else { - this.testResults[provider] = "error"; - this.error = `Invalid API key for ${provider}`; - } - } catch (err: any) { - this.testResults[provider] = "error"; - this.error = `Failed to save key for ${provider}: ${err.message}`; - } finally { - this.savingProvider = ""; - - // Clear test result after 3 seconds - setTimeout(() => { - delete this.testResults[provider]; - this.requestUpdate(); - }, 3000); - } - } - - private async testExistingKey(provider: string) { - this.testingProvider = provider; - this.testResults[provider] = "testing"; - this.error = ""; - - try { - const apiKey = await getKeyStore().getKey(provider); - if (!apiKey) { - this.testResults[provider] = "error"; - this.error = `No API key found for ${provider}`; - return; - } - - const isValid = await this.testApiKey(provider, apiKey); - - if (isValid) { - this.testResults[provider] = "success"; - } else { - this.testResults[provider] = "error"; - this.error = `API key for ${provider} is no longer valid`; - } - } catch (err: any) { - this.testResults[provider] = "error"; - this.error = `Test failed for ${provider}: ${err.message}`; - } finally { - this.testingProvider = ""; - - // Clear test result after 3 seconds - setTimeout(() => { - delete this.testResults[provider]; - this.requestUpdate(); - }, 3000); - } - } - - private async removeKey(provider: string) { - if (!confirm(`Remove API key for ${provider}?`)) return; - - await getKeyStore().removeKey(provider); - this.apiKeyInputs[provider] = ""; - await this.loadKeys(); - } - - protected override renderContent(): TemplateResult { - const providers = getProviders(); - - return html` -
- -
- ${DialogHeader({ title: i18n("API Keys Configuration") })} -

- ${i18n("Configure API keys for LLM providers. Keys are stored locally in your browser.")} -

-
- - - ${ - this.error - ? html` -
${Alert(this.error, "destructive")}
- ` - : "" - } - - - -
-
- ${providers.map( - (provider) => html` -
-
- ${provider} - ${ - this.apiKeys[provider] - ? Badge({ children: i18n("Configured"), variant: "default" }) - : Badge({ children: i18n("Not configured"), variant: "secondary" }) - } - ${ - this.testResults[provider] === "success" - ? Badge({ children: i18n("✓ Valid"), variant: "default" }) - : this.testResults[provider] === "error" - ? Badge({ children: i18n("✗ Invalid"), variant: "destructive" }) - : this.testResults[provider] === "testing" - ? Badge({ children: i18n("Testing..."), variant: "secondary" }) - : "" - } -
- -
- ${Input({ - type: "password", - placeholder: this.apiKeys[provider] ? i18n("Update API key") : i18n("Enter API key"), - value: this.apiKeyInputs[provider] || "", - onInput: (e: Event) => { - this.apiKeyInputs[provider] = (e.target as HTMLInputElement).value; - this.requestUpdate(); - }, - className: "flex-1", - })} - - ${Button({ - onClick: () => this.saveKey(provider), - variant: "default", - size: "sm", - disabled: !this.apiKeyInputs[provider] || this.savingProvider === provider, - loading: this.savingProvider === provider, - children: - this.savingProvider === provider - ? i18n("Testing...") - : this.apiKeys[provider] - ? i18n("Update") - : i18n("Save"), - })} - - ${ - this.apiKeys[provider] - ? html` - ${Button({ - onClick: () => this.testExistingKey(provider), - variant: "outline", - size: "sm", - loading: this.testingProvider === provider, - disabled: this.testingProvider !== "" && this.testingProvider !== provider, - children: - this.testingProvider === provider ? i18n("Testing...") : i18n("Test"), - })} - ${Button({ - onClick: () => this.removeKey(provider), - variant: "ghost", - size: "sm", - children: i18n("Remove"), - })} - ` - : "" - } -
-
- `, - )} -
-
- - -
-

- ${i18n("API keys are required to use AI models. Get your keys from the provider's website.")} -

-
-
- `; - } -} diff --git a/packages/web-ui/src/dialogs/SettingsDialog.ts b/packages/web-ui/src/dialogs/SettingsDialog.ts new file mode 100644 index 00000000..20717367 --- /dev/null +++ b/packages/web-ui/src/dialogs/SettingsDialog.ts @@ -0,0 +1,223 @@ +import { + Dialog, + DialogContent, + DialogHeader, + html, + Input, + i18n, + Label, + Switch, + type TemplateResult, +} from "@mariozechner/mini-lit"; +import { getProviders } from "@mariozechner/pi-ai"; +import { LitElement } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import "../components/ProviderKeyInput.js"; +import { getAppStorage } from "../storage/app-storage.js"; + +// Base class for settings tabs +export abstract class SettingsTab extends LitElement { + abstract getTabName(): string; + + protected createRenderRoot() { + return this; + } +} + +// API Keys Tab +@customElement("api-keys-tab") +export class ApiKeysTab extends SettingsTab { + getTabName(): string { + return i18n("API Keys"); + } + + render(): TemplateResult { + const providers = getProviders(); + + return html` +
+

+ ${i18n("Configure API keys for LLM providers. Keys are stored locally in your browser.")} +

+ ${providers.map((provider) => html``)} +
+ `; + } +} + +// Proxy Tab +@customElement("proxy-tab") +export class ProxyTab extends SettingsTab { + @state() private proxyEnabled = false; + @state() private proxyUrl = "http://localhost:3001"; + + override async connectedCallback() { + super.connectedCallback(); + // Load proxy settings when tab is connected + try { + const storage = getAppStorage(); + 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; + } catch (error) { + console.error("Failed to load proxy settings:", error); + } + } + + private async saveProxySettings() { + try { + const storage = getAppStorage(); + 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); + } + } + + getTabName(): string { + return i18n("Proxy"); + } + + render(): TemplateResult { + return html` +
+

+ ${i18n("The CORS proxy strips CORS headers from API responses, allowing browser-based apps to make direct calls to LLM providers without CORS restrictions. It forwards requests to providers while removing headers that would otherwise block cross-origin requests.")} +

+ +
+ ${i18n("Use CORS Proxy")} + ${Switch({ + checked: this.proxyEnabled, + onChange: (checked: boolean) => { + this.proxyEnabled = checked; + this.saveProxySettings(); + }, + })} +
+ +
+ ${Label({ children: i18n("Proxy URL") })} + ${Input({ + type: "text", + value: this.proxyUrl, + disabled: !this.proxyEnabled, + onInput: (e) => { + this.proxyUrl = (e.target as HTMLInputElement).value; + }, + onChange: () => this.saveProxySettings(), + })} +
+
+ `; + } +} + +@customElement("settings-dialog") +export class SettingsDialog extends LitElement { + @property({ type: Array, attribute: false }) tabs: SettingsTab[] = []; + @state() private isOpen = false; + @state() private activeTabIndex = 0; + + protected createRenderRoot() { + return this; + } + + static async open(tabs: SettingsTab[]) { + const dialog = new SettingsDialog(); + dialog.tabs = tabs; + dialog.isOpen = true; + document.body.appendChild(dialog); + } + + private setActiveTab(index: number) { + this.activeTabIndex = index; + } + + private renderSidebarItem(tab: SettingsTab, index: number): TemplateResult { + const isActive = this.activeTabIndex === index; + return html` + + `; + } + + private renderMobileTab(tab: SettingsTab, index: number): TemplateResult { + const isActive = this.activeTabIndex === index; + return html` + + `; + } + + render() { + if (this.tabs.length === 0) { + return html``; + } + + return Dialog({ + isOpen: this.isOpen, + onClose: () => { + this.isOpen = false; + this.remove(); + }, + width: "min(1000px, 90vw)", + height: "min(800px, 90vh)", + children: html` + ${DialogContent({ + className: "h-full p-6", + children: html` +
+ +
${DialogHeader({ title: i18n("Settings") })}
+ + +
+ ${this.tabs.map((tab, index) => this.renderMobileTab(tab, index))} +
+ + +
+ + + + +
+ ${this.tabs.map( + (tab, index) => + html`
${tab}
`, + )} +
+
+ + +
+

+ ${i18n("Settings are stored locally in your browser")} +

+
+
+ `, + })} + `, + }); + } +} diff --git a/packages/web-ui/src/index.ts b/packages/web-ui/src/index.ts index 5e140953..2eca6545 100644 --- a/packages/web-ui/src/index.ts +++ b/packages/web-ui/src/index.ts @@ -17,23 +17,28 @@ export { type SandboxUrlProvider, } from "./components/SandboxedIframe.js"; export { StreamingMessageContainer } from "./components/StreamingMessageContainer.js"; -export { ApiKeysDialog } from "./dialogs/ApiKeysDialog.js"; +export { ApiKeyPromptDialog } from "./dialogs/ApiKeyPromptDialog.js"; export { AttachmentOverlay } from "./dialogs/AttachmentOverlay.js"; // Dialogs export { ModelSelector } from "./dialogs/ModelSelector.js"; +export { ApiKeysTab, ProxyTab, SettingsDialog, SettingsTab } from "./dialogs/SettingsDialog.js"; export type { AgentSessionState, ThinkingLevel } from "./state/agent-session.js"; // State management export { AgentSession } from "./state/agent-session.js"; -export type { KeyStore } from "./state/key-store.js"; -export { getKeyStore, LocalStorageKeyStore, setKeyStore } from "./state/key-store.js"; -export type { StorageAdapter } from "./state/storage-adapter.js"; -export { ChromeStorageAdapter, LocalStorageAdapter } from "./state/storage-adapter.js"; // Transports -export { DirectTransport } from "./state/transports/DirectTransport.js"; -export { ProxyTransport } from "./state/transports/ProxyTransport.js"; +export { AppTransport } from "./state/transports/AppTransport.js"; +export { ProviderTransport } from "./state/transports/ProviderTransport.js"; export type { ProxyAssistantMessageEvent } from "./state/transports/proxy-types.js"; export type { AgentRunConfig, AgentTransport } from "./state/transports/types.js"; +// Storage +export { AppStorage, getAppStorage, initAppStorage, setAppStorage } from "./storage/app-storage.js"; +export { ChromeStorageBackend } from "./storage/backends/chrome-storage-backend.js"; +export { IndexedDBBackend } from "./storage/backends/indexeddb-backend.js"; +export { LocalStorageBackend } from "./storage/backends/local-storage-backend.js"; +export { ProviderKeysRepository } from "./storage/repositories/provider-keys-repository.js"; +export { SettingsRepository } from "./storage/repositories/settings-repository.js"; +export type { AppStorageConfig, StorageBackend } from "./storage/types.js"; // Artifacts export { ArtifactElement } from "./tools/artifacts/ArtifactElement.js"; export { type Artifact, ArtifactsPanel, type ArtifactsParams } from "./tools/artifacts/artifacts.js"; diff --git a/packages/web-ui/src/state/agent-session.ts b/packages/web-ui/src/state/agent-session.ts index 208b2730..f07a529a 100644 --- a/packages/web-ui/src/state/agent-session.ts +++ b/packages/web-ui/src/state/agent-session.ts @@ -10,8 +10,8 @@ import { } from "@mariozechner/pi-ai"; import type { AppMessage } from "../components/Messages.js"; import type { Attachment } from "../utils/attachment-utils.js"; -import { DirectTransport } from "./transports/DirectTransport.js"; -import { ProxyTransport } from "./transports/ProxyTransport.js"; +import { AppTransport } from "./transports/AppTransport.js"; +import { ProviderTransport } from "./transports/ProviderTransport.js"; import type { AgentRunConfig, AgentTransport } from "./transports/types.js"; import type { DebugLogEntry } from "./types.js"; @@ -35,7 +35,7 @@ export type AgentSessionEvent = | { type: "error-no-model" } | { type: "error-no-api-key"; provider: string }; -export type TransportMode = "direct" | "proxy"; +export type TransportMode = "provider" | "app"; export interface AgentSessionOptions { initialState?: Partial; @@ -69,12 +69,12 @@ export class AgentSession { this.messagePreprocessor = opts.messagePreprocessor; this.debugListener = opts.debugListener; - const mode = opts.transportMode || "direct"; + const mode = opts.transportMode || "provider"; - if (mode === "proxy") { - this.transport = new ProxyTransport(async () => this.preprocessMessages()); + if (mode === "app") { + this.transport = new AppTransport(async () => this.preprocessMessages()); } else { - this.transport = new DirectTransport(async () => this.preprocessMessages()); + this.transport = new ProviderTransport(async () => this.preprocessMessages()); } } diff --git a/packages/web-ui/src/state/key-store.ts b/packages/web-ui/src/state/key-store.ts deleted file mode 100644 index 1712896e..00000000 --- a/packages/web-ui/src/state/key-store.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { getProviders } from "@mariozechner/pi-ai"; -import { LocalStorageAdapter, type StorageAdapter } from "./storage-adapter.js"; - -/** - * API key storage interface - */ -export interface KeyStore { - getKey(provider: string): Promise; - setKey(provider: string, key: string): Promise; - removeKey(provider: string): Promise; - getAllKeys(): Promise>; -} - -/** - * API key storage implementation using a pluggable storage adapter - */ -export class LocalStorageKeyStore implements KeyStore { - private readonly prefix = "apiKey_"; - - constructor(private readonly storage: StorageAdapter) {} - - async getKey(provider: string): Promise { - const key = `${this.prefix}${provider}`; - return await this.storage.get(key); - } - - async setKey(provider: string, key: string): Promise { - const storageKey = `${this.prefix}${provider}`; - await this.storage.set(storageKey, key); - } - - async removeKey(provider: string): Promise { - const key = `${this.prefix}${provider}`; - await this.storage.remove(key); - } - - async getAllKeys(): Promise> { - const providers = getProviders(); - const allStorage = await this.storage.getAll(); - const result: Record = {}; - - for (const provider of providers) { - const key = `${this.prefix}${provider}`; - result[provider] = !!allStorage[key]; - } - - return result; - } -} - -// Default instance using localStorage -let _keyStore: KeyStore = new LocalStorageKeyStore(new LocalStorageAdapter()); - -/** - * Get the current KeyStore instance - */ -export function getKeyStore(): KeyStore { - return _keyStore; -} - -/** - * Set a custom KeyStore implementation - * Call this once at application startup before any components are initialized - */ -export function setKeyStore(store: KeyStore): void { - _keyStore = store; -} diff --git a/packages/web-ui/src/state/storage-adapter.ts b/packages/web-ui/src/state/storage-adapter.ts deleted file mode 100644 index f9b9d056..00000000 --- a/packages/web-ui/src/state/storage-adapter.ts +++ /dev/null @@ -1,77 +0,0 @@ -/** - * Generic storage adapter interface for key/value persistence - */ -export interface StorageAdapter { - get(key: string): Promise; - set(key: string, value: string): Promise; - remove(key: string): Promise; - getAll(): Promise>; -} - -/** - * LocalStorage implementation - */ -export class LocalStorageAdapter implements StorageAdapter { - async get(key: string): Promise { - return localStorage.getItem(key); - } - - async set(key: string, value: string): Promise { - localStorage.setItem(key, value); - } - - async remove(key: string): Promise { - localStorage.removeItem(key); - } - - async getAll(): Promise> { - const result: Record = {}; - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i); - if (key) { - const value = localStorage.getItem(key); - if (value) result[key] = value; - } - } - return result; - } -} - -/** - * Chrome/Firefox extension storage implementation - */ -export class ChromeStorageAdapter implements StorageAdapter { - private readonly storage: any; - - constructor() { - const isBrowser = typeof globalThis !== "undefined"; - const hasChrome = isBrowser && (globalThis as any).chrome?.storage; - const hasBrowser = isBrowser && (globalThis as any).browser?.storage; - - if (hasBrowser) { - this.storage = (globalThis as any).browser.storage.local; - } else if (hasChrome) { - this.storage = (globalThis as any).chrome.storage.local; - } else { - throw new Error("Chrome/Browser storage not available"); - } - } - - async get(key: string): Promise { - const result = await this.storage.get(key); - return result[key] || null; - } - - async set(key: string, value: string): Promise { - await this.storage.set({ [key]: value }); - } - - async remove(key: string): Promise { - await this.storage.remove(key); - } - - async getAll(): Promise> { - const result = await this.storage.get(); - return result || {}; - } -} diff --git a/packages/web-ui/src/state/transports/ProxyTransport.ts b/packages/web-ui/src/state/transports/AppTransport.ts similarity index 98% rename from packages/web-ui/src/state/transports/ProxyTransport.ts rename to packages/web-ui/src/state/transports/AppTransport.ts index 16781e07..09c026c9 100644 --- a/packages/web-ui/src/state/transports/ProxyTransport.ts +++ b/packages/web-ui/src/state/transports/AppTransport.ts @@ -314,7 +314,11 @@ function streamSimpleProxy( } // Proxy transport executes the turn using a remote proxy server -export class ProxyTransport implements AgentTransport { +/** + * Transport that uses an app server with user authentication tokens. + * The server manages user accounts and proxies requests to LLM providers. + */ +export class AppTransport implements AgentTransport { // Hardcoded proxy URL for now - will be made configurable later private readonly proxyUrl = "https://genai.mariozechner.at"; diff --git a/packages/web-ui/src/state/transports/DirectTransport.ts b/packages/web-ui/src/state/transports/DirectTransport.ts deleted file mode 100644 index 844dd366..00000000 --- a/packages/web-ui/src/state/transports/DirectTransport.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { type AgentContext, agentLoop, type Message, type PromptConfig, type UserMessage } from "@mariozechner/pi-ai"; -import { getKeyStore } from "../key-store.js"; -import type { AgentRunConfig, AgentTransport } from "./types.js"; - -export class DirectTransport implements AgentTransport { - constructor(private readonly getMessages: () => Promise) {} - - async *run(userMessage: Message, cfg: AgentRunConfig, signal?: AbortSignal) { - // Get API key from KeyStore - const apiKey = await getKeyStore().getKey(cfg.model.provider); - if (!apiKey) { - throw new Error("no-api-key"); - } - - const context: AgentContext = { - systemPrompt: cfg.systemPrompt, - messages: await this.getMessages(), - tools: cfg.tools, - }; - - const pc: PromptConfig = { - model: cfg.model, - reasoning: cfg.reasoning, - apiKey, - }; - - // Yield events from agentLoop - for await (const ev of agentLoop(userMessage as unknown as UserMessage, context, pc, signal)) { - yield ev; - } - } -} diff --git a/packages/web-ui/src/state/transports/ProviderTransport.ts b/packages/web-ui/src/state/transports/ProviderTransport.ts new file mode 100644 index 00000000..8ed13fcf --- /dev/null +++ b/packages/web-ui/src/state/transports/ProviderTransport.ts @@ -0,0 +1,49 @@ +import { type AgentContext, agentLoop, type Message, type PromptConfig, type UserMessage } from "@mariozechner/pi-ai"; +import { getAppStorage } from "../../storage/app-storage.js"; +import type { AgentRunConfig, AgentTransport } from "./types.js"; + +/** + * Transport that calls LLM providers directly. + * Optionally routes calls through a CORS proxy if enabled in settings. + */ +export class ProviderTransport implements AgentTransport { + constructor(private readonly getMessages: () => Promise) {} + + async *run(userMessage: Message, cfg: AgentRunConfig, signal?: AbortSignal) { + // Get API key from storage + const apiKey = await getAppStorage().providerKeys.getKey(cfg.model.provider); + if (!apiKey) { + throw new Error("no-api-key"); + } + + // Check if CORS proxy is enabled + 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; + if (proxyEnabled && proxyUrl && cfg.model.baseUrl) { + model = { + ...cfg.model, + baseUrl: `${proxyUrl}/?url=${encodeURIComponent(cfg.model.baseUrl)}`, + }; + } + + const context: AgentContext = { + systemPrompt: cfg.systemPrompt, + messages: await this.getMessages(), + tools: cfg.tools, + }; + + const pc: PromptConfig = { + model, + reasoning: cfg.reasoning, + apiKey, + }; + + // Yield events from agentLoop + for await (const ev of agentLoop(userMessage as unknown as UserMessage, context, pc, signal)) { + yield ev; + } + } +} diff --git a/packages/web-ui/src/state/transports/index.ts b/packages/web-ui/src/state/transports/index.ts index 1d91e36f..8dd56057 100644 --- a/packages/web-ui/src/state/transports/index.ts +++ b/packages/web-ui/src/state/transports/index.ts @@ -1,3 +1,3 @@ -export * from "./DirectTransport.js"; -export * from "./ProxyTransport.js"; +export * from "./AppTransport.js"; +export * from "./ProviderTransport.js"; export * from "./types.js"; diff --git a/packages/web-ui/src/storage/app-storage.ts b/packages/web-ui/src/storage/app-storage.ts new file mode 100644 index 00000000..da1b3ed6 --- /dev/null +++ b/packages/web-ui/src/storage/app-storage.ts @@ -0,0 +1,53 @@ +import { LocalStorageBackend } from "./backends/local-storage-backend.js"; +import { ProviderKeysRepository } from "./repositories/provider-keys-repository.js"; +import { SettingsRepository } from "./repositories/settings-repository.js"; +import type { AppStorageConfig } from "./types.js"; + +/** + * High-level storage API aggregating all repositories. + * Apps configure backends and use repositories through this interface. + */ +export class AppStorage { + readonly settings: SettingsRepository; + readonly providerKeys: ProviderKeysRepository; + + constructor(config: AppStorageConfig = {}) { + // Use LocalStorage with prefixes as defaults + const settingsBackend = config.settings ?? new LocalStorageBackend("settings"); + const providerKeysBackend = config.providerKeys ?? new LocalStorageBackend("providerKeys"); + + this.settings = new SettingsRepository(settingsBackend); + this.providerKeys = new ProviderKeysRepository(providerKeysBackend); + } +} + +// Global instance management +let globalAppStorage: AppStorage | null = null; + +/** + * Get the global AppStorage instance. + * Throws if not initialized. + */ +export function getAppStorage(): AppStorage { + if (!globalAppStorage) { + throw new Error("AppStorage not initialized. Call setAppStorage() first."); + } + return globalAppStorage; +} + +/** + * Set the global AppStorage instance. + */ +export function setAppStorage(storage: AppStorage): void { + globalAppStorage = storage; +} + +/** + * Initialize AppStorage with default configuration if not already set. + */ +export function initAppStorage(config: AppStorageConfig = {}): AppStorage { + if (!globalAppStorage) { + globalAppStorage = new AppStorage(config); + } + return globalAppStorage; +} diff --git a/packages/web-ui/src/storage/backends/chrome-storage-backend.ts b/packages/web-ui/src/storage/backends/chrome-storage-backend.ts new file mode 100644 index 00000000..873afc48 --- /dev/null +++ b/packages/web-ui/src/storage/backends/chrome-storage-backend.ts @@ -0,0 +1,82 @@ +import type { StorageBackend } from "../types.js"; + +// Chrome extension API types (optional) +declare const chrome: any; + +/** + * Storage backend using chrome.storage.local. + * Good for: Browser extensions, syncing across devices (with chrome.storage.sync). + * Limits: ~10MB for local, ~100KB for sync, async API. + */ +export class ChromeStorageBackend implements StorageBackend { + constructor(private prefix: string = "") {} + + private getKey(key: string): string { + return this.prefix ? `${this.prefix}:${key}` : key; + } + + async get(key: string): Promise { + if (!chrome?.storage?.local) { + throw new Error("chrome.storage.local is not available"); + } + + const fullKey = this.getKey(key); + const result = await chrome.storage.local.get([fullKey]); + return result[fullKey] !== undefined ? (result[fullKey] as T) : null; + } + + async set(key: string, value: T): Promise { + if (!chrome?.storage?.local) { + throw new Error("chrome.storage.local is not available"); + } + + const fullKey = this.getKey(key); + await chrome.storage.local.set({ [fullKey]: value }); + } + + async delete(key: string): Promise { + if (!chrome?.storage?.local) { + throw new Error("chrome.storage.local is not available"); + } + + const fullKey = this.getKey(key); + await chrome.storage.local.remove(fullKey); + } + + async keys(): Promise { + if (!chrome?.storage?.local) { + throw new Error("chrome.storage.local is not available"); + } + + const allData = await chrome.storage.local.get(null); + const allKeys = Object.keys(allData); + const prefixWithColon = this.prefix ? `${this.prefix}:` : ""; + + if (this.prefix) { + return allKeys + .filter((key) => key.startsWith(prefixWithColon)) + .map((key) => key.substring(prefixWithColon.length)); + } + + return allKeys; + } + + async clear(): Promise { + if (!chrome?.storage?.local) { + throw new Error("chrome.storage.local is not available"); + } + + if (this.prefix) { + const keysToRemove = await this.keys(); + const fullKeys = keysToRemove.map((key) => this.getKey(key)); + await chrome.storage.local.remove(fullKeys); + } else { + await chrome.storage.local.clear(); + } + } + + async has(key: string): Promise { + const value = await this.get(key); + return value !== null; + } +} diff --git a/packages/web-ui/src/storage/backends/indexeddb-backend.ts b/packages/web-ui/src/storage/backends/indexeddb-backend.ts new file mode 100644 index 00000000..b6ecaf03 --- /dev/null +++ b/packages/web-ui/src/storage/backends/indexeddb-backend.ts @@ -0,0 +1,107 @@ +import type { StorageBackend } from "../types.js"; + +/** + * Storage backend using IndexedDB. + * Good for: Large data, binary blobs, complex queries. + * Limits: ~50MB-unlimited (browser dependent), async API, more complex. + */ +export class IndexedDBBackend implements StorageBackend { + private dbPromise: Promise | null = null; + + constructor( + private dbName: string, + private storeName: string = "keyvalue", + ) {} + + private async getDB(): Promise { + if (this.dbPromise) { + return this.dbPromise; + } + + this.dbPromise = new Promise((resolve, reject) => { + const request = indexedDB.open(this.dbName, 1); + + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + if (!db.objectStoreNames.contains(this.storeName)) { + db.createObjectStore(this.storeName); + } + }; + }); + + return this.dbPromise; + } + + async get(key: string): Promise { + const db = await this.getDB(); + return new Promise((resolve, reject) => { + const transaction = db.transaction(this.storeName, "readonly"); + const store = transaction.objectStore(this.storeName); + const request = store.get(key); + + request.onerror = () => reject(request.error); + request.onsuccess = () => { + const value = request.result; + resolve(value !== undefined ? (value as T) : null); + }; + }); + } + + async set(key: string, value: T): Promise { + const db = await this.getDB(); + return new Promise((resolve, reject) => { + const transaction = db.transaction(this.storeName, "readwrite"); + const store = transaction.objectStore(this.storeName); + const request = store.put(value, key); + + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(); + }); + } + + async delete(key: string): Promise { + const db = await this.getDB(); + return new Promise((resolve, reject) => { + const transaction = db.transaction(this.storeName, "readwrite"); + const store = transaction.objectStore(this.storeName); + const request = store.delete(key); + + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(); + }); + } + + async keys(): Promise { + const db = await this.getDB(); + return new Promise((resolve, reject) => { + const transaction = db.transaction(this.storeName, "readonly"); + const store = transaction.objectStore(this.storeName); + const request = store.getAllKeys(); + + request.onerror = () => reject(request.error); + request.onsuccess = () => { + resolve(request.result.map((key) => String(key))); + }; + }); + } + + async clear(): Promise { + const db = await this.getDB(); + return new Promise((resolve, reject) => { + const transaction = db.transaction(this.storeName, "readwrite"); + const store = transaction.objectStore(this.storeName); + const request = store.clear(); + + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(); + }); + } + + async has(key: string): Promise { + const value = await this.get(key); + return value !== null; + } +} diff --git a/packages/web-ui/src/storage/backends/local-storage-backend.ts b/packages/web-ui/src/storage/backends/local-storage-backend.ts new file mode 100644 index 00000000..210ed0bf --- /dev/null +++ b/packages/web-ui/src/storage/backends/local-storage-backend.ts @@ -0,0 +1,74 @@ +import type { StorageBackend } from "../types.js"; + +/** + * Storage backend using browser localStorage. + * Good for: Simple settings, small data. + * Limits: ~5MB, synchronous API (wrapped in promises), string-only (JSON serialization). + */ +export class LocalStorageBackend implements StorageBackend { + constructor(private prefix: string = "") {} + + private getKey(key: string): string { + return this.prefix ? `${this.prefix}:${key}` : key; + } + + async get(key: string): Promise { + const fullKey = this.getKey(key); + const value = localStorage.getItem(fullKey); + if (value === null) return null; + + try { + return JSON.parse(value) as T; + } catch { + // If JSON parse fails, return as string + return value as T; + } + } + + async set(key: string, value: T): Promise { + const fullKey = this.getKey(key); + const serialized = JSON.stringify(value); + localStorage.setItem(fullKey, serialized); + } + + async delete(key: string): Promise { + const fullKey = this.getKey(key); + localStorage.removeItem(fullKey); + } + + async keys(): Promise { + const allKeys: string[] = []; + const prefixWithColon = this.prefix ? `${this.prefix}:` : ""; + + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key) { + if (this.prefix) { + if (key.startsWith(prefixWithColon)) { + allKeys.push(key.substring(prefixWithColon.length)); + } + } else { + allKeys.push(key); + } + } + } + + return allKeys; + } + + async clear(): Promise { + if (this.prefix) { + const keysToRemove = await this.keys(); + for (const key of keysToRemove) { + await this.delete(key); + } + } else { + localStorage.clear(); + } + } + + async has(key: string): Promise { + const fullKey = this.getKey(key); + return localStorage.getItem(fullKey) !== null; + } +} diff --git a/packages/web-ui/src/storage/repositories/provider-keys-repository.ts b/packages/web-ui/src/storage/repositories/provider-keys-repository.ts new file mode 100644 index 00000000..3852c0e2 --- /dev/null +++ b/packages/web-ui/src/storage/repositories/provider-keys-repository.ts @@ -0,0 +1,55 @@ +import type { StorageBackend } from "../types.js"; + +/** + * Repository for managing provider API keys. + * Provides domain-specific methods for key management. + */ +export class ProviderKeysRepository { + constructor(private backend: StorageBackend) {} + + /** + * Get the API key for a provider. + */ + async getKey(provider: string): Promise { + return this.backend.get(`key:${provider}`); + } + + /** + * Set the API key for a provider. + */ + async setKey(provider: string, key: string): Promise { + await this.backend.set(`key:${provider}`, key); + } + + /** + * Remove the API key for a provider. + */ + async removeKey(provider: string): Promise { + await this.backend.delete(`key:${provider}`); + } + + /** + * Get all providers that have keys stored. + */ + async getProviders(): Promise { + const allKeys = await this.backend.keys(); + return allKeys.filter((key) => key.startsWith("key:")).map((key) => key.substring(4)); + } + + /** + * Check if a provider has a key stored. + */ + async hasKey(provider: string): Promise { + return this.backend.has(`key:${provider}`); + } + + /** + * Clear all stored API keys. + */ + async clearAll(): Promise { + const providers = await this.getProviders(); + for (const provider of providers) { + await this.removeKey(provider); + } + } +} diff --git a/packages/web-ui/src/storage/repositories/settings-repository.ts b/packages/web-ui/src/storage/repositories/settings-repository.ts new file mode 100644 index 00000000..ad4a4659 --- /dev/null +++ b/packages/web-ui/src/storage/repositories/settings-repository.ts @@ -0,0 +1,51 @@ +import type { StorageBackend } from "../types.js"; + +/** + * Repository for simple application settings (proxy, theme, etc.). + * Uses a single backend for all settings. + */ +export class SettingsRepository { + constructor(private backend: StorageBackend) {} + + /** + * Get a setting value by key. + */ + async get(key: string): Promise { + return this.backend.get(key); + } + + /** + * Set a setting value. + */ + async set(key: string, value: T): Promise { + await this.backend.set(key, value); + } + + /** + * Delete a setting. + */ + async delete(key: string): Promise { + await this.backend.delete(key); + } + + /** + * Get all setting keys. + */ + async keys(): Promise { + return this.backend.keys(); + } + + /** + * Check if a setting exists. + */ + async has(key: string): Promise { + return this.backend.has(key); + } + + /** + * Clear all settings. + */ + async clear(): Promise { + await this.backend.clear(); + } +} diff --git a/packages/web-ui/src/storage/types.ts b/packages/web-ui/src/storage/types.ts new file mode 100644 index 00000000..d89bd628 --- /dev/null +++ b/packages/web-ui/src/storage/types.ts @@ -0,0 +1,48 @@ +/** + * Base interface for all storage backends. + * Provides a simple key-value storage abstraction that can be implemented + * by localStorage, IndexedDB, chrome.storage, or remote APIs. + */ +export interface StorageBackend { + /** + * Get a value by key. Returns null if key doesn't exist. + */ + get(key: string): Promise; + + /** + * Set a value for a key. + */ + set(key: string, value: T): Promise; + + /** + * Delete a key. + */ + delete(key: string): Promise; + + /** + * Get all keys. + */ + keys(): Promise; + + /** + * Clear all data. + */ + clear(): Promise; + + /** + * Check if a key exists. + */ + has(key: string): Promise; +} + +/** + * Options for configuring AppStorage. + */ +export interface AppStorageConfig { + /** Backend for simple settings (proxy, theme, etc.) */ + settings?: StorageBackend; + /** Backend for provider API keys */ + providerKeys?: StorageBackend; + /** Backend for sessions (chat history, attachments) */ + sessions?: StorageBackend; +} diff --git a/packages/web-ui/src/utils/i18n.ts b/packages/web-ui/src/utils/i18n.ts index 4e1666a5..6bdb5366 100644 --- a/packages/web-ui/src/utils/i18n.ts +++ b/packages/web-ui/src/utils/i18n.ts @@ -98,6 +98,16 @@ declare module "@mariozechner/mini-lit" { Download: string; "No logs for {filename}": string; "API Keys Settings": string; + Settings: string; + "API Keys": string; + Proxy: string; + "Use CORS Proxy": string; + "Proxy URL": string; + "Settings are stored locally in your browser": string; + Clear: string; + "API Key Required": string; + "Enter your API key for {provider}": string; + "The CORS proxy strips CORS headers from API responses, allowing browser-based apps to make direct calls to LLM providers without CORS restrictions. It forwards requests to providers while removing headers that would otherwise block cross-origin requests.": string; } } @@ -202,6 +212,17 @@ const translations = { Download: "Download", "No logs for {filename}": "No logs for {filename}", "API Keys Settings": "API Keys Settings", + Settings: "Settings", + "API Keys": "API Keys", + Proxy: "Proxy", + "Use CORS Proxy": "Use CORS Proxy", + "Proxy URL": "Proxy URL", + "Settings are stored locally in your browser": "Settings are stored locally in your browser", + Clear: "Clear", + "API Key Required": "API Key Required", + "Enter your API key for {provider}": "Enter your API key for {provider}", + "The CORS proxy strips CORS headers from API responses, allowing browser-based apps to make direct calls to LLM providers without CORS restrictions. It forwards requests to providers while removing headers that would otherwise block cross-origin requests.": + "The CORS proxy strips CORS headers from API responses, allowing browser-based apps to make direct calls to LLM providers without CORS restrictions. It forwards requests to providers while removing headers that would otherwise block cross-origin requests.", }, de: { ...defaultGerman, @@ -303,6 +324,17 @@ const translations = { Download: "Herunterladen", "No logs for {filename}": "Keine Logs für {filename}", "API Keys Settings": "API-Schlüssel Einstellungen", + Settings: "Einstellungen", + "API Keys": "API-Schlüssel", + Proxy: "Proxy", + "Use CORS Proxy": "CORS-Proxy verwenden", + "Proxy URL": "Proxy-URL", + "Settings are stored locally in your browser": "Einstellungen werden lokal in Ihrem Browser gespeichert", + Clear: "Löschen", + "API Key Required": "API-Schlüssel erforderlich", + "Enter your API key for {provider}": "Geben Sie Ihren API-Schlüssel für {provider} ein", + "The CORS proxy strips CORS headers from API responses, allowing browser-based apps to make direct calls to LLM providers without CORS restrictions. It forwards requests to providers while removing headers that would otherwise block cross-origin requests.": + "Der CORS-Proxy entfernt CORS-Header aus API-Antworten und ermöglicht browserbasierte Anwendungen, direkte Aufrufe an LLM-Anbieter ohne CORS-Einschränkungen durchzuführen. Er leitet Anfragen an Anbieter weiter und entfernt Header, die sonst Cross-Origin-Anfragen blockieren würden.", }, };