Add Anthropic prompt caching, pluggable storage, and CORS proxy support

Storage Architecture:
- New pluggable storage system with backends (LocalStorage, ChromeStorage, IndexedDB)
- SettingsRepository for app settings (proxy config, etc.)
- ProviderKeysRepository for API key management
- AppStorage with global accessors (getAppStorage, setAppStorage, initAppStorage)

Transport Refactoring:
- Renamed DirectTransport → ProviderTransport (calls LLM providers with optional CORS proxy)
- Renamed ProxyTransport → AppTransport (uses app server with user auth)
- Updated TransportMode: "direct" → "provider", "proxy" → "app"

CORS Proxy Integration:
- ProviderTransport checks proxy.enabled/proxy.url from storage
- When enabled, modifies model baseUrl to route through proxy: {proxyUrl}/?url={originalBaseUrl}
- ProviderKeyInput test function also honors proxy settings
- Settings dialog with Proxy tab (Switch toggle, URL input, explanatory description)

Anthropic Prompt Caching:
- System prompt cached with cache_control markers (both OAuth and regular API keys)
- Last user message cached to cache conversation history
- Saves 90% on input tokens for cached content (10x cost reduction)

Settings Dialog Improvements:
- Configurable tab system with SettingsTab base class
- ApiKeysTab and ProxyTab as custom elements
- Switch toggle for proxy enable (instead of Checkbox)
- Explanatory paragraphs for each tab
- ApiKeyPromptDialog reuses ProviderKeyInput component

Removed:
- Deprecated ApiKeysDialog (replaced by ProviderKeyInput in SettingsDialog)
- Old storage-adapter and key-store (replaced by new storage architecture)
This commit is contained in:
Mario Zechner 2025-10-05 23:00:36 +02:00
parent 66f092c0c6
commit 0496651308
31 changed files with 1141 additions and 488 deletions

View file

@ -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;
}

View file

@ -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()]);
},
})}
</div>
@ -98,6 +110,10 @@ class App extends LitElement {
return this;
}
private async handleApiKeyRequired(provider: string): Promise<boolean> {
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)}
></pi-chat-panel>
</div>
`;

View file

@ -6,7 +6,7 @@
<title>Pi Web UI - Example</title>
<meta name="description" content="Example usage of @mariozechner/pi-web-ui - Reusable AI chat interface" />
</head>
<body>
<body class="bg-background">
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>

View file

@ -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",
})}
</div>
</div>

View file

@ -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",

View file

@ -23,6 +23,7 @@ export class ChatPanel extends LitElement {
@property({ type: String }) systemPrompt = "You are a helpful AI assistant.";
@property({ type: Array }) additionalTools: AgentTool<any, any>[] = [];
@property({ attribute: false }) sandboxUrlProvider?: () => string;
@property({ attribute: false }) onApiKeyRequired?: (provider: string) => Promise<boolean>;
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}
></agent-interface>
</div>

View file

@ -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;
}

View file

@ -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<boolean>;
// 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;
}
}

View file

@ -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<string, string> = {
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<boolean> {
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<boolean>("proxy.enabled");
const proxyUrl = await getAppStorage().settings.get<string>("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`
<div class="space-y-3">
<div class="flex items-center gap-2">
<span class="text-sm font-medium capitalize text-foreground">${this.provider}</span>
${
this.testing
? Badge({ children: i18n("Testing..."), variant: "secondary" })
: this.hasKey
? html`<span class="text-green-600 dark:text-green-400">✓</span>`
: ""
}
${this.failed ? Badge({ children: i18n("✗ Invalid"), variant: "destructive" }) : ""}
</div>
<div class="flex items-center gap-2">
${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"),
})
}
</div>
</div>
`;
}
}

View file

@ -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<boolean> {
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),
})}
<div class="mt-4">
<provider-key-input .provider=${this.provider}></provider-key-input>
</div>
`,
})}
`;
}
}

View file

@ -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<string, string> = {
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<string, boolean> = {}; // provider -> configured
@state() apiKeyInputs: Record<string, string> = {};
@state() testResults: Record<string, "success" | "error" | "testing"> = {};
@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<void> {
super.firstUpdated(changedProperties);
await this.loadKeys();
}
private async loadKeys() {
this.apiKeys = await getKeyStore().getAllKeys();
}
private async testApiKey(provider: string, apiKey: string): Promise<boolean> {
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`
<div class="flex flex-col h-full">
<!-- Header -->
<div class="p-6 pb-4 border-b border-border flex-shrink-0">
${DialogHeader({ title: i18n("API Keys Configuration") })}
<p class="text-sm text-muted-foreground mt-2">
${i18n("Configure API keys for LLM providers. Keys are stored locally in your browser.")}
</p>
</div>
<!-- Error message -->
${
this.error
? html`
<div class="px-6 pt-4">${Alert(this.error, "destructive")}</div>
`
: ""
}
<!-- test-->
<!-- Scrollable content -->
<div class="flex-1 overflow-y-auto p-6">
<div class="space-y-6">
${providers.map(
(provider) => html`
<div class="space-y-3">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-muted-foreground capitalize">${provider}</span>
${
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" })
: ""
}
</div>
<div class="flex gap-2">
${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"),
})}
`
: ""
}
</div>
</div>
`,
)}
</div>
</div>
<!-- Footer with help text -->
<div class="p-6 pt-4 border-t border-border flex-shrink-0">
<p class="text-xs text-muted-foreground">
${i18n("API keys are required to use AI models. Get your keys from the provider's website.")}
</p>
</div>
</div>
`;
}
}

View file

@ -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`
<div class="flex flex-col gap-6">
<p class="text-sm text-muted-foreground">
${i18n("Configure API keys for LLM providers. Keys are stored locally in your browser.")}
</p>
${providers.map((provider) => html`<provider-key-input .provider=${provider}></provider-key-input>`)}
</div>
`;
}
}
// 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<boolean>("proxy.enabled");
const url = await storage.settings.get<string>("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`
<div class="flex flex-col gap-4">
<p class="text-sm text-muted-foreground">
${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.")}
</p>
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-foreground">${i18n("Use CORS Proxy")}</span>
${Switch({
checked: this.proxyEnabled,
onChange: (checked: boolean) => {
this.proxyEnabled = checked;
this.saveProxySettings();
},
})}
</div>
<div class="space-y-2">
${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(),
})}
</div>
</div>
`;
}
}
@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`
<button
class="w-full text-left px-4 py-3 rounded-md transition-colors ${
isActive
? "bg-secondary text-foreground font-medium"
: "text-muted-foreground hover:bg-secondary/50 hover:text-foreground"
}"
@click=${() => this.setActiveTab(index)}
>
${tab.getTabName()}
</button>
`;
}
private renderMobileTab(tab: SettingsTab, index: number): TemplateResult {
const isActive = this.activeTabIndex === index;
return html`
<button
class="px-3 py-2 text-sm font-medium transition-colors ${
isActive ? "border-b-2 border-primary text-foreground" : "text-muted-foreground hover:text-foreground"
}"
@click=${() => this.setActiveTab(index)}
>
${tab.getTabName()}
</button>
`;
}
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`
<div class="flex flex-col h-full overflow-hidden">
<!-- Header -->
<div class="pb-4 flex-shrink-0">${DialogHeader({ title: i18n("Settings") })}</div>
<!-- Mobile Tabs -->
<div class="md:hidden flex flex-shrink-0 pb-4">
${this.tabs.map((tab, index) => this.renderMobileTab(tab, index))}
</div>
<!-- Layout -->
<div class="flex flex-1 overflow-hidden">
<!-- Sidebar (desktop only) -->
<div class="hidden md:block w-64 flex-shrink-0 space-y-1">
${this.tabs.map((tab, index) => this.renderSidebarItem(tab, index))}
</div>
<!-- Content -->
<div class="flex-1 overflow-y-auto md:pl-6">
${this.tabs.map(
(tab, index) =>
html`<div style="display: ${this.activeTabIndex === index ? "block" : "none"}">${tab}</div>`,
)}
</div>
</div>
<!-- Footer -->
<div class="pt-4 flex-shrink-0">
<p class="text-xs text-muted-foreground text-center">
${i18n("Settings are stored locally in your browser")}
</p>
</div>
</div>
`,
})}
`,
});
}
}

View file

@ -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";

View file

@ -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<AgentSessionState>;
@ -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());
}
}

View file

@ -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<string | null>;
setKey(provider: string, key: string): Promise<void>;
removeKey(provider: string): Promise<void>;
getAllKeys(): Promise<Record<string, boolean>>;
}
/**
* 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<string | null> {
const key = `${this.prefix}${provider}`;
return await this.storage.get(key);
}
async setKey(provider: string, key: string): Promise<void> {
const storageKey = `${this.prefix}${provider}`;
await this.storage.set(storageKey, key);
}
async removeKey(provider: string): Promise<void> {
const key = `${this.prefix}${provider}`;
await this.storage.remove(key);
}
async getAllKeys(): Promise<Record<string, boolean>> {
const providers = getProviders();
const allStorage = await this.storage.getAll();
const result: Record<string, boolean> = {};
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;
}

View file

@ -1,77 +0,0 @@
/**
* Generic storage adapter interface for key/value persistence
*/
export interface StorageAdapter {
get(key: string): Promise<string | null>;
set(key: string, value: string): Promise<void>;
remove(key: string): Promise<void>;
getAll(): Promise<Record<string, string>>;
}
/**
* LocalStorage implementation
*/
export class LocalStorageAdapter implements StorageAdapter {
async get(key: string): Promise<string | null> {
return localStorage.getItem(key);
}
async set(key: string, value: string): Promise<void> {
localStorage.setItem(key, value);
}
async remove(key: string): Promise<void> {
localStorage.removeItem(key);
}
async getAll(): Promise<Record<string, string>> {
const result: Record<string, string> = {};
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<string | null> {
const result = await this.storage.get(key);
return result[key] || null;
}
async set(key: string, value: string): Promise<void> {
await this.storage.set({ [key]: value });
}
async remove(key: string): Promise<void> {
await this.storage.remove(key);
}
async getAll(): Promise<Record<string, string>> {
const result = await this.storage.get();
return result || {};
}
}

View file

@ -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";

View file

@ -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<Message[]>) {}
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;
}
}
}

View file

@ -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<Message[]>) {}
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<boolean>("proxy.enabled");
const proxyUrl = await getAppStorage().settings.get<string>("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;
}
}
}

View file

@ -1,3 +1,3 @@
export * from "./DirectTransport.js";
export * from "./ProxyTransport.js";
export * from "./AppTransport.js";
export * from "./ProviderTransport.js";
export * from "./types.js";

View file

@ -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;
}

View file

@ -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<T = unknown>(key: string): Promise<T | null> {
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<T = unknown>(key: string, value: T): Promise<void> {
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<void> {
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<string[]> {
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<void> {
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<boolean> {
const value = await this.get(key);
return value !== null;
}
}

View file

@ -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<IDBDatabase> | null = null;
constructor(
private dbName: string,
private storeName: string = "keyvalue",
) {}
private async getDB(): Promise<IDBDatabase> {
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<T = unknown>(key: string): Promise<T | null> {
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<T = unknown>(key: string, value: T): Promise<void> {
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<void> {
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<string[]> {
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<void> {
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<boolean> {
const value = await this.get(key);
return value !== null;
}
}

View file

@ -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<T = unknown>(key: string): Promise<T | null> {
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<T = unknown>(key: string, value: T): Promise<void> {
const fullKey = this.getKey(key);
const serialized = JSON.stringify(value);
localStorage.setItem(fullKey, serialized);
}
async delete(key: string): Promise<void> {
const fullKey = this.getKey(key);
localStorage.removeItem(fullKey);
}
async keys(): Promise<string[]> {
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<void> {
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<boolean> {
const fullKey = this.getKey(key);
return localStorage.getItem(fullKey) !== null;
}
}

View file

@ -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<string | null> {
return this.backend.get<string>(`key:${provider}`);
}
/**
* Set the API key for a provider.
*/
async setKey(provider: string, key: string): Promise<void> {
await this.backend.set(`key:${provider}`, key);
}
/**
* Remove the API key for a provider.
*/
async removeKey(provider: string): Promise<void> {
await this.backend.delete(`key:${provider}`);
}
/**
* Get all providers that have keys stored.
*/
async getProviders(): Promise<string[]> {
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<boolean> {
return this.backend.has(`key:${provider}`);
}
/**
* Clear all stored API keys.
*/
async clearAll(): Promise<void> {
const providers = await this.getProviders();
for (const provider of providers) {
await this.removeKey(provider);
}
}
}

View file

@ -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<T = unknown>(key: string): Promise<T | null> {
return this.backend.get<T>(key);
}
/**
* Set a setting value.
*/
async set<T = unknown>(key: string, value: T): Promise<void> {
await this.backend.set(key, value);
}
/**
* Delete a setting.
*/
async delete(key: string): Promise<void> {
await this.backend.delete(key);
}
/**
* Get all setting keys.
*/
async keys(): Promise<string[]> {
return this.backend.keys();
}
/**
* Check if a setting exists.
*/
async has(key: string): Promise<boolean> {
return this.backend.has(key);
}
/**
* Clear all settings.
*/
async clear(): Promise<void> {
await this.backend.clear();
}
}

View file

@ -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<T = unknown>(key: string): Promise<T | null>;
/**
* Set a value for a key.
*/
set<T = unknown>(key: string, value: T): Promise<void>;
/**
* Delete a key.
*/
delete(key: string): Promise<void>;
/**
* Get all keys.
*/
keys(): Promise<string[]>;
/**
* Clear all data.
*/
clear(): Promise<void>;
/**
* Check if a key exists.
*/
has(key: string): Promise<boolean>;
}
/**
* 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;
}

View file

@ -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.",
},
};