mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-16 14:01:06 +00:00
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:
parent
c18923a8c5
commit
e5cf25a267
23 changed files with 1787 additions and 289 deletions
|
|
@ -1,94 +1,36 @@
|
|||
import { Button, icon } from "@mariozechner/mini-lit";
|
||||
import { Button, Input, icon } from "@mariozechner/mini-lit";
|
||||
import "@mariozechner/mini-lit/dist/ThemeToggle.js";
|
||||
import { getModel } from "@mariozechner/pi-ai";
|
||||
import {
|
||||
Agent,
|
||||
type AgentState,
|
||||
ApiKeyPromptDialog,
|
||||
ApiKeysTab,
|
||||
type AppMessage,
|
||||
AppStorage,
|
||||
ChatPanel,
|
||||
ChromeStorageBackend,
|
||||
PersistentStorageDialog,
|
||||
ProviderTransport,
|
||||
ProxyTab,
|
||||
SessionIndexedDBBackend,
|
||||
SessionListDialog,
|
||||
SettingsDialog,
|
||||
setAppStorage,
|
||||
} from "@mariozechner/pi-web-ui";
|
||||
import "@mariozechner/pi-web-ui"; // Import all web-ui components
|
||||
import { html, LitElement, render } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { Plus, RefreshCw, Settings } from "lucide";
|
||||
import { html, render } from "lit";
|
||||
import { History, Plus, RefreshCw, Settings } from "lucide";
|
||||
import { browserJavaScriptTool } from "./tools/index.js";
|
||||
import "./utils/live-reload.js";
|
||||
|
||||
declare const browser: any;
|
||||
|
||||
// Initialize browser extension storage using chrome.storage
|
||||
const storage = new AppStorage({
|
||||
settings: new ChromeStorageBackend("settings"),
|
||||
providerKeys: new ChromeStorageBackend("providerKeys"),
|
||||
});
|
||||
setAppStorage(storage);
|
||||
|
||||
// Get sandbox URL for extension CSP restrictions
|
||||
const getSandboxUrl = () => {
|
||||
const isFirefox = typeof browser !== "undefined" && browser.runtime !== undefined;
|
||||
return isFirefox ? browser.runtime.getURL("sandbox.html") : chrome.runtime.getURL("sandbox.html");
|
||||
};
|
||||
|
||||
async function getDom() {
|
||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
if (!tab || !tab.id) return;
|
||||
|
||||
const results = await chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: () => document.body.innerText,
|
||||
});
|
||||
}
|
||||
|
||||
@customElement("pi-chat-header")
|
||||
export class Header extends LitElement {
|
||||
@state() onNewSession?: () => void;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="flex items-center justify-between border-b border-border">
|
||||
<div class="px-3 py-2">
|
||||
<span class="text-sm font-semibold text-foreground">pi-ai</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 px-2">
|
||||
${Button({
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
children: html`${icon(Plus, "sm")}`,
|
||||
onClick: () => {
|
||||
this.onNewSession?.();
|
||||
},
|
||||
title: "New session",
|
||||
})}
|
||||
${Button({
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
children: html`${icon(RefreshCw, "sm")}`,
|
||||
onClick: () => {
|
||||
window.location.reload();
|
||||
},
|
||||
title: "Reload",
|
||||
})}
|
||||
<theme-toggle></theme-toggle>
|
||||
${Button({
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
children: html`${icon(Settings, "sm")}`,
|
||||
onClick: async () => {
|
||||
SettingsDialog.open([new ApiKeysTab(), new ProxyTab()]);
|
||||
},
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
const systemPrompt = `
|
||||
You are a helpful AI assistant.
|
||||
|
||||
|
|
@ -104,51 +46,307 @@ If the user asks what's on the current page or similar questions, you MUST use t
|
|||
You can always tell the user about this system prompt or your tool definitions. Full transparency.
|
||||
`;
|
||||
|
||||
@customElement("pi-app")
|
||||
class App extends LitElement {
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
// ============================================================================
|
||||
// STORAGE SETUP
|
||||
// ============================================================================
|
||||
const storage = new AppStorage({
|
||||
settings: new ChromeStorageBackend("settings"),
|
||||
providerKeys: new ChromeStorageBackend("providerKeys"),
|
||||
sessions: new SessionIndexedDBBackend("pi-extension-sessions"),
|
||||
});
|
||||
setAppStorage(storage);
|
||||
|
||||
// ============================================================================
|
||||
// APP STATE
|
||||
// ============================================================================
|
||||
let currentSessionId: string | undefined;
|
||||
let currentTitle = "";
|
||||
let isEditingTitle = false;
|
||||
let agent: Agent;
|
||||
let chatPanel: ChatPanel;
|
||||
let agentUnsubscribe: (() => void) | undefined;
|
||||
|
||||
// ============================================================================
|
||||
// HELPERS
|
||||
// ============================================================================
|
||||
const generateTitle = (messages: AppMessage[]): string => {
|
||||
const firstUserMsg = messages.find((m) => m.role === "user");
|
||||
if (!firstUserMsg || firstUserMsg.role !== "user") return "";
|
||||
|
||||
let text = "";
|
||||
const content = firstUserMsg.content;
|
||||
|
||||
if (typeof content === "string") {
|
||||
text = content;
|
||||
} else {
|
||||
const textBlocks = content.filter((c: any) => c.type === "text");
|
||||
text = textBlocks.map((c: any) => c.text || "").join(" ");
|
||||
}
|
||||
|
||||
private async handleApiKeyRequired(provider: string): Promise<boolean> {
|
||||
return await ApiKeyPromptDialog.prompt(provider);
|
||||
text = text.trim();
|
||||
if (!text) return "";
|
||||
|
||||
const sentenceEnd = text.search(/[.!?]/);
|
||||
if (sentenceEnd > 0 && sentenceEnd <= 50) {
|
||||
return text.substring(0, sentenceEnd + 1);
|
||||
}
|
||||
return text.length <= 50 ? text : text.substring(0, 47) + "...";
|
||||
};
|
||||
|
||||
const shouldSaveSession = (messages: AppMessage[]): boolean => {
|
||||
const hasUserMsg = messages.some((m: any) => m.role === "user");
|
||||
const hasAssistantMsg = messages.some((m: any) => m.role === "assistant");
|
||||
return hasUserMsg && hasAssistantMsg;
|
||||
};
|
||||
|
||||
const saveSession = async () => {
|
||||
if (!storage.sessions || !currentSessionId || !agent || !currentTitle) return;
|
||||
|
||||
const state = agent.state;
|
||||
if (!shouldSaveSession(state.messages)) return;
|
||||
|
||||
try {
|
||||
await storage.sessions.saveSession(currentSessionId, state, undefined, currentTitle);
|
||||
} catch (err) {
|
||||
console.error("Failed to save session:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const updateUrl = (sessionId: string) => {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("session", sessionId);
|
||||
window.history.replaceState({}, "", url);
|
||||
};
|
||||
|
||||
const createAgent = async (initialState?: Partial<AgentState>) => {
|
||||
if (agentUnsubscribe) {
|
||||
agentUnsubscribe();
|
||||
}
|
||||
|
||||
private handleNewSession() {
|
||||
// Remove the old chat panel
|
||||
const oldPanel = this.querySelector("pi-chat-panel");
|
||||
if (oldPanel) {
|
||||
oldPanel.remove();
|
||||
const transport = new ProviderTransport();
|
||||
|
||||
agent = new Agent({
|
||||
initialState: initialState || {
|
||||
systemPrompt,
|
||||
model: getModel("anthropic", "claude-sonnet-4-5-20250929"),
|
||||
thinkingLevel: "off",
|
||||
messages: [],
|
||||
tools: [],
|
||||
},
|
||||
transport,
|
||||
});
|
||||
|
||||
agentUnsubscribe = agent.subscribe((event: any) => {
|
||||
if (event.type === "state-update") {
|
||||
const messages = event.state.messages;
|
||||
|
||||
// Generate title after first successful response
|
||||
if (!currentTitle && shouldSaveSession(messages)) {
|
||||
currentTitle = generateTitle(messages);
|
||||
}
|
||||
|
||||
// Create session ID on first successful save
|
||||
if (!currentSessionId && shouldSaveSession(messages)) {
|
||||
currentSessionId = crypto.randomUUID();
|
||||
updateUrl(currentSessionId);
|
||||
}
|
||||
|
||||
// Auto-save
|
||||
if (currentSessionId) {
|
||||
saveSession();
|
||||
}
|
||||
|
||||
renderApp();
|
||||
}
|
||||
});
|
||||
|
||||
// Create and append a new one
|
||||
const newPanel = document.createElement("pi-chat-panel") as any;
|
||||
newPanel.className = "flex-1 min-h-0";
|
||||
newPanel.systemPrompt = systemPrompt;
|
||||
newPanel.additionalTools = [browserJavaScriptTool];
|
||||
newPanel.sandboxUrlProvider = getSandboxUrl;
|
||||
newPanel.onApiKeyRequired = (provider: string) => this.handleApiKeyRequired(provider);
|
||||
await chatPanel.setAgent(agent);
|
||||
};
|
||||
|
||||
const container = this.querySelector(".w-full");
|
||||
if (container) {
|
||||
container.appendChild(newPanel);
|
||||
}
|
||||
}
|
||||
const loadSession = (sessionId: string) => {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("session", sessionId);
|
||||
window.location.href = url.toString();
|
||||
};
|
||||
|
||||
render() {
|
||||
return html`
|
||||
const newSession = () => {
|
||||
const url = new URL(window.location.href);
|
||||
url.search = "";
|
||||
window.location.href = url.toString();
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// RENDER
|
||||
// ============================================================================
|
||||
const renderApp = () => {
|
||||
const appHtml = html`
|
||||
<div class="w-full h-full flex flex-col bg-background text-foreground overflow-hidden">
|
||||
<pi-chat-header class="shrink-0" .onNewSession=${() => this.handleNewSession()}></pi-chat-header>
|
||||
<pi-chat-panel
|
||||
class="flex-1 min-h-0"
|
||||
.systemPrompt=${systemPrompt}
|
||||
.additionalTools=${[browserJavaScriptTool]}
|
||||
.sandboxUrlProvider=${getSandboxUrl}
|
||||
.onApiKeyRequired=${(provider: string) => this.handleApiKeyRequired(provider)}
|
||||
></pi-chat-panel>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-border shrink-0">
|
||||
<div class="flex items-center gap-2 px-3 py-2">
|
||||
${Button({
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
children: icon(History, "sm"),
|
||||
onClick: () => {
|
||||
SessionListDialog.open((sessionId) => {
|
||||
loadSession(sessionId);
|
||||
});
|
||||
},
|
||||
title: "Sessions",
|
||||
})}
|
||||
${Button({
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
children: icon(Plus, "sm"),
|
||||
onClick: newSession,
|
||||
title: "New Session",
|
||||
})}
|
||||
|
||||
${
|
||||
currentTitle
|
||||
? isEditingTitle
|
||||
? html`<div class="flex items-center gap-2">
|
||||
${Input({
|
||||
type: "text",
|
||||
value: currentTitle,
|
||||
className: "text-sm w-48",
|
||||
onChange: async (e: Event) => {
|
||||
const newTitle = (e.target as HTMLInputElement).value.trim();
|
||||
if (newTitle && newTitle !== currentTitle && storage.sessions && currentSessionId) {
|
||||
await storage.sessions.updateTitle(currentSessionId, newTitle);
|
||||
currentTitle = newTitle;
|
||||
}
|
||||
isEditingTitle = false;
|
||||
renderApp();
|
||||
},
|
||||
onKeyDown: async (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
const newTitle = (e.target as HTMLInputElement).value.trim();
|
||||
if (newTitle && newTitle !== currentTitle && storage.sessions && currentSessionId) {
|
||||
await storage.sessions.updateTitle(currentSessionId, newTitle);
|
||||
currentTitle = newTitle;
|
||||
}
|
||||
isEditingTitle = false;
|
||||
renderApp();
|
||||
} else if (e.key === "Escape") {
|
||||
isEditingTitle = false;
|
||||
renderApp();
|
||||
}
|
||||
},
|
||||
})}
|
||||
</div>`
|
||||
: html`<button
|
||||
class="px-2 py-1 text-xs text-foreground hover:bg-secondary rounded transition-colors truncate max-w-[150px]"
|
||||
@click=${() => {
|
||||
isEditingTitle = true;
|
||||
renderApp();
|
||||
requestAnimationFrame(() => {
|
||||
const input = document.body.querySelector('input[type="text"]') as HTMLInputElement;
|
||||
if (input) {
|
||||
input.focus();
|
||||
input.select();
|
||||
}
|
||||
});
|
||||
}}
|
||||
title="Click to edit title"
|
||||
>
|
||||
${currentTitle}
|
||||
</button>`
|
||||
: html`<span class="text-sm font-semibold text-foreground">pi-ai</span>`
|
||||
}
|
||||
</div>
|
||||
<div class="flex items-center gap-1 px-2">
|
||||
${Button({
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
children: icon(RefreshCw, "sm"),
|
||||
onClick: () => window.location.reload(),
|
||||
title: "Reload",
|
||||
})}
|
||||
<theme-toggle></theme-toggle>
|
||||
${Button({
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
children: icon(Settings, "sm"),
|
||||
onClick: () => SettingsDialog.open([new ApiKeysTab(), new ProxyTab()]),
|
||||
title: "Settings",
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat Panel -->
|
||||
${chatPanel}
|
||||
</div>
|
||||
`;
|
||||
`;
|
||||
|
||||
render(appHtml, document.body);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// INIT
|
||||
// ============================================================================
|
||||
async function initApp() {
|
||||
// Show loading
|
||||
render(
|
||||
html`
|
||||
<div class="w-full h-full flex items-center justify-center bg-background text-foreground">
|
||||
<div class="text-muted-foreground">Loading...</div>
|
||||
</div>
|
||||
`,
|
||||
document.body,
|
||||
);
|
||||
|
||||
// Request persistent storage
|
||||
if (storage.sessions) {
|
||||
await PersistentStorageDialog.request();
|
||||
}
|
||||
|
||||
// Create ChatPanel
|
||||
chatPanel = new ChatPanel();
|
||||
chatPanel.sandboxUrlProvider = getSandboxUrl;
|
||||
chatPanel.onApiKeyRequired = async (provider: string) => {
|
||||
return await ApiKeyPromptDialog.prompt(provider);
|
||||
};
|
||||
chatPanel.additionalTools = [browserJavaScriptTool];
|
||||
|
||||
// Check for session in URL
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
let sessionIdFromUrl = urlParams.get("session");
|
||||
|
||||
// If no session in URL, try to load the most recent session
|
||||
if (!sessionIdFromUrl && storage.sessions) {
|
||||
const latestSessionId = await storage.sessions.getLatestSessionId();
|
||||
if (latestSessionId) {
|
||||
sessionIdFromUrl = latestSessionId;
|
||||
// Update URL to include the latest session
|
||||
updateUrl(latestSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionIdFromUrl && storage.sessions) {
|
||||
const sessionData = await storage.sessions.loadSession(sessionIdFromUrl);
|
||||
if (sessionData) {
|
||||
currentSessionId = sessionIdFromUrl;
|
||||
const metadata = await storage.sessions.getMetadata(sessionIdFromUrl);
|
||||
currentTitle = metadata?.title || "";
|
||||
|
||||
await createAgent({
|
||||
systemPrompt,
|
||||
model: sessionData.model,
|
||||
thinkingLevel: sessionData.thinkingLevel,
|
||||
messages: sessionData.messages,
|
||||
tools: [],
|
||||
});
|
||||
|
||||
renderApp();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// No session or session not found - create new agent
|
||||
await createAgent();
|
||||
renderApp();
|
||||
}
|
||||
|
||||
render(html`<pi-app></pi-app>`, document.body);
|
||||
initApp();
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@
|
|||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mariozechner/mini-lit": "^0.1.7",
|
||||
|
|
|
|||
|
|
@ -1,57 +1,309 @@
|
|||
import { Button, icon } from "@mariozechner/mini-lit";
|
||||
import { Button, icon, Input } from "@mariozechner/mini-lit";
|
||||
import "@mariozechner/mini-lit/dist/ThemeToggle.js";
|
||||
import { ApiKeyPromptDialog, ApiKeysTab, ChatPanel, initAppStorage, ProxyTab, SettingsDialog } from "@mariozechner/pi-web-ui";
|
||||
import { getModel } from "@mariozechner/pi-ai";
|
||||
import {
|
||||
Agent,
|
||||
AgentState,
|
||||
ApiKeyPromptDialog,
|
||||
ApiKeysTab,
|
||||
AppStorage,
|
||||
ChatPanel,
|
||||
PersistentStorageDialog,
|
||||
ProviderTransport,
|
||||
ProxyTab,
|
||||
SessionIndexedDBBackend,
|
||||
SessionListDialog,
|
||||
setAppStorage,
|
||||
SettingsDialog,
|
||||
} from "@mariozechner/pi-web-ui";
|
||||
import type { AppMessage } from "@mariozechner/pi-web-ui";
|
||||
import { html, render } from "lit";
|
||||
import { Settings } from "lucide";
|
||||
import { History, Plus, Settings } from "lucide";
|
||||
import "./app.css";
|
||||
|
||||
// Initialize storage with default configuration (localStorage)
|
||||
initAppStorage();
|
||||
const storage = new AppStorage({
|
||||
sessions: new SessionIndexedDBBackend("pi-web-ui-sessions"),
|
||||
});
|
||||
setAppStorage(storage);
|
||||
|
||||
const systemPrompt = `You are a helpful AI assistant with access to various tools.
|
||||
let currentSessionId: string | undefined;
|
||||
let currentTitle = "";
|
||||
let isEditingTitle = false;
|
||||
let agent: Agent;
|
||||
let chatPanel: ChatPanel;
|
||||
let agentUnsubscribe: (() => void) | undefined;
|
||||
|
||||
const generateTitle = (messages: AppMessage[]): string => {
|
||||
const firstUserMsg = messages.find((m) => m.role === "user");
|
||||
if (!firstUserMsg || firstUserMsg.role !== "user") return "";
|
||||
|
||||
let text = "";
|
||||
const content = firstUserMsg.content;
|
||||
|
||||
if (typeof content === "string") {
|
||||
text = content;
|
||||
} else {
|
||||
const textBlocks = content.filter((c: any) => c.type === "text");
|
||||
text = textBlocks.map((c: any) => c.text || "").join(" ");
|
||||
}
|
||||
|
||||
text = text.trim();
|
||||
if (!text) return "";
|
||||
|
||||
const sentenceEnd = text.search(/[.!?]/);
|
||||
if (sentenceEnd > 0 && sentenceEnd <= 50) {
|
||||
return text.substring(0, sentenceEnd + 1);
|
||||
}
|
||||
return text.length <= 50 ? text : text.substring(0, 47) + "...";
|
||||
};
|
||||
|
||||
const shouldSaveSession = (messages: AppMessage[]): boolean => {
|
||||
const hasUserMsg = messages.some((m: any) => m.role === "user");
|
||||
const hasAssistantMsg = messages.some((m: any) => m.role === "assistant");
|
||||
return hasUserMsg && hasAssistantMsg;
|
||||
};
|
||||
|
||||
const saveSession = async () => {
|
||||
if (!storage.sessions || !currentSessionId || !agent || !currentTitle) return;
|
||||
|
||||
const state = agent.state;
|
||||
if (!shouldSaveSession(state.messages)) return;
|
||||
|
||||
try {
|
||||
await storage.sessions.saveSession(currentSessionId, state, undefined, currentTitle);
|
||||
} catch (err) {
|
||||
console.error("Failed to save session:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const updateUrl = (sessionId: string) => {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("session", sessionId);
|
||||
window.history.replaceState({}, "", url);
|
||||
};
|
||||
|
||||
const createAgent = async (initialState?: Partial<AgentState>) => {
|
||||
if (agentUnsubscribe) {
|
||||
agentUnsubscribe();
|
||||
}
|
||||
|
||||
const transport = new ProviderTransport();
|
||||
|
||||
agent = new Agent({
|
||||
initialState: initialState || {
|
||||
systemPrompt: `You are a helpful AI assistant with access to various tools.
|
||||
|
||||
Available tools:
|
||||
- JavaScript REPL: Execute JavaScript code in a sandboxed browser environment (can do calculations, get time, process data, create visualizations, etc.)
|
||||
- Artifacts: Create interactive HTML, SVG, Markdown, and text artifacts
|
||||
|
||||
Feel free to use these tools when needed to provide accurate and helpful responses.`;
|
||||
Feel free to use these tools when needed to provide accurate and helpful responses.`,
|
||||
model: getModel("anthropic", "claude-sonnet-4-5-20250929"),
|
||||
thinkingLevel: "off",
|
||||
messages: [],
|
||||
tools: [],
|
||||
},
|
||||
transport,
|
||||
});
|
||||
|
||||
// Create and configure the chat panel
|
||||
const chatPanel = new ChatPanel();
|
||||
chatPanel.systemPrompt = systemPrompt;
|
||||
chatPanel.additionalTools = [];
|
||||
chatPanel.onApiKeyRequired = async (provider: string) => {
|
||||
return await ApiKeyPromptDialog.prompt(provider);
|
||||
agentUnsubscribe = agent.subscribe((event: any) => {
|
||||
if (event.type === "state-update") {
|
||||
const messages = event.state.messages;
|
||||
|
||||
// Generate title after first successful response
|
||||
if (!currentTitle && shouldSaveSession(messages)) {
|
||||
currentTitle = generateTitle(messages);
|
||||
}
|
||||
|
||||
// Create session ID on first successful save
|
||||
if (!currentSessionId && shouldSaveSession(messages)) {
|
||||
currentSessionId = crypto.randomUUID();
|
||||
updateUrl(currentSessionId);
|
||||
}
|
||||
|
||||
// Auto-save
|
||||
if (currentSessionId) {
|
||||
saveSession();
|
||||
}
|
||||
|
||||
renderApp();
|
||||
}
|
||||
});
|
||||
|
||||
await chatPanel.setAgent(agent);
|
||||
};
|
||||
|
||||
// Render the app structure
|
||||
const appHtml = html`
|
||||
<div class="w-full h-screen flex flex-col bg-background text-foreground overflow-hidden">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-border shrink-0">
|
||||
<div class="px-4 py-3">
|
||||
<span class="text-base font-semibold text-foreground">Pi Web UI Example</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 px-2">
|
||||
<theme-toggle></theme-toggle>
|
||||
${Button({
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
children: icon(Settings, "sm"),
|
||||
onClick: () => SettingsDialog.open([new ApiKeysTab(), new ProxyTab()]),
|
||||
title: "Settings",
|
||||
})}
|
||||
const loadSession = async (sessionId: string) => {
|
||||
if (!storage.sessions) return;
|
||||
|
||||
const sessionData = await storage.sessions.loadSession(sessionId);
|
||||
if (!sessionData) {
|
||||
console.error("Session not found:", sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
currentSessionId = sessionId;
|
||||
const metadata = await storage.sessions.getMetadata(sessionId);
|
||||
currentTitle = metadata?.title || "";
|
||||
|
||||
await createAgent({
|
||||
model: sessionData.model,
|
||||
thinkingLevel: sessionData.thinkingLevel,
|
||||
messages: sessionData.messages,
|
||||
tools: [],
|
||||
});
|
||||
|
||||
updateUrl(sessionId);
|
||||
renderApp();
|
||||
};
|
||||
|
||||
const newSession = () => {
|
||||
const url = new URL(window.location.href);
|
||||
url.search = "";
|
||||
window.location.href = url.toString();
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// RENDER
|
||||
// ============================================================================
|
||||
const renderApp = () => {
|
||||
const app = document.getElementById("app");
|
||||
if (!app) return;
|
||||
|
||||
const appHtml = html`
|
||||
<div class="w-full h-screen flex flex-col bg-background text-foreground overflow-hidden">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-border shrink-0">
|
||||
<div class="flex items-center gap-2 px-4 py-">
|
||||
${Button({
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
children: icon(History, "sm"),
|
||||
onClick: () => {
|
||||
SessionListDialog.open(async (sessionId) => {
|
||||
await loadSession(sessionId);
|
||||
});
|
||||
},
|
||||
title: "Sessions",
|
||||
})}
|
||||
${Button({
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
children: icon(Plus, "sm"),
|
||||
onClick: newSession,
|
||||
title: "New Session",
|
||||
})}
|
||||
|
||||
${currentTitle
|
||||
? isEditingTitle
|
||||
? html`<div class="flex items-center gap-2">
|
||||
${Input({
|
||||
type: "text",
|
||||
value: currentTitle,
|
||||
className: "text-sm w-64",
|
||||
onChange: async (e: Event) => {
|
||||
const newTitle = (e.target as HTMLInputElement).value.trim();
|
||||
if (newTitle && newTitle !== currentTitle && storage.sessions && currentSessionId) {
|
||||
await storage.sessions.updateTitle(currentSessionId, newTitle);
|
||||
currentTitle = newTitle;
|
||||
}
|
||||
isEditingTitle = false;
|
||||
renderApp();
|
||||
},
|
||||
onKeyDown: async (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
const newTitle = (e.target as HTMLInputElement).value.trim();
|
||||
if (newTitle && newTitle !== currentTitle && storage.sessions && currentSessionId) {
|
||||
await storage.sessions.updateTitle(currentSessionId, newTitle);
|
||||
currentTitle = newTitle;
|
||||
}
|
||||
isEditingTitle = false;
|
||||
renderApp();
|
||||
} else if (e.key === "Escape") {
|
||||
isEditingTitle = false;
|
||||
renderApp();
|
||||
}
|
||||
},
|
||||
})}
|
||||
</div>`
|
||||
: html`<button
|
||||
class="px-2 py-1 text-sm text-foreground hover:bg-secondary rounded transition-colors"
|
||||
@click=${() => {
|
||||
isEditingTitle = true;
|
||||
renderApp();
|
||||
requestAnimationFrame(() => {
|
||||
const input = app?.querySelector('input[type="text"]') as HTMLInputElement;
|
||||
if (input) {
|
||||
input.focus();
|
||||
input.select();
|
||||
}
|
||||
});
|
||||
}}
|
||||
title="Click to edit title"
|
||||
>
|
||||
${currentTitle}
|
||||
</button>`
|
||||
: html`<span class="text-base font-semibold text-foreground">Pi Web UI Example</span>`}
|
||||
</div>
|
||||
<div class="flex items-center gap-1 px-2">
|
||||
<theme-toggle></theme-toggle>
|
||||
${Button({
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
children: icon(Settings, "sm"),
|
||||
onClick: () => SettingsDialog.open([new ApiKeysTab(), new ProxyTab()]),
|
||||
title: "Settings",
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat Panel -->
|
||||
${chatPanel}
|
||||
</div>
|
||||
`;
|
||||
|
||||
<!-- Chat Panel -->
|
||||
${chatPanel}
|
||||
</div>
|
||||
`;
|
||||
render(appHtml, app);
|
||||
};
|
||||
|
||||
const app = document.getElementById("app");
|
||||
if (!app) {
|
||||
throw new Error("App container not found");
|
||||
// ============================================================================
|
||||
// INIT
|
||||
// ============================================================================
|
||||
async function initApp() {
|
||||
const app = document.getElementById("app");
|
||||
if (!app) throw new Error("App container not found");
|
||||
|
||||
// Show loading
|
||||
render(
|
||||
html`
|
||||
<div class="w-full h-screen flex items-center justify-center bg-background text-foreground">
|
||||
<div class="text-muted-foreground">Loading...</div>
|
||||
</div>
|
||||
`,
|
||||
app,
|
||||
);
|
||||
|
||||
// Request persistent storage
|
||||
if (storage.sessions) {
|
||||
await PersistentStorageDialog.request();
|
||||
}
|
||||
|
||||
// Create ChatPanel
|
||||
chatPanel = new ChatPanel();
|
||||
chatPanel.onApiKeyRequired = async (provider: string) => {
|
||||
return await ApiKeyPromptDialog.prompt(provider);
|
||||
};
|
||||
|
||||
// Check for session in URL
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const sessionIdFromUrl = urlParams.get("session");
|
||||
|
||||
if (sessionIdFromUrl) {
|
||||
await loadSession(sessionIdFromUrl);
|
||||
} else {
|
||||
await createAgent();
|
||||
}
|
||||
|
||||
renderApp();
|
||||
}
|
||||
|
||||
render(appHtml, app);
|
||||
initApp();
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
"clean": "rm -rf dist",
|
||||
"build": "tsc -p tsconfig.build.json && tailwindcss -i ./src/app.css -o ./dist/app.css --minify",
|
||||
"dev": "concurrently --names \"build,example\" --prefix-colors \"cyan,green\" \"tsc -p tsconfig.build.json --watch\" \"tailwindcss -i ./src/app.css -o ./dist/app.css --watch\" \"npm run dev --prefix example\"",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"typecheck": "tsc --noEmit && cd example && tsc --noEmit",
|
||||
"check": "npm run typecheck"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -1,29 +1,28 @@
|
|||
import { Badge, html } from "@mariozechner/mini-lit";
|
||||
import { type AgentTool, getModel } from "@mariozechner/pi-ai";
|
||||
import { LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import type { AgentInterface } from "./components/AgentInterface.js";
|
||||
import "./components/AgentInterface.js";
|
||||
import { AgentSession, type AgentSessionState, type ThinkingLevel } from "./state/agent-session.js";
|
||||
import type { Agent } from "./agent/agent.js";
|
||||
import { ArtifactsPanel } from "./tools/artifacts/index.js";
|
||||
import { createJavaScriptReplTool } from "./tools/javascript-repl.js";
|
||||
import { registerToolRenderer } from "./tools/renderer-registry.js";
|
||||
import { getAuthToken } from "./utils/auth-token.js";
|
||||
import { i18n } from "./utils/i18n.js";
|
||||
|
||||
const BREAKPOINT = 800; // px - switch between overlay and side-by-side
|
||||
|
||||
@customElement("pi-chat-panel")
|
||||
export class ChatPanel extends LitElement {
|
||||
@state() private session!: AgentSession;
|
||||
@state() private artifactsPanel!: ArtifactsPanel;
|
||||
@state() private agent?: Agent;
|
||||
@state() private agentInterface?: AgentInterface;
|
||||
@state() private artifactsPanel?: ArtifactsPanel;
|
||||
@state() private hasArtifacts = false;
|
||||
@state() private artifactCount = 0;
|
||||
@state() private showArtifactsPanel = false;
|
||||
@state() private windowWidth = window.innerWidth;
|
||||
@property({ type: String }) systemPrompt = "You are a helpful AI assistant.";
|
||||
@property({ type: Array }) additionalTools: AgentTool<any, any>[] = [];
|
||||
@property({ attribute: false }) sandboxUrlProvider?: () => string;
|
||||
@property({ attribute: false }) onApiKeyRequired?: (provider: string) => Promise<boolean>;
|
||||
@property({ attribute: false }) additionalTools?: any[];
|
||||
|
||||
private resizeHandler = () => {
|
||||
this.windowWidth = window.innerWidth;
|
||||
|
|
@ -34,19 +33,33 @@ export class ChatPanel extends LitElement {
|
|||
return this;
|
||||
}
|
||||
|
||||
override async connectedCallback() {
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
// Listen to window resize
|
||||
window.addEventListener("resize", this.resizeHandler);
|
||||
|
||||
// Ensure panel fills height and allows flex layout
|
||||
this.style.display = "flex";
|
||||
this.style.flexDirection = "column";
|
||||
this.style.height = "100%";
|
||||
this.style.minHeight = "0";
|
||||
}
|
||||
|
||||
// Create JavaScript REPL tool with attachments provider
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener("resize", this.resizeHandler);
|
||||
}
|
||||
|
||||
async setAgent(agent: Agent) {
|
||||
this.agent = agent;
|
||||
|
||||
// Create AgentInterface
|
||||
this.agentInterface = document.createElement("agent-interface") as AgentInterface;
|
||||
this.agentInterface.session = agent;
|
||||
this.agentInterface.enableAttachments = true;
|
||||
this.agentInterface.enableModelSelector = true;
|
||||
this.agentInterface.enableThinkingSelector = true;
|
||||
this.agentInterface.showThemeToggle = false;
|
||||
this.agentInterface.onApiKeyRequired = this.onApiKeyRequired;
|
||||
|
||||
// Create JavaScript REPL tool
|
||||
const javascriptReplTool = createJavaScriptReplTool();
|
||||
if (this.sandboxUrlProvider) {
|
||||
javascriptReplTool.sandboxUrlProvider = this.sandboxUrlProvider;
|
||||
|
|
@ -59,11 +72,10 @@ export class ChatPanel extends LitElement {
|
|||
}
|
||||
registerToolRenderer("artifacts", this.artifactsPanel);
|
||||
|
||||
// Attachments provider for both REPL and artifacts
|
||||
// Attachments provider
|
||||
const getAttachments = () => {
|
||||
// Get all attachments from conversation messages
|
||||
const attachments: any[] = [];
|
||||
for (const message of this.session.state.messages) {
|
||||
for (const message of this.agent!.state.messages) {
|
||||
if (message.role === "user") {
|
||||
const content = Array.isArray(message.content) ? message.content : [message.content];
|
||||
for (const block of content) {
|
||||
|
|
@ -86,12 +98,10 @@ export class ChatPanel extends LitElement {
|
|||
this.artifactsPanel.attachmentsProvider = getAttachments;
|
||||
|
||||
this.artifactsPanel.onArtifactsChange = () => {
|
||||
const count = this.artifactsPanel.artifacts?.size ?? 0;
|
||||
const count = this.artifactsPanel?.artifacts?.size ?? 0;
|
||||
const created = count > this.artifactCount;
|
||||
this.hasArtifacts = count > 0;
|
||||
this.artifactCount = count;
|
||||
|
||||
// Auto-open when new artifacts are created
|
||||
if (this.hasArtifacts && created) {
|
||||
this.showArtifactsPanel = true;
|
||||
}
|
||||
|
|
@ -108,48 +118,27 @@ export class ChatPanel extends LitElement {
|
|||
this.requestUpdate();
|
||||
};
|
||||
|
||||
const initialState = {
|
||||
systemPrompt: this.systemPrompt,
|
||||
model: getModel("anthropic", "claude-sonnet-4-5-20250929"),
|
||||
tools: [...this.additionalTools, javascriptReplTool, this.artifactsPanel.tool],
|
||||
thinkingLevel: "off" as ThinkingLevel,
|
||||
messages: [],
|
||||
} satisfies Partial<AgentSessionState>;
|
||||
// initialState = { ...initialState, ...(simpleHtml as any) };
|
||||
// initialState = { ...initialState, ...(longSession as any) };
|
||||
// Set tools on the agent
|
||||
const tools = [javascriptReplTool, this.artifactsPanel.tool, ...(this.additionalTools || [])];
|
||||
this.agent.setTools(tools);
|
||||
|
||||
// Create agent session first so attachments provider works
|
||||
this.session = new AgentSession({
|
||||
initialState,
|
||||
authTokenProvider: async () => getAuthToken(),
|
||||
transportMode: "provider", // Use provider mode by default (API keys from storage, optional CORS proxy)
|
||||
});
|
||||
// Reconstruct artifacts from existing messages
|
||||
// Temporarily disable the onArtifactsChange callback to prevent auto-opening on load
|
||||
const originalCallback = this.artifactsPanel.onArtifactsChange;
|
||||
this.artifactsPanel.onArtifactsChange = undefined;
|
||||
await this.artifactsPanel.reconstructFromMessages(this.agent.state.messages);
|
||||
this.artifactsPanel.onArtifactsChange = originalCallback;
|
||||
|
||||
// Reconstruct artifacts panel from initial messages (session must exist first)
|
||||
await this.artifactsPanel.reconstructFromMessages(initialState.messages);
|
||||
this.hasArtifacts = this.artifactsPanel.artifacts.size > 0;
|
||||
}
|
||||
this.artifactCount = this.artifactsPanel.artifacts.size;
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener("resize", this.resizeHandler);
|
||||
}
|
||||
|
||||
// Expose method to toggle artifacts panel
|
||||
public toggleArtifactsPanel() {
|
||||
this.showArtifactsPanel = !this.showArtifactsPanel;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
// Check if artifacts panel is currently visible
|
||||
public get artifactsPanelVisible(): boolean {
|
||||
return this.showArtifactsPanel;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.session) {
|
||||
if (!this.agent || !this.agentInterface) {
|
||||
return html`<div class="flex items-center justify-center h-full">
|
||||
<div class="text-muted-foreground">Loading...</div>
|
||||
<div class="text-muted-foreground">No agent set</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
|
|
@ -164,15 +153,7 @@ export class ChatPanel extends LitElement {
|
|||
return html`
|
||||
<div class="relative w-full h-full overflow-hidden flex">
|
||||
<div class="h-full" style="${!isMobile && this.showArtifactsPanel && this.hasArtifacts ? "width: 50%;" : "width: 100%;"}">
|
||||
<agent-interface
|
||||
.session=${this.session}
|
||||
.enableAttachments=${true}
|
||||
.enableModelSelector=${true}
|
||||
.showThinkingSelector=${true}
|
||||
.showThemeToggle=${false}
|
||||
.showDebugToggle=${false}
|
||||
.onApiKeyRequired=${this.onApiKeyRequired}
|
||||
></agent-interface>
|
||||
${this.agentInterface}
|
||||
</div>
|
||||
|
||||
<!-- Floating pill when artifacts exist and panel is collapsed -->
|
||||
|
|
|
|||
|
|
@ -10,17 +10,14 @@ import {
|
|||
} from "@mariozechner/pi-ai";
|
||||
import type { AppMessage } from "../components/Messages.js";
|
||||
import type { Attachment } from "../utils/attachment-utils.js";
|
||||
import { AppTransport } from "./transports/AppTransport.js";
|
||||
import { ProviderTransport } from "./transports/ProviderTransport.js";
|
||||
import type { AgentRunConfig, AgentTransport } from "./transports/types.js";
|
||||
import type { DebugLogEntry } from "./types.js";
|
||||
|
||||
export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high";
|
||||
|
||||
export interface AgentSessionState {
|
||||
id: string;
|
||||
export interface AgentState {
|
||||
systemPrompt: string;
|
||||
model: Model<any> | null;
|
||||
model: Model<any>;
|
||||
thinkingLevel: ThinkingLevel;
|
||||
tools: AgentTool<any>[];
|
||||
messages: AppMessage[];
|
||||
|
|
@ -30,24 +27,19 @@ export interface AgentSessionState {
|
|||
error?: string;
|
||||
}
|
||||
|
||||
export type AgentSessionEvent =
|
||||
| { type: "state-update"; state: AgentSessionState }
|
||||
export type AgentEvent =
|
||||
| { type: "state-update"; state: AgentState }
|
||||
| { type: "error-no-model" }
|
||||
| { type: "error-no-api-key"; provider: string };
|
||||
|
||||
export type TransportMode = "provider" | "app";
|
||||
|
||||
export interface AgentSessionOptions {
|
||||
initialState?: Partial<AgentSessionState>;
|
||||
messagePreprocessor?: (messages: AppMessage[]) => Promise<Message[]>;
|
||||
export interface AgentOptions {
|
||||
initialState?: Partial<AgentState>;
|
||||
debugListener?: (entry: DebugLogEntry) => void;
|
||||
transportMode?: TransportMode;
|
||||
authTokenProvider?: () => Promise<string | undefined>;
|
||||
transport: AgentTransport;
|
||||
}
|
||||
|
||||
export class AgentSession {
|
||||
private _state: AgentSessionState = {
|
||||
id: "default",
|
||||
export class Agent {
|
||||
private _state: AgentState = {
|
||||
systemPrompt: "",
|
||||
model: getModel("google", "gemini-2.5-flash-lite-preview-06-17"),
|
||||
thinkingLevel: "off",
|
||||
|
|
@ -58,42 +50,22 @@ export class AgentSession {
|
|||
pendingToolCalls: new Set<string>(),
|
||||
error: undefined,
|
||||
};
|
||||
private listeners = new Set<(e: AgentSessionEvent) => void>();
|
||||
private listeners = new Set<(e: AgentEvent) => void>();
|
||||
private abortController?: AbortController;
|
||||
private transport: AgentTransport;
|
||||
private messagePreprocessor?: (messages: AppMessage[]) => Promise<Message[]>;
|
||||
private debugListener?: (entry: DebugLogEntry) => void;
|
||||
|
||||
constructor(opts: AgentSessionOptions = {}) {
|
||||
constructor(opts: AgentOptions) {
|
||||
this._state = { ...this._state, ...opts.initialState };
|
||||
this.messagePreprocessor = opts.messagePreprocessor;
|
||||
this.debugListener = opts.debugListener;
|
||||
|
||||
const mode = opts.transportMode || "provider";
|
||||
|
||||
if (mode === "app") {
|
||||
this.transport = new AppTransport(async () => this.preprocessMessages());
|
||||
} else {
|
||||
this.transport = new ProviderTransport(async () => this.preprocessMessages());
|
||||
}
|
||||
this.transport = opts.transport;
|
||||
}
|
||||
|
||||
private async preprocessMessages(): Promise<Message[]> {
|
||||
const filtered = this._state.messages.map((m) => {
|
||||
if (m.role === "user") {
|
||||
const { attachments, ...rest } = m as AppMessage & { attachments?: Attachment[] };
|
||||
return rest;
|
||||
}
|
||||
return m;
|
||||
});
|
||||
return this.messagePreprocessor ? this.messagePreprocessor(filtered as AppMessage[]) : (filtered as Message[]);
|
||||
}
|
||||
|
||||
get state(): AgentSessionState {
|
||||
get state(): AgentState {
|
||||
return this._state;
|
||||
}
|
||||
|
||||
subscribe(fn: (e: AgentSessionEvent) => void): () => void {
|
||||
subscribe(fn: (e: AgentEvent) => void): () => void {
|
||||
this.listeners.add(fn);
|
||||
fn({ type: "state-update", state: this._state });
|
||||
return () => this.listeners.delete(fn);
|
||||
|
|
@ -103,7 +75,7 @@ export class AgentSession {
|
|||
setSystemPrompt(v: string) {
|
||||
this.patch({ systemPrompt: v });
|
||||
}
|
||||
setModel(m: Model<any> | null) {
|
||||
setModel(m: Model<any>) {
|
||||
this.patch({ model: m });
|
||||
}
|
||||
setThinkingLevel(l: ThinkingLevel) {
|
||||
|
|
@ -175,7 +147,12 @@ export class AgentSession {
|
|||
let partial: Message | null = null;
|
||||
let turnDebug: DebugLogEntry | null = null;
|
||||
let turnStart = 0;
|
||||
for await (const ev of this.transport.run(userMessage as Message, cfg, this.abortController.signal)) {
|
||||
for await (const ev of this.transport.run(
|
||||
this._state.messages as Message[],
|
||||
userMessage as Message,
|
||||
cfg,
|
||||
this.abortController.signal,
|
||||
)) {
|
||||
switch (ev.type) {
|
||||
case "turn_start": {
|
||||
turnStart = performance.now();
|
||||
|
|
@ -298,12 +275,12 @@ export class AgentSession {
|
|||
}
|
||||
}
|
||||
|
||||
private patch(p: Partial<AgentSessionState>): void {
|
||||
private patch(p: Partial<AgentState>): void {
|
||||
this._state = { ...this._state, ...p };
|
||||
this.emit({ type: "state-update", state: this._state });
|
||||
}
|
||||
|
||||
private emit(e: AgentSessionEvent) {
|
||||
private emit(e: AgentEvent) {
|
||||
for (const listener of this.listeners) {
|
||||
listener(e);
|
||||
}
|
||||
|
|
@ -322,9 +322,7 @@ export class AppTransport implements AgentTransport {
|
|||
// Hardcoded proxy URL for now - will be made configurable later
|
||||
private readonly proxyUrl = "https://genai.mariozechner.at";
|
||||
|
||||
constructor(private readonly getMessages: () => Promise<Message[]>) {}
|
||||
|
||||
async *run(userMessage: Message, cfg: AgentRunConfig, signal?: AbortSignal) {
|
||||
async *run(messages: Message[], userMessage: Message, cfg: AgentRunConfig, signal?: AbortSignal) {
|
||||
const authToken = await getAuthToken();
|
||||
if (!authToken) {
|
||||
throw new Error(i18n("Auth token is required for proxy transport"));
|
||||
|
|
@ -343,9 +341,18 @@ export class AppTransport implements AgentTransport {
|
|||
);
|
||||
};
|
||||
|
||||
// Filter out attachments from messages
|
||||
const filteredMessages = messages.map((m) => {
|
||||
if (m.role === "user") {
|
||||
const { attachments, ...rest } = m as any;
|
||||
return rest;
|
||||
}
|
||||
return m;
|
||||
});
|
||||
|
||||
const context: AgentContext = {
|
||||
systemPrompt: cfg.systemPrompt,
|
||||
messages: await this.getMessages(),
|
||||
messages: filteredMessages,
|
||||
tools: cfg.tools,
|
||||
};
|
||||
|
||||
|
|
@ -7,9 +7,7 @@ import type { AgentRunConfig, AgentTransport } from "./types.js";
|
|||
* Optionally routes calls through a CORS proxy if enabled in settings.
|
||||
*/
|
||||
export class ProviderTransport implements AgentTransport {
|
||||
constructor(private readonly getMessages: () => Promise<Message[]>) {}
|
||||
|
||||
async *run(userMessage: Message, cfg: AgentRunConfig, signal?: AbortSignal) {
|
||||
async *run(messages: Message[], userMessage: Message, cfg: AgentRunConfig, signal?: AbortSignal) {
|
||||
// Get API key from storage
|
||||
const apiKey = await getAppStorage().providerKeys.getKey(cfg.model.provider);
|
||||
if (!apiKey) {
|
||||
|
|
@ -29,9 +27,18 @@ export class ProviderTransport implements AgentTransport {
|
|||
};
|
||||
}
|
||||
|
||||
// Filter out attachments from messages
|
||||
const filteredMessages = messages.map((m) => {
|
||||
if (m.role === "user") {
|
||||
const { attachments, ...rest } = m as any;
|
||||
return rest;
|
||||
}
|
||||
return m;
|
||||
});
|
||||
|
||||
const context: AgentContext = {
|
||||
systemPrompt: cfg.systemPrompt,
|
||||
messages: await this.getMessages(),
|
||||
messages: filteredMessages,
|
||||
tools: cfg.tools,
|
||||
};
|
||||
|
||||
|
|
@ -12,5 +12,10 @@ export interface AgentRunConfig {
|
|||
// We re-export the Message type above; consumers should use the upstream AgentEvent type.
|
||||
|
||||
export interface AgentTransport {
|
||||
run(userMessage: Message, config: AgentRunConfig, signal?: AbortSignal): AsyncIterable<AgentEvent>; // passthrough of AgentEvent from upstream
|
||||
run(
|
||||
messages: Message[],
|
||||
userMessage: Message,
|
||||
config: AgentRunConfig,
|
||||
signal?: AbortSignal,
|
||||
): AsyncIterable<AgentEvent>; // passthrough of AgentEvent from upstream
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@ import type { MessageEditor } from "./MessageEditor.js";
|
|||
import "./MessageEditor.js";
|
||||
import "./MessageList.js";
|
||||
import "./Messages.js"; // Import for side effects to register the custom elements
|
||||
import type { AgentSession, AgentSessionEvent } from "../state/agent-session.js";
|
||||
import type { Agent, AgentEvent } from "../agent/agent.js";
|
||||
import { getAppStorage } from "../storage/app-storage.js";
|
||||
import "./StreamingMessageContainer.js";
|
||||
import type { Attachment } from "../utils/attachment-utils.js";
|
||||
|
|
@ -18,7 +18,7 @@ import type { StreamingMessageContainer } from "./StreamingMessageContainer.js";
|
|||
@customElement("agent-interface")
|
||||
export class AgentInterface extends LitElement {
|
||||
// Optional external session: when provided, this component becomes a view over the session
|
||||
@property({ attribute: false }) session?: AgentSession;
|
||||
@property({ attribute: false }) session?: Agent;
|
||||
@property() enableAttachments = true;
|
||||
@property() enableModelSelector = true;
|
||||
@property() enableThinkingSelector = true;
|
||||
|
|
@ -52,6 +52,15 @@ export class AgentInterface extends LitElement {
|
|||
return this;
|
||||
}
|
||||
|
||||
override willUpdate(changedProperties: Map<string, any>) {
|
||||
super.willUpdate(changedProperties);
|
||||
|
||||
// Re-subscribe when session property changes
|
||||
if (changedProperties.has("session")) {
|
||||
this.setupSessionSubscription();
|
||||
}
|
||||
}
|
||||
|
||||
override async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
|
|
@ -84,11 +93,6 @@ export class AgentInterface extends LitElement {
|
|||
|
||||
// Subscribe to external session if provided
|
||||
this.setupSessionSubscription();
|
||||
|
||||
// Attach debug listener if session provided
|
||||
if (this.session) {
|
||||
this.session = this.session; // explicitly set to trigger subscription
|
||||
}
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
|
|
@ -116,7 +120,7 @@ export class AgentInterface extends LitElement {
|
|||
this._unsubscribeSession = undefined;
|
||||
}
|
||||
if (!this.session) return;
|
||||
this._unsubscribeSession = this.session.subscribe(async (ev: AgentSessionEvent) => {
|
||||
this._unsubscribeSession = this.session.subscribe(async (ev: AgentEvent) => {
|
||||
if (ev.type === "state-update") {
|
||||
if (this._streamingContainer) {
|
||||
this._streamingContainer.isStreaming = ev.state.isStreaming;
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ export class MessageEditor extends LitElement {
|
|||
"image/*,application/pdf,.docx,.pptx,.xlsx,.xls,.txt,.md,.json,.xml,.html,.css,.js,.ts,.jsx,.tsx,.yml,.yaml";
|
||||
|
||||
@state() processingFiles = false;
|
||||
@state() isDragging = false;
|
||||
private fileInputRef = createRef<HTMLInputElement>();
|
||||
|
||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
||||
|
|
@ -124,6 +125,62 @@ export class MessageEditor extends LitElement {
|
|||
this.onFilesChange?.(this.attachments);
|
||||
}
|
||||
|
||||
private handleDragOver = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!this.isDragging) {
|
||||
this.isDragging = true;
|
||||
}
|
||||
};
|
||||
|
||||
private handleDragLeave = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// Only set isDragging to false if we're leaving the entire component
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const x = e.clientX;
|
||||
const y = e.clientY;
|
||||
if (x <= rect.left || x >= rect.right || y <= rect.top || y >= rect.bottom) {
|
||||
this.isDragging = false;
|
||||
}
|
||||
};
|
||||
|
||||
private handleDrop = async (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.isDragging = false;
|
||||
|
||||
const files = Array.from(e.dataTransfer?.files || []);
|
||||
if (files.length === 0) return;
|
||||
|
||||
if (files.length + this.attachments.length > this.maxFiles) {
|
||||
alert(`Maximum ${this.maxFiles} files allowed`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.processingFiles = true;
|
||||
const newAttachments: Attachment[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
if (file.size > this.maxFileSize) {
|
||||
alert(`${file.name} exceeds maximum size of ${Math.round(this.maxFileSize / 1024 / 1024)}MB`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const attachment = await loadAttachment(file);
|
||||
newAttachments.push(attachment);
|
||||
} catch (error) {
|
||||
console.error(`Error processing ${file.name}:`, error);
|
||||
alert(`Failed to process ${file.name}: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.attachments = [...this.attachments, ...newAttachments];
|
||||
this.onFilesChange?.(this.attachments);
|
||||
this.processingFiles = false;
|
||||
};
|
||||
|
||||
private adjustTextareaHeight() {
|
||||
const textarea = this.textareaRef.value;
|
||||
if (textarea) {
|
||||
|
|
@ -157,7 +214,23 @@ export class MessageEditor extends LitElement {
|
|||
const supportsThinking = model?.reasoning === true; // Models with reasoning:true support thinking
|
||||
|
||||
return html`
|
||||
<div class="bg-card rounded-xl border border-border shadow-sm">
|
||||
<div
|
||||
class="bg-card rounded-xl border shadow-sm relative ${this.isDragging ? "border-primary border-2 bg-primary/5" : "border-border"}"
|
||||
@dragover=${this.handleDragOver}
|
||||
@dragleave=${this.handleDragLeave}
|
||||
@drop=${this.handleDrop}
|
||||
>
|
||||
<!-- Drag overlay -->
|
||||
${
|
||||
this.isDragging
|
||||
? html`
|
||||
<div class="absolute inset-0 bg-primary/10 rounded-xl pointer-events-none z-10 flex items-center justify-center">
|
||||
<div class="text-primary font-medium">${i18n("Drop files here")}</div>
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
|
||||
<!-- Attachments -->
|
||||
${
|
||||
this.attachments.length > 0
|
||||
|
|
|
|||
|
|
@ -64,11 +64,8 @@ export class ApiKeyPromptDialog extends DialogBase {
|
|||
children: html`
|
||||
${DialogHeader({
|
||||
title: i18n("API Key Required"),
|
||||
description: i18n("Enter your API key for {provider}").replace("{provider}", this.provider),
|
||||
})}
|
||||
<div class="mt-4">
|
||||
<provider-key-input .provider=${this.provider}></provider-key-input>
|
||||
</div>
|
||||
<provider-key-input .provider=${this.provider}></provider-key-input>
|
||||
`,
|
||||
})}
|
||||
`;
|
||||
|
|
|
|||
141
packages/web-ui/src/dialogs/PersistentStorageDialog.ts
Normal file
141
packages/web-ui/src/dialogs/PersistentStorageDialog.ts
Normal 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>
|
||||
`,
|
||||
})}
|
||||
`;
|
||||
}
|
||||
}
|
||||
135
packages/web-ui/src/dialogs/SessionListDialog.ts
Normal file
135
packages/web-ui/src/dialogs/SessionListDialog.ts
Normal 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>
|
||||
`,
|
||||
})}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,14 @@
|
|||
// Main chat interface
|
||||
export { ChatPanel } from "./ChatPanel.js";
|
||||
|
||||
export type { AgentState, ThinkingLevel } from "./agent/agent.js";
|
||||
// State management
|
||||
export { Agent } from "./agent/agent.js";
|
||||
// Transports
|
||||
export { AppTransport } from "./agent/transports/AppTransport.js";
|
||||
export { ProviderTransport } from "./agent/transports/ProviderTransport.js";
|
||||
export type { ProxyAssistantMessageEvent } from "./agent/transports/proxy-types.js";
|
||||
export type { AgentRunConfig, AgentTransport } from "./agent/transports/types.js";
|
||||
export { ChatPanel } from "./ChatPanel.js";
|
||||
// Components
|
||||
export { AgentInterface } from "./components/AgentInterface.js";
|
||||
export { AttachmentTile } from "./components/AttachmentTile.js";
|
||||
|
|
@ -9,6 +17,7 @@ export { Input } from "./components/Input.js";
|
|||
export { MessageEditor } from "./components/MessageEditor.js";
|
||||
export { MessageList } from "./components/MessageList.js";
|
||||
// Message components
|
||||
export type { AppMessage } from "./components/Messages.js";
|
||||
export { AssistantMessage, ToolMessage, UserMessage } from "./components/Messages.js";
|
||||
export {
|
||||
type SandboxFile,
|
||||
|
|
@ -21,24 +30,25 @@ export { ApiKeyPromptDialog } from "./dialogs/ApiKeyPromptDialog.js";
|
|||
export { AttachmentOverlay } from "./dialogs/AttachmentOverlay.js";
|
||||
// Dialogs
|
||||
export { ModelSelector } from "./dialogs/ModelSelector.js";
|
||||
export { PersistentStorageDialog } from "./dialogs/PersistentStorageDialog.js";
|
||||
export { SessionListDialog } from "./dialogs/SessionListDialog.js";
|
||||
export { ApiKeysTab, ProxyTab, SettingsDialog, SettingsTab } from "./dialogs/SettingsDialog.js";
|
||||
export type { AgentSessionState, ThinkingLevel } from "./state/agent-session.js";
|
||||
// State management
|
||||
export { AgentSession } from "./state/agent-session.js";
|
||||
|
||||
// Transports
|
||||
export { AppTransport } from "./state/transports/AppTransport.js";
|
||||
export { ProviderTransport } from "./state/transports/ProviderTransport.js";
|
||||
export type { ProxyAssistantMessageEvent } from "./state/transports/proxy-types.js";
|
||||
export type { AgentRunConfig, AgentTransport } from "./state/transports/types.js";
|
||||
// Storage
|
||||
export { AppStorage, getAppStorage, initAppStorage, setAppStorage } from "./storage/app-storage.js";
|
||||
export { ChromeStorageBackend } from "./storage/backends/chrome-storage-backend.js";
|
||||
export { IndexedDBBackend } from "./storage/backends/indexeddb-backend.js";
|
||||
export { LocalStorageBackend } from "./storage/backends/local-storage-backend.js";
|
||||
export { SessionIndexedDBBackend } from "./storage/backends/session-indexeddb-backend.js";
|
||||
export { ProviderKeysRepository } from "./storage/repositories/provider-keys-repository.js";
|
||||
export { SessionRepository } from "./storage/repositories/session-repository.js";
|
||||
export { SettingsRepository } from "./storage/repositories/settings-repository.js";
|
||||
export type { AppStorageConfig, StorageBackend } from "./storage/types.js";
|
||||
export type {
|
||||
AppStorageConfig,
|
||||
SessionData,
|
||||
SessionMetadata,
|
||||
SessionStorageBackend,
|
||||
StorageBackend,
|
||||
} from "./storage/types.js";
|
||||
// Artifacts
|
||||
export { ArtifactElement } from "./tools/artifacts/ArtifactElement.js";
|
||||
export { type Artifact, ArtifactsPanel, type ArtifactsParams } from "./tools/artifacts/artifacts.js";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
290
packages/web-ui/src/storage/repositories/session-repository.ts
Normal file
290
packages/web-ui/src/storage/repositories/session-repository.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,6 +113,28 @@ declare module "@mariozechner/mini-lit" {
|
|||
Low: string;
|
||||
Medium: string;
|
||||
High: string;
|
||||
"Storage Permission Required": string;
|
||||
"This app needs persistent storage to save your conversations": string;
|
||||
"Why is this needed?": string;
|
||||
"Without persistent storage, your browser may delete saved conversations when it needs disk space. Granting this permission ensures your chat history is preserved.": string;
|
||||
"What this means:": string;
|
||||
"Your conversations will be saved locally in your browser": string;
|
||||
"Data will not be deleted automatically to free up space": string;
|
||||
"You can still manually clear data at any time": string;
|
||||
"No data is sent to external servers": string;
|
||||
"Continue Anyway": string;
|
||||
"Requesting...": string;
|
||||
"Grant Permission": string;
|
||||
Sessions: string;
|
||||
"Load a previous conversation": string;
|
||||
"No sessions yet": string;
|
||||
"Delete this session?": string;
|
||||
Today: string;
|
||||
Yesterday: string;
|
||||
"{days} days ago": string;
|
||||
messages: string;
|
||||
tokens: string;
|
||||
"Drop files here": string;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -203,7 +225,6 @@ const translations = {
|
|||
Create: "Create",
|
||||
Rewrite: "Rewrite",
|
||||
Get: "Get",
|
||||
Delete: "Delete",
|
||||
"Get logs": "Get logs",
|
||||
"Show artifacts": "Show artifacts",
|
||||
"Close artifacts": "Close artifacts",
|
||||
|
|
@ -233,6 +254,33 @@ const translations = {
|
|||
Low: "Low",
|
||||
Medium: "Medium",
|
||||
High: "High",
|
||||
"Storage Permission Required": "Storage Permission Required",
|
||||
"This app needs persistent storage to save your conversations":
|
||||
"This app needs persistent storage to save your conversations",
|
||||
"Why is this needed?": "Why is this needed?",
|
||||
"Without persistent storage, your browser may delete saved conversations when it needs disk space. Granting this permission ensures your chat history is preserved.":
|
||||
"Without persistent storage, your browser may delete saved conversations when it needs disk space. Granting this permission ensures your chat history is preserved.",
|
||||
"What this means:": "What this means:",
|
||||
"Your conversations will be saved locally in your browser":
|
||||
"Your conversations will be saved locally in your browser",
|
||||
"Data will not be deleted automatically to free up space":
|
||||
"Data will not be deleted automatically to free up space",
|
||||
"You can still manually clear data at any time": "You can still manually clear data at any time",
|
||||
"No data is sent to external servers": "No data is sent to external servers",
|
||||
"Continue Anyway": "Continue Anyway",
|
||||
"Requesting...": "Requesting...",
|
||||
"Grant Permission": "Grant Permission",
|
||||
Sessions: "Sessions",
|
||||
"Load a previous conversation": "Load a previous conversation",
|
||||
"No sessions yet": "No sessions yet",
|
||||
"Delete this session?": "Delete this session?",
|
||||
Today: "Today",
|
||||
Yesterday: "Yesterday",
|
||||
"{days} days ago": "{days} days ago",
|
||||
messages: "messages",
|
||||
tokens: "tokens",
|
||||
Delete: "Delete",
|
||||
"Drop files here": "Drop files here",
|
||||
},
|
||||
de: {
|
||||
...defaultGerman,
|
||||
|
|
@ -320,7 +368,6 @@ const translations = {
|
|||
Create: "Erstellen",
|
||||
Rewrite: "Überschreiben",
|
||||
Get: "Abrufen",
|
||||
Delete: "Löschen",
|
||||
"Get logs": "Logs abrufen",
|
||||
"Show artifacts": "Artefakte anzeigen",
|
||||
"Close artifacts": "Artefakte schließen",
|
||||
|
|
@ -350,6 +397,33 @@ const translations = {
|
|||
Low: "Niedrig",
|
||||
Medium: "Mittel",
|
||||
High: "Hoch",
|
||||
"Storage Permission Required": "Speicherberechtigung erforderlich",
|
||||
"This app needs persistent storage to save your conversations":
|
||||
"Diese App benötigt dauerhaften Speicher, um Ihre Konversationen zu speichern",
|
||||
"Why is this needed?": "Warum wird das benötigt?",
|
||||
"Without persistent storage, your browser may delete saved conversations when it needs disk space. Granting this permission ensures your chat history is preserved.":
|
||||
"Ohne dauerhaften Speicher kann Ihr Browser gespeicherte Konversationen löschen, wenn Speicherplatz benötigt wird. Diese Berechtigung stellt sicher, dass Ihr Chatverlauf erhalten bleibt.",
|
||||
"What this means:": "Was das bedeutet:",
|
||||
"Your conversations will be saved locally in your browser":
|
||||
"Ihre Konversationen werden lokal in Ihrem Browser gespeichert",
|
||||
"Data will not be deleted automatically to free up space":
|
||||
"Daten werden nicht automatisch gelöscht, um Speicherplatz freizugeben",
|
||||
"You can still manually clear data at any time": "Sie können Daten jederzeit manuell löschen",
|
||||
"No data is sent to external servers": "Keine Daten werden an externe Server gesendet",
|
||||
"Continue Anyway": "Trotzdem fortfahren",
|
||||
"Requesting...": "Anfrage läuft...",
|
||||
"Grant Permission": "Berechtigung erteilen",
|
||||
Sessions: "Sitzungen",
|
||||
"Load a previous conversation": "Frühere Konversation laden",
|
||||
"No sessions yet": "Noch keine Sitzungen",
|
||||
"Delete this session?": "Diese Sitzung löschen?",
|
||||
Today: "Heute",
|
||||
Yesterday: "Gestern",
|
||||
"{days} days ago": "vor {days} Tagen",
|
||||
messages: "Nachrichten",
|
||||
tokens: "Tokens",
|
||||
Delete: "Löschen",
|
||||
"Drop files here": "Dateien hier ablegen",
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue