Skip to content

Instantly share code, notes, and snippets.

@oxyflour
Created March 25, 2026 16:03
Show Gist options
  • Select an option

  • Save oxyflour/e4902d37e7e50f041892f47a000c6dab to your computer and use it in GitHub Desktop.

Select an option

Save oxyflour/e4902d37e7e50f041892f47a000c6dab to your computer and use it in GitHub Desktop.
minimal pi-agent-web-ui
/**
* 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