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