import "@mariozechner/mini-lit/dist/ThemeToggle.js"; import { Agent, type AgentMessage } from "@mariozechner/pi-agent-core"; import { getModel } from "@mariozechner/pi-ai"; import { type AgentState, ApiKeyPromptDialog, AppStorage, ChatPanel, CustomProvidersStore, createJavaScriptReplTool, IndexedDBStorageBackend, // PersistentStorageDialog, // TODO: Fix - currently broken ProviderKeysStore, ProvidersModelsTab, ProxyTab, SessionListDialog, SessionsStore, SettingsDialog, SettingsStore, setAppStorage, } from "@mariozechner/pi-web-ui"; import { html, render } from "lit"; import { Bell, History, Plus, Settings } from "lucide"; import "./app.css"; import { icon } from "@mariozechner/mini-lit"; import { Button } from "@mariozechner/mini-lit/dist/Button.js"; import { Input } from "@mariozechner/mini-lit/dist/Input.js"; import { createSystemNotification, customConvertToLlm, registerCustomMessageRenderers } from "./custom-messages.js"; // Register custom message renderers registerCustomMessageRenderers(); // Create stores const settings = new SettingsStore(); const providerKeys = new ProviderKeysStore(); const sessions = new SessionsStore(); const customProviders = new CustomProvidersStore(); // Gather configs const configs = [ settings.getConfig(), SessionsStore.getMetadataConfig(), providerKeys.getConfig(), customProviders.getConfig(), sessions.getConfig(), ]; // Create backend const backend = new IndexedDBStorageBackend({ dbName: "pi-web-ui-example", version: 2, // Incremented for custom-providers store stores: configs, }); // Wire backend to stores settings.setBackend(backend); providerKeys.setBackend(backend); customProviders.setBackend(backend); sessions.setBackend(backend); // Create and set app storage const storage = new AppStorage(settings, providerKeys, sessions, customProviders, backend); setAppStorage(storage); let currentSessionId: string | undefined; let currentTitle = ""; let isEditingTitle = false; let agent: Agent; let chatPanel: ChatPanel; let agentUnsubscribe: (() => void) | undefined; const generateTitle = (messages: AgentMessage[]): string => { const firstUserMsg = messages.find((m) => m.role === "user" || m.role === "user-with-attachments"); if (!firstUserMsg || (firstUserMsg.role !== "user" && firstUserMsg.role !== "user-with-attachments")) 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: AgentMessage[]): boolean => { const hasUserMsg = messages.some((m: any) => m.role === "user" || m.role === "user-with-attachments"); 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 { // Create session data const sessionData = { id: currentSessionId, title: currentTitle, model: state.model!, thinkingLevel: state.thinkingLevel, messages: state.messages, createdAt: new Date().toISOString(), lastModified: new Date().toISOString(), }; // Create session metadata const metadata = { id: currentSessionId, title: currentTitle, createdAt: sessionData.createdAt, lastModified: sessionData.lastModified, messageCount: state.messages.length, usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0, }, }, modelId: state.model?.id || null, thinkingLevel: state.thinkingLevel, preview: generateTitle(state.messages), }; await storage.sessions.save(sessionData, metadata); } catch (err) { console.error("Failed to save session:", err); } }; const updateUrl = (sessionId: string) => { const url = new URL(window.location.href); url.searchParams.set("session", sessionId); window.history.replaceState({}, "", url); }; const createAgent = async (initialState?: Partial) => { if (agentUnsubscribe) { agentUnsubscribe(); } 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.`, model: getModel("anthropic", "claude-sonnet-4-5-20250929"), thinkingLevel: "off", messages: [], tools: [], }, // Custom transformer: convert custom messages to LLM-compatible format convertToLlm: customConvertToLlm, }); 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, { onApiKeyRequired: async (provider: string) => { return await ApiKeyPromptDialog.prompt(provider); }, toolsFactory: (_agent, _agentInterface, _artifactsPanel, runtimeProvidersFactory) => { // Create javascript_repl tool with access to attachments + artifacts const replTool = createJavaScriptReplTool(); replTool.runtimeProvidersFactory = runtimeProvidersFactory; return [replTool]; }, }); }; const loadSession = async (sessionId: string): Promise => { if (!storage.sessions) return false; const sessionData = await storage.sessions.get(sessionId); if (!sessionData) { console.error("Session not found:", sessionId); return false; } 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(); return true; }; const newSession = () => { const url = new URL(window.location.href); url.search = ""; window.location.href = url.toString(); }; // ============================================================================ // RENDER // ============================================================================ const renderApp = () => { const app = document.getElementById("app"); if (!app) return; const appHtml = html`
${Button({ variant: "ghost", size: "sm", children: icon(History, "sm"), onClick: () => { SessionListDialog.open( async (sessionId) => { await loadSession(sessionId); }, (deletedSessionId) => { // Only reload if the current session was deleted if (deletedSessionId === currentSessionId) { newSession(); } }, ); }, title: "Sessions", })} ${Button({ variant: "ghost", size: "sm", children: icon(Plus, "sm"), onClick: newSession, title: "New Session", })} ${ currentTitle ? isEditingTitle ? html`
${Input({ type: "text", value: currentTitle, className: "text-sm w-64", onChange: async (e: Event) => { const newTitle = (e.target as HTMLInputElement).value.trim(); if (newTitle && newTitle !== currentTitle && storage.sessions && currentSessionId) { await storage.sessions.updateTitle(currentSessionId, newTitle); currentTitle = newTitle; } isEditingTitle = false; renderApp(); }, onKeyDown: async (e: KeyboardEvent) => { if (e.key === "Enter") { const newTitle = (e.target as HTMLInputElement).value.trim(); if (newTitle && newTitle !== currentTitle && storage.sessions && currentSessionId) { await storage.sessions.updateTitle(currentSessionId, newTitle); currentTitle = newTitle; } isEditingTitle = false; renderApp(); } else if (e.key === "Escape") { isEditingTitle = false; renderApp(); } }, })}
` : html`` : html`Pi Web UI Example` }
${Button({ variant: "ghost", size: "sm", children: icon(Bell, "sm"), onClick: () => { // Demo: Inject custom message if (agent) { agent.appendMessage( createSystemNotification( "This is a custom message! It appears in the UI but is never sent to the LLM.", ), ); } }, title: "Demo: Add Custom Notification", })} ${Button({ variant: "ghost", size: "sm", children: icon(Settings, "sm"), onClick: () => SettingsDialog.open([new ProvidersModelsTab(), new ProxyTab()]), title: "Settings", })}
${chatPanel}
`; render(appHtml, app); }; // ============================================================================ // INIT // ============================================================================ async function initApp() { const app = document.getElementById("app"); if (!app) throw new Error("App container not found"); // Show loading render( html`
Loading...
`, app, ); // TODO: Fix PersistentStorageDialog - currently broken // Request persistent storage // if (storage.sessions) { // await PersistentStorageDialog.request(); // } // Create ChatPanel chatPanel = new ChatPanel(); // Check for session in URL const urlParams = new URLSearchParams(window.location.search); const sessionIdFromUrl = urlParams.get("session"); if (sessionIdFromUrl) { const loaded = await loadSession(sessionIdFromUrl); if (!loaded) { // Session doesn't exist, redirect to new session newSession(); return; } } else { await createAgent(); } renderApp(); } initApp();