import { Button, icon, Input } 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, // PersistentStorageDialog, // TODO: Fix - currently broken ProviderTransport, ProxyTab, SessionIndexedDBBackend, SessionListDialog, setAppStorage, SettingsDialog, } from "@mariozechner/pi-web-ui"; import { html, render } from "lit"; import { Bell, History, Plus, Settings } from "lucide"; import "./app.css"; import { createSystemNotification, customMessageTransformer, registerCustomMessageRenderers } from "./custom-messages.js"; // Register custom message renderers registerCustomMessageRenderers(); const storage = new AppStorage({ sessions: new SessionIndexedDBBackend("pi-web-ui-sessions"), }); setAppStorage(storage); 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) => { 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.`, model: getModel("anthropic", "claude-sonnet-4-5-20250929"), thinkingLevel: "off", messages: [], tools: [], }, transport, // Custom transformer: convert system notifications to user messages with tags messageTransformer: customMessageTransformer, }); 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); }; const loadSession = async (sessionId: string): Promise => { if (!storage.sessions) return false; const sessionData = await storage.sessions.loadSession(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 ApiKeysTab(), 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(); 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) { const loaded = await loadSession(sessionIdFromUrl); if (!loaded) { // Session doesn't exist, redirect to new session newSession(); return; } } else { await createAgent(); } renderApp(); } initApp();