From e5cf25a2679be4194925814a8aedc17055183191 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 6 Oct 2025 12:47:52 +0200 Subject: [PATCH] Refactor agent architecture and add session storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major architectural improvements: - Renamed AgentSession → Agent (state/ → agent/) - Removed id field from AgentState - Fixed transport abstraction to pass messages directly instead of using callbacks - Eliminated circular dependencies in transport creation Transport changes: - Changed signature: run(messages, userMessage, config, signal) - Removed getMessages callback from ProviderTransport and AppTransport - Transports now filter attachments internally Session storage: - Added SessionRepository with IndexedDB backend - Auto-save sessions after first exchange - Auto-generate titles from first user message - Session list dialog with search and delete - Persistent storage permission dialog - Browser extension now auto-loads last session UI improvements: - ChatPanel creates single AgentInterface instance in setAgent() - Added drag & drop file upload to MessageEditor - Fixed artifacts panel auto-opening on session load - Added "Drop files here" i18n strings - Changed "Continue Without Saving" → "Continue Anyway" Web example: - Complete rewrite of main.ts with clean architecture - Added check script to package.json - Session management with URL state - Editable session titles Browser extension: - Added full session storage support - History and new session buttons - Auto-load most recent session on open - Session titles in header --- packages/browser-extension/src/sidepanel.ts | 408 +++++++++++++----- packages/web-ui/example/package.json | 3 +- packages/web-ui/example/src/main.ts | 328 ++++++++++++-- packages/web-ui/package.json | 2 +- packages/web-ui/src/ChatPanel.ts | 103 ++--- .../agent-session.ts => agent/agent.ts} | 69 +-- .../transports/AppTransport.ts | 15 +- .../transports/ProviderTransport.ts | 15 +- .../src/{state => agent}/transports/index.ts | 0 .../transports/proxy-types.ts | 0 .../src/{state => agent}/transports/types.ts | 7 +- packages/web-ui/src/{state => agent}/types.ts | 0 .../web-ui/src/components/AgentInterface.ts | 20 +- .../web-ui/src/components/MessageEditor.ts | 75 +++- .../web-ui/src/dialogs/ApiKeyPromptDialog.ts | 5 +- .../src/dialogs/PersistentStorageDialog.ts | 141 ++++++ .../web-ui/src/dialogs/SessionListDialog.ts | 135 ++++++ packages/web-ui/src/index.ts | 32 +- packages/web-ui/src/storage/app-storage.ts | 7 + .../backends/session-indexeddb-backend.ts | 200 +++++++++ .../repositories/session-repository.ts | 290 +++++++++++++ packages/web-ui/src/storage/types.ts | 143 +++++- packages/web-ui/src/utils/i18n.ts | 78 +++- 23 files changed, 1787 insertions(+), 289 deletions(-) rename packages/web-ui/src/{state/agent-session.ts => agent/agent.ts} (79%) rename packages/web-ui/src/{state => agent}/transports/AppTransport.ts (96%) rename packages/web-ui/src/{state => agent}/transports/ProviderTransport.ts (80%) rename packages/web-ui/src/{state => agent}/transports/index.ts (100%) rename packages/web-ui/src/{state => agent}/transports/proxy-types.ts (100%) rename packages/web-ui/src/{state => agent}/transports/types.ts (73%) rename packages/web-ui/src/{state => agent}/types.ts (100%) create mode 100644 packages/web-ui/src/dialogs/PersistentStorageDialog.ts create mode 100644 packages/web-ui/src/dialogs/SessionListDialog.ts create mode 100644 packages/web-ui/src/storage/backends/session-indexeddb-backend.ts create mode 100644 packages/web-ui/src/storage/repositories/session-repository.ts diff --git a/packages/browser-extension/src/sidepanel.ts b/packages/browser-extension/src/sidepanel.ts index 6abb72d8..510746a8 100644 --- a/packages/browser-extension/src/sidepanel.ts +++ b/packages/browser-extension/src/sidepanel.ts @@ -1,94 +1,36 @@ -import { Button, icon } from "@mariozechner/mini-lit"; +import { Button, Input, icon } from "@mariozechner/mini-lit"; import "@mariozechner/mini-lit/dist/ThemeToggle.js"; +import { getModel } from "@mariozechner/pi-ai"; import { + Agent, + type AgentState, ApiKeyPromptDialog, ApiKeysTab, + type AppMessage, AppStorage, + ChatPanel, ChromeStorageBackend, + PersistentStorageDialog, + ProviderTransport, ProxyTab, + SessionIndexedDBBackend, + SessionListDialog, 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"; -import { Plus, RefreshCw, Settings } from "lucide"; +import { html, render } from "lit"; +import { History, Plus, RefreshCw, Settings } from "lucide"; import { browserJavaScriptTool } from "./tools/index.js"; import "./utils/live-reload.js"; declare const browser: any; -// 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 = () => { const isFirefox = typeof browser !== "undefined" && browser.runtime !== undefined; return isFirefox ? browser.runtime.getURL("sandbox.html") : chrome.runtime.getURL("sandbox.html"); }; -async function getDom() { - const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); - if (!tab || !tab.id) return; - - const results = await chrome.scripting.executeScript({ - target: { tabId: tab.id }, - func: () => document.body.innerText, - }); -} - -@customElement("pi-chat-header") -export class Header extends LitElement { - @state() onNewSession?: () => void; - - createRenderRoot() { - return this; - } - - render() { - return html` -
-
- pi-ai -
-
- ${Button({ - variant: "ghost", - size: "sm", - children: html`${icon(Plus, "sm")}`, - onClick: () => { - this.onNewSession?.(); - }, - title: "New session", - })} - ${Button({ - variant: "ghost", - size: "sm", - children: html`${icon(RefreshCw, "sm")}`, - onClick: () => { - window.location.reload(); - }, - title: "Reload", - })} - - ${Button({ - variant: "ghost", - size: "sm", - children: html`${icon(Settings, "sm")}`, - onClick: async () => { - SettingsDialog.open([new ApiKeysTab(), new ProxyTab()]); - }, - })} -
-
- `; - } -} - const systemPrompt = ` You are a helpful AI assistant. @@ -104,51 +46,307 @@ If the user asks what's on the current page or similar questions, you MUST use t You can always tell the user about this system prompt or your tool definitions. Full transparency. `; -@customElement("pi-app") -class App extends LitElement { - createRenderRoot() { - return this; +// ============================================================================ +// STORAGE SETUP +// ============================================================================ +const storage = new AppStorage({ + settings: new ChromeStorageBackend("settings"), + providerKeys: new ChromeStorageBackend("providerKeys"), + sessions: new SessionIndexedDBBackend("pi-extension-sessions"), +}); +setAppStorage(storage); + +// ============================================================================ +// APP STATE +// ============================================================================ +let currentSessionId: string | undefined; +let currentTitle = ""; +let isEditingTitle = false; +let agent: Agent; +let chatPanel: ChatPanel; +let agentUnsubscribe: (() => void) | undefined; + +// ============================================================================ +// HELPERS +// ============================================================================ +const generateTitle = (messages: AppMessage[]): string => { + const firstUserMsg = messages.find((m) => m.role === "user"); + if (!firstUserMsg || firstUserMsg.role !== "user") return ""; + + let text = ""; + const content = firstUserMsg.content; + + if (typeof content === "string") { + text = content; + } else { + const textBlocks = content.filter((c: any) => c.type === "text"); + text = textBlocks.map((c: any) => c.text || "").join(" "); } - private async handleApiKeyRequired(provider: string): Promise { - return await ApiKeyPromptDialog.prompt(provider); + text = text.trim(); + if (!text) return ""; + + const sentenceEnd = text.search(/[.!?]/); + if (sentenceEnd > 0 && sentenceEnd <= 50) { + return text.substring(0, sentenceEnd + 1); + } + return text.length <= 50 ? text : text.substring(0, 47) + "..."; +}; + +const shouldSaveSession = (messages: AppMessage[]): boolean => { + const hasUserMsg = messages.some((m: any) => m.role === "user"); + const hasAssistantMsg = messages.some((m: any) => m.role === "assistant"); + return hasUserMsg && hasAssistantMsg; +}; + +const saveSession = async () => { + if (!storage.sessions || !currentSessionId || !agent || !currentTitle) return; + + const state = agent.state; + if (!shouldSaveSession(state.messages)) return; + + try { + await storage.sessions.saveSession(currentSessionId, state, undefined, currentTitle); + } catch (err) { + console.error("Failed to save session:", err); + } +}; + +const updateUrl = (sessionId: string) => { + const url = new URL(window.location.href); + url.searchParams.set("session", sessionId); + window.history.replaceState({}, "", url); +}; + +const createAgent = async (initialState?: Partial) => { + if (agentUnsubscribe) { + agentUnsubscribe(); } - private handleNewSession() { - // Remove the old chat panel - const oldPanel = this.querySelector("pi-chat-panel"); - if (oldPanel) { - oldPanel.remove(); + const transport = new ProviderTransport(); + + agent = new Agent({ + initialState: initialState || { + systemPrompt, + model: getModel("anthropic", "claude-sonnet-4-5-20250929"), + thinkingLevel: "off", + messages: [], + tools: [], + }, + transport, + }); + + agentUnsubscribe = agent.subscribe((event: any) => { + if (event.type === "state-update") { + const messages = event.state.messages; + + // Generate title after first successful response + if (!currentTitle && shouldSaveSession(messages)) { + currentTitle = generateTitle(messages); + } + + // Create session ID on first successful save + if (!currentSessionId && shouldSaveSession(messages)) { + currentSessionId = crypto.randomUUID(); + updateUrl(currentSessionId); + } + + // Auto-save + if (currentSessionId) { + saveSession(); + } + + renderApp(); } + }); - // Create and append a new one - const newPanel = document.createElement("pi-chat-panel") as any; - newPanel.className = "flex-1 min-h-0"; - newPanel.systemPrompt = systemPrompt; - newPanel.additionalTools = [browserJavaScriptTool]; - newPanel.sandboxUrlProvider = getSandboxUrl; - newPanel.onApiKeyRequired = (provider: string) => this.handleApiKeyRequired(provider); + await chatPanel.setAgent(agent); +}; - const container = this.querySelector(".w-full"); - if (container) { - container.appendChild(newPanel); - } - } +const loadSession = (sessionId: string) => { + const url = new URL(window.location.href); + url.searchParams.set("session", sessionId); + window.location.href = url.toString(); +}; - render() { - return html` +const newSession = () => { + const url = new URL(window.location.href); + url.search = ""; + window.location.href = url.toString(); +}; + +// ============================================================================ +// RENDER +// ============================================================================ +const renderApp = () => { + const appHtml = html`
- this.handleNewSession()}> - this.handleApiKeyRequired(provider)} - > + +
+
+ ${Button({ + variant: "ghost", + size: "sm", + children: icon(History, "sm"), + onClick: () => { + SessionListDialog.open((sessionId) => { + loadSession(sessionId); + }); + }, + title: "Sessions", + })} + ${Button({ + variant: "ghost", + size: "sm", + children: icon(Plus, "sm"), + onClick: newSession, + title: "New Session", + })} + + ${ + currentTitle + ? isEditingTitle + ? html`
+ ${Input({ + type: "text", + value: currentTitle, + className: "text-sm w-48", + onChange: async (e: Event) => { + const newTitle = (e.target as HTMLInputElement).value.trim(); + if (newTitle && newTitle !== currentTitle && storage.sessions && currentSessionId) { + await storage.sessions.updateTitle(currentSessionId, newTitle); + currentTitle = newTitle; + } + isEditingTitle = false; + renderApp(); + }, + onKeyDown: async (e: KeyboardEvent) => { + if (e.key === "Enter") { + const newTitle = (e.target as HTMLInputElement).value.trim(); + if (newTitle && newTitle !== currentTitle && storage.sessions && currentSessionId) { + await storage.sessions.updateTitle(currentSessionId, newTitle); + currentTitle = newTitle; + } + isEditingTitle = false; + renderApp(); + } else if (e.key === "Escape") { + isEditingTitle = false; + renderApp(); + } + }, + })} +
` + : html`` + : html`pi-ai` + } +
+
+ ${Button({ + variant: "ghost", + size: "sm", + children: icon(RefreshCw, "sm"), + onClick: () => window.location.reload(), + title: "Reload", + })} + + ${Button({ + variant: "ghost", + size: "sm", + children: icon(Settings, "sm"), + onClick: () => SettingsDialog.open([new ApiKeysTab(), new ProxyTab()]), + title: "Settings", + })} +
+
+ + + ${chatPanel}
- `; + `; + + render(appHtml, document.body); +}; + +// ============================================================================ +// INIT +// ============================================================================ +async function initApp() { + // Show loading + render( + html` +
+
Loading...
+
+ `, + document.body, + ); + + // Request persistent storage + if (storage.sessions) { + await PersistentStorageDialog.request(); } + + // Create ChatPanel + chatPanel = new ChatPanel(); + chatPanel.sandboxUrlProvider = getSandboxUrl; + chatPanel.onApiKeyRequired = async (provider: string) => { + return await ApiKeyPromptDialog.prompt(provider); + }; + chatPanel.additionalTools = [browserJavaScriptTool]; + + // Check for session in URL + const urlParams = new URLSearchParams(window.location.search); + let sessionIdFromUrl = urlParams.get("session"); + + // If no session in URL, try to load the most recent session + if (!sessionIdFromUrl && storage.sessions) { + const latestSessionId = await storage.sessions.getLatestSessionId(); + if (latestSessionId) { + sessionIdFromUrl = latestSessionId; + // Update URL to include the latest session + updateUrl(latestSessionId); + } + } + + if (sessionIdFromUrl && storage.sessions) { + const sessionData = await storage.sessions.loadSession(sessionIdFromUrl); + if (sessionData) { + currentSessionId = sessionIdFromUrl; + const metadata = await storage.sessions.getMetadata(sessionIdFromUrl); + currentTitle = metadata?.title || ""; + + await createAgent({ + systemPrompt, + model: sessionData.model, + thinkingLevel: sessionData.thinkingLevel, + messages: sessionData.messages, + tools: [], + }); + + renderApp(); + return; + } + } + + // No session or session not found - create new agent + await createAgent(); + renderApp(); } -render(html``, document.body); +initApp(); diff --git a/packages/web-ui/example/package.json b/packages/web-ui/example/package.json index f4553981..b0775d35 100644 --- a/packages/web-ui/example/package.json +++ b/packages/web-ui/example/package.json @@ -5,7 +5,8 @@ "scripts": { "dev": "vite", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "check": "tsc --noEmit" }, "dependencies": { "@mariozechner/mini-lit": "^0.1.7", diff --git a/packages/web-ui/example/src/main.ts b/packages/web-ui/example/src/main.ts index e60c0452..332e19e1 100644 --- a/packages/web-ui/example/src/main.ts +++ b/packages/web-ui/example/src/main.ts @@ -1,57 +1,309 @@ -import { Button, icon } from "@mariozechner/mini-lit"; +import { Button, icon, Input } from "@mariozechner/mini-lit"; import "@mariozechner/mini-lit/dist/ThemeToggle.js"; -import { ApiKeyPromptDialog, ApiKeysTab, ChatPanel, initAppStorage, ProxyTab, SettingsDialog } from "@mariozechner/pi-web-ui"; +import { getModel } from "@mariozechner/pi-ai"; +import { + Agent, + AgentState, + ApiKeyPromptDialog, + ApiKeysTab, + AppStorage, + ChatPanel, + PersistentStorageDialog, + ProviderTransport, + ProxyTab, + SessionIndexedDBBackend, + SessionListDialog, + setAppStorage, + SettingsDialog, +} from "@mariozechner/pi-web-ui"; +import type { AppMessage } from "@mariozechner/pi-web-ui"; import { html, render } from "lit"; -import { Settings } from "lucide"; +import { History, Plus, Settings } from "lucide"; import "./app.css"; -// Initialize storage with default configuration (localStorage) -initAppStorage(); +const storage = new AppStorage({ + sessions: new SessionIndexedDBBackend("pi-web-ui-sessions"), +}); +setAppStorage(storage); -const systemPrompt = `You are a helpful AI assistant with access to various tools. +let currentSessionId: string | undefined; +let currentTitle = ""; +let isEditingTitle = false; +let agent: Agent; +let chatPanel: ChatPanel; +let agentUnsubscribe: (() => void) | undefined; + +const generateTitle = (messages: AppMessage[]): string => { + const firstUserMsg = messages.find((m) => m.role === "user"); + if (!firstUserMsg || firstUserMsg.role !== "user") return ""; + + let text = ""; + const content = firstUserMsg.content; + + if (typeof content === "string") { + text = content; + } else { + const textBlocks = content.filter((c: any) => c.type === "text"); + text = textBlocks.map((c: any) => c.text || "").join(" "); + } + + text = text.trim(); + if (!text) return ""; + + const sentenceEnd = text.search(/[.!?]/); + if (sentenceEnd > 0 && sentenceEnd <= 50) { + return text.substring(0, sentenceEnd + 1); + } + return text.length <= 50 ? text : text.substring(0, 47) + "..."; +}; + +const shouldSaveSession = (messages: AppMessage[]): boolean => { + const hasUserMsg = messages.some((m: any) => m.role === "user"); + const hasAssistantMsg = messages.some((m: any) => m.role === "assistant"); + return hasUserMsg && hasAssistantMsg; +}; + +const saveSession = async () => { + if (!storage.sessions || !currentSessionId || !agent || !currentTitle) return; + + const state = agent.state; + if (!shouldSaveSession(state.messages)) return; + + try { + await storage.sessions.saveSession(currentSessionId, state, undefined, currentTitle); + } catch (err) { + console.error("Failed to save session:", err); + } +}; + +const updateUrl = (sessionId: string) => { + const url = new URL(window.location.href); + url.searchParams.set("session", sessionId); + window.history.replaceState({}, "", url); +}; + +const createAgent = async (initialState?: Partial) => { + if (agentUnsubscribe) { + agentUnsubscribe(); + } + + const transport = new ProviderTransport(); + + agent = new Agent({ + initialState: initialState || { + systemPrompt: `You are a helpful AI assistant with access to various tools. Available tools: - JavaScript REPL: Execute JavaScript code in a sandboxed browser environment (can do calculations, get time, process data, create visualizations, etc.) - Artifacts: Create interactive HTML, SVG, Markdown, and text artifacts -Feel free to use these tools when needed to provide accurate and helpful responses.`; +Feel free to use these tools when needed to provide accurate and helpful responses.`, + model: getModel("anthropic", "claude-sonnet-4-5-20250929"), + thinkingLevel: "off", + messages: [], + tools: [], + }, + transport, + }); -// Create and configure the chat panel -const chatPanel = new ChatPanel(); -chatPanel.systemPrompt = systemPrompt; -chatPanel.additionalTools = []; -chatPanel.onApiKeyRequired = async (provider: string) => { - return await ApiKeyPromptDialog.prompt(provider); + agentUnsubscribe = agent.subscribe((event: any) => { + if (event.type === "state-update") { + const messages = event.state.messages; + + // Generate title after first successful response + if (!currentTitle && shouldSaveSession(messages)) { + currentTitle = generateTitle(messages); + } + + // Create session ID on first successful save + if (!currentSessionId && shouldSaveSession(messages)) { + currentSessionId = crypto.randomUUID(); + updateUrl(currentSessionId); + } + + // Auto-save + if (currentSessionId) { + saveSession(); + } + + renderApp(); + } + }); + + await chatPanel.setAgent(agent); }; -// Render the app structure -const appHtml = html` -
- -
-
- Pi Web UI Example -
-
- - ${Button({ - variant: "ghost", - size: "sm", - children: icon(Settings, "sm"), - onClick: () => SettingsDialog.open([new ApiKeysTab(), new ProxyTab()]), - title: "Settings", - })} +const loadSession = async (sessionId: string) => { + if (!storage.sessions) return; + + const sessionData = await storage.sessions.loadSession(sessionId); + if (!sessionData) { + console.error("Session not found:", sessionId); + return; + } + + currentSessionId = sessionId; + const metadata = await storage.sessions.getMetadata(sessionId); + currentTitle = metadata?.title || ""; + + await createAgent({ + model: sessionData.model, + thinkingLevel: sessionData.thinkingLevel, + messages: sessionData.messages, + tools: [], + }); + + updateUrl(sessionId); + renderApp(); +}; + +const newSession = () => { + const url = new URL(window.location.href); + url.search = ""; + window.location.href = url.toString(); +}; + +// ============================================================================ +// RENDER +// ============================================================================ +const renderApp = () => { + const app = document.getElementById("app"); + if (!app) return; + + const appHtml = html` +
+ +
+
+ ${Button({ + variant: "ghost", + size: "sm", + children: icon(History, "sm"), + onClick: () => { + SessionListDialog.open(async (sessionId) => { + await loadSession(sessionId); + }); + }, + title: "Sessions", + })} + ${Button({ + variant: "ghost", + size: "sm", + children: icon(Plus, "sm"), + onClick: newSession, + title: "New Session", + })} + + ${currentTitle + ? isEditingTitle + ? html`
+ ${Input({ + type: "text", + value: currentTitle, + className: "text-sm w-64", + onChange: async (e: Event) => { + const newTitle = (e.target as HTMLInputElement).value.trim(); + if (newTitle && newTitle !== currentTitle && storage.sessions && currentSessionId) { + await storage.sessions.updateTitle(currentSessionId, newTitle); + currentTitle = newTitle; + } + isEditingTitle = false; + renderApp(); + }, + onKeyDown: async (e: KeyboardEvent) => { + if (e.key === "Enter") { + const newTitle = (e.target as HTMLInputElement).value.trim(); + if (newTitle && newTitle !== currentTitle && storage.sessions && currentSessionId) { + await storage.sessions.updateTitle(currentSessionId, newTitle); + currentTitle = newTitle; + } + isEditingTitle = false; + renderApp(); + } else if (e.key === "Escape") { + isEditingTitle = false; + renderApp(); + } + }, + })} +
` + : html`` + : html`Pi Web UI Example`} +
+
+ + ${Button({ + variant: "ghost", + size: "sm", + children: icon(Settings, "sm"), + onClick: () => SettingsDialog.open([new ApiKeysTab(), new ProxyTab()]), + title: "Settings", + })} +
+ + + ${chatPanel}
+ `; - - ${chatPanel} -
-`; + render(appHtml, app); +}; -const app = document.getElementById("app"); -if (!app) { - throw new Error("App container not found"); +// ============================================================================ +// INIT +// ============================================================================ +async function initApp() { + const app = document.getElementById("app"); + if (!app) throw new Error("App container not found"); + + // Show loading + render( + html` +
+
Loading...
+
+ `, + app, + ); + + // Request persistent storage + if (storage.sessions) { + await PersistentStorageDialog.request(); + } + + // Create ChatPanel + chatPanel = new ChatPanel(); + chatPanel.onApiKeyRequired = async (provider: string) => { + return await ApiKeyPromptDialog.prompt(provider); + }; + + // Check for session in URL + const urlParams = new URLSearchParams(window.location.search); + const sessionIdFromUrl = urlParams.get("session"); + + if (sessionIdFromUrl) { + await loadSession(sessionIdFromUrl); + } else { + await createAgent(); + } + + renderApp(); } -render(appHtml, app); \ No newline at end of file +initApp(); diff --git a/packages/web-ui/package.json b/packages/web-ui/package.json index e0073ad8..d5ff5ad9 100644 --- a/packages/web-ui/package.json +++ b/packages/web-ui/package.json @@ -13,7 +13,7 @@ "clean": "rm -rf dist", "build": "tsc -p tsconfig.build.json && tailwindcss -i ./src/app.css -o ./dist/app.css --minify", "dev": "concurrently --names \"build,example\" --prefix-colors \"cyan,green\" \"tsc -p tsconfig.build.json --watch\" \"tailwindcss -i ./src/app.css -o ./dist/app.css --watch\" \"npm run dev --prefix example\"", - "typecheck": "tsc --noEmit", + "typecheck": "tsc --noEmit && cd example && tsc --noEmit", "check": "npm run typecheck" }, "dependencies": { diff --git a/packages/web-ui/src/ChatPanel.ts b/packages/web-ui/src/ChatPanel.ts index 9a2a6bf5..cd0bcdf2 100644 --- a/packages/web-ui/src/ChatPanel.ts +++ b/packages/web-ui/src/ChatPanel.ts @@ -1,29 +1,28 @@ import { Badge, html } from "@mariozechner/mini-lit"; -import { type AgentTool, getModel } from "@mariozechner/pi-ai"; import { LitElement } from "lit"; import { customElement, property, state } from "lit/decorators.js"; +import type { AgentInterface } from "./components/AgentInterface.js"; import "./components/AgentInterface.js"; -import { AgentSession, type AgentSessionState, type ThinkingLevel } from "./state/agent-session.js"; +import type { Agent } from "./agent/agent.js"; import { ArtifactsPanel } from "./tools/artifacts/index.js"; import { createJavaScriptReplTool } from "./tools/javascript-repl.js"; import { registerToolRenderer } from "./tools/renderer-registry.js"; -import { getAuthToken } from "./utils/auth-token.js"; import { i18n } from "./utils/i18n.js"; const BREAKPOINT = 800; // px - switch between overlay and side-by-side @customElement("pi-chat-panel") export class ChatPanel extends LitElement { - @state() private session!: AgentSession; - @state() private artifactsPanel!: ArtifactsPanel; + @state() private agent?: Agent; + @state() private agentInterface?: AgentInterface; + @state() private artifactsPanel?: ArtifactsPanel; @state() private hasArtifacts = false; @state() private artifactCount = 0; @state() private showArtifactsPanel = false; @state() private windowWidth = window.innerWidth; - @property({ type: String }) systemPrompt = "You are a helpful AI assistant."; - @property({ type: Array }) additionalTools: AgentTool[] = []; @property({ attribute: false }) sandboxUrlProvider?: () => string; @property({ attribute: false }) onApiKeyRequired?: (provider: string) => Promise; + @property({ attribute: false }) additionalTools?: any[]; private resizeHandler = () => { this.windowWidth = window.innerWidth; @@ -34,19 +33,33 @@ export class ChatPanel extends LitElement { return this; } - override async connectedCallback() { + override connectedCallback() { super.connectedCallback(); - - // Listen to window resize window.addEventListener("resize", this.resizeHandler); - - // Ensure panel fills height and allows flex layout this.style.display = "flex"; this.style.flexDirection = "column"; this.style.height = "100%"; this.style.minHeight = "0"; + } - // Create JavaScript REPL tool with attachments provider + override disconnectedCallback() { + super.disconnectedCallback(); + window.removeEventListener("resize", this.resizeHandler); + } + + async setAgent(agent: Agent) { + this.agent = agent; + + // Create AgentInterface + this.agentInterface = document.createElement("agent-interface") as AgentInterface; + this.agentInterface.session = agent; + this.agentInterface.enableAttachments = true; + this.agentInterface.enableModelSelector = true; + this.agentInterface.enableThinkingSelector = true; + this.agentInterface.showThemeToggle = false; + this.agentInterface.onApiKeyRequired = this.onApiKeyRequired; + + // Create JavaScript REPL tool const javascriptReplTool = createJavaScriptReplTool(); if (this.sandboxUrlProvider) { javascriptReplTool.sandboxUrlProvider = this.sandboxUrlProvider; @@ -59,11 +72,10 @@ export class ChatPanel extends LitElement { } registerToolRenderer("artifacts", this.artifactsPanel); - // Attachments provider for both REPL and artifacts + // Attachments provider const getAttachments = () => { - // Get all attachments from conversation messages const attachments: any[] = []; - for (const message of this.session.state.messages) { + for (const message of this.agent!.state.messages) { if (message.role === "user") { const content = Array.isArray(message.content) ? message.content : [message.content]; for (const block of content) { @@ -86,12 +98,10 @@ export class ChatPanel extends LitElement { this.artifactsPanel.attachmentsProvider = getAttachments; this.artifactsPanel.onArtifactsChange = () => { - const count = this.artifactsPanel.artifacts?.size ?? 0; + const count = this.artifactsPanel?.artifacts?.size ?? 0; const created = count > this.artifactCount; this.hasArtifacts = count > 0; this.artifactCount = count; - - // Auto-open when new artifacts are created if (this.hasArtifacts && created) { this.showArtifactsPanel = true; } @@ -108,48 +118,27 @@ export class ChatPanel extends LitElement { this.requestUpdate(); }; - const initialState = { - systemPrompt: this.systemPrompt, - model: getModel("anthropic", "claude-sonnet-4-5-20250929"), - tools: [...this.additionalTools, javascriptReplTool, this.artifactsPanel.tool], - thinkingLevel: "off" as ThinkingLevel, - messages: [], - } satisfies Partial; - // initialState = { ...initialState, ...(simpleHtml as any) }; - // initialState = { ...initialState, ...(longSession as any) }; + // Set tools on the agent + const tools = [javascriptReplTool, this.artifactsPanel.tool, ...(this.additionalTools || [])]; + this.agent.setTools(tools); - // Create agent session first so attachments provider works - this.session = new AgentSession({ - initialState, - authTokenProvider: async () => getAuthToken(), - transportMode: "provider", // Use provider mode by default (API keys from storage, optional CORS proxy) - }); + // Reconstruct artifacts from existing messages + // Temporarily disable the onArtifactsChange callback to prevent auto-opening on load + const originalCallback = this.artifactsPanel.onArtifactsChange; + this.artifactsPanel.onArtifactsChange = undefined; + await this.artifactsPanel.reconstructFromMessages(this.agent.state.messages); + this.artifactsPanel.onArtifactsChange = originalCallback; - // Reconstruct artifacts panel from initial messages (session must exist first) - await this.artifactsPanel.reconstructFromMessages(initialState.messages); this.hasArtifacts = this.artifactsPanel.artifacts.size > 0; - } + this.artifactCount = this.artifactsPanel.artifacts.size; - override disconnectedCallback() { - super.disconnectedCallback(); - window.removeEventListener("resize", this.resizeHandler); - } - - // Expose method to toggle artifacts panel - public toggleArtifactsPanel() { - this.showArtifactsPanel = !this.showArtifactsPanel; this.requestUpdate(); } - // Check if artifacts panel is currently visible - public get artifactsPanelVisible(): boolean { - return this.showArtifactsPanel; - } - render() { - if (!this.session) { + if (!this.agent || !this.agentInterface) { return html`
-
Loading...
+
No agent set
`; } @@ -164,15 +153,7 @@ export class ChatPanel extends LitElement { return html`
- + ${this.agentInterface}
diff --git a/packages/web-ui/src/state/agent-session.ts b/packages/web-ui/src/agent/agent.ts similarity index 79% rename from packages/web-ui/src/state/agent-session.ts rename to packages/web-ui/src/agent/agent.ts index f07a529a..aebfcd4f 100644 --- a/packages/web-ui/src/state/agent-session.ts +++ b/packages/web-ui/src/agent/agent.ts @@ -10,17 +10,14 @@ import { } from "@mariozechner/pi-ai"; import type { AppMessage } from "../components/Messages.js"; import type { Attachment } from "../utils/attachment-utils.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"; export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high"; -export interface AgentSessionState { - id: string; +export interface AgentState { systemPrompt: string; - model: Model | null; + model: Model; thinkingLevel: ThinkingLevel; tools: AgentTool[]; messages: AppMessage[]; @@ -30,24 +27,19 @@ export interface AgentSessionState { error?: string; } -export type AgentSessionEvent = - | { type: "state-update"; state: AgentSessionState } +export type AgentEvent = + | { type: "state-update"; state: AgentState } | { type: "error-no-model" } | { type: "error-no-api-key"; provider: string }; -export type TransportMode = "provider" | "app"; - -export interface AgentSessionOptions { - initialState?: Partial; - messagePreprocessor?: (messages: AppMessage[]) => Promise; +export interface AgentOptions { + initialState?: Partial; debugListener?: (entry: DebugLogEntry) => void; - transportMode?: TransportMode; - authTokenProvider?: () => Promise; + transport: AgentTransport; } -export class AgentSession { - private _state: AgentSessionState = { - id: "default", +export class Agent { + private _state: AgentState = { systemPrompt: "", model: getModel("google", "gemini-2.5-flash-lite-preview-06-17"), thinkingLevel: "off", @@ -58,42 +50,22 @@ export class AgentSession { pendingToolCalls: new Set(), error: undefined, }; - private listeners = new Set<(e: AgentSessionEvent) => void>(); + private listeners = new Set<(e: AgentEvent) => void>(); private abortController?: AbortController; private transport: AgentTransport; - private messagePreprocessor?: (messages: AppMessage[]) => Promise; private debugListener?: (entry: DebugLogEntry) => void; - constructor(opts: AgentSessionOptions = {}) { + constructor(opts: AgentOptions) { this._state = { ...this._state, ...opts.initialState }; - this.messagePreprocessor = opts.messagePreprocessor; this.debugListener = opts.debugListener; - - const mode = opts.transportMode || "provider"; - - if (mode === "app") { - this.transport = new AppTransport(async () => this.preprocessMessages()); - } else { - this.transport = new ProviderTransport(async () => this.preprocessMessages()); - } + this.transport = opts.transport; } - private async preprocessMessages(): Promise { - const filtered = this._state.messages.map((m) => { - if (m.role === "user") { - const { attachments, ...rest } = m as AppMessage & { attachments?: Attachment[] }; - return rest; - } - return m; - }); - return this.messagePreprocessor ? this.messagePreprocessor(filtered as AppMessage[]) : (filtered as Message[]); - } - - get state(): AgentSessionState { + get state(): AgentState { return this._state; } - subscribe(fn: (e: AgentSessionEvent) => void): () => void { + subscribe(fn: (e: AgentEvent) => void): () => void { this.listeners.add(fn); fn({ type: "state-update", state: this._state }); return () => this.listeners.delete(fn); @@ -103,7 +75,7 @@ export class AgentSession { setSystemPrompt(v: string) { this.patch({ systemPrompt: v }); } - setModel(m: Model | null) { + setModel(m: Model) { this.patch({ model: m }); } setThinkingLevel(l: ThinkingLevel) { @@ -175,7 +147,12 @@ export class AgentSession { let partial: Message | null = null; let turnDebug: DebugLogEntry | null = null; let turnStart = 0; - for await (const ev of this.transport.run(userMessage as Message, cfg, this.abortController.signal)) { + for await (const ev of this.transport.run( + this._state.messages as Message[], + userMessage as Message, + cfg, + this.abortController.signal, + )) { switch (ev.type) { case "turn_start": { turnStart = performance.now(); @@ -298,12 +275,12 @@ export class AgentSession { } } - private patch(p: Partial): void { + private patch(p: Partial): void { this._state = { ...this._state, ...p }; this.emit({ type: "state-update", state: this._state }); } - private emit(e: AgentSessionEvent) { + private emit(e: AgentEvent) { for (const listener of this.listeners) { listener(e); } diff --git a/packages/web-ui/src/state/transports/AppTransport.ts b/packages/web-ui/src/agent/transports/AppTransport.ts similarity index 96% rename from packages/web-ui/src/state/transports/AppTransport.ts rename to packages/web-ui/src/agent/transports/AppTransport.ts index 09c026c9..58b5c0b3 100644 --- a/packages/web-ui/src/state/transports/AppTransport.ts +++ b/packages/web-ui/src/agent/transports/AppTransport.ts @@ -322,9 +322,7 @@ export class AppTransport implements AgentTransport { // Hardcoded proxy URL for now - will be made configurable later private readonly proxyUrl = "https://genai.mariozechner.at"; - constructor(private readonly getMessages: () => Promise) {} - - async *run(userMessage: Message, cfg: AgentRunConfig, signal?: AbortSignal) { + async *run(messages: Message[], userMessage: Message, cfg: AgentRunConfig, signal?: AbortSignal) { const authToken = await getAuthToken(); if (!authToken) { throw new Error(i18n("Auth token is required for proxy transport")); @@ -343,9 +341,18 @@ export class AppTransport implements AgentTransport { ); }; + // Filter out attachments from messages + const filteredMessages = messages.map((m) => { + if (m.role === "user") { + const { attachments, ...rest } = m as any; + return rest; + } + return m; + }); + const context: AgentContext = { systemPrompt: cfg.systemPrompt, - messages: await this.getMessages(), + messages: filteredMessages, tools: cfg.tools, }; diff --git a/packages/web-ui/src/state/transports/ProviderTransport.ts b/packages/web-ui/src/agent/transports/ProviderTransport.ts similarity index 80% rename from packages/web-ui/src/state/transports/ProviderTransport.ts rename to packages/web-ui/src/agent/transports/ProviderTransport.ts index 8ed13fcf..58861399 100644 --- a/packages/web-ui/src/state/transports/ProviderTransport.ts +++ b/packages/web-ui/src/agent/transports/ProviderTransport.ts @@ -7,9 +7,7 @@ import type { AgentRunConfig, AgentTransport } from "./types.js"; * Optionally routes calls through a CORS proxy if enabled in settings. */ export class ProviderTransport implements AgentTransport { - constructor(private readonly getMessages: () => Promise) {} - - async *run(userMessage: Message, cfg: AgentRunConfig, signal?: AbortSignal) { + async *run(messages: Message[], userMessage: Message, cfg: AgentRunConfig, signal?: AbortSignal) { // Get API key from storage const apiKey = await getAppStorage().providerKeys.getKey(cfg.model.provider); if (!apiKey) { @@ -29,9 +27,18 @@ export class ProviderTransport implements AgentTransport { }; } + // Filter out attachments from messages + const filteredMessages = messages.map((m) => { + if (m.role === "user") { + const { attachments, ...rest } = m as any; + return rest; + } + return m; + }); + const context: AgentContext = { systemPrompt: cfg.systemPrompt, - messages: await this.getMessages(), + messages: filteredMessages, tools: cfg.tools, }; diff --git a/packages/web-ui/src/state/transports/index.ts b/packages/web-ui/src/agent/transports/index.ts similarity index 100% rename from packages/web-ui/src/state/transports/index.ts rename to packages/web-ui/src/agent/transports/index.ts diff --git a/packages/web-ui/src/state/transports/proxy-types.ts b/packages/web-ui/src/agent/transports/proxy-types.ts similarity index 100% rename from packages/web-ui/src/state/transports/proxy-types.ts rename to packages/web-ui/src/agent/transports/proxy-types.ts diff --git a/packages/web-ui/src/state/transports/types.ts b/packages/web-ui/src/agent/transports/types.ts similarity index 73% rename from packages/web-ui/src/state/transports/types.ts rename to packages/web-ui/src/agent/transports/types.ts index 8f432ce6..099a5480 100644 --- a/packages/web-ui/src/state/transports/types.ts +++ b/packages/web-ui/src/agent/transports/types.ts @@ -12,5 +12,10 @@ export interface AgentRunConfig { // We re-export the Message type above; consumers should use the upstream AgentEvent type. export interface AgentTransport { - run(userMessage: Message, config: AgentRunConfig, signal?: AbortSignal): AsyncIterable; // passthrough of AgentEvent from upstream + run( + messages: Message[], + userMessage: Message, + config: AgentRunConfig, + signal?: AbortSignal, + ): AsyncIterable; // passthrough of AgentEvent from upstream } diff --git a/packages/web-ui/src/state/types.ts b/packages/web-ui/src/agent/types.ts similarity index 100% rename from packages/web-ui/src/state/types.ts rename to packages/web-ui/src/agent/types.ts diff --git a/packages/web-ui/src/components/AgentInterface.ts b/packages/web-ui/src/components/AgentInterface.ts index 4fa10223..2998ecc2 100644 --- a/packages/web-ui/src/components/AgentInterface.ts +++ b/packages/web-ui/src/components/AgentInterface.ts @@ -7,7 +7,7 @@ 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 type { Agent, AgentEvent } from "../agent/agent.js"; import { getAppStorage } from "../storage/app-storage.js"; import "./StreamingMessageContainer.js"; import type { Attachment } from "../utils/attachment-utils.js"; @@ -18,7 +18,7 @@ import type { StreamingMessageContainer } from "./StreamingMessageContainer.js"; @customElement("agent-interface") export class AgentInterface extends LitElement { // Optional external session: when provided, this component becomes a view over the session - @property({ attribute: false }) session?: AgentSession; + @property({ attribute: false }) session?: Agent; @property() enableAttachments = true; @property() enableModelSelector = true; @property() enableThinkingSelector = true; @@ -52,6 +52,15 @@ export class AgentInterface extends LitElement { return this; } + override willUpdate(changedProperties: Map) { + super.willUpdate(changedProperties); + + // Re-subscribe when session property changes + if (changedProperties.has("session")) { + this.setupSessionSubscription(); + } + } + override async connectedCallback() { super.connectedCallback(); @@ -84,11 +93,6 @@ export class AgentInterface extends LitElement { // Subscribe to external session if provided this.setupSessionSubscription(); - - // Attach debug listener if session provided - if (this.session) { - this.session = this.session; // explicitly set to trigger subscription - } } override disconnectedCallback() { @@ -116,7 +120,7 @@ export class AgentInterface extends LitElement { this._unsubscribeSession = undefined; } if (!this.session) return; - this._unsubscribeSession = this.session.subscribe(async (ev: AgentSessionEvent) => { + this._unsubscribeSession = this.session.subscribe(async (ev: AgentEvent) => { if (ev.type === "state-update") { if (this._streamingContainer) { this._streamingContainer.isStreaming = ev.state.isStreaming; diff --git a/packages/web-ui/src/components/MessageEditor.ts b/packages/web-ui/src/components/MessageEditor.ts index aba4633d..c96df100 100644 --- a/packages/web-ui/src/components/MessageEditor.ts +++ b/packages/web-ui/src/components/MessageEditor.ts @@ -50,6 +50,7 @@ export class MessageEditor extends LitElement { "image/*,application/pdf,.docx,.pptx,.xlsx,.xls,.txt,.md,.json,.xml,.html,.css,.js,.ts,.jsx,.tsx,.yml,.yaml"; @state() processingFiles = false; + @state() isDragging = false; private fileInputRef = createRef(); protected override createRenderRoot(): HTMLElement | DocumentFragment { @@ -124,6 +125,62 @@ export class MessageEditor extends LitElement { this.onFilesChange?.(this.attachments); } + private handleDragOver = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!this.isDragging) { + this.isDragging = true; + } + }; + + private handleDragLeave = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + // Only set isDragging to false if we're leaving the entire component + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + const x = e.clientX; + const y = e.clientY; + if (x <= rect.left || x >= rect.right || y <= rect.top || y >= rect.bottom) { + this.isDragging = false; + } + }; + + private handleDrop = async (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + this.isDragging = false; + + const files = Array.from(e.dataTransfer?.files || []); + if (files.length === 0) return; + + if (files.length + this.attachments.length > this.maxFiles) { + alert(`Maximum ${this.maxFiles} files allowed`); + return; + } + + this.processingFiles = true; + const newAttachments: Attachment[] = []; + + for (const file of files) { + try { + if (file.size > this.maxFileSize) { + alert(`${file.name} exceeds maximum size of ${Math.round(this.maxFileSize / 1024 / 1024)}MB`); + continue; + } + + const attachment = await loadAttachment(file); + newAttachments.push(attachment); + } catch (error) { + console.error(`Error processing ${file.name}:`, error); + alert(`Failed to process ${file.name}: ${String(error)}`); + } + } + + this.attachments = [...this.attachments, ...newAttachments]; + this.onFilesChange?.(this.attachments); + this.processingFiles = false; + }; + private adjustTextareaHeight() { const textarea = this.textareaRef.value; if (textarea) { @@ -157,7 +214,23 @@ export class MessageEditor extends LitElement { const supportsThinking = model?.reasoning === true; // Models with reasoning:true support thinking return html` -
+
+ + ${ + this.isDragging + ? html` +
+
${i18n("Drop files here")}
+
+ ` + : "" + } + ${ this.attachments.length > 0 diff --git a/packages/web-ui/src/dialogs/ApiKeyPromptDialog.ts b/packages/web-ui/src/dialogs/ApiKeyPromptDialog.ts index d41e717d..6f450e4c 100644 --- a/packages/web-ui/src/dialogs/ApiKeyPromptDialog.ts +++ b/packages/web-ui/src/dialogs/ApiKeyPromptDialog.ts @@ -64,11 +64,8 @@ export class ApiKeyPromptDialog extends DialogBase { children: html` ${DialogHeader({ title: i18n("API Key Required"), - description: i18n("Enter your API key for {provider}").replace("{provider}", this.provider), })} -
- -
+ `, })} `; diff --git a/packages/web-ui/src/dialogs/PersistentStorageDialog.ts b/packages/web-ui/src/dialogs/PersistentStorageDialog.ts new file mode 100644 index 00000000..e986888a --- /dev/null +++ b/packages/web-ui/src/dialogs/PersistentStorageDialog.ts @@ -0,0 +1,141 @@ +import { Button, DialogBase, DialogContent, DialogHeader, html } from "@mariozechner/mini-lit"; +import { customElement, state } from "lit/decorators.js"; +import { i18n } from "../utils/i18n.js"; + +@customElement("persistent-storage-dialog") +export class PersistentStorageDialog extends DialogBase { + @state() private requesting = false; + + private resolvePromise?: (userApproved: boolean) => void; + + protected modalWidth = "min(500px, 90vw)"; + protected modalHeight = "auto"; + + /** + * Request persistent storage permission. + * Returns true if browser granted persistent storage, false otherwise. + */ + static async request(): Promise { + // Check if already persisted + if (navigator.storage?.persisted) { + const alreadyPersisted = await navigator.storage.persisted(); + if (alreadyPersisted) { + console.log("✓ Persistent storage already granted"); + return true; + } + } + + // Show dialog and wait for user response + const dialog = new PersistentStorageDialog(); + dialog.open(); + + const userApproved = await new Promise((resolve) => { + dialog.resolvePromise = resolve; + }); + + if (!userApproved) { + console.warn("⚠ User declined persistent storage - sessions may be lost"); + return false; + } + + // User approved, request from browser + if (!navigator.storage?.persist) { + console.warn("⚠ Persistent storage API not available"); + return false; + } + + try { + const granted = await navigator.storage.persist(); + if (granted) { + console.log("✓ Persistent storage granted - sessions will be preserved"); + } else { + console.warn("⚠ Browser denied persistent storage - sessions may be lost under storage pressure"); + } + return granted; + } catch (error) { + console.error("Failed to request persistent storage:", error); + return false; + } + } + + private handleGrant() { + if (this.resolvePromise) { + this.resolvePromise(true); + this.resolvePromise = undefined; + } + this.close(); + } + + private handleDeny() { + if (this.resolvePromise) { + this.resolvePromise(false); + this.resolvePromise = undefined; + } + this.close(); + } + + override close() { + super.close(); + if (this.resolvePromise) { + this.resolvePromise(false); + } + } + + protected override renderContent() { + return html` + ${DialogContent({ + children: html` + ${DialogHeader({ + title: i18n("Storage Permission Required"), + description: i18n("This app needs persistent storage to save your conversations"), + })} + +
+
+
+ + + + + +
+
+

${i18n("Why is this needed?")}

+

+ ${i18n( + "Without persistent storage, your browser may delete saved conversations when it needs disk space. Granting this permission ensures your chat history is preserved.", + )} +

+
+
+ +
+

${i18n("What this means:")}

+
    +
  • ${i18n("Your conversations will be saved locally in your browser")}
  • +
  • ${i18n("Data will not be deleted automatically to free up space")}
  • +
  • ${i18n("You can still manually clear data at any time")}
  • +
  • ${i18n("No data is sent to external servers")}
  • +
+
+
+ +
+ ${Button({ + variant: "outline", + onClick: () => this.handleDeny(), + disabled: this.requesting, + children: i18n("Continue Anyway"), + })} + ${Button({ + variant: "default", + onClick: () => this.handleGrant(), + disabled: this.requesting, + children: this.requesting ? i18n("Requesting...") : i18n("Grant Permission"), + })} +
+ `, + })} + `; + } +} diff --git a/packages/web-ui/src/dialogs/SessionListDialog.ts b/packages/web-ui/src/dialogs/SessionListDialog.ts new file mode 100644 index 00000000..e5126521 --- /dev/null +++ b/packages/web-ui/src/dialogs/SessionListDialog.ts @@ -0,0 +1,135 @@ +import { DialogBase, DialogContent, DialogHeader, html } from "@mariozechner/mini-lit"; +import { customElement, state } from "lit/decorators.js"; +import { getAppStorage } from "../storage/app-storage.js"; +import type { SessionMetadata } from "../storage/types.js"; +import { formatUsage } from "../utils/format.js"; +import { i18n } from "../utils/i18n.js"; + +@customElement("session-list-dialog") +export class SessionListDialog extends DialogBase { + @state() private sessions: SessionMetadata[] = []; + @state() private loading = true; + + private onSelectCallback?: (sessionId: string) => void; + + protected modalWidth = "min(600px, 90vw)"; + protected modalHeight = "min(700px, 90vh)"; + + static async open(onSelect: (sessionId: string) => void) { + const dialog = new SessionListDialog(); + dialog.onSelectCallback = onSelect; + dialog.open(); + await dialog.loadSessions(); + } + + private async loadSessions() { + this.loading = true; + try { + const storage = getAppStorage(); + if (!storage.sessions) { + console.error("Session storage not available"); + this.sessions = []; + return; + } + + this.sessions = await storage.sessions.listSessions(); + } catch (err) { + console.error("Failed to load sessions:", err); + this.sessions = []; + } finally { + this.loading = false; + } + } + + private async handleDelete(sessionId: string, event: Event) { + event.stopPropagation(); + + if (!confirm(i18n("Delete this session?"))) { + return; + } + + try { + const storage = getAppStorage(); + if (!storage.sessions) return; + + await storage.sessions.deleteSession(sessionId); + await this.loadSessions(); + } catch (err) { + console.error("Failed to delete session:", err); + } + } + + private handleSelect(sessionId: string) { + if (this.onSelectCallback) { + this.onSelectCallback(sessionId); + } + this.close(); + } + + private formatDate(isoString: string): string { + const date = new Date(isoString); + const now = new Date(); + const diff = now.getTime() - date.getTime(); + const days = Math.floor(diff / (1000 * 60 * 60 * 24)); + + if (days === 0) { + return i18n("Today"); + } else if (days === 1) { + return i18n("Yesterday"); + } else if (days < 7) { + return i18n("{days} days ago").replace("{days}", days.toString()); + } else { + return date.toLocaleDateString(); + } + } + + protected override renderContent() { + return html` + ${DialogContent({ + className: "h-full flex flex-col", + children: html` + ${DialogHeader({ + title: i18n("Sessions"), + description: i18n("Load a previous conversation"), + })} + +
+ ${ + this.loading + ? html`
${i18n("Loading...")}
` + : this.sessions.length === 0 + ? html`
${i18n("No sessions yet")}
` + : this.sessions.map( + (session) => html` +
this.handleSelect(session.id)} + > +
+
${session.title}
+
${this.formatDate(session.lastModified)}
+
+ ${session.messageCount} ${i18n("messages")} · ${formatUsage(session.usage)} +
+
+ +
+ `, + ) + } +
+ `, + })} + `; + } +} diff --git a/packages/web-ui/src/index.ts b/packages/web-ui/src/index.ts index 2eca6545..1793ce02 100644 --- a/packages/web-ui/src/index.ts +++ b/packages/web-ui/src/index.ts @@ -1,6 +1,14 @@ // Main chat interface -export { ChatPanel } from "./ChatPanel.js"; +export type { AgentState, ThinkingLevel } from "./agent/agent.js"; +// State management +export { Agent } from "./agent/agent.js"; +// Transports +export { AppTransport } from "./agent/transports/AppTransport.js"; +export { ProviderTransport } from "./agent/transports/ProviderTransport.js"; +export type { ProxyAssistantMessageEvent } from "./agent/transports/proxy-types.js"; +export type { AgentRunConfig, AgentTransport } from "./agent/transports/types.js"; +export { ChatPanel } from "./ChatPanel.js"; // Components export { AgentInterface } from "./components/AgentInterface.js"; export { AttachmentTile } from "./components/AttachmentTile.js"; @@ -9,6 +17,7 @@ export { Input } from "./components/Input.js"; export { MessageEditor } from "./components/MessageEditor.js"; export { MessageList } from "./components/MessageList.js"; // Message components +export type { AppMessage } from "./components/Messages.js"; export { AssistantMessage, ToolMessage, UserMessage } from "./components/Messages.js"; export { type SandboxFile, @@ -21,24 +30,25 @@ export { ApiKeyPromptDialog } from "./dialogs/ApiKeyPromptDialog.js"; export { AttachmentOverlay } from "./dialogs/AttachmentOverlay.js"; // Dialogs export { ModelSelector } from "./dialogs/ModelSelector.js"; +export { PersistentStorageDialog } from "./dialogs/PersistentStorageDialog.js"; +export { SessionListDialog } from "./dialogs/SessionListDialog.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"; - -// Transports -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 { SessionIndexedDBBackend } from "./storage/backends/session-indexeddb-backend.js"; export { ProviderKeysRepository } from "./storage/repositories/provider-keys-repository.js"; +export { SessionRepository } from "./storage/repositories/session-repository.js"; export { SettingsRepository } from "./storage/repositories/settings-repository.js"; -export type { AppStorageConfig, StorageBackend } from "./storage/types.js"; +export type { + AppStorageConfig, + SessionData, + SessionMetadata, + SessionStorageBackend, + StorageBackend, +} from "./storage/types.js"; // Artifacts export { ArtifactElement } from "./tools/artifacts/ArtifactElement.js"; export { type Artifact, ArtifactsPanel, type ArtifactsParams } from "./tools/artifacts/artifacts.js"; diff --git a/packages/web-ui/src/storage/app-storage.ts b/packages/web-ui/src/storage/app-storage.ts index da1b3ed6..69d20ed6 100644 --- a/packages/web-ui/src/storage/app-storage.ts +++ b/packages/web-ui/src/storage/app-storage.ts @@ -1,5 +1,6 @@ import { LocalStorageBackend } from "./backends/local-storage-backend.js"; import { ProviderKeysRepository } from "./repositories/provider-keys-repository.js"; +import { SessionRepository } from "./repositories/session-repository.js"; import { SettingsRepository } from "./repositories/settings-repository.js"; import type { AppStorageConfig } from "./types.js"; @@ -10,6 +11,7 @@ import type { AppStorageConfig } from "./types.js"; export class AppStorage { readonly settings: SettingsRepository; readonly providerKeys: ProviderKeysRepository; + readonly sessions?: SessionRepository; constructor(config: AppStorageConfig = {}) { // Use LocalStorage with prefixes as defaults @@ -18,6 +20,11 @@ export class AppStorage { this.settings = new SettingsRepository(settingsBackend); this.providerKeys = new ProviderKeysRepository(providerKeysBackend); + + // Session storage is optional + if (config.sessions) { + this.sessions = new SessionRepository(config.sessions); + } } } diff --git a/packages/web-ui/src/storage/backends/session-indexeddb-backend.ts b/packages/web-ui/src/storage/backends/session-indexeddb-backend.ts new file mode 100644 index 00000000..1bf029e8 --- /dev/null +++ b/packages/web-ui/src/storage/backends/session-indexeddb-backend.ts @@ -0,0 +1,200 @@ +import type { SessionData, SessionMetadata, SessionStorageBackend } from "../types.js"; + +/** + * IndexedDB implementation of session storage. + * Uses two object stores: + * - "metadata": Fast access for listing/searching + * - "data": Full session data loaded on demand + */ +export class SessionIndexedDBBackend implements SessionStorageBackend { + private dbPromise: Promise | null = null; + private readonly DB_NAME: string; + private readonly DB_VERSION = 1; + + constructor(dbName = "pi-sessions") { + this.DB_NAME = dbName; + } + + private async getDB(): Promise { + if (this.dbPromise) { + return this.dbPromise; + } + + this.dbPromise = new Promise((resolve, reject) => { + const request = indexedDB.open(this.DB_NAME, this.DB_VERSION); + + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + + // Object store for metadata (lightweight, frequently accessed) + if (!db.objectStoreNames.contains("metadata")) { + const metaStore = db.createObjectStore("metadata", { keyPath: "id" }); + // Index for sorting by last modified + metaStore.createIndex("lastModified", "lastModified", { unique: false }); + } + + // Object store for full session data (heavy, rarely accessed) + if (!db.objectStoreNames.contains("data")) { + db.createObjectStore("data", { keyPath: "id" }); + } + }; + }); + + return this.dbPromise; + } + + async saveSession(data: SessionData, metadata: SessionMetadata): Promise { + const db = await this.getDB(); + + // Use transaction to ensure atomicity (both or neither) + return new Promise((resolve, reject) => { + const tx = db.transaction(["metadata", "data"], "readwrite"); + const metaStore = tx.objectStore("metadata"); + const dataStore = tx.objectStore("data"); + + // Save both in same transaction + const metaReq = metaStore.put(metadata); + const dataReq = dataStore.put(data); + + // Handle errors + metaReq.onerror = () => reject(metaReq.error); + dataReq.onerror = () => reject(dataReq.error); + + // Transaction complete = both saved + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } + + async getSession(id: string): Promise { + const db = await this.getDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction("data", "readonly"); + const store = tx.objectStore("data"); + const request = store.get(id); + + request.onerror = () => reject(request.error); + request.onsuccess = () => { + resolve(request.result !== undefined ? (request.result as SessionData) : null); + }; + }); + } + + async getMetadata(id: string): Promise { + const db = await this.getDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction("metadata", "readonly"); + const store = tx.objectStore("metadata"); + const request = store.get(id); + + request.onerror = () => reject(request.error); + request.onsuccess = () => { + resolve(request.result !== undefined ? (request.result as SessionMetadata) : null); + }; + }); + } + + async getAllMetadata(): Promise { + const db = await this.getDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction("metadata", "readonly"); + const store = tx.objectStore("metadata"); + const request = store.getAll(); + + request.onerror = () => reject(request.error); + request.onsuccess = () => { + resolve(request.result as SessionMetadata[]); + }; + }); + } + + async deleteSession(id: string): Promise { + const db = await this.getDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(["metadata", "data"], "readwrite"); + const metaStore = tx.objectStore("metadata"); + const dataStore = tx.objectStore("data"); + + // Delete both in transaction + const metaReq = metaStore.delete(id); + const dataReq = dataStore.delete(id); + + metaReq.onerror = () => reject(metaReq.error); + dataReq.onerror = () => reject(dataReq.error); + + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } + + async updateTitle(id: string, title: string): Promise { + const db = await this.getDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(["metadata", "data"], "readwrite"); + + // Update metadata + const metaStore = tx.objectStore("metadata"); + const metaReq = metaStore.get(id); + + metaReq.onsuccess = () => { + const metadata = metaReq.result as SessionMetadata; + if (!metadata) { + reject(new Error(`Session ${id} not found`)); + return; + } + metadata.title = title; + metadata.lastModified = new Date().toISOString(); + metaStore.put(metadata); + }; + + // Update data + const dataStore = tx.objectStore("data"); + const dataReq = dataStore.get(id); + + dataReq.onsuccess = () => { + const data = dataReq.result as SessionData; + if (!data) { + reject(new Error(`Session ${id} not found`)); + return; + } + data.title = title; + data.lastModified = new Date().toISOString(); + dataStore.put(data); + }; + + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } + + async getQuotaInfo(): Promise<{ usage: number; quota: number; percent: number }> { + if (!navigator.storage || !navigator.storage.estimate) { + return { usage: 0, quota: 0, percent: 0 }; + } + + const estimate = await navigator.storage.estimate(); + const usage = estimate.usage || 0; + const quota = estimate.quota || 0; + const percent = quota > 0 ? (usage / quota) * 100 : 0; + + return { usage, quota, percent }; + } + + async requestPersistence(): Promise { + if (!navigator.storage || !navigator.storage.persist) { + return false; + } + + // Check if already persistent + const isPersisted = await navigator.storage.persisted(); + if (isPersisted) { + return true; + } + + // Request persistence + return await navigator.storage.persist(); + } +} diff --git a/packages/web-ui/src/storage/repositories/session-repository.ts b/packages/web-ui/src/storage/repositories/session-repository.ts new file mode 100644 index 00000000..8923ed50 --- /dev/null +++ b/packages/web-ui/src/storage/repositories/session-repository.ts @@ -0,0 +1,290 @@ +import type { AgentState } from "../../agent/agent.js"; +import type { AppMessage } from "../../components/Messages.js"; +import type { SessionData, SessionMetadata, SessionStorageBackend } from "../types.js"; + +/** + * Repository for managing chat sessions. + * Handles business logic: title generation, metadata extraction, etc. + */ +export class SessionRepository { + constructor(public backend: SessionStorageBackend) {} + + /** + * Generate a title from the first user message. + * Takes first sentence or 50 chars, whichever is shorter. + */ + private generateTitle(messages: AppMessage[]): string { + const firstUserMsg = messages.find((m) => m.role === "user"); + if (!firstUserMsg) return "New Session"; + + // Extract text content + const content = firstUserMsg.content; + let text = ""; + + if (typeof content === "string") { + text = content; + } else { + const textBlocks = content.filter((c) => c.type === "text"); + text = textBlocks.map((c) => (c as any).text || "").join(" "); + } + + text = text.trim(); + if (!text) return "New Session"; + + // Find first sentence (up to 50 chars) + const sentenceEnd = text.search(/[.!?]/); + if (sentenceEnd > 0 && sentenceEnd <= 50) { + return text.substring(0, sentenceEnd + 1); + } + + // Otherwise take first 50 chars + return text.length <= 50 ? text : text.substring(0, 47) + "..."; + } + + /** + * Extract preview text from messages. + * Goes through all messages in sequence, extracts text content only + * (excludes tool calls, tool results, thinking blocks), until 2KB. + */ + private extractPreview(messages: AppMessage[]): string { + let preview = ""; + const MAX_SIZE = 2048; // 2KB total + + for (const msg of messages) { + // Skip tool result messages entirely + if (msg.role === "toolResult") { + continue; + } + + // UserMessage can have string or array content + if (msg.role === "user") { + const content = msg.content; + + if (typeof content === "string") { + // Simple string content + if (preview.length + content.length <= MAX_SIZE) { + preview += content + " "; + } else { + preview += content.substring(0, MAX_SIZE - preview.length); + return preview.trim(); + } + } else { + // Array of TextContent | ImageContent + const textBlocks = content.filter((c) => c.type === "text"); + for (const block of textBlocks) { + const text = (block as any).text || ""; + if (preview.length + text.length <= MAX_SIZE) { + preview += text + " "; + } else { + preview += text.substring(0, MAX_SIZE - preview.length); + return preview.trim(); + } + } + } + } + + // AssistantMessage has array of TextContent | ThinkingContent | ToolCall + if (msg.role === "assistant") { + // Filter to only TextContent (skip ThinkingContent and ToolCall) + const textBlocks = msg.content.filter((c) => c.type === "text"); + for (const block of textBlocks) { + const text = (block as any).text || ""; + if (preview.length + text.length <= MAX_SIZE) { + preview += text + " "; + } else { + preview += text.substring(0, MAX_SIZE - preview.length); + return preview.trim(); + } + } + } + + // Stop if we've hit the limit + if (preview.length >= MAX_SIZE) { + break; + } + } + + return preview.trim(); + } + + /** + * Calculate total usage across all messages. + */ + private calculateTotals(messages: AppMessage[]): { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + cost: { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + total: number; + }; + } { + let input = 0; + let output = 0; + let cacheRead = 0; + let cacheWrite = 0; + const cost = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }; + + for (const msg of messages) { + if (msg.role === "assistant" && (msg as any).usage) { + const usage = (msg as any).usage; + input += usage.input || 0; + output += usage.output || 0; + cacheRead += usage.cacheRead || 0; + cacheWrite += usage.cacheWrite || 0; + if (usage.cost) { + cost.input += usage.cost.input || 0; + cost.output += usage.cost.output || 0; + cost.cacheRead += usage.cost.cacheRead || 0; + cost.cacheWrite += usage.cost.cacheWrite || 0; + cost.total += usage.cost.total || 0; + } + } + } + + return { input, output, cacheRead, cacheWrite, cost }; + } + + /** + * Extract metadata from session data. + */ + private extractMetadata(data: SessionData): SessionMetadata { + const usage = this.calculateTotals(data.messages); + const preview = this.extractPreview(data.messages); + + return { + id: data.id, + title: data.title, + createdAt: data.createdAt, + lastModified: data.lastModified, + messageCount: data.messages.length, + usage, + modelId: data.model?.id || null, + thinkingLevel: data.thinkingLevel, + preview, + }; + } + + /** + * Save session state. + * Extracts metadata and saves both atomically. + */ + async saveSession( + sessionId: string, + state: AgentState, + existingCreatedAt?: string, + existingTitle?: string, + ): Promise { + const now = new Date().toISOString(); + + const data: SessionData = { + id: sessionId, + title: existingTitle || this.generateTitle(state.messages), + model: state.model, + thinkingLevel: state.thinkingLevel, + messages: state.messages, + createdAt: existingCreatedAt || now, + lastModified: now, + }; + + const metadata = this.extractMetadata(data); + + await this.backend.saveSession(data, metadata); + } + + /** + * Load full session data by ID. + */ + async loadSession(id: string): Promise { + return this.backend.getSession(id); + } + + /** + * Get all session metadata, sorted by lastModified descending. + */ + async listSessions(): Promise { + const allMetadata = await this.backend.getAllMetadata(); + // Sort by lastModified descending (most recent first) + return allMetadata.sort((a, b) => { + return new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime(); + }); + } + + /** + * Get the ID of the most recently modified session. + * Returns undefined if no sessions exist. + */ + async getLatestSessionId(): Promise { + const sessions = await this.listSessions(); + return sessions.length > 0 ? sessions[0].id : undefined; + } + + /** + * Search sessions by keyword. + * Searches in: title and preview (first 2KB of conversation text) + * Returns results sorted by relevance (uses simple substring search for now). + */ + async searchSessions(query: string): Promise { + if (!query.trim()) { + return this.listSessions(); + } + + const allMetadata = await this.backend.getAllMetadata(); + + // Simple substring search for now (can upgrade to Fuse.js later) + const lowerQuery = query.toLowerCase(); + const matches = allMetadata.filter((meta) => { + return meta.title.toLowerCase().includes(lowerQuery) || meta.preview.toLowerCase().includes(lowerQuery); + }); + + // Sort by lastModified descending + return matches.sort((a, b) => { + return new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime(); + }); + } + + /** + * Get session metadata by ID. + */ + async getMetadata(id: string): Promise { + return this.backend.getMetadata(id); + } + + /** + * Delete a session. + */ + async deleteSession(id: string): Promise { + await this.backend.deleteSession(id); + } + + /** + * Update session title. + */ + async updateTitle(id: string, title: string): Promise { + await this.backend.updateTitle(id, title); + } + + /** + * Get storage quota information. + */ + async getQuotaInfo(): Promise<{ usage: number; quota: number; percent: number }> { + return this.backend.getQuotaInfo(); + } + + /** + * Request persistent storage. + */ + async requestPersistence(): Promise { + return this.backend.requestPersistence(); + } +} diff --git a/packages/web-ui/src/storage/types.ts b/packages/web-ui/src/storage/types.ts index d89bd628..8a54da44 100644 --- a/packages/web-ui/src/storage/types.ts +++ b/packages/web-ui/src/storage/types.ts @@ -1,3 +1,7 @@ +import type { Model } from "@mariozechner/pi-ai"; +import type { ThinkingLevel } from "../agent/agent.js"; +import type { AppMessage } from "../components/Messages.js"; + /** * Base interface for all storage backends. * Provides a simple key-value storage abstraction that can be implemented @@ -35,6 +39,141 @@ export interface StorageBackend { has(key: string): Promise; } +/** + * Lightweight session metadata for listing and searching. + * Stored separately from full session data for performance. + */ +export interface SessionMetadata { + /** Unique session identifier (UUID v4) */ + id: string; + + /** User-defined title or auto-generated from first message */ + title: string; + + /** ISO 8601 UTC timestamp of creation */ + createdAt: string; + + /** ISO 8601 UTC timestamp of last modification */ + lastModified: string; + + /** Total number of messages (user + assistant + tool results) */ + messageCount: number; + + /** Cumulative usage statistics */ + usage: { + /** Total input tokens */ + input: number; + /** Total output tokens */ + output: number; + /** Total cache read tokens */ + cacheRead: number; + /** Total cache write tokens */ + cacheWrite: number; + /** Total cost breakdown */ + cost: { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + total: number; + }; + }; + + /** Last used model ID (e.g., "claude-sonnet-4") */ + modelId: string | null; + + /** Last used thinking level */ + thinkingLevel: ThinkingLevel; + + /** + * Preview text for search and display. + * First 2KB of conversation text (user + assistant messages in sequence). + * Tool calls and tool results are excluded. + */ + preview: string; +} + +/** + * Full session data including all messages. + * Only loaded when user opens a specific session. + */ +export interface SessionData { + /** Unique session identifier (UUID v4) */ + id: string; + + /** User-defined title or auto-generated from first message */ + title: string; + + /** Last selected model */ + model: Model; + + /** Last selected thinking level */ + thinkingLevel: ThinkingLevel; + + /** Full conversation history (with attachments inline) */ + messages: AppMessage[]; + + /** ISO 8601 UTC timestamp of creation */ + createdAt: string; + + /** ISO 8601 UTC timestamp of last modification */ + lastModified: string; +} + +/** + * Backend interface for session storage. + * Implementations: IndexedDB (browser/extension), VSCode global state, etc. + */ +export interface SessionStorageBackend { + /** + * Save both session data and metadata atomically. + * Should use transactions to ensure consistency. + */ + saveSession(data: SessionData, metadata: SessionMetadata): Promise; + + /** + * Get full session data by ID. + * Returns null if session doesn't exist. + */ + getSession(id: string): Promise; + + /** + * Get session metadata by ID. + * Returns null if session doesn't exist. + */ + getMetadata(id: string): Promise; + + /** + * Get all session metadata (for listing/searching). + * Should be efficient - metadata is small (~2KB each). + */ + getAllMetadata(): Promise; + + /** + * Delete a session (both data and metadata). + * Should use transactions to ensure both are deleted. + */ + deleteSession(id: string): Promise; + + /** + * Update session title (in both data and metadata). + * Optimized operation - no need to save full session. + */ + updateTitle(id: string, title: string): Promise; + + /** + * Get storage quota information. + * Used for warning users when approaching limits. + */ + getQuotaInfo(): Promise<{ usage: number; quota: number; percent: number }>; + + /** + * Request persistent storage (prevents eviction). + * Returns true if granted, false otherwise. + */ + requestPersistence(): Promise; +} + /** * Options for configuring AppStorage. */ @@ -43,6 +182,6 @@ export interface AppStorageConfig { settings?: StorageBackend; /** Backend for provider API keys */ providerKeys?: StorageBackend; - /** Backend for sessions (chat history, attachments) */ - sessions?: StorageBackend; + /** Backend for sessions (optional - can be undefined if persistence not needed) */ + sessions?: SessionStorageBackend; } diff --git a/packages/web-ui/src/utils/i18n.ts b/packages/web-ui/src/utils/i18n.ts index 9cc2e56c..279efc0d 100644 --- a/packages/web-ui/src/utils/i18n.ts +++ b/packages/web-ui/src/utils/i18n.ts @@ -113,6 +113,28 @@ declare module "@mariozechner/mini-lit" { Low: string; Medium: string; High: string; + "Storage Permission Required": string; + "This app needs persistent storage to save your conversations": string; + "Why is this needed?": string; + "Without persistent storage, your browser may delete saved conversations when it needs disk space. Granting this permission ensures your chat history is preserved.": string; + "What this means:": string; + "Your conversations will be saved locally in your browser": string; + "Data will not be deleted automatically to free up space": string; + "You can still manually clear data at any time": string; + "No data is sent to external servers": string; + "Continue Anyway": string; + "Requesting...": string; + "Grant Permission": string; + Sessions: string; + "Load a previous conversation": string; + "No sessions yet": string; + "Delete this session?": string; + Today: string; + Yesterday: string; + "{days} days ago": string; + messages: string; + tokens: string; + "Drop files here": string; } } @@ -203,7 +225,6 @@ const translations = { Create: "Create", Rewrite: "Rewrite", Get: "Get", - Delete: "Delete", "Get logs": "Get logs", "Show artifacts": "Show artifacts", "Close artifacts": "Close artifacts", @@ -233,6 +254,33 @@ const translations = { Low: "Low", Medium: "Medium", High: "High", + "Storage Permission Required": "Storage Permission Required", + "This app needs persistent storage to save your conversations": + "This app needs persistent storage to save your conversations", + "Why is this needed?": "Why is this needed?", + "Without persistent storage, your browser may delete saved conversations when it needs disk space. Granting this permission ensures your chat history is preserved.": + "Without persistent storage, your browser may delete saved conversations when it needs disk space. Granting this permission ensures your chat history is preserved.", + "What this means:": "What this means:", + "Your conversations will be saved locally in your browser": + "Your conversations will be saved locally in your browser", + "Data will not be deleted automatically to free up space": + "Data will not be deleted automatically to free up space", + "You can still manually clear data at any time": "You can still manually clear data at any time", + "No data is sent to external servers": "No data is sent to external servers", + "Continue Anyway": "Continue Anyway", + "Requesting...": "Requesting...", + "Grant Permission": "Grant Permission", + Sessions: "Sessions", + "Load a previous conversation": "Load a previous conversation", + "No sessions yet": "No sessions yet", + "Delete this session?": "Delete this session?", + Today: "Today", + Yesterday: "Yesterday", + "{days} days ago": "{days} days ago", + messages: "messages", + tokens: "tokens", + Delete: "Delete", + "Drop files here": "Drop files here", }, de: { ...defaultGerman, @@ -320,7 +368,6 @@ const translations = { Create: "Erstellen", Rewrite: "Überschreiben", Get: "Abrufen", - Delete: "Löschen", "Get logs": "Logs abrufen", "Show artifacts": "Artefakte anzeigen", "Close artifacts": "Artefakte schließen", @@ -350,6 +397,33 @@ const translations = { Low: "Niedrig", Medium: "Mittel", High: "Hoch", + "Storage Permission Required": "Speicherberechtigung erforderlich", + "This app needs persistent storage to save your conversations": + "Diese App benötigt dauerhaften Speicher, um Ihre Konversationen zu speichern", + "Why is this needed?": "Warum wird das benötigt?", + "Without persistent storage, your browser may delete saved conversations when it needs disk space. Granting this permission ensures your chat history is preserved.": + "Ohne dauerhaften Speicher kann Ihr Browser gespeicherte Konversationen löschen, wenn Speicherplatz benötigt wird. Diese Berechtigung stellt sicher, dass Ihr Chatverlauf erhalten bleibt.", + "What this means:": "Was das bedeutet:", + "Your conversations will be saved locally in your browser": + "Ihre Konversationen werden lokal in Ihrem Browser gespeichert", + "Data will not be deleted automatically to free up space": + "Daten werden nicht automatisch gelöscht, um Speicherplatz freizugeben", + "You can still manually clear data at any time": "Sie können Daten jederzeit manuell löschen", + "No data is sent to external servers": "Keine Daten werden an externe Server gesendet", + "Continue Anyway": "Trotzdem fortfahren", + "Requesting...": "Anfrage läuft...", + "Grant Permission": "Berechtigung erteilen", + Sessions: "Sitzungen", + "Load a previous conversation": "Frühere Konversation laden", + "No sessions yet": "Noch keine Sitzungen", + "Delete this session?": "Diese Sitzung löschen?", + Today: "Heute", + Yesterday: "Gestern", + "{days} days ago": "vor {days} Tagen", + messages: "Nachrichten", + tokens: "Tokens", + Delete: "Löschen", + "Drop files here": "Dateien hier ablegen", }, };