Created
March 25, 2026 16:03
-
-
Save oxyflour/e4902d37e7e50f041892f47a000c6dab to your computer and use it in GitHub Desktop.
minimal pi-agent-web-ui
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * Pi Web UI - Starter Example | |
| * | |
| * This demonstrates the core features of @mariozechner/pi-web-ui: | |
| * - ChatPanel component with streaming, tool execution, and artifacts | |
| * - IndexedDB storage for sessions, settings, and API keys | |
| * - JavaScript REPL tool for code execution | |
| * - Session management (save/load/list) | |
| */ | |
| import { Agent } from "@mariozechner/pi-agent-core"; | |
| import { getModel, streamSimple, type Context, type Model, type SimpleStreamOptions } from "@mariozechner/pi-ai"; | |
| import { | |
| ChatPanel, | |
| AppStorage, | |
| IndexedDBStorageBackend, | |
| ProviderKeysStore, | |
| SessionsStore, | |
| SettingsStore, | |
| CustomProvidersStore, | |
| setAppStorage, | |
| defaultConvertToLlm, | |
| ApiKeyPromptDialog, | |
| SessionListDialog, | |
| SettingsDialog, | |
| ProvidersModelsTab, | |
| ProxyTab, | |
| ApiKeysTab, | |
| createJavaScriptReplTool, | |
| } from "@mariozechner/pi-web-ui"; | |
| import "@mariozechner/pi-web-ui/app.css"; | |
| import { html, render } from "lit"; | |
| import { History, Plus, Settings } from "lucide"; | |
| import { icon } from "@mariozechner/mini-lit"; | |
| import { Button } from "@mariozechner/mini-lit/dist/Button.js"; | |
| // ============================================================================ | |
| // STORAGE SETUP | |
| // ============================================================================ | |
| // Create stores | |
| const settings = new SettingsStore(); | |
| const providerKeys = new ProviderKeysStore(); | |
| const sessions = new SessionsStore(); | |
| const customProviders = new CustomProvidersStore(); | |
| // Create IndexedDB backend | |
| const backend = new IndexedDBStorageBackend({ | |
| dbName: "pi-starter-app", | |
| version: 1, | |
| stores: [ | |
| settings.getConfig(), | |
| providerKeys.getConfig(), | |
| sessions.getConfig(), | |
| SessionsStore.getMetadataConfig(), | |
| customProviders.getConfig(), | |
| ], | |
| }); | |
| // Wire stores to backend | |
| settings.setBackend(backend); | |
| providerKeys.setBackend(backend); | |
| sessions.setBackend(backend); | |
| customProviders.setBackend(backend); | |
| // Create and set global app storage | |
| const storage = new AppStorage(settings, providerKeys, sessions, customProviders, backend); | |
| setAppStorage(storage); | |
| // ============================================================================ | |
| // APP STATE | |
| // ============================================================================ | |
| let agent: Agent; | |
| let chatPanel: ChatPanel; | |
| let currentSessionId: string | undefined; | |
| let currentTitle = ""; | |
| // ============================================================================ | |
| // SESSION MANAGEMENT | |
| // ============================================================================ | |
| /** | |
| * Generate a title from the first user message | |
| */ | |
| const generateTitle = (messages: any[]): string => { | |
| const firstUserMsg = messages.find((m) => m.role === "user" || m.role === "user-with-attachments"); | |
| if (!firstUserMsg) 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)}...`; | |
| }; | |
| /** | |
| * Check if session should be saved (has both user and assistant messages) | |
| */ | |
| const shouldSaveSession = (messages: any[]): boolean => { | |
| const hasUserMsg = messages.some((m) => m.role === "user" || m.role === "user-with-attachments"); | |
| const hasAssistantMsg = messages.some((m) => m.role === "assistant"); | |
| return hasUserMsg && hasAssistantMsg; | |
| }; | |
| /** | |
| * Save current session to IndexedDB | |
| */ | |
| const saveSession = async () => { | |
| if (!currentSessionId || !agent || !currentTitle) return; | |
| const state = agent.state; | |
| if (!shouldSaveSession(state.messages)) return; | |
| const sessionData = { | |
| id: currentSessionId, | |
| title: currentTitle, | |
| model: state.model!, | |
| thinkingLevel: state.thinkingLevel, | |
| messages: state.messages, | |
| createdAt: new Date().toISOString(), | |
| lastModified: new Date().toISOString(), | |
| }; | |
| 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); | |
| }; | |
| /** | |
| * Create a new agent instance | |
| */ | |
| let agentUnsubscribe: (() => void) | undefined; | |
| const createAgent = async (initialState?: any) => { | |
| // Clean up previous agent subscription | |
| if (agentUnsubscribe) { | |
| agentUnsubscribe(); | |
| agentUnsubscribe = undefined; | |
| } | |
| // Custom stream function that routes through Vite's local CORS proxy | |
| // The proxy is at /api-proxy/:provider/* | |
| const streamFn = async (model: Model<any>, context: Context, options?: SimpleStreamOptions) => { | |
| console.log("[streamFn] Original model:", { provider: model.provider, baseUrl: model.baseUrl, id: model.id }); | |
| // Validate model has required fields | |
| if (!model.baseUrl) { | |
| console.error("[streamFn] ERROR: model.baseUrl is missing!"); | |
| throw new Error("Model baseUrl is required"); | |
| } | |
| if (!model.provider) { | |
| console.error("[streamFn] ERROR: model.provider is missing!"); | |
| throw new Error("Model provider is required"); | |
| } | |
| // Extract path from original baseUrl (e.g., "https://api.anthropic.com/v1" -> "/v1") | |
| const originalBaseUrl = model.baseUrl; | |
| const pathMatch = originalBaseUrl.match(/^https?:\/\/[^\/]+(.*)$/); | |
| const apiPath = pathMatch ? pathMatch[1] : ""; | |
| // MUST be a full URL (not relative) for the OpenAI client | |
| const proxiedBaseUrl = `${window.location.origin}/api-proxy/${model.provider}${apiPath}`; | |
| console.log("[streamFn] Constructed URL:", { originalBaseUrl, apiPath, proxiedBaseUrl }); | |
| // Create a proxied model that uses the local Vite proxy | |
| const proxiedModel = { | |
| ...model, | |
| baseUrl: proxiedBaseUrl, | |
| }; | |
| console.log("[streamFn] Calling streamSimple with:", { baseUrl: proxiedModel.baseUrl, provider: proxiedModel.provider }); | |
| try { | |
| return streamSimple(proxiedModel, context, options); | |
| } catch (err) { | |
| console.error("[streamFn] streamSimple failed:", err); | |
| throw err; | |
| } | |
| }; | |
| const model = initialState?.model || getModel("anthropic", "claude-sonnet-4-5-20250929"); | |
| console.log("[createAgent] Final model:", { id: model.id, provider: model.provider, baseUrl: model.baseUrl, api: model.api }); | |
| agent = new Agent({ | |
| initialState: initialState || { | |
| systemPrompt: `You are a helpful AI assistant with access to JavaScript execution. | |
| You can execute JavaScript code using the javascript_repl tool to help with: | |
| - Calculations and data processing | |
| - Getting the current time/date | |
| - Creating visualizations and charts | |
| - Testing code snippets | |
| When you need to perform calculations or get real-time data, use the JavaScript REPL tool.`, | |
| model, | |
| thinkingLevel: "off", | |
| messages: [], | |
| tools: [], | |
| }, | |
| convertToLlm: defaultConvertToLlm, | |
| streamFn, | |
| }); | |
| // Subscribe to state changes for auto-save | |
| agentUnsubscribe = agent.subscribe((event: any) => { | |
| if (event.type === "state-update") { | |
| const messages = event.state.messages; | |
| // Generate title on first successful exchange | |
| if (!currentTitle && shouldSaveSession(messages)) { | |
| currentTitle = generateTitle(messages); | |
| } | |
| // Create session ID on first save | |
| if (!currentSessionId && shouldSaveSession(messages)) { | |
| currentSessionId = crypto.randomUUID(); | |
| updateUrl(currentSessionId); | |
| } | |
| // Auto-save session | |
| if (currentSessionId) { | |
| saveSession(); | |
| } | |
| renderApp(); | |
| } | |
| }); | |
| // Configure ChatPanel with the agent | |
| await chatPanel.setAgent(agent, { | |
| // Prompt for API key when needed | |
| onApiKeyRequired: async (provider: string) => { | |
| return await ApiKeyPromptDialog.prompt(provider); | |
| }, | |
| // Add JavaScript REPL tool with runtime providers | |
| toolsFactory: (_agent, _agentInterface, _artifactsPanel, runtimeProvidersFactory) => { | |
| const replTool = createJavaScriptReplTool(); | |
| replTool.runtimeProvidersFactory = runtimeProvidersFactory; | |
| return [replTool]; | |
| }, | |
| }); | |
| }; | |
| /** | |
| * Load an existing session | |
| */ | |
| const loadSession = async (sessionId: string): Promise<boolean> => { | |
| 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; | |
| }; | |
| /** | |
| * Start a new session | |
| */ | |
| const newSession = () => { | |
| const url = new URL(window.location.href); | |
| url.search = ""; | |
| window.location.href = url.toString(); | |
| }; | |
| /** | |
| * Update URL with session ID | |
| */ | |
| const updateUrl = (sessionId: string) => { | |
| const url = new URL(window.location.href); | |
| url.searchParams.set("session", sessionId); | |
| window.history.replaceState({}, "", url); | |
| }; | |
| // ============================================================================ | |
| // UI RENDERING | |
| // ============================================================================ | |
| 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 px-4 py-2 shrink-0"> | |
| <div class="flex items-center gap-2"> | |
| <!-- Session List Button --> | |
| ${Button({ | |
| variant: "ghost", | |
| size: "sm", | |
| children: icon(History, "sm"), | |
| onClick: () => { | |
| SessionListDialog.open( | |
| async (sessionId) => loadSession(sessionId), | |
| (deletedId) => { | |
| if (deletedId === currentSessionId) newSession(); | |
| } | |
| ); | |
| }, | |
| title: "Session History", | |
| })} | |
| <!-- New Session Button --> | |
| ${Button({ | |
| variant: "ghost", | |
| size: "sm", | |
| children: icon(Plus, "sm"), | |
| onClick: newSession, | |
| title: "New Session", | |
| })} | |
| <!-- Title --> | |
| <span class="text-sm font-medium text-foreground ml-2"> | |
| ${currentTitle || "New Chat"} | |
| </span> | |
| </div> | |
| <!-- Right Side Controls --> | |
| <div class="flex items-center gap-1"> | |
| <theme-toggle></theme-toggle> | |
| ${Button({ | |
| variant: "ghost", | |
| size: "sm", | |
| children: icon(Settings, "sm"), | |
| onClick: () => | |
| SettingsDialog.open([ | |
| new ProvidersModelsTab(), // Custom providers & models | |
| new ProxyTab(), // CORS proxy settings | |
| new ApiKeysTab(), // API keys per provider | |
| ]), | |
| title: "Settings", | |
| })} | |
| </div> | |
| </div> | |
| <!-- Chat Panel --> | |
| <div class="flex-1 overflow-hidden"> | |
| ${chatPanel} | |
| </div> | |
| </div> | |
| `; | |
| render(appHtml, app); | |
| }; | |
| // ============================================================================ | |
| // APP INITIALIZATION | |
| // ============================================================================ | |
| async function initApp() { | |
| const app = document.getElementById("app"); | |
| if (!app) throw new Error("App container not found"); | |
| // Show loading state | |
| 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 | |
| ); | |
| // Create ChatPanel instance | |
| chatPanel = new ChatPanel(); | |
| // Check for session ID in URL | |
| const urlParams = new URLSearchParams(window.location.search); | |
| const sessionIdFromUrl = urlParams.get("session"); | |
| if (sessionIdFromUrl) { | |
| const loaded = await loadSession(sessionIdFromUrl); | |
| if (!loaded) { | |
| newSession(); | |
| return; | |
| } | |
| } else { | |
| await createAgent(); | |
| } | |
| renderApp(); | |
| } | |
| // Start the app | |
| initApp(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment