mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 09:01:14 +00:00
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:
parent
66f092c0c6
commit
0496651308
31 changed files with 1141 additions and 488 deletions
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
170
packages/web-ui/src/components/ProviderKeyInput.ts
Normal file
170
packages/web-ui/src/components/ProviderKeyInput.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
76
packages/web-ui/src/dialogs/ApiKeyPromptDialog.ts
Normal file
76
packages/web-ui/src/dialogs/ApiKeyPromptDialog.ts
Normal 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>
|
||||
`,
|
||||
})}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
223
packages/web-ui/src/dialogs/SettingsDialog.ts
Normal file
223
packages/web-ui/src/dialogs/SettingsDialog.ts
Normal 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>
|
||||
`,
|
||||
})}
|
||||
`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 || {};
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
49
packages/web-ui/src/state/transports/ProviderTransport.ts
Normal file
49
packages/web-ui/src/state/transports/ProviderTransport.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
export * from "./DirectTransport.js";
|
||||
export * from "./ProxyTransport.js";
|
||||
export * from "./AppTransport.js";
|
||||
export * from "./ProviderTransport.js";
|
||||
export * from "./types.js";
|
||||
|
|
|
|||
53
packages/web-ui/src/storage/app-storage.ts
Normal file
53
packages/web-ui/src/storage/app-storage.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
107
packages/web-ui/src/storage/backends/indexeddb-backend.ts
Normal file
107
packages/web-ui/src/storage/backends/indexeddb-backend.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
48
packages/web-ui/src/storage/types.ts
Normal file
48
packages/web-ui/src/storage/types.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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.",
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue