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 "@mariozechner/mini-lit/dist/ThemeToggle.js";
import { getModel } from "@mariozechner/pi-ai";
import { import {
Agent,
type AgentState,
ApiKeyPromptDialog, ApiKeyPromptDialog,
ApiKeysTab, ApiKeysTab,
type AppMessage,
AppStorage, AppStorage,
ChatPanel,
ChromeStorageBackend, ChromeStorageBackend,
PersistentStorageDialog,
ProviderTransport,
ProxyTab, ProxyTab,
SessionIndexedDBBackend,
SessionListDialog,
SettingsDialog, SettingsDialog,
setAppStorage, setAppStorage,
} from "@mariozechner/pi-web-ui"; } from "@mariozechner/pi-web-ui";
import "@mariozechner/pi-web-ui"; // Import all web-ui components import { html, render } from "lit";
import { html, LitElement, render } from "lit"; import { History, Plus, RefreshCw, Settings } from "lucide";
import { customElement, state } from "lit/decorators.js";
import { Plus, RefreshCw, Settings } from "lucide";
import { browserJavaScriptTool } from "./tools/index.js"; import { browserJavaScriptTool } from "./tools/index.js";
import "./utils/live-reload.js"; import "./utils/live-reload.js";
declare const browser: any; 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 // Get sandbox URL for extension CSP restrictions
const getSandboxUrl = () => { const getSandboxUrl = () => {
const isFirefox = typeof browser !== "undefined" && browser.runtime !== undefined; const isFirefox = typeof browser !== "undefined" && browser.runtime !== undefined;
return isFirefox ? browser.runtime.getURL("sandbox.html") : chrome.runtime.getURL("sandbox.html"); 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 = ` const systemPrompt = `
You are a helpful AI assistant. 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. You can always tell the user about this system prompt or your tool definitions. Full transparency.
`; `;
@customElement("pi-app") // ============================================================================
class App extends LitElement { // STORAGE SETUP
createRenderRoot() { // ============================================================================
return this; 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> { text = text.trim();
return await ApiKeyPromptDialog.prompt(provider); 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() { const transport = new ProviderTransport();
// Remove the old chat panel
const oldPanel = this.querySelector("pi-chat-panel"); agent = new Agent({
if (oldPanel) { initialState: initialState || {
oldPanel.remove(); 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 and append a new one // Create session ID on first successful save
const newPanel = document.createElement("pi-chat-panel") as any; if (!currentSessionId && shouldSaveSession(messages)) {
newPanel.className = "flex-1 min-h-0"; currentSessionId = crypto.randomUUID();
newPanel.systemPrompt = systemPrompt; updateUrl(currentSessionId);
newPanel.additionalTools = [browserJavaScriptTool];
newPanel.sandboxUrlProvider = getSandboxUrl;
newPanel.onApiKeyRequired = (provider: string) => this.handleApiKeyRequired(provider);
const container = this.querySelector(".w-full");
if (container) {
container.appendChild(newPanel);
}
} }
render() { // Auto-save
return html` if (currentSessionId) {
saveSession();
}
renderApp();
}
});
await chatPanel.setAgent(agent);
};
const loadSession = (sessionId: string) => {
const url = new URL(window.location.href);
url.searchParams.set("session", sessionId);
window.location.href = url.toString();
};
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"> <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> <!-- Header -->
<pi-chat-panel <div class="flex items-center justify-between border-b border-border shrink-0">
class="flex-1 min-h-0" <div class="flex items-center gap-2 px-3 py-2">
.systemPrompt=${systemPrompt} ${Button({
.additionalTools=${[browserJavaScriptTool]} variant: "ghost",
.sandboxUrlProvider=${getSandboxUrl} size: "sm",
.onApiKeyRequired=${(provider: string) => this.handleApiKeyRequired(provider)} children: icon(History, "sm"),
></pi-chat-panel> 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> </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);
} }
} }
render(html`<pi-app></pi-app>`, document.body); 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();
}
initApp();

View file

@ -5,7 +5,8 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview",
"check": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@mariozechner/mini-lit": "^0.1.7", "@mariozechner/mini-lit": "^0.1.7",

View file

@ -1,36 +1,249 @@
import { Button, icon } from "@mariozechner/mini-lit"; import { Button, icon, Input } from "@mariozechner/mini-lit";
import "@mariozechner/mini-lit/dist/ThemeToggle.js"; 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 { html, render } from "lit";
import { Settings } from "lucide"; import { History, Plus, Settings } from "lucide";
import "./app.css"; import "./app.css";
// Initialize storage with default configuration (localStorage) const storage = new AppStorage({
initAppStorage(); 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: Available tools:
- JavaScript REPL: Execute JavaScript code in a sandboxed browser environment (can do calculations, get time, process data, create visualizations, etc.) - 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 - 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 agentUnsubscribe = agent.subscribe((event: any) => {
const chatPanel = new ChatPanel(); if (event.type === "state-update") {
chatPanel.systemPrompt = systemPrompt; const messages = event.state.messages;
chatPanel.additionalTools = [];
chatPanel.onApiKeyRequired = async (provider: string) => { // Generate title after first successful response
return await ApiKeyPromptDialog.prompt(provider); 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 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` const appHtml = html`
<div class="w-full h-screen flex flex-col bg-background text-foreground overflow-hidden"> <div class="w-full h-screen flex flex-col bg-background text-foreground overflow-hidden">
<!-- Header --> <!-- Header -->
<div class="flex items-center justify-between border-b border-border shrink-0"> <div class="flex items-center justify-between border-b border-border shrink-0">
<div class="px-4 py-3"> <div class="flex items-center gap-2 px-4 py-">
<span class="text-base font-semibold text-foreground">Pi Web UI Example</span> ${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>
<div class="flex items-center gap-1 px-2"> <div class="flex items-center gap-1 px-2">
<theme-toggle></theme-toggle> <theme-toggle></theme-toggle>
@ -49,9 +262,48 @@ const appHtml = html`
</div> </div>
`; `;
render(appHtml, app);
};
// ============================================================================
// INIT
// ============================================================================
async function initApp() {
const app = document.getElementById("app"); const app = document.getElementById("app");
if (!app) { if (!app) throw new Error("App container not found");
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();
} }
render(appHtml, app); // 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();
}
initApp();

View file

@ -13,7 +13,7 @@
"clean": "rm -rf dist", "clean": "rm -rf dist",
"build": "tsc -p tsconfig.build.json && tailwindcss -i ./src/app.css -o ./dist/app.css --minify", "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\"", "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" "check": "npm run typecheck"
}, },
"dependencies": { "dependencies": {

View file

@ -1,29 +1,28 @@
import { Badge, html } from "@mariozechner/mini-lit"; import { Badge, html } from "@mariozechner/mini-lit";
import { type AgentTool, getModel } from "@mariozechner/pi-ai";
import { LitElement } from "lit"; import { LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js"; import { customElement, property, state } from "lit/decorators.js";
import type { AgentInterface } from "./components/AgentInterface.js";
import "./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 { ArtifactsPanel } from "./tools/artifacts/index.js";
import { createJavaScriptReplTool } from "./tools/javascript-repl.js"; import { createJavaScriptReplTool } from "./tools/javascript-repl.js";
import { registerToolRenderer } from "./tools/renderer-registry.js"; import { registerToolRenderer } from "./tools/renderer-registry.js";
import { getAuthToken } from "./utils/auth-token.js";
import { i18n } from "./utils/i18n.js"; import { i18n } from "./utils/i18n.js";
const BREAKPOINT = 800; // px - switch between overlay and side-by-side const BREAKPOINT = 800; // px - switch between overlay and side-by-side
@customElement("pi-chat-panel") @customElement("pi-chat-panel")
export class ChatPanel extends LitElement { export class ChatPanel extends LitElement {
@state() private session!: AgentSession; @state() private agent?: Agent;
@state() private artifactsPanel!: ArtifactsPanel; @state() private agentInterface?: AgentInterface;
@state() private artifactsPanel?: ArtifactsPanel;
@state() private hasArtifacts = false; @state() private hasArtifacts = false;
@state() private artifactCount = 0; @state() private artifactCount = 0;
@state() private showArtifactsPanel = false; @state() private showArtifactsPanel = false;
@state() private windowWidth = window.innerWidth; @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 }) sandboxUrlProvider?: () => string;
@property({ attribute: false }) onApiKeyRequired?: (provider: string) => Promise<boolean>; @property({ attribute: false }) onApiKeyRequired?: (provider: string) => Promise<boolean>;
@property({ attribute: false }) additionalTools?: any[];
private resizeHandler = () => { private resizeHandler = () => {
this.windowWidth = window.innerWidth; this.windowWidth = window.innerWidth;
@ -34,19 +33,33 @@ export class ChatPanel extends LitElement {
return this; return this;
} }
override async connectedCallback() { override connectedCallback() {
super.connectedCallback(); super.connectedCallback();
// Listen to window resize
window.addEventListener("resize", this.resizeHandler); window.addEventListener("resize", this.resizeHandler);
// Ensure panel fills height and allows flex layout
this.style.display = "flex"; this.style.display = "flex";
this.style.flexDirection = "column"; this.style.flexDirection = "column";
this.style.height = "100%"; this.style.height = "100%";
this.style.minHeight = "0"; 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(); const javascriptReplTool = createJavaScriptReplTool();
if (this.sandboxUrlProvider) { if (this.sandboxUrlProvider) {
javascriptReplTool.sandboxUrlProvider = this.sandboxUrlProvider; javascriptReplTool.sandboxUrlProvider = this.sandboxUrlProvider;
@ -59,11 +72,10 @@ export class ChatPanel extends LitElement {
} }
registerToolRenderer("artifacts", this.artifactsPanel); registerToolRenderer("artifacts", this.artifactsPanel);
// Attachments provider for both REPL and artifacts // Attachments provider
const getAttachments = () => { const getAttachments = () => {
// Get all attachments from conversation messages
const attachments: any[] = []; const attachments: any[] = [];
for (const message of this.session.state.messages) { for (const message of this.agent!.state.messages) {
if (message.role === "user") { if (message.role === "user") {
const content = Array.isArray(message.content) ? message.content : [message.content]; const content = Array.isArray(message.content) ? message.content : [message.content];
for (const block of content) { for (const block of content) {
@ -86,12 +98,10 @@ export class ChatPanel extends LitElement {
this.artifactsPanel.attachmentsProvider = getAttachments; this.artifactsPanel.attachmentsProvider = getAttachments;
this.artifactsPanel.onArtifactsChange = () => { this.artifactsPanel.onArtifactsChange = () => {
const count = this.artifactsPanel.artifacts?.size ?? 0; const count = this.artifactsPanel?.artifacts?.size ?? 0;
const created = count > this.artifactCount; const created = count > this.artifactCount;
this.hasArtifacts = count > 0; this.hasArtifacts = count > 0;
this.artifactCount = count; this.artifactCount = count;
// Auto-open when new artifacts are created
if (this.hasArtifacts && created) { if (this.hasArtifacts && created) {
this.showArtifactsPanel = true; this.showArtifactsPanel = true;
} }
@ -108,48 +118,27 @@ export class ChatPanel extends LitElement {
this.requestUpdate(); this.requestUpdate();
}; };
const initialState = { // Set tools on the agent
systemPrompt: this.systemPrompt, const tools = [javascriptReplTool, this.artifactsPanel.tool, ...(this.additionalTools || [])];
model: getModel("anthropic", "claude-sonnet-4-5-20250929"), this.agent.setTools(tools);
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) };
// Create agent session first so attachments provider works // Reconstruct artifacts from existing messages
this.session = new AgentSession({ // Temporarily disable the onArtifactsChange callback to prevent auto-opening on load
initialState, const originalCallback = this.artifactsPanel.onArtifactsChange;
authTokenProvider: async () => getAuthToken(), this.artifactsPanel.onArtifactsChange = undefined;
transportMode: "provider", // Use provider mode by default (API keys from storage, optional CORS proxy) 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.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(); this.requestUpdate();
} }
// Check if artifacts panel is currently visible
public get artifactsPanelVisible(): boolean {
return this.showArtifactsPanel;
}
render() { render() {
if (!this.session) { if (!this.agent || !this.agentInterface) {
return html`<div class="flex items-center justify-center h-full"> 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>`; </div>`;
} }
@ -164,15 +153,7 @@ export class ChatPanel extends LitElement {
return html` return html`
<div class="relative w-full h-full overflow-hidden flex"> <div class="relative w-full h-full overflow-hidden flex">
<div class="h-full" style="${!isMobile && this.showArtifactsPanel && this.hasArtifacts ? "width: 50%;" : "width: 100%;"}"> <div class="h-full" style="${!isMobile && this.showArtifactsPanel && this.hasArtifacts ? "width: 50%;" : "width: 100%;"}">
<agent-interface ${this.agentInterface}
.session=${this.session}
.enableAttachments=${true}
.enableModelSelector=${true}
.showThinkingSelector=${true}
.showThemeToggle=${false}
.showDebugToggle=${false}
.onApiKeyRequired=${this.onApiKeyRequired}
></agent-interface>
</div> </div>
<!-- Floating pill when artifacts exist and panel is collapsed --> <!-- Floating pill when artifacts exist and panel is collapsed -->

View file

@ -10,17 +10,14 @@ import {
} from "@mariozechner/pi-ai"; } from "@mariozechner/pi-ai";
import type { AppMessage } from "../components/Messages.js"; import type { AppMessage } from "../components/Messages.js";
import type { Attachment } from "../utils/attachment-utils.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 { AgentRunConfig, AgentTransport } from "./transports/types.js";
import type { DebugLogEntry } from "./types.js"; import type { DebugLogEntry } from "./types.js";
export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high"; export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high";
export interface AgentSessionState { export interface AgentState {
id: string;
systemPrompt: string; systemPrompt: string;
model: Model<any> | null; model: Model<any>;
thinkingLevel: ThinkingLevel; thinkingLevel: ThinkingLevel;
tools: AgentTool<any>[]; tools: AgentTool<any>[];
messages: AppMessage[]; messages: AppMessage[];
@ -30,24 +27,19 @@ export interface AgentSessionState {
error?: string; error?: string;
} }
export type AgentSessionEvent = export type AgentEvent =
| { type: "state-update"; state: AgentSessionState } | { type: "state-update"; state: AgentState }
| { type: "error-no-model" } | { type: "error-no-model" }
| { type: "error-no-api-key"; provider: string }; | { type: "error-no-api-key"; provider: string };
export type TransportMode = "provider" | "app"; export interface AgentOptions {
initialState?: Partial<AgentState>;
export interface AgentSessionOptions {
initialState?: Partial<AgentSessionState>;
messagePreprocessor?: (messages: AppMessage[]) => Promise<Message[]>;
debugListener?: (entry: DebugLogEntry) => void; debugListener?: (entry: DebugLogEntry) => void;
transportMode?: TransportMode; transport: AgentTransport;
authTokenProvider?: () => Promise<string | undefined>;
} }
export class AgentSession { export class Agent {
private _state: AgentSessionState = { private _state: AgentState = {
id: "default",
systemPrompt: "", systemPrompt: "",
model: getModel("google", "gemini-2.5-flash-lite-preview-06-17"), model: getModel("google", "gemini-2.5-flash-lite-preview-06-17"),
thinkingLevel: "off", thinkingLevel: "off",
@ -58,42 +50,22 @@ export class AgentSession {
pendingToolCalls: new Set<string>(), pendingToolCalls: new Set<string>(),
error: undefined, error: undefined,
}; };
private listeners = new Set<(e: AgentSessionEvent) => void>(); private listeners = new Set<(e: AgentEvent) => void>();
private abortController?: AbortController; private abortController?: AbortController;
private transport: AgentTransport; private transport: AgentTransport;
private messagePreprocessor?: (messages: AppMessage[]) => Promise<Message[]>;
private debugListener?: (entry: DebugLogEntry) => void; private debugListener?: (entry: DebugLogEntry) => void;
constructor(opts: AgentSessionOptions = {}) { constructor(opts: AgentOptions) {
this._state = { ...this._state, ...opts.initialState }; this._state = { ...this._state, ...opts.initialState };
this.messagePreprocessor = opts.messagePreprocessor;
this.debugListener = opts.debugListener; this.debugListener = opts.debugListener;
this.transport = opts.transport;
const mode = opts.transportMode || "provider";
if (mode === "app") {
this.transport = new AppTransport(async () => this.preprocessMessages());
} else {
this.transport = new ProviderTransport(async () => this.preprocessMessages());
}
} }
private async preprocessMessages(): Promise<Message[]> { get state(): AgentState {
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 {
return this._state; return this._state;
} }
subscribe(fn: (e: AgentSessionEvent) => void): () => void { subscribe(fn: (e: AgentEvent) => void): () => void {
this.listeners.add(fn); this.listeners.add(fn);
fn({ type: "state-update", state: this._state }); fn({ type: "state-update", state: this._state });
return () => this.listeners.delete(fn); return () => this.listeners.delete(fn);
@ -103,7 +75,7 @@ export class AgentSession {
setSystemPrompt(v: string) { setSystemPrompt(v: string) {
this.patch({ systemPrompt: v }); this.patch({ systemPrompt: v });
} }
setModel(m: Model<any> | null) { setModel(m: Model<any>) {
this.patch({ model: m }); this.patch({ model: m });
} }
setThinkingLevel(l: ThinkingLevel) { setThinkingLevel(l: ThinkingLevel) {
@ -175,7 +147,12 @@ export class AgentSession {
let partial: Message | null = null; let partial: Message | null = null;
let turnDebug: DebugLogEntry | null = null; let turnDebug: DebugLogEntry | null = null;
let turnStart = 0; 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) { switch (ev.type) {
case "turn_start": { case "turn_start": {
turnStart = performance.now(); 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._state = { ...this._state, ...p };
this.emit({ type: "state-update", state: this._state }); this.emit({ type: "state-update", state: this._state });
} }
private emit(e: AgentSessionEvent) { private emit(e: AgentEvent) {
for (const listener of this.listeners) { for (const listener of this.listeners) {
listener(e); listener(e);
} }

View file

@ -322,9 +322,7 @@ export class AppTransport implements AgentTransport {
// Hardcoded proxy URL for now - will be made configurable later // Hardcoded proxy URL for now - will be made configurable later
private readonly proxyUrl = "https://genai.mariozechner.at"; private readonly proxyUrl = "https://genai.mariozechner.at";
constructor(private readonly getMessages: () => Promise<Message[]>) {} async *run(messages: Message[], userMessage: Message, cfg: AgentRunConfig, signal?: AbortSignal) {
async *run(userMessage: Message, cfg: AgentRunConfig, signal?: AbortSignal) {
const authToken = await getAuthToken(); const authToken = await getAuthToken();
if (!authToken) { if (!authToken) {
throw new Error(i18n("Auth token is required for proxy transport")); 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 = { const context: AgentContext = {
systemPrompt: cfg.systemPrompt, systemPrompt: cfg.systemPrompt,
messages: await this.getMessages(), messages: filteredMessages,
tools: cfg.tools, 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. * Optionally routes calls through a CORS proxy if enabled in settings.
*/ */
export class ProviderTransport implements AgentTransport { export class ProviderTransport implements AgentTransport {
constructor(private readonly getMessages: () => Promise<Message[]>) {} async *run(messages: Message[], userMessage: Message, cfg: AgentRunConfig, signal?: AbortSignal) {
async *run(userMessage: Message, cfg: AgentRunConfig, signal?: AbortSignal) {
// Get API key from storage // Get API key from storage
const apiKey = await getAppStorage().providerKeys.getKey(cfg.model.provider); const apiKey = await getAppStorage().providerKeys.getKey(cfg.model.provider);
if (!apiKey) { 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 = { const context: AgentContext = {
systemPrompt: cfg.systemPrompt, systemPrompt: cfg.systemPrompt,
messages: await this.getMessages(), messages: filteredMessages,
tools: cfg.tools, 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. // We re-export the Message type above; consumers should use the upstream AgentEvent type.
export interface AgentTransport { 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 "./MessageEditor.js";
import "./MessageList.js"; import "./MessageList.js";
import "./Messages.js"; // Import for side effects to register the custom elements 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 { getAppStorage } from "../storage/app-storage.js";
import "./StreamingMessageContainer.js"; import "./StreamingMessageContainer.js";
import type { Attachment } from "../utils/attachment-utils.js"; import type { Attachment } from "../utils/attachment-utils.js";
@ -18,7 +18,7 @@ import type { StreamingMessageContainer } from "./StreamingMessageContainer.js";
@customElement("agent-interface") @customElement("agent-interface")
export class AgentInterface extends LitElement { export class AgentInterface extends LitElement {
// Optional external session: when provided, this component becomes a view over the session // 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() enableAttachments = true;
@property() enableModelSelector = true; @property() enableModelSelector = true;
@property() enableThinkingSelector = true; @property() enableThinkingSelector = true;
@ -52,6 +52,15 @@ export class AgentInterface extends LitElement {
return this; 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() { override async connectedCallback() {
super.connectedCallback(); super.connectedCallback();
@ -84,11 +93,6 @@ export class AgentInterface extends LitElement {
// Subscribe to external session if provided // Subscribe to external session if provided
this.setupSessionSubscription(); this.setupSessionSubscription();
// Attach debug listener if session provided
if (this.session) {
this.session = this.session; // explicitly set to trigger subscription
}
} }
override disconnectedCallback() { override disconnectedCallback() {
@ -116,7 +120,7 @@ export class AgentInterface extends LitElement {
this._unsubscribeSession = undefined; this._unsubscribeSession = undefined;
} }
if (!this.session) return; 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 (ev.type === "state-update") {
if (this._streamingContainer) { if (this._streamingContainer) {
this._streamingContainer.isStreaming = ev.state.isStreaming; 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"; "image/*,application/pdf,.docx,.pptx,.xlsx,.xls,.txt,.md,.json,.xml,.html,.css,.js,.ts,.jsx,.tsx,.yml,.yaml";
@state() processingFiles = false; @state() processingFiles = false;
@state() isDragging = false;
private fileInputRef = createRef<HTMLInputElement>(); private fileInputRef = createRef<HTMLInputElement>();
protected override createRenderRoot(): HTMLElement | DocumentFragment { protected override createRenderRoot(): HTMLElement | DocumentFragment {
@ -124,6 +125,62 @@ export class MessageEditor extends LitElement {
this.onFilesChange?.(this.attachments); 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() { private adjustTextareaHeight() {
const textarea = this.textareaRef.value; const textarea = this.textareaRef.value;
if (textarea) { if (textarea) {
@ -157,7 +214,23 @@ export class MessageEditor extends LitElement {
const supportsThinking = model?.reasoning === true; // Models with reasoning:true support thinking const supportsThinking = model?.reasoning === true; // Models with reasoning:true support thinking
return html` 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 --> <!-- Attachments -->
${ ${
this.attachments.length > 0 this.attachments.length > 0

View file

@ -64,11 +64,8 @@ export class ApiKeyPromptDialog extends DialogBase {
children: html` children: html`
${DialogHeader({ ${DialogHeader({
title: i18n("API Key Required"), 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> <provider-key-input .provider=${this.provider}></provider-key-input>
</div>
`, `,
})} })}
`; `;

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 // 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 // Components
export { AgentInterface } from "./components/AgentInterface.js"; export { AgentInterface } from "./components/AgentInterface.js";
export { AttachmentTile } from "./components/AttachmentTile.js"; export { AttachmentTile } from "./components/AttachmentTile.js";
@ -9,6 +17,7 @@ export { Input } from "./components/Input.js";
export { MessageEditor } from "./components/MessageEditor.js"; export { MessageEditor } from "./components/MessageEditor.js";
export { MessageList } from "./components/MessageList.js"; export { MessageList } from "./components/MessageList.js";
// Message components // Message components
export type { AppMessage } from "./components/Messages.js";
export { AssistantMessage, ToolMessage, UserMessage } from "./components/Messages.js"; export { AssistantMessage, ToolMessage, UserMessage } from "./components/Messages.js";
export { export {
type SandboxFile, type SandboxFile,
@ -21,24 +30,25 @@ export { ApiKeyPromptDialog } from "./dialogs/ApiKeyPromptDialog.js";
export { AttachmentOverlay } from "./dialogs/AttachmentOverlay.js"; export { AttachmentOverlay } from "./dialogs/AttachmentOverlay.js";
// Dialogs // Dialogs
export { ModelSelector } from "./dialogs/ModelSelector.js"; 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 { 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 // Storage
export { AppStorage, getAppStorage, initAppStorage, setAppStorage } from "./storage/app-storage.js"; export { AppStorage, getAppStorage, initAppStorage, setAppStorage } from "./storage/app-storage.js";
export { ChromeStorageBackend } from "./storage/backends/chrome-storage-backend.js"; export { ChromeStorageBackend } from "./storage/backends/chrome-storage-backend.js";
export { IndexedDBBackend } from "./storage/backends/indexeddb-backend.js"; export { IndexedDBBackend } from "./storage/backends/indexeddb-backend.js";
export { LocalStorageBackend } from "./storage/backends/local-storage-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 { 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 { 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 // Artifacts
export { ArtifactElement } from "./tools/artifacts/ArtifactElement.js"; export { ArtifactElement } from "./tools/artifacts/ArtifactElement.js";
export { type Artifact, ArtifactsPanel, type ArtifactsParams } from "./tools/artifacts/artifacts.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 { LocalStorageBackend } from "./backends/local-storage-backend.js";
import { ProviderKeysRepository } from "./repositories/provider-keys-repository.js"; import { ProviderKeysRepository } from "./repositories/provider-keys-repository.js";
import { SessionRepository } from "./repositories/session-repository.js";
import { SettingsRepository } from "./repositories/settings-repository.js"; import { SettingsRepository } from "./repositories/settings-repository.js";
import type { AppStorageConfig } from "./types.js"; import type { AppStorageConfig } from "./types.js";
@ -10,6 +11,7 @@ import type { AppStorageConfig } from "./types.js";
export class AppStorage { export class AppStorage {
readonly settings: SettingsRepository; readonly settings: SettingsRepository;
readonly providerKeys: ProviderKeysRepository; readonly providerKeys: ProviderKeysRepository;
readonly sessions?: SessionRepository;
constructor(config: AppStorageConfig = {}) { constructor(config: AppStorageConfig = {}) {
// Use LocalStorage with prefixes as defaults // Use LocalStorage with prefixes as defaults
@ -18,6 +20,11 @@ export class AppStorage {
this.settings = new SettingsRepository(settingsBackend); this.settings = new SettingsRepository(settingsBackend);
this.providerKeys = new ProviderKeysRepository(providerKeysBackend); 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. * Base interface for all storage backends.
* Provides a simple key-value storage abstraction that can be implemented * Provides a simple key-value storage abstraction that can be implemented
@ -35,6 +39,141 @@ export interface StorageBackend {
has(key: string): Promise<boolean>; 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. * Options for configuring AppStorage.
*/ */
@ -43,6 +182,6 @@ export interface AppStorageConfig {
settings?: StorageBackend; settings?: StorageBackend;
/** Backend for provider API keys */ /** Backend for provider API keys */
providerKeys?: StorageBackend; providerKeys?: StorageBackend;
/** Backend for sessions (chat history, attachments) */ /** Backend for sessions (optional - can be undefined if persistence not needed) */
sessions?: StorageBackend; sessions?: SessionStorageBackend;
} }

View file

@ -113,6 +113,28 @@ declare module "@mariozechner/mini-lit" {
Low: string; Low: string;
Medium: string; Medium: string;
High: 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", Create: "Create",
Rewrite: "Rewrite", Rewrite: "Rewrite",
Get: "Get", Get: "Get",
Delete: "Delete",
"Get logs": "Get logs", "Get logs": "Get logs",
"Show artifacts": "Show artifacts", "Show artifacts": "Show artifacts",
"Close artifacts": "Close artifacts", "Close artifacts": "Close artifacts",
@ -233,6 +254,33 @@ const translations = {
Low: "Low", Low: "Low",
Medium: "Medium", Medium: "Medium",
High: "High", 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: { de: {
...defaultGerman, ...defaultGerman,
@ -320,7 +368,6 @@ const translations = {
Create: "Erstellen", Create: "Erstellen",
Rewrite: "Überschreiben", Rewrite: "Überschreiben",
Get: "Abrufen", Get: "Abrufen",
Delete: "Löschen",
"Get logs": "Logs abrufen", "Get logs": "Logs abrufen",
"Show artifacts": "Artefakte anzeigen", "Show artifacts": "Artefakte anzeigen",
"Close artifacts": "Artefakte schließen", "Close artifacts": "Artefakte schließen",
@ -350,6 +397,33 @@ const translations = {
Low: "Niedrig", Low: "Niedrig",
Medium: "Mittel", Medium: "Mittel",
High: "Hoch", 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",
}, },
}; };