Refactor agent architecture and add session storage

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
This commit is contained in:
Mario Zechner 2025-10-06 12:47:52 +02:00
parent c18923a8c5
commit e5cf25a267
23 changed files with 1787 additions and 289 deletions

View file

@ -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`
<div class="flex items-center justify-between border-b border-border">
<div class="px-3 py-2">
<span class="text-sm font-semibold text-foreground">pi-ai</span>
</div>
<div class="flex items-center gap-1 px-2">
${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",
})}
<theme-toggle></theme-toggle>
${Button({
variant: "ghost",
size: "sm",
children: html`${icon(Settings, "sm")}`,
onClick: async () => {
SettingsDialog.open([new ApiKeysTab(), new ProxyTab()]);
},
})}
</div>
</div>
`;
}
}
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<boolean> {
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<AgentState>) => {
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`
<div class="w-full h-full flex flex-col bg-background text-foreground overflow-hidden">
<pi-chat-header class="shrink-0" .onNewSession=${() => this.handleNewSession()}></pi-chat-header>
<pi-chat-panel
class="flex-1 min-h-0"
.systemPrompt=${systemPrompt}
.additionalTools=${[browserJavaScriptTool]}
.sandboxUrlProvider=${getSandboxUrl}
.onApiKeyRequired=${(provider: string) => this.handleApiKeyRequired(provider)}
></pi-chat-panel>
<!-- Header -->
<div class="flex items-center justify-between border-b border-border shrink-0">
<div class="flex items-center gap-2 px-3 py-2">
${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`<div class="flex items-center gap-2">
${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();
}
},
})}
</div>`
: html`<button
class="px-2 py-1 text-xs text-foreground hover:bg-secondary rounded transition-colors truncate max-w-[150px]"
@click=${() => {
isEditingTitle = true;
renderApp();
requestAnimationFrame(() => {
const input = document.body.querySelector('input[type="text"]') as HTMLInputElement;
if (input) {
input.focus();
input.select();
}
});
}}
title="Click to edit title"
>
${currentTitle}
</button>`
: html`<span class="text-sm font-semibold text-foreground">pi-ai</span>`
}
</div>
<div class="flex items-center gap-1 px-2">
${Button({
variant: "ghost",
size: "sm",
children: icon(RefreshCw, "sm"),
onClick: () => window.location.reload(),
title: "Reload",
})}
<theme-toggle></theme-toggle>
${Button({
variant: "ghost",
size: "sm",
children: icon(Settings, "sm"),
onClick: () => SettingsDialog.open([new ApiKeysTab(), new ProxyTab()]),
title: "Settings",
})}
</div>
</div>
<!-- Chat Panel -->
${chatPanel}
</div>
`;
`;
render(appHtml, document.body);
};
// ============================================================================
// INIT
// ============================================================================
async function initApp() {
// Show loading
render(
html`
<div class="w-full h-full flex items-center justify-center bg-background text-foreground">
<div class="text-muted-foreground">Loading...</div>
</div>
`,
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`<pi-app></pi-app>`, document.body);
initApp();

View file

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

View file

@ -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<AgentState>) => {
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`
<div class="w-full h-screen flex flex-col bg-background text-foreground overflow-hidden">
<!-- Header -->
<div class="flex items-center justify-between border-b border-border shrink-0">
<div class="px-4 py-3">
<span class="text-base font-semibold text-foreground">Pi Web UI Example</span>
</div>
<div class="flex items-center gap-1 px-2">
<theme-toggle></theme-toggle>
${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`
<div class="w-full h-screen flex flex-col bg-background text-foreground overflow-hidden">
<!-- Header -->
<div class="flex items-center justify-between border-b border-border shrink-0">
<div class="flex items-center gap-2 px-4 py-">
${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`<div class="flex items-center gap-2">
${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();
}
},
})}
</div>`
: html`<button
class="px-2 py-1 text-sm text-foreground hover:bg-secondary rounded transition-colors"
@click=${() => {
isEditingTitle = true;
renderApp();
requestAnimationFrame(() => {
const input = app?.querySelector('input[type="text"]') as HTMLInputElement;
if (input) {
input.focus();
input.select();
}
});
}}
title="Click to edit title"
>
${currentTitle}
</button>`
: html`<span class="text-base font-semibold text-foreground">Pi Web UI Example</span>`}
</div>
<div class="flex items-center gap-1 px-2">
<theme-toggle></theme-toggle>
${Button({
variant: "ghost",
size: "sm",
children: icon(Settings, "sm"),
onClick: () => SettingsDialog.open([new ApiKeysTab(), new ProxyTab()]),
title: "Settings",
})}
</div>
</div>
<!-- Chat Panel -->
${chatPanel}
</div>
`;
<!-- Chat Panel -->
${chatPanel}
</div>
`;
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`
<div class="w-full h-screen flex items-center justify-center bg-background text-foreground">
<div class="text-muted-foreground">Loading...</div>
</div>
`,
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);
initApp();

View file

@ -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": {

View file

@ -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<any, any>[] = [];
@property({ attribute: false }) sandboxUrlProvider?: () => string;
@property({ attribute: false }) onApiKeyRequired?: (provider: string) => Promise<boolean>;
@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<AgentSessionState>;
// 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`<div class="flex items-center justify-center h-full">
<div class="text-muted-foreground">Loading...</div>
<div class="text-muted-foreground">No agent set</div>
</div>`;
}
@ -164,15 +153,7 @@ export class ChatPanel extends LitElement {
return html`
<div class="relative w-full h-full overflow-hidden flex">
<div class="h-full" style="${!isMobile && this.showArtifactsPanel && this.hasArtifacts ? "width: 50%;" : "width: 100%;"}">
<agent-interface
.session=${this.session}
.enableAttachments=${true}
.enableModelSelector=${true}
.showThinkingSelector=${true}
.showThemeToggle=${false}
.showDebugToggle=${false}
.onApiKeyRequired=${this.onApiKeyRequired}
></agent-interface>
${this.agentInterface}
</div>
<!-- Floating pill when artifacts exist and panel is collapsed -->

View file

@ -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<any> | null;
model: Model<any>;
thinkingLevel: ThinkingLevel;
tools: AgentTool<any>[];
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<AgentSessionState>;
messagePreprocessor?: (messages: AppMessage[]) => Promise<Message[]>;
export interface AgentOptions {
initialState?: Partial<AgentState>;
debugListener?: (entry: DebugLogEntry) => void;
transportMode?: TransportMode;
authTokenProvider?: () => Promise<string | undefined>;
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<string>(),
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<Message[]>;
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<Message[]> {
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<any> | null) {
setModel(m: Model<any>) {
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<AgentSessionState>): void {
private patch(p: Partial<AgentState>): 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);
}

View file

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

View file

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

View file

@ -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<AgentEvent>; // passthrough of AgentEvent from upstream
run(
messages: Message[],
userMessage: Message,
config: AgentRunConfig,
signal?: AbortSignal,
): AsyncIterable<AgentEvent>; // passthrough of AgentEvent from upstream
}

View file

@ -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<string, any>) {
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;

View file

@ -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<HTMLInputElement>();
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`
<div class="bg-card rounded-xl border border-border shadow-sm">
<div
class="bg-card rounded-xl border shadow-sm relative ${this.isDragging ? "border-primary border-2 bg-primary/5" : "border-border"}"
@dragover=${this.handleDragOver}
@dragleave=${this.handleDragLeave}
@drop=${this.handleDrop}
>
<!-- Drag overlay -->
${
this.isDragging
? html`
<div class="absolute inset-0 bg-primary/10 rounded-xl pointer-events-none z-10 flex items-center justify-center">
<div class="text-primary font-medium">${i18n("Drop files here")}</div>
</div>
`
: ""
}
<!-- Attachments -->
${
this.attachments.length > 0

View file

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

View file

@ -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<boolean> {
// 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<boolean>((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"),
})}
<div class="mt-4 flex flex-col gap-4">
<div class="flex gap-3 p-4 bg-warning/10 border border-warning/20 rounded-lg">
<div class="flex-shrink-0 text-warning">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
<line x1="12" y1="9" x2="12" y2="13"></line>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>
</div>
<div class="text-sm">
<p class="font-medium text-foreground mb-1">${i18n("Why is this needed?")}</p>
<p class="text-muted-foreground">
${i18n(
"Without persistent storage, your browser may delete saved conversations when it needs disk space. Granting this permission ensures your chat history is preserved.",
)}
</p>
</div>
</div>
<div class="text-sm text-muted-foreground">
<p class="mb-2">${i18n("What this means:")}</p>
<ul class="list-disc list-inside space-y-1 ml-2">
<li>${i18n("Your conversations will be saved locally in your browser")}</li>
<li>${i18n("Data will not be deleted automatically to free up space")}</li>
<li>${i18n("You can still manually clear data at any time")}</li>
<li>${i18n("No data is sent to external servers")}</li>
</ul>
</div>
</div>
<div class="mt-6 flex gap-3 justify-end">
${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"),
})}
</div>
`,
})}
`;
}
}

View file

@ -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"),
})}
<div class="flex-1 overflow-y-auto mt-4 space-y-2">
${
this.loading
? html`<div class="text-center py-8 text-muted-foreground">${i18n("Loading...")}</div>`
: this.sessions.length === 0
? html`<div class="text-center py-8 text-muted-foreground">${i18n("No sessions yet")}</div>`
: this.sessions.map(
(session) => html`
<div
class="group flex items-start gap-3 p-3 rounded-lg border border-border hover:bg-secondary/50 cursor-pointer transition-colors"
@click=${() => this.handleSelect(session.id)}
>
<div class="flex-1 min-w-0">
<div class="font-medium text-sm text-foreground truncate">${session.title}</div>
<div class="text-xs text-muted-foreground mt-1">${this.formatDate(session.lastModified)}</div>
<div class="text-xs text-muted-foreground mt-1">
${session.messageCount} ${i18n("messages")} · ${formatUsage(session.usage)}
</div>
</div>
<button
class="opacity-0 group-hover:opacity-100 p-1 rounded hover:bg-destructive/10 text-destructive transition-opacity"
@click=${(e: Event) => this.handleDelete(session.id, e)}
title=${i18n("Delete")}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18"></path>
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path>
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path>
</svg>
</button>
</div>
`,
)
}
</div>
`,
})}
`;
}
}

View file

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

View file

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

View file

@ -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<IDBDatabase> | null = null;
private readonly DB_NAME: string;
private readonly DB_VERSION = 1;
constructor(dbName = "pi-sessions") {
this.DB_NAME = dbName;
}
private async getDB(): Promise<IDBDatabase> {
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<void> {
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<SessionData | null> {
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<SessionMetadata | null> {
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<SessionMetadata[]> {
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<void> {
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<void> {
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<boolean> {
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();
}
}

View file

@ -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<void> {
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<SessionData | null> {
return this.backend.getSession(id);
}
/**
* Get all session metadata, sorted by lastModified descending.
*/
async listSessions(): Promise<SessionMetadata[]> {
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<string | undefined> {
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<SessionMetadata[]> {
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<SessionMetadata | null> {
return this.backend.getMetadata(id);
}
/**
* Delete a session.
*/
async deleteSession(id: string): Promise<void> {
await this.backend.deleteSession(id);
}
/**
* Update session title.
*/
async updateTitle(id: string, title: string): Promise<void> {
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<boolean> {
return this.backend.requestPersistence();
}
}

View file

@ -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<boolean>;
}
/**
* 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<any>;
/** 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<void>;
/**
* Get full session data by ID.
* Returns null if session doesn't exist.
*/
getSession(id: string): Promise<SessionData | null>;
/**
* Get session metadata by ID.
* Returns null if session doesn't exist.
*/
getMetadata(id: string): Promise<SessionMetadata | null>;
/**
* Get all session metadata (for listing/searching).
* Should be efficient - metadata is small (~2KB each).
*/
getAllMetadata(): Promise<SessionMetadata[]>;
/**
* Delete a session (both data and metadata).
* Should use transactions to ensure both are deleted.
*/
deleteSession(id: string): Promise<void>;
/**
* Update session title (in both data and metadata).
* Optimized operation - no need to save full session.
*/
updateTitle(id: string, title: string): Promise<void>;
/**
* 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<boolean>;
}
/**
* 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;
}

View file

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