diff --git a/package-lock.json b/package-lock.json
index fd7f765f..fa004d42 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -5293,6 +5293,7 @@
"dependencies": {
"@mariozechner/mini-lit": "^0.1.7",
"@mariozechner/pi-ai": "^0.5.43",
+ "@mariozechner/pi-web-ui": "^0.5.43",
"docx-preview": "^0.3.7",
"js-interpreter": "^6.0.1",
"jszip": "^3.10.1",
diff --git a/packages/web-ui/README.md b/packages/web-ui/README.md
new file mode 100644
index 00000000..37156bce
--- /dev/null
+++ b/packages/web-ui/README.md
@@ -0,0 +1,252 @@
+# @mariozechner/pi-web-ui
+
+Reusable web UI components for building AI chat interfaces powered by [@mariozechner/pi-ai](../ai).
+
+Built with [mini-lit](https://github.com/mariozechner/mini-lit) web components and Tailwind CSS v4.
+
+## Features
+
+- 🎨 **Modern Chat Interface** - Complete chat UI with message history, streaming responses, and tool execution
+- 🔧 **Tool Support** - Built-in renderers for calculator, bash, time, and custom tools
+- 📎 **Attachments** - PDF, Office documents, images with preview and text extraction
+- 🎭 **Artifacts** - HTML, SVG, Markdown, and text artifact rendering with sandboxed execution
+- 🔌 **Pluggable Transports** - Direct API calls or proxy server support
+- 🌐 **Platform Agnostic** - Works in browser extensions, web apps, VS Code extensions, Electron apps
+- 🎯 **TypeScript** - Full type safety with TypeScript
+
+## Installation
+
+```bash
+npm install @mariozechner/pi-web-ui
+```
+
+## Quick Start
+
+See the [example](./example) directory for a complete working application.
+
+```typescript
+import { ChatPanel } from '@mariozechner/pi-web-ui';
+import { calculateTool, getCurrentTimeTool } from '@mariozechner/pi-ai';
+import '@mariozechner/pi-web-ui/app.css';
+
+// Create a chat panel
+const chatPanel = new ChatPanel();
+chatPanel.systemPrompt = 'You are a helpful assistant.';
+chatPanel.additionalTools = [calculateTool, getCurrentTimeTool];
+
+document.body.appendChild(chatPanel);
+```
+
+**Run the example:**
+
+```bash
+cd example
+npm install
+npm run dev
+```
+
+## Core Components
+
+### ChatPanel
+
+The main chat interface component. Manages agent sessions, model selection, and conversation flow.
+
+```typescript
+import { ChatPanel } from '@mariozechner/pi-web-ui';
+
+const panel = new ChatPanel({
+ initialModel: 'anthropic/claude-sonnet-4-20250514',
+ systemPrompt: 'You are a helpful assistant.',
+ transportMode: 'direct', // or 'proxy'
+});
+```
+
+### AgentInterface
+
+Lower-level chat interface for custom implementations.
+
+```typescript
+import { AgentInterface } from '@mariozechner/pi-web-ui';
+
+const chat = new AgentInterface();
+chat.session = myAgentSession;
+```
+
+## State Management
+
+### AgentSession
+
+Manages conversation state, tool execution, and streaming.
+
+```typescript
+import { AgentSession, DirectTransport } from '@mariozechner/pi-web-ui';
+import { getModel, calculateTool, getCurrentTimeTool } from '@mariozechner/pi-ai';
+
+const session = new AgentSession({
+ initialState: {
+ model: getModel('anthropic', 'claude-3-5-haiku-20241022'),
+ systemPrompt: 'You are a helpful assistant.',
+ tools: [calculateTool, getCurrentTimeTool],
+ messages: [],
+ },
+ transportMode: 'direct',
+});
+
+// Subscribe to state changes
+session.subscribe((state) => {
+ console.log('Messages:', state.messages);
+ console.log('Streaming:', state.streaming);
+});
+
+// Send a message
+await session.send('What is 25 * 18?');
+```
+
+### Transports
+
+Transport layers handle communication with AI providers.
+
+#### DirectTransport
+
+Calls AI provider APIs directly from the browser using API keys stored locally.
+
+```typescript
+import { DirectTransport, KeyStore } from '@mariozechner/pi-web-ui';
+
+// Set API keys
+const keyStore = new KeyStore();
+await keyStore.setKey('anthropic', 'sk-ant-...');
+await keyStore.setKey('openai', 'sk-...');
+
+// Use direct transport (default)
+const session = new AgentSession({
+ transportMode: 'direct',
+ // ...
+});
+```
+
+#### ProxyTransport
+
+Routes requests through a proxy server using auth tokens.
+
+```typescript
+import { ProxyTransport, setAuthToken } from '@mariozechner/pi-web-ui';
+
+// Set auth token
+setAuthToken('your-auth-token');
+
+// Use proxy transport
+const session = new AgentSession({
+ transportMode: 'proxy',
+ // ...
+});
+```
+
+## Tool Renderers
+
+Customize how tool calls and results are displayed.
+
+```typescript
+import { registerToolRenderer, type ToolRenderer } from '@mariozechner/pi-web-ui';
+import { html } from '@mariozechner/mini-lit';
+
+const myRenderer: ToolRenderer = {
+ renderParams(params, isStreaming) {
+ return html`
Calling tool with: ${JSON.stringify(params)}
`;
+ },
+
+ renderResult(params, result) {
+ return html`Result: ${result.output}
`;
+ }
+};
+
+registerToolRenderer('my_tool', myRenderer);
+```
+
+## Artifacts
+
+Render rich content with sandboxed execution.
+
+```typescript
+import { artifactTools } from '@mariozechner/pi-web-ui';
+import { getModel } from '@mariozechner/pi-ai';
+
+const session = new AgentSession({
+ initialState: {
+ tools: [...artifactTools],
+ // ...
+ }
+});
+
+// AI can now create HTML artifacts, SVG diagrams, etc.
+```
+
+## Styling
+
+The package includes pre-built Tailwind CSS with the Claude theme:
+
+```typescript
+import '@mariozechner/pi-web-ui/app.css';
+```
+
+Or customize with your own Tailwind config:
+
+```css
+@import '@mariozechner/mini-lit/themes/claude.css';
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+```
+
+## Platform Integration
+
+### Browser Extension
+
+```typescript
+import { ChatPanel, KeyStore } from '@mariozechner/pi-web-ui';
+
+// Use chrome.storage for persistence
+const keyStore = new KeyStore({
+ get: async (key) => {
+ const result = await chrome.storage.local.get(key);
+ return result[key];
+ },
+ set: async (key, value) => {
+ await chrome.storage.local.set({ [key]: value });
+ }
+});
+```
+
+### Web Application
+
+```typescript
+import { ChatPanel } from '@mariozechner/pi-web-ui';
+
+// Uses localStorage by default
+const panel = new ChatPanel();
+document.querySelector('#app').appendChild(panel);
+```
+
+### VS Code Extension
+
+```typescript
+import { AgentSession, DirectTransport } from '@mariozechner/pi-web-ui';
+
+// Custom storage using VS Code's globalState
+const storage = {
+ get: async (key) => context.globalState.get(key),
+ set: async (key, value) => context.globalState.update(key, value)
+};
+```
+
+## Examples
+
+See the [browser-extension](../browser-extension) package for a complete implementation example.
+
+## API Reference
+
+See [src/index.ts](src/index.ts) for the full public API.
+
+## License
+
+MIT
diff --git a/packages/web-ui/example/.gitignore b/packages/web-ui/example/.gitignore
new file mode 100644
index 00000000..0ca39c00
--- /dev/null
+++ b/packages/web-ui/example/.gitignore
@@ -0,0 +1,3 @@
+node_modules
+dist
+.DS_Store
diff --git a/packages/web-ui/example/README.md b/packages/web-ui/example/README.md
new file mode 100644
index 00000000..5fad0572
--- /dev/null
+++ b/packages/web-ui/example/README.md
@@ -0,0 +1,61 @@
+# Pi Web UI - Example
+
+This is a minimal example showing how to use `@mariozechner/pi-web-ui` in a web application.
+
+## Setup
+
+```bash
+npm install
+```
+
+## Development
+
+```bash
+npm run dev
+```
+
+Open [http://localhost:5173](http://localhost:5173) in your browser.
+
+## What's Included
+
+This example demonstrates:
+
+- **ChatPanel** - The main chat interface component
+- **System Prompt** - Custom configuration for the AI assistant
+- **Tools** - JavaScript REPL and artifacts tool
+
+## Configuration
+
+### API Keys
+
+The example uses **Direct Mode** by default, which means it calls AI provider APIs directly from the browser.
+
+To use the chat:
+
+1. Click the settings icon (⚙️) in the chat interface
+2. Click "Manage API Keys"
+3. Add your API key for your preferred provider:
+ - **Anthropic**: Get a key from [console.anthropic.com](https://console.anthropic.com/)
+ - **OpenAI**: Get a key from [platform.openai.com](https://platform.openai.com/)
+ - **Google**: Get a key from [makersuite.google.com](https://makersuite.google.com/)
+
+API keys are stored in your browser's localStorage and never sent to any server except the AI provider's API.
+
+## Project Structure
+
+```
+example/
+├── src/
+│ ├── main.ts # Main application entry point
+│ └── app.css # Tailwind CSS configuration
+├── index.html # HTML entry point
+├── package.json # Dependencies
+├── vite.config.ts # Vite configuration
+└── tsconfig.json # TypeScript configuration
+```
+
+## Learn More
+
+- [Pi Web UI Documentation](../README.md)
+- [Pi AI Documentation](../../ai/README.md)
+- [Mini Lit Documentation](https://github.com/mariozechner/mini-lit)
diff --git a/packages/web-ui/example/index.html b/packages/web-ui/example/index.html
new file mode 100644
index 00000000..5e844470
--- /dev/null
+++ b/packages/web-ui/example/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+ Pi Web UI - Example
+
+
+
+
+
+
+
diff --git a/packages/web-ui/example/package-lock.json b/packages/web-ui/example/package-lock.json
new file mode 100644
index 00000000..f8472689
--- /dev/null
+++ b/packages/web-ui/example/package-lock.json
@@ -0,0 +1,1965 @@
+{
+ "name": "pi-web-ui-example",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "pi-web-ui-example",
+ "version": "1.0.0",
+ "dependencies": {
+ "@mariozechner/mini-lit": "^0.1.7",
+ "@mariozechner/pi-ai": "file:../../ai",
+ "@mariozechner/pi-web-ui": "file:../",
+ "@tailwindcss/vite": "^4.1.13",
+ "lit": "^3.3.1",
+ "lucide": "^0.544.0"
+ },
+ "devDependencies": {
+ "typescript": "^5.7.3",
+ "vite": "^7.1.6"
+ }
+ },
+ "..": {
+ "name": "@mariozechner/pi-web-ui",
+ "version": "0.5.43",
+ "license": "MIT",
+ "dependencies": {
+ "@mariozechner/mini-lit": "^0.1.7",
+ "@mariozechner/pi-ai": "^0.5.43",
+ "docx-preview": "^0.3.7",
+ "jszip": "^3.10.1",
+ "lit": "^3.3.1",
+ "lucide": "^0.544.0",
+ "pdfjs-dist": "^5.4.149",
+ "xlsx": "^0.18.5"
+ },
+ "devDependencies": {
+ "@tailwindcss/cli": "^4.0.0-beta.14",
+ "concurrently": "^9.2.1",
+ "typescript": "^5.7.3"
+ }
+ },
+ "../../ai": {
+ "name": "@mariozechner/pi-ai",
+ "version": "0.5.43",
+ "license": "MIT",
+ "dependencies": {
+ "@anthropic-ai/sdk": "^0.61.0",
+ "@google/genai": "^1.17.0",
+ "@sinclair/typebox": "^0.34.41",
+ "ajv": "^8.17.1",
+ "ajv-formats": "^3.0.1",
+ "chalk": "^5.6.2",
+ "openai": "5.21.0",
+ "partial-json": "^0.1.7",
+ "zod-to-json-schema": "^3.24.6"
+ },
+ "devDependencies": {
+ "@types/node": "^24.3.0",
+ "canvas": "^3.2.0",
+ "vitest": "^3.2.4"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.10",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz",
+ "integrity": "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.25.10",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.10.tgz",
+ "integrity": "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.25.10",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.10.tgz",
+ "integrity": "sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.25.10",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.10.tgz",
+ "integrity": "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.10",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.10.tgz",
+ "integrity": "sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.10",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.10.tgz",
+ "integrity": "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.10",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.10.tgz",
+ "integrity": "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.10",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.10.tgz",
+ "integrity": "sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.25.10",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.10.tgz",
+ "integrity": "sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.10",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.10.tgz",
+ "integrity": "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.10",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.10.tgz",
+ "integrity": "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.10",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.10.tgz",
+ "integrity": "sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==",
+ "cpu": [
+ "loong64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.10",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.10.tgz",
+ "integrity": "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==",
+ "cpu": [
+ "mips64el"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.10",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.10.tgz",
+ "integrity": "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.10",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.10.tgz",
+ "integrity": "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.10",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.10.tgz",
+ "integrity": "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.25.10",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.10.tgz",
+ "integrity": "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.10",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.10.tgz",
+ "integrity": "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.10",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.10.tgz",
+ "integrity": "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.10",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.10.tgz",
+ "integrity": "sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.10",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.10.tgz",
+ "integrity": "sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.10",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.10.tgz",
+ "integrity": "sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.10",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.10.tgz",
+ "integrity": "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.10",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.10.tgz",
+ "integrity": "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.10",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.10.tgz",
+ "integrity": "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.25.10",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.10.tgz",
+ "integrity": "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@isaacs/fs-minipass": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
+ "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
+ "license": "ISC",
+ "dependencies": {
+ "minipass": "^7.0.4"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@lit-labs/ssr-dom-shim": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.4.0.tgz",
+ "integrity": "sha512-ficsEARKnmmW5njugNYKipTm4SFnbik7CXtoencDZzmzo/dQ+2Q0bgkzJuoJP20Aj0F+izzJjOqsnkd6F/o1bw==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@lit/reactive-element": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.1.1.tgz",
+ "integrity": "sha512-N+dm5PAYdQ8e6UlywyyrgI2t++wFGXfHx+dSJ1oBrg6FAxUj40jId++EaRm80MKX5JnlH1sBsyZ5h0bcZKemCg==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@lit-labs/ssr-dom-shim": "^1.4.0"
+ }
+ },
+ "node_modules/@mariozechner/mini-lit": {
+ "version": "0.1.7",
+ "resolved": "https://registry.npmjs.org/@mariozechner/mini-lit/-/mini-lit-0.1.7.tgz",
+ "integrity": "sha512-P77V7xTaJrc3mocG7wWtxFrhidkBGirD0hRq0aFWpOf+lpjFinkMCnzgY3uB2HcngajDDJpZk5OlvNl+SoiHaw==",
+ "dependencies": {
+ "@preact/signals-core": "^1.12.1",
+ "class-variance-authority": "^0.7.1",
+ "diff": "^8.0.2",
+ "highlight.js": "^11.11.1",
+ "html-parse-string": "^0.0.9",
+ "katex": "^0.16.22",
+ "lucide": "^0.544.0",
+ "marked": "^16.3.0",
+ "tailwind-merge": "^3.3.1",
+ "tailwind-variants": "^3.1.1",
+ "uhtml": "^5.0.9"
+ },
+ "peerDependencies": {
+ "lit": "^3.3.1"
+ }
+ },
+ "node_modules/@mariozechner/pi-ai": {
+ "resolved": "../../ai",
+ "link": true
+ },
+ "node_modules/@mariozechner/pi-web-ui": {
+ "resolved": "..",
+ "link": true
+ },
+ "node_modules/@preact/signals-core": {
+ "version": "1.12.1",
+ "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.12.1.tgz",
+ "integrity": "sha512-BwbTXpj+9QutoZLQvbttRg5x3l5468qaV2kufh+51yha1c53ep5dY4kTuZR35+3pAZxpfQerGJiQqg34ZNZ6uA==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/preact"
+ }
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.52.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.4.tgz",
+ "integrity": "sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.52.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.4.tgz",
+ "integrity": "sha512-P9LDQiC5vpgGFgz7GSM6dKPCiqR3XYN1WwJKA4/BUVDjHpYsf3iBEmVz62uyq20NGYbiGPR5cNHI7T1HqxNs2w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.52.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.4.tgz",
+ "integrity": "sha512-QRWSW+bVccAvZF6cbNZBJwAehmvG9NwfWHwMy4GbWi/BQIA/laTIktebT2ipVjNncqE6GLPxOok5hsECgAxGZg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.52.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.4.tgz",
+ "integrity": "sha512-hZgP05pResAkRJxL1b+7yxCnXPGsXU0fG9Yfd6dUaoGk+FhdPKCJ5L1Sumyxn8kvw8Qi5PvQ8ulenUbRjzeCTw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.52.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.4.tgz",
+ "integrity": "sha512-xmc30VshuBNUd58Xk4TKAEcRZHaXlV+tCxIXELiE9sQuK3kG8ZFgSPi57UBJt8/ogfhAF5Oz4ZSUBN77weM+mQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.52.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.4.tgz",
+ "integrity": "sha512-WdSLpZFjOEqNZGmHflxyifolwAiZmDQzuOzIq9L27ButpCVpD7KzTRtEG1I0wMPFyiyUdOO+4t8GvrnBLQSwpw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.52.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.4.tgz",
+ "integrity": "sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.52.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.4.tgz",
+ "integrity": "sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.52.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.4.tgz",
+ "integrity": "sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.52.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.4.tgz",
+ "integrity": "sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.52.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.4.tgz",
+ "integrity": "sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.52.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.4.tgz",
+ "integrity": "sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.52.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.4.tgz",
+ "integrity": "sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.52.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.4.tgz",
+ "integrity": "sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.52.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.4.tgz",
+ "integrity": "sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.52.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.4.tgz",
+ "integrity": "sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.52.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.4.tgz",
+ "integrity": "sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.52.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.4.tgz",
+ "integrity": "sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.52.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.4.tgz",
+ "integrity": "sha512-8GKr640PdFNXwzIE0IrkMWUNUomILLkfeHjXBi/nUvFlpZP+FA8BKGKpacjW6OUUHaNI6sUURxR2U2g78FOHWQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.52.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.4.tgz",
+ "integrity": "sha512-AIy/jdJ7WtJ/F6EcfOb2GjR9UweO0n43jNObQMb6oGxkYTfLcnN7vYYpG+CN3lLxrQkzWnMOoNSHTW54pgbVxw==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.52.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.4.tgz",
+ "integrity": "sha512-UF9KfsH9yEam0UjTwAgdK0anlQ7c8/pWPU2yVjyWcF1I1thABt6WXE47cI71pGiZ8wGvxohBoLnxM04L/wj8mQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.52.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.4.tgz",
+ "integrity": "sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@tailwindcss/node": {
+ "version": "4.1.14",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.14.tgz",
+ "integrity": "sha512-hpz+8vFk3Ic2xssIA3e01R6jkmsAhvkQdXlEbRTk6S10xDAtiQiM3FyvZVGsucefq764euO/b8WUW9ysLdThHw==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.4",
+ "enhanced-resolve": "^5.18.3",
+ "jiti": "^2.6.0",
+ "lightningcss": "1.30.1",
+ "magic-string": "^0.30.19",
+ "source-map-js": "^1.2.1",
+ "tailwindcss": "4.1.14"
+ }
+ },
+ "node_modules/@tailwindcss/oxide": {
+ "version": "4.1.14",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.14.tgz",
+ "integrity": "sha512-23yx+VUbBwCg2x5XWdB8+1lkPajzLmALEfMb51zZUBYaYVPDQvBSD/WYDqiVyBIo2BZFa3yw1Rpy3G2Jp+K0dw==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "detect-libc": "^2.0.4",
+ "tar": "^7.5.1"
+ },
+ "engines": {
+ "node": ">= 10"
+ },
+ "optionalDependencies": {
+ "@tailwindcss/oxide-android-arm64": "4.1.14",
+ "@tailwindcss/oxide-darwin-arm64": "4.1.14",
+ "@tailwindcss/oxide-darwin-x64": "4.1.14",
+ "@tailwindcss/oxide-freebsd-x64": "4.1.14",
+ "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.14",
+ "@tailwindcss/oxide-linux-arm64-gnu": "4.1.14",
+ "@tailwindcss/oxide-linux-arm64-musl": "4.1.14",
+ "@tailwindcss/oxide-linux-x64-gnu": "4.1.14",
+ "@tailwindcss/oxide-linux-x64-musl": "4.1.14",
+ "@tailwindcss/oxide-wasm32-wasi": "4.1.14",
+ "@tailwindcss/oxide-win32-arm64-msvc": "4.1.14",
+ "@tailwindcss/oxide-win32-x64-msvc": "4.1.14"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-android-arm64": {
+ "version": "4.1.14",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.14.tgz",
+ "integrity": "sha512-a94ifZrGwMvbdeAxWoSuGcIl6/DOP5cdxagid7xJv6bwFp3oebp7y2ImYsnZBMTwjn5Ev5xESvS3FFYUGgPODQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-arm64": {
+ "version": "4.1.14",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.14.tgz",
+ "integrity": "sha512-HkFP/CqfSh09xCnrPJA7jud7hij5ahKyWomrC3oiO2U9i0UjP17o9pJbxUN0IJ471GTQQmzwhp0DEcpbp4MZTA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-x64": {
+ "version": "4.1.14",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.14.tgz",
+ "integrity": "sha512-eVNaWmCgdLf5iv6Qd3s7JI5SEFBFRtfm6W0mphJYXgvnDEAZ5sZzqmI06bK6xo0IErDHdTA5/t7d4eTfWbWOFw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-freebsd-x64": {
+ "version": "4.1.14",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.14.tgz",
+ "integrity": "sha512-QWLoRXNikEuqtNb0dhQN6wsSVVjX6dmUFzuuiL09ZeXju25dsei2uIPl71y2Ic6QbNBsB4scwBoFnlBfabHkEw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
+ "version": "4.1.14",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.14.tgz",
+ "integrity": "sha512-VB4gjQni9+F0VCASU+L8zSIyjrLLsy03sjcR3bM0V2g4SNamo0FakZFKyUQ96ZVwGK4CaJsc9zd/obQy74o0Fw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
+ "version": "4.1.14",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.14.tgz",
+ "integrity": "sha512-qaEy0dIZ6d9vyLnmeg24yzA8XuEAD9WjpM5nIM1sUgQ/Zv7cVkharPDQcmm/t/TvXoKo/0knI3me3AGfdx6w1w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
+ "version": "4.1.14",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.14.tgz",
+ "integrity": "sha512-ISZjT44s59O8xKsPEIesiIydMG/sCXoMBCqsphDm/WcbnuWLxxb+GcvSIIA5NjUw6F8Tex7s5/LM2yDy8RqYBQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
+ "version": "4.1.14",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.14.tgz",
+ "integrity": "sha512-02c6JhLPJj10L2caH4U0zF8Hji4dOeahmuMl23stk0MU1wfd1OraE7rOloidSF8W5JTHkFdVo/O7uRUJJnUAJg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-musl": {
+ "version": "4.1.14",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.14.tgz",
+ "integrity": "sha512-TNGeLiN1XS66kQhxHG/7wMeQDOoL0S33x9BgmydbrWAb9Qw0KYdd8o1ifx4HOGDWhVmJ+Ul+JQ7lyknQFilO3Q==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi": {
+ "version": "4.1.14",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.14.tgz",
+ "integrity": "sha512-uZYAsaW/jS/IYkd6EWPJKW/NlPNSkWkBlaeVBi/WsFQNP05/bzkebUL8FH1pdsqx4f2fH/bWFcUABOM9nfiJkQ==",
+ "bundleDependencies": [
+ "@napi-rs/wasm-runtime",
+ "@emnapi/core",
+ "@emnapi/runtime",
+ "@tybys/wasm-util",
+ "@emnapi/wasi-threads",
+ "tslib"
+ ],
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "^1.5.0",
+ "@emnapi/runtime": "^1.5.0",
+ "@emnapi/wasi-threads": "^1.1.0",
+ "@napi-rs/wasm-runtime": "^1.0.5",
+ "@tybys/wasm-util": "^0.10.1",
+ "tslib": "^2.4.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
+ "version": "4.1.14",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.14.tgz",
+ "integrity": "sha512-Az0RnnkcvRqsuoLH2Z4n3JfAef0wElgzHD5Aky/e+0tBUxUhIeIqFBTMNQvmMRSP15fWwmvjBxZ3Q8RhsDnxAA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
+ "version": "4.1.14",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.14.tgz",
+ "integrity": "sha512-ttblVGHgf68kEE4om1n/n44I0yGPkCPbLsqzjvybhpwa6mKKtgFfAzy6btc3HRmuW7nHe0OOrSeNP9sQmmH9XA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/vite": {
+ "version": "4.1.14",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.14.tgz",
+ "integrity": "sha512-BoFUoU0XqgCUS1UXWhmDJroKKhNXeDzD7/XwabjkDIAbMnc4ULn5e2FuEuBbhZ6ENZoSYzKlzvZ44Yr6EUDUSA==",
+ "license": "MIT",
+ "dependencies": {
+ "@tailwindcss/node": "4.1.14",
+ "@tailwindcss/oxide": "4.1.14",
+ "tailwindcss": "4.1.14"
+ },
+ "peerDependencies": {
+ "vite": "^5.2.0 || ^6 || ^7"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "license": "MIT"
+ },
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+ "license": "MIT"
+ },
+ "node_modules/@webreflection/alien-signals": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/@webreflection/alien-signals/-/alien-signals-0.3.2.tgz",
+ "integrity": "sha512-DmNjD8Kq5iM+Toirp3llS/izAiI3Dwav5nHRvKdR/YJBTgun3y4xK76rs9CFYD2bZwZJN/rP+HjEqKTteGK+Yw==",
+ "license": "MIT",
+ "dependencies": {
+ "alien-signals": "^2.0.6"
+ }
+ },
+ "node_modules/alien-signals": {
+ "version": "2.0.8",
+ "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-2.0.8.tgz",
+ "integrity": "sha512-844G1VLkk0Pe2SJjY0J8vp8ADI73IM4KliNu2OGlYzWpO28NexEUvjHTcFjFX3VXoiUtwTbHxLNI9ImkcoBqzA==",
+ "license": "MIT"
+ },
+ "node_modules/chownr": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
+ "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/class-variance-authority": {
+ "version": "0.7.1",
+ "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
+ "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "clsx": "^2.1.1"
+ },
+ "funding": {
+ "url": "https://polar.sh/cva"
+ }
+ },
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/commander": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
+ "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.1.tgz",
+ "integrity": "sha512-ecqj/sy1jcK1uWrwpR67UhYrIFQ+5WlGxth34WquCbamhFA6hkkwiu37o6J5xCHdo1oixJRfVRw+ywV+Hq/0Aw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/diff": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.2.tgz",
+ "integrity": "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.3.1"
+ }
+ },
+ "node_modules/enhanced-resolve": {
+ "version": "5.18.3",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
+ "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==",
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.4",
+ "tapable": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.25.10",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz",
+ "integrity": "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.10",
+ "@esbuild/android-arm": "0.25.10",
+ "@esbuild/android-arm64": "0.25.10",
+ "@esbuild/android-x64": "0.25.10",
+ "@esbuild/darwin-arm64": "0.25.10",
+ "@esbuild/darwin-x64": "0.25.10",
+ "@esbuild/freebsd-arm64": "0.25.10",
+ "@esbuild/freebsd-x64": "0.25.10",
+ "@esbuild/linux-arm": "0.25.10",
+ "@esbuild/linux-arm64": "0.25.10",
+ "@esbuild/linux-ia32": "0.25.10",
+ "@esbuild/linux-loong64": "0.25.10",
+ "@esbuild/linux-mips64el": "0.25.10",
+ "@esbuild/linux-ppc64": "0.25.10",
+ "@esbuild/linux-riscv64": "0.25.10",
+ "@esbuild/linux-s390x": "0.25.10",
+ "@esbuild/linux-x64": "0.25.10",
+ "@esbuild/netbsd-arm64": "0.25.10",
+ "@esbuild/netbsd-x64": "0.25.10",
+ "@esbuild/openbsd-arm64": "0.25.10",
+ "@esbuild/openbsd-x64": "0.25.10",
+ "@esbuild/openharmony-arm64": "0.25.10",
+ "@esbuild/sunos-x64": "0.25.10",
+ "@esbuild/win32-arm64": "0.25.10",
+ "@esbuild/win32-ia32": "0.25.10",
+ "@esbuild/win32-x64": "0.25.10"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "license": "ISC"
+ },
+ "node_modules/highlight.js": {
+ "version": "11.11.1",
+ "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
+ "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/html-parse-string": {
+ "version": "0.0.9",
+ "resolved": "https://registry.npmjs.org/html-parse-string/-/html-parse-string-0.0.9.tgz",
+ "integrity": "sha512-wyGnsOolHbNrcb8N6bdJF4EHyzd3zVGCb9/mBxeNjAYBDOZqD7YkqLBz7kXtdgHwNnV8lN/BpSDpsI1zm8Sd8g==",
+ "license": "MIT"
+ },
+ "node_modules/jiti": {
+ "version": "2.6.1",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
+ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
+ "license": "MIT",
+ "bin": {
+ "jiti": "lib/jiti-cli.mjs"
+ }
+ },
+ "node_modules/katex": {
+ "version": "0.16.23",
+ "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.23.tgz",
+ "integrity": "sha512-7VlC1hsEEolL9xNO05v9VjrvWZePkCVBJqj8ruICxYjZfHaHbaU53AlP+PODyFIXEnaEIEWi3wJy7FPZ95JAVg==",
+ "funding": [
+ "https://opencollective.com/katex",
+ "https://github.com/sponsors/katex"
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "commander": "^8.3.0"
+ },
+ "bin": {
+ "katex": "cli.js"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.30.1",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz",
+ "integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==",
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-darwin-arm64": "1.30.1",
+ "lightningcss-darwin-x64": "1.30.1",
+ "lightningcss-freebsd-x64": "1.30.1",
+ "lightningcss-linux-arm-gnueabihf": "1.30.1",
+ "lightningcss-linux-arm64-gnu": "1.30.1",
+ "lightningcss-linux-arm64-musl": "1.30.1",
+ "lightningcss-linux-x64-gnu": "1.30.1",
+ "lightningcss-linux-x64-musl": "1.30.1",
+ "lightningcss-win32-arm64-msvc": "1.30.1",
+ "lightningcss-win32-x64-msvc": "1.30.1"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.30.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz",
+ "integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.30.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz",
+ "integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.30.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz",
+ "integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.30.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz",
+ "integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.30.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz",
+ "integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.30.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz",
+ "integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.30.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz",
+ "integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.30.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz",
+ "integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.30.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz",
+ "integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.30.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz",
+ "integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lit": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.1.tgz",
+ "integrity": "sha512-Ksr/8L3PTapbdXJCk+EJVB78jDodUMaP54gD24W186zGRARvwrsPfS60wae/SSCTCNZVPd1chXqio1qHQmu4NA==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@lit/reactive-element": "^2.1.0",
+ "lit-element": "^4.2.0",
+ "lit-html": "^3.3.0"
+ }
+ },
+ "node_modules/lit-element": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.2.1.tgz",
+ "integrity": "sha512-WGAWRGzirAgyphK2urmYOV72tlvnxw7YfyLDgQ+OZnM9vQQBQnumQ7jUJe6unEzwGU3ahFOjuz1iz1jjrpCPuw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@lit-labs/ssr-dom-shim": "^1.4.0",
+ "@lit/reactive-element": "^2.1.0",
+ "lit-html": "^3.3.0"
+ }
+ },
+ "node_modules/lit-html": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.3.1.tgz",
+ "integrity": "sha512-S9hbyDu/vs1qNrithiNyeyv64c9yqiW9l+DBgI18fL+MTvOtWoFR0FWiyq1TxaYef5wNlpEmzlXoBlZEO+WjoA==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@types/trusted-types": "^2.0.2"
+ }
+ },
+ "node_modules/lucide": {
+ "version": "0.544.0",
+ "resolved": "https://registry.npmjs.org/lucide/-/lucide-0.544.0.tgz",
+ "integrity": "sha512-U5ORwr5z9Sx7bNTDFaW55RbjVdQEnAcT3vws9uz3vRT1G4XXJUDAhRZdxhFoIyHEvjmTkzzlEhjSLYM5n4mb5w==",
+ "license": "ISC"
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.19",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz",
+ "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/marked": {
+ "version": "16.3.0",
+ "resolved": "https://registry.npmjs.org/marked/-/marked-16.3.0.tgz",
+ "integrity": "sha512-K3UxuKu6l6bmA5FUwYho8CfJBlsUWAooKtdGgMcERSpF7gcBUrCGsLH7wDaaNOzwq18JzSUDyoEb/YsrqMac3w==",
+ "license": "MIT",
+ "bin": {
+ "marked": "bin/marked.js"
+ },
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/minipass": {
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
+ "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/minizlib": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
+ "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
+ "license": "MIT",
+ "dependencies": {
+ "minipass": "^7.1.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.6",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
+ "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.52.4",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz",
+ "integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.52.4",
+ "@rollup/rollup-android-arm64": "4.52.4",
+ "@rollup/rollup-darwin-arm64": "4.52.4",
+ "@rollup/rollup-darwin-x64": "4.52.4",
+ "@rollup/rollup-freebsd-arm64": "4.52.4",
+ "@rollup/rollup-freebsd-x64": "4.52.4",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.52.4",
+ "@rollup/rollup-linux-arm-musleabihf": "4.52.4",
+ "@rollup/rollup-linux-arm64-gnu": "4.52.4",
+ "@rollup/rollup-linux-arm64-musl": "4.52.4",
+ "@rollup/rollup-linux-loong64-gnu": "4.52.4",
+ "@rollup/rollup-linux-ppc64-gnu": "4.52.4",
+ "@rollup/rollup-linux-riscv64-gnu": "4.52.4",
+ "@rollup/rollup-linux-riscv64-musl": "4.52.4",
+ "@rollup/rollup-linux-s390x-gnu": "4.52.4",
+ "@rollup/rollup-linux-x64-gnu": "4.52.4",
+ "@rollup/rollup-linux-x64-musl": "4.52.4",
+ "@rollup/rollup-openharmony-arm64": "4.52.4",
+ "@rollup/rollup-win32-arm64-msvc": "4.52.4",
+ "@rollup/rollup-win32-ia32-msvc": "4.52.4",
+ "@rollup/rollup-win32-x64-gnu": "4.52.4",
+ "@rollup/rollup-win32-x64-msvc": "4.52.4",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tailwind-merge": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz",
+ "integrity": "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/dcastil"
+ }
+ },
+ "node_modules/tailwind-variants": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-3.1.1.tgz",
+ "integrity": "sha512-ftLXe3krnqkMHsuBTEmaVUXYovXtPyTK7ckEfDRXS8PBZx0bAUas+A0jYxuKA5b8qg++wvQ3d2MQ7l/xeZxbZQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16.x",
+ "pnpm": ">=7.x"
+ },
+ "peerDependencies": {
+ "tailwind-merge": ">=3.0.0",
+ "tailwindcss": "*"
+ },
+ "peerDependenciesMeta": {
+ "tailwind-merge": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tailwindcss": {
+ "version": "4.1.14",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.14.tgz",
+ "integrity": "sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA==",
+ "license": "MIT"
+ },
+ "node_modules/tapable": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
+ "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/tar": {
+ "version": "7.5.1",
+ "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.1.tgz",
+ "integrity": "sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==",
+ "license": "ISC",
+ "dependencies": {
+ "@isaacs/fs-minipass": "^4.0.0",
+ "chownr": "^3.0.0",
+ "minipass": "^7.1.2",
+ "minizlib": "^3.1.0",
+ "yallist": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.15",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/uhtml": {
+ "version": "5.0.9",
+ "resolved": "https://registry.npmjs.org/uhtml/-/uhtml-5.0.9.tgz",
+ "integrity": "sha512-qPyu3vGilaLe6zrjOCD/xezWEHLwdevxmbY3hzyhT25KBDF4F7YYW3YZcL3kylD/6dMoVISHjn8ggV3+9FY+5g==",
+ "license": "MIT",
+ "dependencies": {
+ "@webreflection/alien-signals": "^0.3.2"
+ }
+ },
+ "node_modules/vite": {
+ "version": "7.1.9",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.9.tgz",
+ "integrity": "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg==",
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.25.0",
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.6",
+ "rollup": "^4.43.0",
+ "tinyglobby": "^0.2.15"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "lightningcss": "^1.21.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/yallist": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
+ "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=18"
+ }
+ }
+ }
+}
diff --git a/packages/web-ui/example/package.json b/packages/web-ui/example/package.json
new file mode 100644
index 00000000..f4553981
--- /dev/null
+++ b/packages/web-ui/example/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "pi-web-ui-example",
+ "version": "1.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@mariozechner/mini-lit": "^0.1.7",
+ "@mariozechner/pi-ai": "file:../../ai",
+ "@mariozechner/pi-web-ui": "file:../",
+ "@tailwindcss/vite": "^4.1.13",
+ "lit": "^3.3.1",
+ "lucide": "^0.544.0"
+ },
+ "devDependencies": {
+ "typescript": "^5.7.3",
+ "vite": "^7.1.6"
+ }
+}
diff --git a/packages/web-ui/example/src/app.css b/packages/web-ui/example/src/app.css
new file mode 100644
index 00000000..695386b0
--- /dev/null
+++ b/packages/web-ui/example/src/app.css
@@ -0,0 +1 @@
+@import "../../dist/app.css";
diff --git a/packages/web-ui/example/src/main.ts b/packages/web-ui/example/src/main.ts
new file mode 100644
index 00000000..4ba2ee7d
--- /dev/null
+++ b/packages/web-ui/example/src/main.ts
@@ -0,0 +1,51 @@
+import { Button, icon } from "@mariozechner/mini-lit";
+import "@mariozechner/mini-lit/dist/ThemeToggle.js";
+import { ChatPanel, ApiKeysDialog } from "@mariozechner/pi-web-ui";
+import { html, render } from "lit";
+import { Settings } from "lucide";
+import "./app.css";
+
+const 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.`;
+
+// Create and configure the chat panel
+const chatPanel = new ChatPanel();
+chatPanel.systemPrompt = systemPrompt;
+chatPanel.additionalTools = [];
+
+// Render the app structure
+const appHtml = html`
+
+
+
+
+ Pi Web UI Example
+
+
+
+ ${Button({
+ variant: "ghost",
+ size: "sm",
+ children: icon(Settings, "sm"),
+ onClick: () => ApiKeysDialog.open(),
+ title: "API Keys Settings",
+ })}
+
+
+
+
+ ${chatPanel}
+
+`;
+
+const app = document.getElementById("app");
+if (!app) {
+ throw new Error("App container not found");
+}
+
+render(appHtml, app);
\ No newline at end of file
diff --git a/packages/web-ui/example/src/test-sessions.ts b/packages/web-ui/example/src/test-sessions.ts
new file mode 100644
index 00000000..bb4e008e
--- /dev/null
+++ b/packages/web-ui/example/src/test-sessions.ts
@@ -0,0 +1,104 @@
+const simpleHtml = {
+ "systemPrompt": "You are a helpful AI assistant with access to various tools.\n\nAvailable tools:\n- Calculator: Perform mathematical calculations\n- Current Time: Get the current date and time in any timezone\n- Artifacts: Create interactive HTML, SVG, Markdown, and text artifacts\n\nFeel free to use these tools when needed to provide accurate and helpful responses.",
+ "model": {
+ "id": "claude-sonnet-4-5-20250929",
+ "name": "Claude Sonnet 4.5",
+ "api": "anthropic-messages",
+ "provider": "anthropic",
+ "baseUrl": "https://api.anthropic.com",
+ "reasoning": true,
+ "input": [
+ "text",
+ "image"
+ ],
+ "cost": {
+ "input": 3,
+ "output": 15,
+ "cacheRead": 0.3,
+ "cacheWrite": 3.75
+ },
+ "contextWindow": 200000,
+ "maxTokens": 64000
+ },
+ "messages": [
+ {
+ "role": "user",
+ "content": [
+ {
+ "type": "text",
+ "text": "simple html"
+ }
+ ]
+ },
+ {
+ "role": "assistant",
+ "content": [
+ {
+ "type": "text",
+ "text": "I'll create a simple HTML page for you!"
+ },
+ {
+ "type": "toolCall",
+ "id": "toolu_01JXaq9rMAUt1goJ5tMQXgLh",
+ "name": "artifacts",
+ "arguments": {
+ "command": "create",
+ "filename": "index.html",
+ "title": "Simple HTML Page",
+ "content": "\n\n\n \n \n Simple HTML Page \n \n\n\n \n Welcome to My Simple Page \n \n \n \n Hello! \n This is a simple HTML page with some basic styling.
\n \n Features: \n \n Clean design \n Responsive layout \n Interactive button \n \n \n Click Me! \n
\n \n \n \n\n"
+ }
+ }
+ ],
+ "api": "anthropic-messages",
+ "provider": "anthropic",
+ "model": "claude-sonnet-4-5-20250929",
+ "usage": {
+ "input": 2108,
+ "output": 666,
+ "cacheRead": 0,
+ "cacheWrite": 0,
+ "cost": {
+ "input": 0.006324,
+ "output": 0.00999,
+ "cacheRead": 0,
+ "cacheWrite": 0,
+ "total": 0.016314000000000002
+ }
+ },
+ "stopReason": "toolUse"
+ },
+ {
+ "role": "toolResult",
+ "toolCallId": "toolu_01JXaq9rMAUt1goJ5tMQXgLh",
+ "toolName": "artifacts",
+ "output": "Created file index.html\n\nExecution timed out. Partial logs:\nKeine Logs für index.html",
+ "isError": false
+ },
+ {
+ "role": "assistant",
+ "content": [
+ {
+ "type": "text",
+ "text": "I've created a simple HTML page for you! It includes:\n\n- **Clean header** with a green background\n- **Main content area** with a white card-style design\n- **A list** showing some features\n- **An interactive button** that displays a message when clicked\n- **Responsive styling** that looks good on different screen sizes\n\nThe page has a light gray background and uses simple, clean styling. Try clicking the button to see it in action! \n\nWould you like me to modify anything or add more features?"
+ }
+ ],
+ "api": "anthropic-messages",
+ "provider": "anthropic",
+ "model": "claude-sonnet-4-5-20250929",
+ "usage": {
+ "input": 2811,
+ "output": 115,
+ "cacheRead": 0,
+ "cacheWrite": 0,
+ "cost": {
+ "input": 0.008433,
+ "output": 0.001725,
+ "cacheRead": 0,
+ "cacheWrite": 0,
+ "total": 0.010158
+ }
+ },
+ "stopReason": "stop"
+ }
+ ]
+};
\ No newline at end of file
diff --git a/packages/web-ui/example/tsconfig.json b/packages/web-ui/example/tsconfig.json
new file mode 100644
index 00000000..8321c62b
--- /dev/null
+++ b/packages/web-ui/example/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ES2022",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "moduleResolution": "bundler",
+ "strict": true,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "experimentalDecorators": true,
+ "useDefineForClassFields": false
+ },
+ "include": ["src/**/*"]
+}
diff --git a/packages/web-ui/example/vite.config.ts b/packages/web-ui/example/vite.config.ts
new file mode 100644
index 00000000..6550357b
--- /dev/null
+++ b/packages/web-ui/example/vite.config.ts
@@ -0,0 +1,6 @@
+import tailwindcss from "@tailwindcss/vite";
+import { defineConfig } from "vite";
+
+export default defineConfig({
+ plugins: [tailwindcss()],
+});
diff --git a/packages/web-ui/package.json b/packages/web-ui/package.json
new file mode 100644
index 00000000..4f91c3a0
--- /dev/null
+++ b/packages/web-ui/package.json
@@ -0,0 +1,45 @@
+{
+ "name": "@mariozechner/pi-web-ui",
+ "version": "0.5.43",
+ "description": "Reusable web UI components for AI chat interfaces powered by @mariozechner/pi-ai",
+ "type": "module",
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "exports": {
+ ".": "./dist/index.js",
+ "./app.css": "./dist/app.css"
+ },
+ "scripts": {
+ "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",
+ "check": "npm run typecheck"
+ },
+ "dependencies": {
+ "@mariozechner/mini-lit": "^0.1.7",
+ "@mariozechner/pi-ai": "^0.5.43",
+ "docx-preview": "^0.3.7",
+ "jszip": "^3.10.1",
+ "lit": "^3.3.1",
+ "lucide": "^0.544.0",
+ "pdfjs-dist": "^5.4.149",
+ "xlsx": "^0.18.5"
+ },
+ "devDependencies": {
+ "@tailwindcss/cli": "^4.0.0-beta.14",
+ "concurrently": "^9.2.1",
+ "typescript": "^5.7.3"
+ },
+ "keywords": [
+ "ai",
+ "chat",
+ "ui",
+ "components",
+ "llm",
+ "web-components",
+ "mini-lit"
+ ],
+ "author": "Mario Zechner",
+ "license": "MIT"
+}
diff --git a/packages/web-ui/src/app.css b/packages/web-ui/src/app.css
new file mode 100644
index 00000000..dfa82b33
--- /dev/null
+++ b/packages/web-ui/src/app.css
@@ -0,0 +1,38 @@
+/* Import Claude theme from mini-lit */
+@import "@mariozechner/mini-lit/styles/themes/default.css";
+
+/* Tell Tailwind to scan mini-lit components */
+/* biome-ignore lint/suspicious/noUnknownAtRules: Tailwind 4 source directive */
+@source "../../../node_modules/@mariozechner/mini-lit/dist";
+
+/* Import Tailwind */
+/* biome-ignore lint/correctness/noInvalidPositionAtImportRule: fuck you */
+@import "tailwindcss";
+
+body {
+ font-size: 16px;
+ -webkit-font-smoothing: antialiased;
+}
+
+* {
+ scrollbar-width: thin;
+ scrollbar-color: var(--color-border) rgba(0, 0, 0, 0);
+}
+
+*::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+
+*::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+*::-webkit-scrollbar-thumb {
+ background-color: var(--color-border);
+ border-radius: 4px;
+}
+
+*::-webkit-scrollbar-thumb:hover {
+ background-color: rgba(0, 0, 0, 0);
+}
diff --git a/packages/web-ui/src/components/AgentInterface.ts b/packages/web-ui/src/components/AgentInterface.ts
new file mode 100644
index 00000000..5d760154
--- /dev/null
+++ b/packages/web-ui/src/components/AgentInterface.ts
@@ -0,0 +1,312 @@
+import { html } from "@mariozechner/mini-lit";
+import type { ToolResultMessage, Usage } from "@mariozechner/pi-ai";
+import { LitElement } from "lit";
+import { customElement, property, query } from "lit/decorators.js";
+import { ApiKeysDialog } from "../dialogs/ApiKeysDialog.js";
+import { ModelSelector } from "../dialogs/ModelSelector.js";
+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 { keyStore } from "../state/KeyStore.js";
+import "./StreamingMessageContainer.js";
+import type { Attachment } from "../utils/attachment-utils.js";
+import { formatUsage } from "../utils/format.js";
+import { i18n } from "../utils/i18n.js";
+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() enableAttachments = true;
+ @property() enableModelSelector = true;
+ @property() enableThinking = true;
+ @property() showThemeToggle = false;
+ @property() showDebugToggle = false;
+
+ // References
+ @query("message-editor") private _messageEditor!: MessageEditor;
+ @query("streaming-message-container") private _streamingContainer!: StreamingMessageContainer;
+
+ private _autoScroll = true;
+ private _lastScrollTop = 0;
+ private _lastClientHeight = 0;
+ private _scrollContainer?: HTMLElement;
+ private _resizeObserver?: ResizeObserver;
+ private _unsubscribeSession?: () => void;
+
+ public setInput(text: string, attachments?: Attachment[]) {
+ const update = () => {
+ if (!this._messageEditor) requestAnimationFrame(update);
+ else {
+ this._messageEditor.value = text;
+ this._messageEditor.attachments = attachments || [];
+ }
+ };
+ update();
+ }
+
+ protected override createRenderRoot(): HTMLElement | DocumentFragment {
+ return this;
+ }
+
+ override async connectedCallback() {
+ super.connectedCallback();
+
+ this.style.display = "flex";
+ this.style.flexDirection = "column";
+ this.style.height = "100%";
+ this.style.minHeight = "0";
+
+ // Wait for first render to get scroll container
+ await this.updateComplete;
+ this._scrollContainer = this.querySelector(".overflow-y-auto") as HTMLElement;
+
+ if (this._scrollContainer) {
+ // Set up ResizeObserver to detect content changes
+ this._resizeObserver = new ResizeObserver(() => {
+ if (this._autoScroll && this._scrollContainer) {
+ this._scrollContainer.scrollTop = this._scrollContainer.scrollHeight;
+ }
+ });
+
+ // Observe the content container inside the scroll container
+ const contentContainer = this._scrollContainer.querySelector(".max-w-3xl");
+ if (contentContainer) {
+ this._resizeObserver.observe(contentContainer);
+ }
+
+ // Set up scroll listener with better detection
+ this._scrollContainer.addEventListener("scroll", this._handleScroll);
+ }
+
+ // 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() {
+ super.disconnectedCallback();
+
+ // Clean up observers and listeners
+ if (this._resizeObserver) {
+ this._resizeObserver.disconnect();
+ this._resizeObserver = undefined;
+ }
+
+ if (this._scrollContainer) {
+ this._scrollContainer.removeEventListener("scroll", this._handleScroll);
+ }
+
+ if (this._unsubscribeSession) {
+ this._unsubscribeSession();
+ this._unsubscribeSession = undefined;
+ }
+ }
+
+ private setupSessionSubscription() {
+ if (this._unsubscribeSession) {
+ this._unsubscribeSession();
+ this._unsubscribeSession = undefined;
+ }
+ if (!this.session) return;
+ this._unsubscribeSession = this.session.subscribe(async (ev: AgentSessionEvent) => {
+ if (ev.type === "state-update") {
+ if (this._streamingContainer) {
+ this._streamingContainer.isStreaming = ev.state.isStreaming;
+ this._streamingContainer.setMessage(ev.state.streamMessage, !ev.state.isStreaming);
+ }
+ this.requestUpdate();
+ } else if (ev.type === "error-no-model") {
+ // TODO show some UI feedback
+ } else if (ev.type === "error-no-api-key") {
+ // Open API keys dialog to configure the missing key
+ ApiKeysDialog.open();
+ }
+ });
+ }
+
+ private _handleScroll = (_ev: any) => {
+ if (!this._scrollContainer) return;
+
+ const currentScrollTop = this._scrollContainer.scrollTop;
+ const scrollHeight = this._scrollContainer.scrollHeight;
+ const clientHeight = this._scrollContainer.clientHeight;
+ const distanceFromBottom = scrollHeight - currentScrollTop - clientHeight;
+
+ // Ignore relayout due to message editor getting pushed up by stats
+ if (clientHeight < this._lastClientHeight) {
+ this._lastClientHeight = clientHeight;
+ return;
+ }
+
+ // Only disable auto-scroll if user scrolled UP or is far from bottom
+ if (currentScrollTop !== 0 && currentScrollTop < this._lastScrollTop && distanceFromBottom > 50) {
+ this._autoScroll = false;
+ } else if (distanceFromBottom < 10) {
+ // Re-enable if very close to bottom
+ this._autoScroll = true;
+ }
+
+ this._lastScrollTop = currentScrollTop;
+ this._lastClientHeight = clientHeight;
+ };
+
+ public async sendMessage(input: string, attachments?: Attachment[]) {
+ if ((!input.trim() && attachments?.length === 0) || this.session?.state.isStreaming) return;
+ const session = this.session;
+ if (!session) throw new Error("No session set on AgentInterface");
+ if (!session.state.model) throw new Error("No model set on AgentInterface");
+
+ // Check if API key exists for the provider (only needed in direct mode)
+ const provider = session.state.model.provider;
+ let apiKey = await keyStore.getKey(provider);
+
+ // If no API key, open the API keys dialog
+ if (!apiKey) {
+ await ApiKeysDialog.open();
+ // Check again after dialog closes
+ apiKey = await keyStore.getKey(provider);
+ // If still no API key, abort the send
+ if (!apiKey) {
+ return;
+ }
+ }
+
+ // Only clear editor after we know we can send
+ this._messageEditor.value = "";
+ this._messageEditor.attachments = [];
+ this._autoScroll = true; // Enable auto-scroll when sending a message
+
+ await this.session?.prompt(input, attachments);
+ }
+
+ private renderMessages() {
+ if (!this.session)
+ return html`${i18n("No session available")}
`;
+ const state = this.session.state;
+ // Build a map of tool results to allow inline rendering in assistant messages
+ const toolResultsById = new Map>();
+ for (const message of state.messages) {
+ if (message.role === "toolResult") {
+ toolResultsById.set(message.toolCallId, message);
+ }
+ }
+ return html`
+
+
+ ()}
+ .isStreaming=${state.isStreaming}
+ >
+
+
+
+
+ `;
+ }
+
+ private renderStats() {
+ if (!this.session) return html`
`;
+
+ const state = this.session.state;
+ const totals = state.messages
+ .filter((m) => m.role === "assistant")
+ .reduce(
+ (acc, msg: any) => {
+ const usage = msg.usage;
+ if (usage) {
+ acc.input += usage.input;
+ acc.output += usage.output;
+ acc.cacheRead += usage.cacheRead;
+ acc.cacheWrite += usage.cacheWrite;
+ acc.cost.total += usage.cost.total;
+ }
+ return acc;
+ },
+ {
+ input: 0,
+ output: 0,
+ cacheRead: 0,
+ cacheWrite: 0,
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
+ } satisfies Usage,
+ );
+
+ const hasTotals = totals.input || totals.output || totals.cacheRead || totals.cacheWrite;
+ const totalsText = hasTotals ? formatUsage(totals) : "";
+
+ return html`
+
+
+ ${this.showThemeToggle ? html` ` : html``}
+
+
${totalsText ? html`${totalsText} ` : ""}
+
+ `;
+ }
+
+ override render() {
+ if (!this.session)
+ return html`${i18n("No session set")}
`;
+
+ const session = this.session;
+ const state = this.session.state;
+ return html`
+
+
+
+
${this.renderMessages()}
+
+
+
+
+
+ {
+ this.sendMessage(input, attachments);
+ }}
+ .onAbort=${() => session.abort()}
+ .onModelSelect=${() => {
+ ModelSelector.open(state.model, (model) => session.setModel(model));
+ }}
+ .onThinkingChange=${
+ this.enableThinking
+ ? (level: "off" | "minimal" | "low" | "medium" | "high") => {
+ session.setThinkingLevel(level);
+ }
+ : undefined
+ }
+ >
+ ${this.renderStats()}
+
+
+
+ `;
+ }
+}
+
+// Register custom element with guard
+if (!customElements.get("agent-interface")) {
+ customElements.define("agent-interface", AgentInterface);
+}
diff --git a/packages/web-ui/src/components/AttachmentTile.ts b/packages/web-ui/src/components/AttachmentTile.ts
new file mode 100644
index 00000000..15d3dd03
--- /dev/null
+++ b/packages/web-ui/src/components/AttachmentTile.ts
@@ -0,0 +1,112 @@
+import { html, icon } from "@mariozechner/mini-lit";
+import { LitElement } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import { FileSpreadsheet, FileText, X } from "lucide";
+import { AttachmentOverlay } from "../dialogs/AttachmentOverlay.js";
+import type { Attachment } from "../utils/attachment-utils.js";
+import { i18n } from "../utils/i18n.js";
+
+@customElement("attachment-tile")
+export class AttachmentTile extends LitElement {
+ @property({ type: Object }) attachment!: Attachment;
+ @property({ type: Boolean }) showDelete = false;
+ @property() onDelete?: () => void;
+
+ protected override createRenderRoot(): HTMLElement | DocumentFragment {
+ return this;
+ }
+
+ override connectedCallback(): void {
+ super.connectedCallback();
+ this.style.display = "block";
+ this.classList.add("max-h-16");
+ }
+
+ private handleClick = () => {
+ AttachmentOverlay.open(this.attachment);
+ };
+
+ override render() {
+ const hasPreview = !!this.attachment.preview;
+ const isImage = this.attachment.type === "image";
+ const isPdf = this.attachment.mimeType === "application/pdf";
+ const isDocx =
+ this.attachment.mimeType?.includes("wordprocessingml") ||
+ this.attachment.fileName.toLowerCase().endsWith(".docx");
+ const isPptx =
+ this.attachment.mimeType?.includes("presentationml") ||
+ this.attachment.fileName.toLowerCase().endsWith(".pptx");
+ const isExcel =
+ this.attachment.mimeType?.includes("spreadsheetml") ||
+ this.attachment.fileName.toLowerCase().endsWith(".xlsx") ||
+ this.attachment.fileName.toLowerCase().endsWith(".xls");
+
+ // Choose the appropriate icon
+ const getDocumentIcon = () => {
+ if (isExcel) return icon(FileSpreadsheet, "md");
+ return icon(FileText, "md");
+ };
+
+ return html`
+
+ ${
+ hasPreview
+ ? html`
+
+
+ ${
+ isPdf
+ ? html`
+
+
+ `
+ : ""
+ }
+
+ `
+ : html`
+
+
+ ${getDocumentIcon()}
+
+ ${
+ this.attachment.fileName.length > 10
+ ? this.attachment.fileName.substring(0, 8) + "..."
+ : this.attachment.fileName
+ }
+
+
+ `
+ }
+ ${
+ this.showDelete
+ ? html`
+
{
+ e.stopPropagation();
+ this.onDelete?.();
+ }}
+ class="absolute -top-1 -right-1 w-5 h-5 bg-background hover:bg-muted text-muted-foreground hover:text-foreground rounded-full flex items-center justify-center opacity-100 hover:opacity-100 [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover:opacity-100 transition-opacity border border-input shadow-sm"
+ title="${i18n("Remove")}"
+ >
+ ${icon(X, "xs")}
+
+ `
+ : ""
+ }
+
+ `;
+ }
+}
diff --git a/packages/web-ui/src/components/ConsoleBlock.ts b/packages/web-ui/src/components/ConsoleBlock.ts
new file mode 100644
index 00000000..b4949bee
--- /dev/null
+++ b/packages/web-ui/src/components/ConsoleBlock.ts
@@ -0,0 +1,67 @@
+import { html, icon } from "@mariozechner/mini-lit";
+import { LitElement } from "lit";
+import { property, state } from "lit/decorators.js";
+import { Check, Copy } from "lucide";
+import { i18n } from "../utils/i18n.js";
+
+export class ConsoleBlock extends LitElement {
+ @property() content: string = "";
+ @state() private copied = false;
+
+ protected override createRenderRoot(): HTMLElement | DocumentFragment {
+ return this;
+ }
+
+ override connectedCallback(): void {
+ super.connectedCallback();
+ this.style.display = "block";
+ }
+
+ private async copy() {
+ try {
+ await navigator.clipboard.writeText(this.content || "");
+ this.copied = true;
+ setTimeout(() => {
+ this.copied = false;
+ }, 1500);
+ } catch (e) {
+ console.error("Copy failed", e);
+ }
+ }
+
+ override updated() {
+ // Auto-scroll to bottom on content changes
+ const container = this.querySelector(".console-scroll") as HTMLElement | null;
+ if (container) {
+ container.scrollTop = container.scrollHeight;
+ }
+ }
+
+ override render() {
+ return html`
+
+
+ ${i18n("console")}
+ this.copy()}
+ class="flex items-center gap-1 px-2 py-0.5 text-xs rounded hover:bg-accent text-muted-foreground hover:text-accent-foreground transition-colors"
+ title="${i18n("Copy output")}"
+ >
+ ${this.copied ? icon(Check, "sm") : icon(Copy, "sm")}
+ ${this.copied ? html`${i18n("Copied!")} ` : ""}
+
+
+
+
+ `;
+ }
+}
+
+// Register custom element
+if (!customElements.get("console-block")) {
+ customElements.define("console-block", ConsoleBlock);
+}
diff --git a/packages/web-ui/src/components/Input.ts b/packages/web-ui/src/components/Input.ts
new file mode 100644
index 00000000..f88e44bf
--- /dev/null
+++ b/packages/web-ui/src/components/Input.ts
@@ -0,0 +1,112 @@
+import { type BaseComponentProps, fc, html } from "@mariozechner/mini-lit";
+import { type Ref, ref } from "lit/directives/ref.js";
+import { i18n } from "../utils/i18n.js";
+
+export type InputType = "text" | "email" | "password" | "number" | "url" | "tel" | "search";
+export type InputSize = "sm" | "md" | "lg";
+
+export interface InputProps extends BaseComponentProps {
+ type?: InputType;
+ size?: InputSize;
+ value?: string;
+ placeholder?: string;
+ label?: string;
+ error?: string;
+ disabled?: boolean;
+ required?: boolean;
+ name?: string;
+ autocomplete?: string;
+ min?: number;
+ max?: number;
+ step?: number;
+ inputRef?: Ref;
+ onInput?: (e: Event) => void;
+ onChange?: (e: Event) => void;
+ onKeyDown?: (e: KeyboardEvent) => void;
+ onKeyUp?: (e: KeyboardEvent) => void;
+}
+
+export const Input = fc(
+ ({
+ type = "text",
+ size = "md",
+ value = "",
+ placeholder = "",
+ label = "",
+ error = "",
+ disabled = false,
+ required = false,
+ name = "",
+ autocomplete = "",
+ min,
+ max,
+ step,
+ inputRef,
+ onInput,
+ onChange,
+ onKeyDown,
+ onKeyUp,
+ className = "",
+ }) => {
+ const sizeClasses = {
+ sm: "h-8 px-3 py-1 text-sm",
+ md: "h-9 px-3 py-1 text-sm md:text-sm",
+ lg: "h-10 px-4 py-1 text-base",
+ };
+
+ const baseClasses =
+ "flex w-full min-w-0 rounded-md border bg-transparent text-foreground shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium";
+ const interactionClasses =
+ "placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground";
+ const focusClasses = "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]";
+ const darkClasses = "dark:bg-input/30";
+ const stateClasses = error
+ ? "border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40"
+ : "border-input";
+ const disabledClasses = "disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50";
+
+ const handleInput = (e: Event) => {
+ onInput?.(e);
+ };
+
+ const handleChange = (e: Event) => {
+ onChange?.(e);
+ };
+
+ return html`
+
+ ${
+ label
+ ? html`
+
+ ${label} ${required ? html`${i18n("*")} ` : ""}
+
+ `
+ : ""
+ }
+
+ ${error ? html`${error} ` : ""}
+
+ `;
+ },
+);
diff --git a/packages/web-ui/src/components/MessageEditor.ts b/packages/web-ui/src/components/MessageEditor.ts
new file mode 100644
index 00000000..7e35c2b8
--- /dev/null
+++ b/packages/web-ui/src/components/MessageEditor.ts
@@ -0,0 +1,272 @@
+import { Button, html, icon } from "@mariozechner/mini-lit";
+import type { Model } from "@mariozechner/pi-ai";
+import { LitElement } from "lit";
+import { customElement, property, state } from "lit/decorators.js";
+import { createRef, ref } from "lit/directives/ref.js";
+import { Loader2, Paperclip, Send, Sparkles, Square } from "lucide";
+import "./AttachmentTile.js";
+import { type Attachment, loadAttachment } from "../utils/attachment-utils.js";
+import { i18n } from "../utils/i18n.js";
+
+@customElement("message-editor")
+export class MessageEditor extends LitElement {
+ private _value = "";
+ private textareaRef = createRef();
+
+ @property()
+ get value() {
+ return this._value;
+ }
+
+ set value(val: string) {
+ const oldValue = this._value;
+ this._value = val;
+ this.requestUpdate("value", oldValue);
+ this.updateComplete.then(() => {
+ const textarea = this.textareaRef.value;
+ if (textarea) {
+ textarea.style.height = "auto";
+ textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
+ }
+ });
+ }
+
+ @property() isStreaming = false;
+ @property() currentModel?: Model;
+ @property() showAttachmentButton = true;
+ @property() showModelSelector = true;
+ @property() showThinking = false; // Disabled for now
+ @property() onInput?: (value: string) => void;
+ @property() onSend?: (input: string, attachments: Attachment[]) => void;
+ @property() onAbort?: () => void;
+ @property() onModelSelect?: () => void;
+ @property() onFilesChange?: (files: Attachment[]) => void;
+ @property() attachments: Attachment[] = [];
+ @property() maxFiles = 10;
+ @property() maxFileSize = 20 * 1024 * 1024; // 20MB
+ @property() acceptedTypes =
+ "image/*,application/pdf,.docx,.pptx,.xlsx,.xls,.txt,.md,.json,.xml,.html,.css,.js,.ts,.jsx,.tsx,.yml,.yaml";
+
+ @state() processingFiles = false;
+ private fileInputRef = createRef();
+
+ protected override createRenderRoot(): HTMLElement | DocumentFragment {
+ return this;
+ }
+
+ private handleTextareaInput = (e: Event) => {
+ const textarea = e.target as HTMLTextAreaElement;
+ this.value = textarea.value;
+ textarea.style.height = "auto";
+ textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
+ this.onInput?.(this.value);
+ };
+
+ private handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ if (!this.isStreaming && !this.processingFiles && (this.value.trim() || this.attachments.length > 0)) {
+ this.handleSend();
+ }
+ } else if (e.key === "Escape" && this.isStreaming) {
+ e.preventDefault();
+ this.onAbort?.();
+ }
+ };
+
+ private handleSend = () => {
+ this.onSend?.(this.value, this.attachments);
+ };
+
+ private handleAttachmentClick = () => {
+ this.fileInputRef.value?.click();
+ };
+
+ private async handleFilesSelected(e: Event) {
+ const input = e.target as HTMLInputElement;
+ const files = Array.from(input.files || []);
+ if (files.length === 0) return;
+
+ if (files.length + this.attachments.length > this.maxFiles) {
+ alert(`Maximum ${this.maxFiles} files allowed`);
+ input.value = "";
+ 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;
+ input.value = ""; // Reset input
+ }
+
+ private removeFile(fileId: string) {
+ this.attachments = this.attachments.filter((f) => f.id !== fileId);
+ this.onFilesChange?.(this.attachments);
+ }
+
+ private adjustTextareaHeight() {
+ const textarea = this.textareaRef.value;
+ if (textarea) {
+ // Reset height to auto to get accurate scrollHeight
+ textarea.style.height = "auto";
+ // Only adjust if there's content, otherwise keep minimal height
+ if (this.value.trim()) {
+ textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
+ }
+ }
+ }
+
+ override firstUpdated() {
+ const textarea = this.textareaRef.value;
+ if (textarea) {
+ // Don't adjust height on first render - let it be minimal
+ textarea.focus();
+ }
+ }
+
+ override updated() {
+ // Only adjust height when component updates if there's content
+ if (this.value) {
+ this.adjustTextareaHeight();
+ }
+ }
+
+ override render() {
+ return html`
+
+
+ ${
+ this.attachments.length > 0
+ ? html`
+
+ ${this.attachments.map(
+ (attachment) => html`
+
this.removeFile(attachment.id)}
+ >
+ `,
+ )}
+
+ `
+ : ""
+ }
+
+
+
+
+
+
+
+
+
+
+ ${
+ this.showAttachmentButton
+ ? this.processingFiles
+ ? html`
+
+ ${icon(Loader2, "sm", "animate-spin text-muted-foreground")}
+
+ `
+ : html`
+ ${Button({
+ variant: "ghost",
+ size: "icon",
+ className: "h-8 w-8",
+ onClick: this.handleAttachmentClick,
+ children: icon(Paperclip, "sm"),
+ })}
+ `
+ : ""
+ }
+
+
+
+
+ ${
+ this.showModelSelector && this.currentModel
+ ? html`
+ ${Button({
+ variant: "ghost",
+ size: "sm",
+ onClick: () => {
+ // Focus textarea before opening model selector so focus returns there
+ this.textareaRef.value?.focus();
+ // Wait for next frame to ensure focus takes effect before dialog captures it
+ requestAnimationFrame(() => {
+ this.onModelSelect?.();
+ });
+ },
+ children: html`
+ ${icon(Sparkles, "sm")}
+
${this.currentModel.id}
+ `,
+ className: "h-8 text-xs truncate",
+ })}
+ `
+ : ""
+ }
+ ${
+ this.isStreaming
+ ? html`
+ ${Button({
+ variant: "ghost",
+ size: "icon",
+ onClick: this.onAbort,
+ children: icon(Square, "sm"),
+ className: "h-8 w-8",
+ })}
+ `
+ : html`
+ ${Button({
+ variant: "ghost",
+ size: "icon",
+ onClick: this.handleSend,
+ disabled: (!this.value.trim() && this.attachments.length === 0) || this.processingFiles,
+ children: html`
${icon(Send, "sm")}
`,
+ className: "h-8 w-8",
+ })}
+ `
+ }
+
+
+
+ `;
+ }
+}
diff --git a/packages/web-ui/src/components/MessageList.ts b/packages/web-ui/src/components/MessageList.ts
new file mode 100644
index 00000000..7c6dc790
--- /dev/null
+++ b/packages/web-ui/src/components/MessageList.ts
@@ -0,0 +1,82 @@
+import { html } from "@mariozechner/mini-lit";
+import type {
+ AgentTool,
+ AssistantMessage as AssistantMessageType,
+ Message,
+ ToolResultMessage as ToolResultMessageType,
+} from "@mariozechner/pi-ai";
+import { LitElement, type TemplateResult } from "lit";
+import { property } from "lit/decorators.js";
+import { repeat } from "lit/directives/repeat.js";
+
+export class MessageList extends LitElement {
+ @property({ type: Array }) messages: Message[] = [];
+ @property({ type: Array }) tools: AgentTool[] = [];
+ @property({ type: Object }) pendingToolCalls?: Set;
+ @property({ type: Boolean }) isStreaming: boolean = false;
+
+ protected override createRenderRoot(): HTMLElement | DocumentFragment {
+ return this;
+ }
+
+ override connectedCallback(): void {
+ super.connectedCallback();
+ this.style.display = "block";
+ }
+
+ private buildRenderItems() {
+ // Map tool results by call id for quick lookup
+ const resultByCallId = new Map();
+ for (const message of this.messages) {
+ if (message.role === "toolResult") {
+ resultByCallId.set(message.toolCallId, message);
+ }
+ }
+
+ const items: Array<{ key: string; template: TemplateResult }> = [];
+ let index = 0;
+ for (const msg of this.messages) {
+ if (msg.role === "user") {
+ items.push({
+ key: `msg:${index}`,
+ template: html` `,
+ });
+ index++;
+ } else if (msg.role === "assistant") {
+ const amsg = msg as AssistantMessageType;
+ items.push({
+ key: `msg:${index}`,
+ template: html` `,
+ });
+ index++;
+ } else {
+ // Skip standalone toolResult messages; they are rendered via paired tool-message above
+ // For completeness, other roles are not expected
+ }
+ }
+ return items;
+ }
+
+ override render() {
+ const items = this.buildRenderItems();
+ return html`
+ ${repeat(
+ items,
+ (it) => it.key,
+ (it) => it.template,
+ )}
+
`;
+ }
+}
+
+// Register custom element
+if (!customElements.get("message-list")) {
+ customElements.define("message-list", MessageList);
+}
diff --git a/packages/web-ui/src/components/Messages.ts b/packages/web-ui/src/components/Messages.ts
new file mode 100644
index 00000000..951f49b2
--- /dev/null
+++ b/packages/web-ui/src/components/Messages.ts
@@ -0,0 +1,310 @@
+import { Button, html, icon } from "@mariozechner/mini-lit";
+import type {
+ AgentTool,
+ AssistantMessage as AssistantMessageType,
+ ToolCall,
+ ToolResultMessage as ToolResultMessageType,
+ UserMessage as UserMessageType,
+} from "@mariozechner/pi-ai";
+import type { AgentToolResult } from "@mariozechner/pi-ai/dist/agent/types.js";
+import { LitElement, type TemplateResult } from "lit";
+import { customElement, property, state } from "lit/decorators.js";
+import { Bug, Loader, Wrench } from "lucide";
+import { renderToolParams, renderToolResult } from "../tools/index.js";
+import type { Attachment } from "../utils/attachment-utils.js";
+import { formatUsage } from "../utils/format.js";
+import { i18n } from "../utils/i18n.js";
+
+export type UserMessageWithAttachments = UserMessageType & { attachments?: Attachment[] };
+export type AppMessage = AssistantMessageType | UserMessageWithAttachments | ToolResultMessageType;
+
+@customElement("user-message")
+export class UserMessage extends LitElement {
+ @property({ type: Object }) message!: UserMessageWithAttachments;
+
+ protected override createRenderRoot(): HTMLElement | DocumentFragment {
+ return this;
+ }
+
+ override connectedCallback(): void {
+ super.connectedCallback();
+ this.style.display = "block";
+ }
+
+ override render() {
+ const content =
+ typeof this.message.content === "string"
+ ? this.message.content
+ : this.message.content.find((c) => c.type === "text")?.text || "";
+
+ return html`
+
+
+ ${
+ this.message.attachments && this.message.attachments.length > 0
+ ? html`
+
+ ${this.message.attachments.map(
+ (attachment) => html`
`,
+ )}
+
+ `
+ : ""
+ }
+
+ `;
+ }
+}
+
+@customElement("assistant-message")
+export class AssistantMessage extends LitElement {
+ @property({ type: Object }) message!: AssistantMessageType;
+ @property({ type: Array }) tools?: AgentTool[];
+ @property({ type: Object }) pendingToolCalls?: Set;
+ @property({ type: Boolean }) hideToolCalls = false;
+ @property({ type: Object }) toolResultsById?: Map;
+ @property({ type: Boolean }) isStreaming: boolean = false;
+
+ protected override createRenderRoot(): HTMLElement | DocumentFragment {
+ return this;
+ }
+
+ override connectedCallback(): void {
+ super.connectedCallback();
+ this.style.display = "block";
+ }
+
+ override render() {
+ // Render content in the order it appears
+ const orderedParts: TemplateResult[] = [];
+
+ for (const chunk of this.message.content) {
+ if (chunk.type === "text" && chunk.text.trim() !== "") {
+ orderedParts.push(html` `);
+ } else if (chunk.type === "thinking" && chunk.thinking.trim() !== "") {
+ orderedParts.push(html` `);
+ } else if (chunk.type === "toolCall") {
+ if (!this.hideToolCalls) {
+ const tool = this.tools?.find((t) => t.name === chunk.name);
+ const pending = this.pendingToolCalls?.has(chunk.id) ?? false;
+ const result = this.toolResultsById?.get(chunk.id);
+ const aborted = !pending && !result && !this.isStreaming;
+ orderedParts.push(
+ html` `,
+ );
+ }
+ }
+ }
+
+ return html`
+
+ ${orderedParts.length ? html`
${orderedParts}
` : ""}
+ ${
+ this.message.usage
+ ? html`
${formatUsage(this.message.usage)}
`
+ : ""
+ }
+ ${
+ this.message.stopReason === "error" && this.message.errorMessage
+ ? html`
+
+ ${i18n("Error:")} ${this.message.errorMessage}
+
+ `
+ : ""
+ }
+ ${
+ this.message.stopReason === "aborted"
+ ? html`
${i18n("Request aborted")} `
+ : ""
+ }
+
+ `;
+ }
+}
+
+@customElement("tool-message-debug")
+export class ToolMessageDebugView extends LitElement {
+ @property({ type: Object }) callArgs: any;
+ @property({ type: String }) result?: AgentToolResult;
+ @property({ type: Boolean }) hasResult: boolean = false;
+
+ protected override createRenderRoot(): HTMLElement | DocumentFragment {
+ return this; // light DOM for shared styles
+ }
+
+ override connectedCallback(): void {
+ super.connectedCallback();
+ this.style.display = "block";
+ }
+
+ private pretty(value: unknown): { content: string; isJson: boolean } {
+ try {
+ if (typeof value === "string") {
+ const maybeJson = JSON.parse(value);
+ return { content: JSON.stringify(maybeJson, null, 2), isJson: true };
+ }
+ return { content: JSON.stringify(value, null, 2), isJson: true };
+ } catch {
+ return { content: typeof value === "string" ? value : String(value), isJson: false };
+ }
+ }
+
+ override render() {
+ const output = this.pretty(this.result?.output);
+ const details = this.pretty(this.result?.details);
+
+ return html`
+
+
+
+
${i18n("Result")}
+ ${
+ this.hasResult
+ ? html`
+
`
+ : html`
${i18n("(no result)")}
`
+ }
+
+
+ `;
+ }
+}
+
+@customElement("tool-message")
+export class ToolMessage extends LitElement {
+ @property({ type: Object }) toolCall!: ToolCall;
+ @property({ type: Object }) tool?: AgentTool;
+ @property({ type: Object }) result?: ToolResultMessageType;
+ @property({ type: Boolean }) pending: boolean = false;
+ @property({ type: Boolean }) aborted: boolean = false;
+ @property({ type: Boolean }) isStreaming: boolean = false;
+ @state() private _showDebug = false;
+
+ protected override createRenderRoot(): HTMLElement | DocumentFragment {
+ return this;
+ }
+
+ override connectedCallback(): void {
+ super.connectedCallback();
+ this.style.display = "block";
+ }
+
+ private toggleDebug = () => {
+ this._showDebug = !this._showDebug;
+ };
+
+ override render() {
+ const toolLabel = this.tool?.label || this.toolCall.name;
+ const toolName = this.tool?.name || this.toolCall.name;
+ const isError = this.result?.isError === true;
+ const hasResult = !!this.result;
+
+ let statusIcon: TemplateResult;
+ if (this.pending || (this.isStreaming && !hasResult)) {
+ statusIcon = html`${icon(Loader, "sm")} `;
+ } else if (this.aborted && !hasResult) {
+ statusIcon = html`${icon(Wrench, "sm")} `;
+ } else if (hasResult && isError) {
+ statusIcon = html`${icon(Wrench, "sm")} `;
+ } else if (hasResult) {
+ statusIcon = html`${icon(Wrench, "sm")} `;
+ } else {
+ statusIcon = html`${icon(Wrench, "sm")} `;
+ }
+
+ // Normalize error text
+ let errorMessage = this.result?.output || "";
+ if (isError) {
+ try {
+ const parsed = JSON.parse(errorMessage);
+ if ((parsed as any).error) errorMessage = (parsed as any).error;
+ else if ((parsed as any).message) errorMessage = (parsed as any).message;
+ } catch {}
+ errorMessage = errorMessage.replace(/^(Tool )?Error:\s*/i, "");
+ errorMessage = errorMessage.replace(/^Error:\s*/i, "");
+ }
+
+ const paramsTpl = renderToolParams(
+ toolName,
+ this.toolCall.arguments,
+ this.isStreaming || (this.pending && !hasResult),
+ );
+ const resultTpl =
+ hasResult && !isError ? renderToolResult(toolName, this.toolCall.arguments, this.result!) : undefined;
+
+ return html`
+
+
+
+ ${statusIcon}
+ ${toolLabel}
+
+ ${Button({
+ variant: this._showDebug ? "default" : "ghost",
+ size: "sm",
+ onClick: this.toggleDebug,
+ children: icon(Bug, "sm"),
+ className: "text-muted-foreground",
+ })}
+
+
+ ${
+ this._showDebug
+ ? html`
`
+ : html`
+
${paramsTpl}
+ ${
+ this.pending && !hasResult
+ ? html`
${i18n("Waiting for tool result…")}
`
+ : ""
+ }
+ ${
+ this.aborted && !hasResult
+ ? html`
${i18n("Call was aborted; no result.")}
`
+ : ""
+ }
+ ${
+ hasResult && isError
+ ? html`
+ ${errorMessage}
+
`
+ : ""
+ }
+ ${resultTpl ? html`
${resultTpl}
` : ""}
+ `
+ }
+
+ `;
+ }
+}
+
+@customElement("aborted-message")
+export class AbortedMessage extends LitElement {
+ protected override createRenderRoot(): HTMLElement | DocumentFragment {
+ return this;
+ }
+
+ override connectedCallback(): void {
+ super.connectedCallback();
+ this.style.display = "block";
+ }
+
+ protected override render(): unknown {
+ return html`${i18n("Request aborted")} `;
+ }
+}
diff --git a/packages/web-ui/src/components/SandboxedIframe.ts b/packages/web-ui/src/components/SandboxedIframe.ts
index bdc042ae..24990c2d 100644
--- a/packages/web-ui/src/components/SandboxedIframe.ts
+++ b/packages/web-ui/src/components/SandboxedIframe.ts
@@ -1,5 +1,5 @@
import { LitElement } from "lit";
-import { customElement } from "lit/decorators.js";
+import { customElement, property } from "lit/decorators.js";
import type { Attachment } from "../utils/attachment-utils.js";
export interface SandboxFile {
@@ -15,10 +15,23 @@ export interface SandboxResult {
error?: { message: string; stack: string };
}
+/**
+ * Function that returns the URL to the sandbox HTML file.
+ * Used in browser extensions to load sandbox.html via chrome.runtime.getURL().
+ */
+export type SandboxUrlProvider = () => string;
+
@customElement("sandbox-iframe")
export class SandboxIframe extends LitElement {
private iframe?: HTMLIFrameElement;
+ /**
+ * Optional: Provide a function that returns the sandbox HTML URL.
+ * If provided, the iframe will use this URL instead of srcdoc.
+ * This is required for browser extensions with strict CSP.
+ */
+ @property({ attribute: false }) sandboxUrlProvider?: SandboxUrlProvider;
+
createRenderRoot() {
return this;
}
@@ -41,6 +54,48 @@ export class SandboxIframe extends LitElement {
public loadContent(sandboxId: string, htmlContent: string, attachments: Attachment[]): void {
const completeHtml = this.prepareHtmlDocument(sandboxId, htmlContent, attachments);
+ if (this.sandboxUrlProvider) {
+ // Browser extension mode: use sandbox.html with postMessage
+ this.loadViaSandboxUrl(sandboxId, completeHtml, attachments);
+ } else {
+ // Web mode: use srcdoc
+ this.loadViaSrcdoc(completeHtml);
+ }
+ }
+
+ private loadViaSandboxUrl(sandboxId: string, completeHtml: string, attachments: Attachment[]): void {
+ // Wait for sandbox-ready and send content
+ const readyHandler = (e: MessageEvent) => {
+ if (e.data.type === "sandbox-ready" && e.source === this.iframe?.contentWindow) {
+ window.removeEventListener("message", readyHandler);
+ this.iframe?.contentWindow?.postMessage(
+ {
+ type: "sandbox-load",
+ sandboxId,
+ code: completeHtml,
+ attachments,
+ },
+ "*",
+ );
+ }
+ };
+ window.addEventListener("message", readyHandler);
+
+ // Always recreate iframe to ensure fresh sandbox and sandbox-ready message
+ this.iframe?.remove();
+ this.iframe = document.createElement("iframe");
+ this.iframe.sandbox.add("allow-scripts");
+ this.iframe.sandbox.add("allow-modals");
+ this.iframe.style.width = "100%";
+ this.iframe.style.height = "100%";
+ this.iframe.style.border = "none";
+
+ this.iframe.src = this.sandboxUrlProvider!();
+
+ this.appendChild(this.iframe);
+ }
+
+ private loadViaSrcdoc(completeHtml: string): void {
// Always recreate iframe to ensure fresh sandbox
this.iframe?.remove();
this.iframe = document.createElement("iframe");
@@ -50,7 +105,7 @@ export class SandboxIframe extends LitElement {
this.iframe.style.height = "100%";
this.iframe.style.border = "none";
- // Set content directly via srcdoc (no CSP restrictions in web-ui)
+ // Set content directly via srcdoc (no CSP restrictions in web apps)
this.iframe.srcdoc = completeHtml;
this.appendChild(this.iframe);
@@ -125,9 +180,14 @@ export class SandboxIframe extends LitElement {
}
};
+ let readyHandler: ((e: MessageEvent) => void) | undefined;
+
const cleanup = () => {
window.removeEventListener("message", messageHandler);
signal?.removeEventListener("abort", abortHandler);
+ if (readyHandler) {
+ window.removeEventListener("message", readyHandler);
+ }
clearTimeout(timeoutId);
};
@@ -148,19 +208,52 @@ export class SandboxIframe extends LitElement {
}
}, 30000);
- // NOW create and append iframe AFTER all listeners are set up
- this.iframe?.remove();
- this.iframe = document.createElement("iframe");
- this.iframe.sandbox.add("allow-scripts");
- this.iframe.sandbox.add("allow-modals");
- this.iframe.style.width = "100%";
- this.iframe.style.height = "100%";
- this.iframe.style.border = "none";
+ if (this.sandboxUrlProvider) {
+ // Browser extension mode: wait for sandbox-ready and send content
+ readyHandler = (e: MessageEvent) => {
+ if (e.data.type === "sandbox-ready" && e.source === this.iframe?.contentWindow) {
+ window.removeEventListener("message", readyHandler!);
+ // Send the complete HTML
+ this.iframe?.contentWindow?.postMessage(
+ {
+ type: "sandbox-load",
+ sandboxId,
+ code: completeHtml,
+ attachments,
+ },
+ "*",
+ );
+ }
+ };
+ window.addEventListener("message", readyHandler);
- // Set content via srcdoc BEFORE appending to DOM (no CSP restrictions in web-ui)
- this.iframe.srcdoc = completeHtml;
+ // Create iframe AFTER all listeners are set up
+ this.iframe?.remove();
+ this.iframe = document.createElement("iframe");
+ this.iframe.sandbox.add("allow-scripts");
+ this.iframe.sandbox.add("allow-modals");
+ this.iframe.style.width = "100%";
+ this.iframe.style.height = "100%";
+ this.iframe.style.border = "none";
- this.appendChild(this.iframe);
+ this.iframe.src = this.sandboxUrlProvider();
+
+ this.appendChild(this.iframe);
+ } else {
+ // Web mode: use srcdoc
+ this.iframe?.remove();
+ this.iframe = document.createElement("iframe");
+ this.iframe.sandbox.add("allow-scripts");
+ this.iframe.sandbox.add("allow-modals");
+ this.iframe.style.width = "100%";
+ this.iframe.style.height = "100%";
+ this.iframe.style.border = "none";
+
+ // Set content via srcdoc BEFORE appending to DOM
+ this.iframe.srcdoc = completeHtml;
+
+ this.appendChild(this.iframe);
+ }
});
}
diff --git a/packages/web-ui/src/components/StreamingMessageContainer.ts b/packages/web-ui/src/components/StreamingMessageContainer.ts
new file mode 100644
index 00000000..d4c4b4a6
--- /dev/null
+++ b/packages/web-ui/src/components/StreamingMessageContainer.ts
@@ -0,0 +1,101 @@
+import { html } from "@mariozechner/mini-lit";
+import type { AgentTool, Message, ToolResultMessage } from "@mariozechner/pi-ai";
+import { LitElement } from "lit";
+import { property, state } from "lit/decorators.js";
+
+export class StreamingMessageContainer extends LitElement {
+ @property({ type: Array }) tools: AgentTool[] = [];
+ @property({ type: Boolean }) isStreaming = false;
+ @property({ type: Object }) pendingToolCalls?: Set;
+ @property({ type: Object }) toolResultsById?: Map;
+
+ @state() private _message: Message | null = null;
+ private _pendingMessage: Message | null = null;
+ private _updateScheduled = false;
+ private _immediateUpdate = false;
+
+ protected override createRenderRoot(): HTMLElement | DocumentFragment {
+ return this;
+ }
+
+ override connectedCallback(): void {
+ super.connectedCallback();
+ this.style.display = "block";
+ }
+
+ // Public method to update the message with batching for performance
+ public setMessage(message: Message | null, immediate = false) {
+ // Store the latest message
+ this._pendingMessage = message;
+
+ // If this is an immediate update (like clearing), apply it right away
+ if (immediate || message === null) {
+ this._immediateUpdate = true;
+ this._message = message;
+ this.requestUpdate();
+ // Cancel any pending updates since we're clearing
+ this._pendingMessage = null;
+ this._updateScheduled = false;
+ return;
+ }
+
+ // Otherwise batch updates for performance during streaming
+ if (!this._updateScheduled) {
+ this._updateScheduled = true;
+
+ requestAnimationFrame(async () => {
+ // Only apply the update if we haven't been cleared
+ if (!this._immediateUpdate && this._pendingMessage !== null) {
+ // Deep clone the message to ensure Lit detects changes in nested properties
+ // (like toolCall.arguments being mutated during streaming)
+ this._message = JSON.parse(JSON.stringify(this._pendingMessage));
+ this.requestUpdate();
+ }
+ // Reset for next batch
+ this._pendingMessage = null;
+ this._updateScheduled = false;
+ this._immediateUpdate = false;
+ });
+ }
+ }
+
+ override render() {
+ // Show loading indicator if loading but no message yet
+ if (!this._message) {
+ if (this.isStreaming)
+ return html`
+
+
`;
+ return html``; // Empty until a message is set
+ }
+ const msg = this._message;
+
+ if (msg.role === "toolResult") {
+ // Skip standalone tool result in streaming; the stable list will render paired tool-message
+ return html``;
+ } else if (msg.role === "user") {
+ // Skip standalone tool result in streaming; the stable list will render it immediiately
+ return html``;
+ } else if (msg.role === "assistant") {
+ // Assistant message - render inline tool messages during streaming
+ return html`
+
+
+ ${this.isStreaming ? html`
` : ""}
+
+ `;
+ }
+ }
+}
+
+// Register custom element
+if (!customElements.get("streaming-message-container")) {
+ customElements.define("streaming-message-container", StreamingMessageContainer);
+}
diff --git a/packages/web-ui/src/dialogs/ApiKeysDialog.ts b/packages/web-ui/src/dialogs/ApiKeysDialog.ts
new file mode 100644
index 00000000..30254f9d
--- /dev/null
+++ b/packages/web-ui/src/dialogs/ApiKeysDialog.ts
@@ -0,0 +1,273 @@
+import { Alert, Badge, Button, DialogBase, DialogHeader, html, type TemplateResult } from "@mariozechner/mini-lit";
+import { type Context, complete, getModel, getProviders } from "@mariozechner/pi-ai";
+import type { PropertyValues } from "lit";
+import { customElement, state } from "lit/decorators.js";
+import { Input } from "../components/Input.js";
+import { keyStore } from "../state/KeyStore.js";
+import { i18n } from "../utils/i18n.js";
+
+// Test models for each provider - known to be reliable and cheap
+const TEST_MODELS: Record = {
+ anthropic: "claude-3-5-haiku-20241022",
+ openai: "gpt-4o-mini",
+ google: "gemini-2.0-flash-exp",
+ groq: "llama-3.3-70b-versatile",
+ openrouter: "openai/gpt-4o-mini",
+ cerebras: "llama3.1-8b",
+ xai: "grok-2-1212",
+ zai: "glm-4-plus",
+};
+
+@customElement("api-keys-dialog")
+export class ApiKeysDialog extends DialogBase {
+ @state() apiKeys: Record = {}; // provider -> configured
+ @state() apiKeyInputs: Record = {};
+ @state() testResults: Record = {};
+ @state() savingProvider = "";
+ @state() testingProvider = "";
+ @state() error = "";
+
+ protected override modalWidth = "min(600px, 90vw)";
+ protected override modalHeight = "min(600px, 80vh)";
+
+ static async open() {
+ const dialog = new ApiKeysDialog();
+ dialog.open();
+ await dialog.loadKeys();
+ }
+
+ override async firstUpdated(changedProperties: PropertyValues): Promise {
+ super.firstUpdated(changedProperties);
+ await this.loadKeys();
+ }
+
+ private async loadKeys() {
+ this.apiKeys = await keyStore.getAllKeys();
+ }
+
+ private async testApiKey(provider: string, apiKey: string): Promise {
+ try {
+ // Get the test model for this provider
+ const modelId = TEST_MODELS[provider];
+ if (!modelId) {
+ this.error = `No test model configured for ${provider}`;
+ return false;
+ }
+
+ const model = getModel(provider as any, modelId);
+ if (!model) {
+ this.error = `Test model ${modelId} not found for ${provider}`;
+ return false;
+ }
+
+ // Simple test prompt
+ const context: Context = {
+ messages: [{ role: "user", content: "Reply with exactly: test successful" }],
+ };
+ const response = await complete(model, context, {
+ apiKey,
+ maxTokens: 10, // Keep it minimal for testing
+ } as any);
+
+ // Check if response contains expected text
+ const text = response.content
+ .filter((b) => b.type === "text")
+ .map((b) => b.text)
+ .join("");
+
+ return text.toLowerCase().includes("test successful");
+ } catch (error) {
+ console.error(`API key test failed for ${provider}:`, error);
+ return false;
+ }
+ }
+
+ private async saveKey(provider: string) {
+ const key = this.apiKeyInputs[provider];
+ if (!key) return;
+
+ this.savingProvider = provider;
+ this.testResults[provider] = "testing";
+ this.error = "";
+
+ try {
+ // Test the key first
+ const isValid = await this.testApiKey(provider, key);
+
+ if (isValid) {
+ await keyStore.setKey(provider, key);
+ this.apiKeyInputs[provider] = ""; // Clear input
+ await this.loadKeys();
+ this.testResults[provider] = "success";
+ } else {
+ this.testResults[provider] = "error";
+ this.error = `Invalid API key for ${provider}`;
+ }
+ } catch (err: any) {
+ this.testResults[provider] = "error";
+ this.error = `Failed to save key for ${provider}: ${err.message}`;
+ } finally {
+ this.savingProvider = "";
+
+ // Clear test result after 3 seconds
+ setTimeout(() => {
+ delete this.testResults[provider];
+ this.requestUpdate();
+ }, 3000);
+ }
+ }
+
+ private async testExistingKey(provider: string) {
+ this.testingProvider = provider;
+ this.testResults[provider] = "testing";
+ this.error = "";
+
+ try {
+ const apiKey = await keyStore.getKey(provider);
+ if (!apiKey) {
+ this.testResults[provider] = "error";
+ this.error = `No API key found for ${provider}`;
+ return;
+ }
+
+ const isValid = await this.testApiKey(provider, apiKey);
+
+ if (isValid) {
+ this.testResults[provider] = "success";
+ } else {
+ this.testResults[provider] = "error";
+ this.error = `API key for ${provider} is no longer valid`;
+ }
+ } catch (err: any) {
+ this.testResults[provider] = "error";
+ this.error = `Test failed for ${provider}: ${err.message}`;
+ } finally {
+ this.testingProvider = "";
+
+ // Clear test result after 3 seconds
+ setTimeout(() => {
+ delete this.testResults[provider];
+ this.requestUpdate();
+ }, 3000);
+ }
+ }
+
+ private async removeKey(provider: string) {
+ if (!confirm(`Remove API key for ${provider}?`)) return;
+
+ await keyStore.removeKey(provider);
+ this.apiKeyInputs[provider] = "";
+ await this.loadKeys();
+ }
+
+ protected override renderContent(): TemplateResult {
+ const providers = getProviders();
+
+ return html`
+
+
+
+ ${DialogHeader({ title: i18n("API Keys Configuration") })}
+
+ ${i18n("Configure API keys for LLM providers. Keys are stored locally in your browser.")}
+
+
+
+
+ ${
+ this.error
+ ? html`
+
${Alert(this.error, "destructive")}
+ `
+ : ""
+ }
+
+
+
+
+
+ ${providers.map(
+ (provider) => html`
+
+
+ ${provider}
+ ${
+ this.apiKeys[provider]
+ ? Badge({ children: i18n("Configured"), variant: "default" })
+ : Badge({ children: i18n("Not configured"), variant: "secondary" })
+ }
+ ${
+ this.testResults[provider] === "success"
+ ? Badge({ children: i18n("✓ Valid"), variant: "default" })
+ : this.testResults[provider] === "error"
+ ? Badge({ children: i18n("✗ Invalid"), variant: "destructive" })
+ : this.testResults[provider] === "testing"
+ ? Badge({ children: i18n("Testing..."), variant: "secondary" })
+ : ""
+ }
+
+
+
+ ${Input({
+ type: "password",
+ placeholder: this.apiKeys[provider] ? i18n("Update API key") : i18n("Enter API key"),
+ value: this.apiKeyInputs[provider] || "",
+ onInput: (e: Event) => {
+ this.apiKeyInputs[provider] = (e.target as HTMLInputElement).value;
+ this.requestUpdate();
+ },
+ className: "flex-1",
+ })}
+
+ ${Button({
+ onClick: () => this.saveKey(provider),
+ variant: "default",
+ size: "sm",
+ disabled: !this.apiKeyInputs[provider] || this.savingProvider === provider,
+ loading: this.savingProvider === provider,
+ children:
+ this.savingProvider === provider
+ ? i18n("Testing...")
+ : this.apiKeys[provider]
+ ? i18n("Update")
+ : i18n("Save"),
+ })}
+
+ ${
+ this.apiKeys[provider]
+ ? html`
+ ${Button({
+ onClick: () => this.testExistingKey(provider),
+ variant: "outline",
+ size: "sm",
+ loading: this.testingProvider === provider,
+ disabled: this.testingProvider !== "" && this.testingProvider !== provider,
+ children:
+ this.testingProvider === provider ? i18n("Testing...") : i18n("Test"),
+ })}
+ ${Button({
+ onClick: () => this.removeKey(provider),
+ variant: "ghost",
+ size: "sm",
+ children: i18n("Remove"),
+ })}
+ `
+ : ""
+ }
+
+
+ `,
+ )}
+
+
+
+
+
+
+ ${i18n("API keys are required to use AI models. Get your keys from the provider's website.")}
+
+
+
+ `;
+ }
+}
diff --git a/packages/web-ui/src/dialogs/AttachmentOverlay.ts b/packages/web-ui/src/dialogs/AttachmentOverlay.ts
new file mode 100644
index 00000000..ad68bcf0
--- /dev/null
+++ b/packages/web-ui/src/dialogs/AttachmentOverlay.ts
@@ -0,0 +1,635 @@
+import { Button, html, icon } from "@mariozechner/mini-lit";
+import "@mariozechner/mini-lit/dist/ModeToggle.js";
+import { renderAsync } from "docx-preview";
+import { LitElement } from "lit";
+import { state } from "lit/decorators.js";
+import { Download, X } from "lucide";
+import * as pdfjsLib from "pdfjs-dist";
+import * as XLSX from "xlsx";
+import type { Attachment } from "../utils/attachment-utils.js";
+import { i18n } from "../utils/i18n.js";
+
+type FileType = "image" | "pdf" | "docx" | "pptx" | "excel" | "text";
+
+export class AttachmentOverlay extends LitElement {
+ @state() private attachment?: Attachment;
+ @state() private showExtractedText = false;
+ @state() private error: string | null = null;
+
+ // Track current loading task to cancel if needed
+ private currentLoadingTask: any = null;
+ private onCloseCallback?: () => void;
+ private boundHandleKeyDown?: (e: KeyboardEvent) => void;
+
+ protected override createRenderRoot(): HTMLElement | DocumentFragment {
+ return this;
+ }
+
+ static open(attachment: Attachment, onClose?: () => void) {
+ const overlay = new AttachmentOverlay();
+ overlay.attachment = attachment;
+ overlay.onCloseCallback = onClose;
+ document.body.appendChild(overlay);
+ overlay.setupEventListeners();
+ }
+
+ private setupEventListeners() {
+ this.boundHandleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "Escape") {
+ this.close();
+ }
+ };
+ window.addEventListener("keydown", this.boundHandleKeyDown);
+ }
+
+ private close() {
+ this.cleanup();
+ if (this.boundHandleKeyDown) {
+ window.removeEventListener("keydown", this.boundHandleKeyDown);
+ }
+ this.onCloseCallback?.();
+ this.remove();
+ }
+
+ private getFileType(): FileType {
+ if (!this.attachment) return "text";
+
+ if (this.attachment.type === "image") return "image";
+ if (this.attachment.mimeType === "application/pdf") return "pdf";
+ if (this.attachment.mimeType?.includes("wordprocessingml")) return "docx";
+ if (
+ this.attachment.mimeType?.includes("presentationml") ||
+ this.attachment.fileName.toLowerCase().endsWith(".pptx")
+ )
+ return "pptx";
+ if (
+ this.attachment.mimeType?.includes("spreadsheetml") ||
+ this.attachment.mimeType?.includes("ms-excel") ||
+ this.attachment.fileName.toLowerCase().endsWith(".xlsx") ||
+ this.attachment.fileName.toLowerCase().endsWith(".xls")
+ )
+ return "excel";
+
+ return "text";
+ }
+
+ private getFileTypeLabel(): string {
+ const type = this.getFileType();
+ switch (type) {
+ case "pdf":
+ return i18n("PDF");
+ case "docx":
+ return i18n("Document");
+ case "pptx":
+ return i18n("Presentation");
+ case "excel":
+ return i18n("Spreadsheet");
+ default:
+ return "";
+ }
+ }
+
+ private handleBackdropClick = () => {
+ this.close();
+ };
+
+ private handleDownload = () => {
+ if (!this.attachment) return;
+
+ // Create a blob from the base64 content
+ const byteCharacters = atob(this.attachment.content);
+ const byteNumbers = new Array(byteCharacters.length);
+ for (let i = 0; i < byteCharacters.length; i++) {
+ byteNumbers[i] = byteCharacters.charCodeAt(i);
+ }
+ const byteArray = new Uint8Array(byteNumbers);
+ const blob = new Blob([byteArray], { type: this.attachment.mimeType });
+
+ // Create download link
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = this.attachment.fileName;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ };
+
+ private cleanup() {
+ this.showExtractedText = false;
+ this.error = null;
+ // Cancel any loading PDF task when closing
+ if (this.currentLoadingTask) {
+ this.currentLoadingTask.destroy();
+ this.currentLoadingTask = null;
+ }
+ }
+
+ override render() {
+ if (!this.attachment) return html``;
+
+ return html`
+
+
+
+
e.stopPropagation()}>
+
+
+ ${this.attachment.fileName}
+
+
+ ${this.renderToggle()}
+ ${Button({
+ variant: "ghost",
+ size: "icon",
+ onClick: this.handleDownload,
+ children: icon(Download, "sm"),
+ className: "h-8 w-8",
+ })}
+ ${Button({
+ variant: "ghost",
+ size: "icon",
+ onClick: () => this.close(),
+ children: icon(X, "sm"),
+ className: "h-8 w-8",
+ })}
+
+
+
+
+
+
e.stopPropagation()}>
+ ${this.renderContent()}
+
+
+ `;
+ }
+
+ private renderToggle() {
+ if (!this.attachment) return html``;
+
+ const fileType = this.getFileType();
+ const hasExtractedText = !!this.attachment.extractedText;
+ const showToggle = fileType !== "image" && fileType !== "text" && fileType !== "pptx" && hasExtractedText;
+
+ if (!showToggle) return html``;
+
+ const fileTypeLabel = this.getFileTypeLabel();
+
+ return html`
+ ) => {
+ e.stopPropagation();
+ this.showExtractedText = e.detail.index === 1;
+ this.error = null;
+ }}
+ >
+ `;
+ }
+
+ private renderContent() {
+ if (!this.attachment) return html``;
+
+ // Error state
+ if (this.error) {
+ return html`
+
+
${i18n("Error loading file")}
+
${this.error}
+
+ `;
+ }
+
+ // Content based on file type
+ return this.renderFileContent();
+ }
+
+ private renderFileContent() {
+ if (!this.attachment) return html``;
+
+ const fileType = this.getFileType();
+
+ // Show extracted text if toggled
+ if (this.showExtractedText && fileType !== "image") {
+ return html`
+
+
${
+ this.attachment.extractedText || i18n("No text content available")
+ }
+
+ `;
+ }
+
+ // Render based on file type
+ switch (fileType) {
+ case "image": {
+ const imageUrl = `data:${this.attachment.mimeType};base64,${this.attachment.content}`;
+ return html`
+
+ `;
+ }
+
+ case "pdf":
+ return html`
+
+ `;
+
+ case "docx":
+ return html`
+
+ `;
+
+ case "excel":
+ return html`
`;
+
+ case "pptx":
+ return html`
+
+ `;
+
+ default:
+ return html`
+
+
${
+ this.attachment.extractedText || i18n("No content available")
+ }
+
+ `;
+ }
+ }
+
+ override async updated(changedProperties: Map) {
+ super.updated(changedProperties);
+
+ // Only process if we need to render the actual file (not extracted text)
+ if (
+ (changedProperties.has("attachment") || changedProperties.has("showExtractedText")) &&
+ this.attachment &&
+ !this.showExtractedText &&
+ !this.error
+ ) {
+ const fileType = this.getFileType();
+
+ switch (fileType) {
+ case "pdf":
+ await this.renderPdf();
+ break;
+ case "docx":
+ await this.renderDocx();
+ break;
+ case "excel":
+ await this.renderExcel();
+ break;
+ case "pptx":
+ await this.renderExtractedText();
+ break;
+ }
+ }
+ }
+
+ private async renderPdf() {
+ const container = this.querySelector("#pdf-container");
+ if (!container || !this.attachment) return;
+
+ let pdf: any = null;
+
+ try {
+ // Convert base64 to ArrayBuffer
+ const arrayBuffer = this.base64ToArrayBuffer(this.attachment.content);
+
+ // Cancel any existing loading task
+ if (this.currentLoadingTask) {
+ this.currentLoadingTask.destroy();
+ }
+
+ // Load the PDF
+ this.currentLoadingTask = pdfjsLib.getDocument({ data: arrayBuffer });
+ pdf = await this.currentLoadingTask.promise;
+ this.currentLoadingTask = null;
+
+ // Clear container and add wrapper
+ container.innerHTML = "";
+ const wrapper = document.createElement("div");
+ wrapper.className = "";
+ container.appendChild(wrapper);
+
+ // Render all pages
+ for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) {
+ const page = await pdf.getPage(pageNum);
+
+ // Create a container for each page
+ const pageContainer = document.createElement("div");
+ pageContainer.className = "mb-4 last:mb-0";
+
+ // Create canvas for this page
+ const canvas = document.createElement("canvas");
+ const context = canvas.getContext("2d");
+
+ // Set scale for reasonable resolution
+ const viewport = page.getViewport({ scale: 1.5 });
+ canvas.height = viewport.height;
+ canvas.width = viewport.width;
+
+ // Style the canvas
+ canvas.className = "w-full max-w-full h-auto block mx-auto bg-white rounded shadow-sm border border-border";
+
+ // Fill white background for proper PDF rendering
+ if (context) {
+ context.fillStyle = "white";
+ context.fillRect(0, 0, canvas.width, canvas.height);
+ }
+
+ // Render page
+ await page.render({
+ canvasContext: context!,
+ viewport: viewport,
+ canvas: canvas,
+ }).promise;
+
+ pageContainer.appendChild(canvas);
+
+ // Add page separator for multi-page documents
+ if (pageNum < pdf.numPages) {
+ const separator = document.createElement("div");
+ separator.className = "h-px bg-border my-4";
+ pageContainer.appendChild(separator);
+ }
+
+ wrapper.appendChild(pageContainer);
+ }
+ } catch (error: any) {
+ console.error("Error rendering PDF:", error);
+ this.error = error?.message || i18n("Failed to load PDF");
+ } finally {
+ if (pdf) {
+ pdf.destroy();
+ }
+ }
+ }
+
+ private async renderDocx() {
+ const container = this.querySelector("#docx-container");
+ if (!container || !this.attachment) return;
+
+ try {
+ // Convert base64 to ArrayBuffer
+ const arrayBuffer = this.base64ToArrayBuffer(this.attachment.content);
+
+ // Clear container first
+ container.innerHTML = "";
+
+ // Create a wrapper div for the document
+ const wrapper = document.createElement("div");
+ wrapper.className = "docx-wrapper-custom";
+ container.appendChild(wrapper);
+
+ // Render the DOCX file into the wrapper
+ await renderAsync(arrayBuffer, wrapper as HTMLElement, undefined, {
+ className: "docx",
+ inWrapper: true,
+ ignoreWidth: true, // Let it be responsive
+ ignoreHeight: false,
+ ignoreFonts: false,
+ breakPages: true,
+ ignoreLastRenderedPageBreak: true,
+ experimental: false,
+ trimXmlDeclaration: true,
+ useBase64URL: false,
+ renderHeaders: true,
+ renderFooters: true,
+ renderFootnotes: true,
+ renderEndnotes: true,
+ });
+
+ // Apply custom styles to match theme and fix sizing
+ const style = document.createElement("style");
+ style.textContent = `
+ #docx-container {
+ padding: 0;
+ }
+
+ #docx-container .docx-wrapper-custom {
+ max-width: 100%;
+ overflow-x: auto;
+ }
+
+ #docx-container .docx-wrapper {
+ max-width: 100% !important;
+ margin: 0 !important;
+ background: transparent !important;
+ padding: 0em !important;
+ }
+
+ #docx-container .docx-wrapper > section.docx {
+ box-shadow: none !important;
+ border: none !important;
+ border-radius: 0 !important;
+ margin: 0 !important;
+ padding: 2em !important;
+ background: white !important;
+ color: black !important;
+ max-width: 100% !important;
+ width: 100% !important;
+ min-width: 0 !important;
+ overflow-x: auto !important;
+ }
+
+ /* Fix tables and wide content */
+ #docx-container table {
+ max-width: 100% !important;
+ width: auto !important;
+ overflow-x: auto !important;
+ display: block !important;
+ }
+
+ #docx-container img {
+ max-width: 100% !important;
+ height: auto !important;
+ }
+
+ /* Fix paragraphs and text */
+ #docx-container p,
+ #docx-container span,
+ #docx-container div {
+ max-width: 100% !important;
+ word-wrap: break-word !important;
+ overflow-wrap: break-word !important;
+ }
+
+ /* Hide page breaks in web view */
+ #docx-container .docx-page-break {
+ display: none !important;
+ }
+ `;
+ container.appendChild(style);
+ } catch (error: any) {
+ console.error("Error rendering DOCX:", error);
+ this.error = error?.message || i18n("Failed to load document");
+ }
+ }
+
+ private async renderExcel() {
+ const container = this.querySelector("#excel-container");
+ if (!container || !this.attachment) return;
+
+ try {
+ // Convert base64 to ArrayBuffer
+ const arrayBuffer = this.base64ToArrayBuffer(this.attachment.content);
+
+ // Read the workbook
+ const workbook = XLSX.read(arrayBuffer, { type: "array" });
+
+ // Clear container
+ container.innerHTML = "";
+ const wrapper = document.createElement("div");
+ wrapper.className = "overflow-auto h-full flex flex-col";
+ container.appendChild(wrapper);
+
+ // Create tabs for multiple sheets
+ if (workbook.SheetNames.length > 1) {
+ const tabContainer = document.createElement("div");
+ tabContainer.className = "flex gap-2 mb-4 border-b border-border sticky top-0 bg-card z-10";
+
+ const sheetContents: HTMLElement[] = [];
+
+ workbook.SheetNames.forEach((sheetName, index) => {
+ // Create tab button
+ const tab = document.createElement("button");
+ tab.textContent = sheetName;
+ tab.className =
+ index === 0
+ ? "px-4 py-2 text-sm font-medium border-b-2 border-primary text-primary"
+ : "px-4 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:border-b-2 hover:border-border transition-colors";
+
+ // Create sheet content
+ const sheetDiv = document.createElement("div");
+ sheetDiv.style.display = index === 0 ? "flex" : "none";
+ sheetDiv.className = "flex-1 overflow-auto";
+ sheetDiv.appendChild(this.renderExcelSheet(workbook.Sheets[sheetName], sheetName));
+ sheetContents.push(sheetDiv);
+
+ // Tab click handler
+ tab.onclick = () => {
+ // Update tab styles
+ tabContainer.querySelectorAll("button").forEach((btn, btnIndex) => {
+ if (btnIndex === index) {
+ btn.className = "px-4 py-2 text-sm font-medium border-b-2 border-primary text-primary";
+ } else {
+ btn.className =
+ "px-4 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:border-b-2 hover:border-border transition-colors";
+ }
+ });
+ // Show/hide sheets
+ sheetContents.forEach((content, contentIndex) => {
+ content.style.display = contentIndex === index ? "flex" : "none";
+ });
+ };
+
+ tabContainer.appendChild(tab);
+ });
+
+ wrapper.appendChild(tabContainer);
+ sheetContents.forEach((content) => {
+ wrapper.appendChild(content);
+ });
+ } else {
+ // Single sheet
+ const sheetName = workbook.SheetNames[0];
+ wrapper.appendChild(this.renderExcelSheet(workbook.Sheets[sheetName], sheetName));
+ }
+ } catch (error: any) {
+ console.error("Error rendering Excel:", error);
+ this.error = error?.message || i18n("Failed to load spreadsheet");
+ }
+ }
+
+ private renderExcelSheet(worksheet: any, sheetName: string): HTMLElement {
+ const sheetDiv = document.createElement("div");
+
+ // Generate HTML table
+ const htmlTable = XLSX.utils.sheet_to_html(worksheet, { id: `sheet-${sheetName}` });
+ const tempDiv = document.createElement("div");
+ tempDiv.innerHTML = htmlTable;
+
+ // Find and style the table
+ const table = tempDiv.querySelector("table");
+ if (table) {
+ table.className = "w-full border-collapse text-foreground";
+
+ // Style all cells
+ table.querySelectorAll("td, th").forEach((cell) => {
+ const cellEl = cell as HTMLElement;
+ cellEl.className = "border border-border px-3 py-2 text-sm text-left";
+ });
+
+ // Style header row
+ const headerCells = table.querySelectorAll("thead th, tr:first-child td");
+ if (headerCells.length > 0) {
+ headerCells.forEach((th) => {
+ const thEl = th as HTMLElement;
+ thEl.className =
+ "border border-border px-3 py-2 text-sm font-semibold bg-muted text-foreground sticky top-0";
+ });
+ }
+
+ // Alternate row colors
+ table.querySelectorAll("tbody tr:nth-child(even)").forEach((row) => {
+ const rowEl = row as HTMLElement;
+ rowEl.className = "bg-muted/30";
+ });
+
+ sheetDiv.appendChild(table);
+ }
+
+ return sheetDiv;
+ }
+
+ private base64ToArrayBuffer(base64: string): ArrayBuffer {
+ const binaryString = atob(base64);
+ const bytes = new Uint8Array(binaryString.length);
+ for (let i = 0; i < binaryString.length; i++) {
+ bytes[i] = binaryString.charCodeAt(i);
+ }
+ return bytes.buffer;
+ }
+
+ private async renderExtractedText() {
+ const container = this.querySelector("#pptx-container");
+ if (!container || !this.attachment) return;
+
+ try {
+ // Display the extracted text content
+ container.innerHTML = "";
+ const wrapper = document.createElement("div");
+ wrapper.className = "p-6 overflow-auto";
+
+ // Create a pre element to preserve formatting
+ const pre = document.createElement("pre");
+ pre.className = "whitespace-pre-wrap text-sm text-foreground font-mono";
+ pre.textContent = this.attachment.extractedText || i18n("No text content available");
+
+ wrapper.appendChild(pre);
+ container.appendChild(wrapper);
+ } catch (error: any) {
+ console.error("Error rendering extracted text:", error);
+ this.error = error?.message || i18n("Failed to display text content");
+ }
+ }
+}
+
+// Register the custom element only once
+if (!customElements.get("attachment-overlay")) {
+ customElements.define("attachment-overlay", AttachmentOverlay);
+}
diff --git a/packages/web-ui/src/dialogs/ModelSelector.ts b/packages/web-ui/src/dialogs/ModelSelector.ts
new file mode 100644
index 00000000..36ab9121
--- /dev/null
+++ b/packages/web-ui/src/dialogs/ModelSelector.ts
@@ -0,0 +1,324 @@
+import { Badge, Button, DialogBase, DialogHeader, html, icon, type TemplateResult } from "@mariozechner/mini-lit";
+import type { Model } from "@mariozechner/pi-ai";
+import { MODELS } from "@mariozechner/pi-ai/dist/models.generated.js";
+import type { PropertyValues } from "lit";
+import { customElement, state } from "lit/decorators.js";
+import { createRef, ref } from "lit/directives/ref.js";
+import { Brain, Image as ImageIcon } from "lucide";
+import { Ollama } from "ollama/dist/browser.mjs";
+import { Input } from "../components/Input.js";
+import { formatModelCost } from "../utils/format.js";
+import { i18n } from "../utils/i18n.js";
+
+@customElement("agent-model-selector")
+export class ModelSelector extends DialogBase {
+ @state() currentModel: Model | null = null;
+ @state() searchQuery = "";
+ @state() filterThinking = false;
+ @state() filterVision = false;
+ @state() ollamaModels: Model[] = [];
+ @state() ollamaError: string | null = null;
+ @state() selectedIndex = 0;
+ @state() private navigationMode: "mouse" | "keyboard" = "mouse";
+
+ private onSelectCallback?: (model: Model) => void;
+ private scrollContainerRef = createRef();
+ private searchInputRef = createRef();
+ private lastMousePosition = { x: 0, y: 0 };
+
+ protected override modalWidth = "min(400px, 90vw)";
+
+ static async open(currentModel: Model | null, onSelect: (model: Model) => void) {
+ const selector = new ModelSelector();
+ selector.currentModel = currentModel;
+ selector.onSelectCallback = onSelect;
+ selector.open();
+ selector.fetchOllamaModels();
+ }
+
+ override async firstUpdated(changedProperties: PropertyValues): Promise {
+ super.firstUpdated(changedProperties);
+ // Wait for dialog to be fully rendered
+ await this.updateComplete;
+ // Focus the search input when dialog opens
+ this.searchInputRef.value?.focus();
+
+ // Track actual mouse movement
+ this.addEventListener("mousemove", (e: MouseEvent) => {
+ // Check if mouse actually moved
+ if (e.clientX !== this.lastMousePosition.x || e.clientY !== this.lastMousePosition.y) {
+ this.lastMousePosition = { x: e.clientX, y: e.clientY };
+ // Only switch to mouse mode on actual mouse movement
+ if (this.navigationMode === "keyboard") {
+ this.navigationMode = "mouse";
+ // Update selection to the item under the mouse
+ const target = e.target as HTMLElement;
+ const modelItem = target.closest("[data-model-item]");
+ if (modelItem) {
+ const allItems = this.scrollContainerRef.value?.querySelectorAll("[data-model-item]");
+ if (allItems) {
+ const index = Array.from(allItems).indexOf(modelItem);
+ if (index !== -1) {
+ this.selectedIndex = index;
+ }
+ }
+ }
+ }
+ }
+ });
+
+ // Add global keyboard handler for the dialog
+ this.addEventListener("keydown", (e: KeyboardEvent) => {
+ // Get filtered models to know the bounds
+ const filteredModels = this.getFilteredModels();
+
+ if (e.key === "ArrowDown") {
+ e.preventDefault();
+ this.navigationMode = "keyboard";
+ this.selectedIndex = Math.min(this.selectedIndex + 1, filteredModels.length - 1);
+ this.scrollToSelected();
+ } else if (e.key === "ArrowUp") {
+ e.preventDefault();
+ this.navigationMode = "keyboard";
+ this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
+ this.scrollToSelected();
+ } else if (e.key === "Enter") {
+ e.preventDefault();
+ if (filteredModels[this.selectedIndex]) {
+ this.handleSelect(filteredModels[this.selectedIndex].model);
+ }
+ }
+ });
+ }
+
+ private async fetchOllamaModels() {
+ try {
+ // Create Ollama client
+ const ollama = new Ollama({ host: "http://localhost:11434" });
+
+ // Get list of available models
+ const { models } = await ollama.list();
+
+ // Fetch details for each model and convert to Model format
+ const ollamaModelPromises: Promise | null>[] = models
+ .map(async (model) => {
+ try {
+ // Get model details
+ const details = await ollama.show({
+ model: model.name,
+ });
+
+ // Some Ollama servers don't report capabilities; don't filter on them
+
+ // Extract model info
+ const modelInfo: any = details.model_info || {};
+
+ // Get context window size - look for architecture-specific keys
+ const architecture = modelInfo["general.architecture"] || "";
+ const contextKey = `${architecture}.context_length`;
+ const contextWindow = parseInt(modelInfo[contextKey] || "8192", 10);
+ const maxTokens = 4096; // Default max output tokens
+
+ // Create Model object manually since ollama models aren't in MODELS constant
+ const ollamaModel: Model = {
+ id: model.name,
+ name: model.name,
+ api: "openai-completions" as any,
+ provider: "ollama",
+ baseUrl: "http://localhost:11434/v1",
+ reasoning: false,
+ input: ["text"],
+ cost: {
+ input: 0,
+ output: 0,
+ cacheRead: 0,
+ cacheWrite: 0,
+ },
+ contextWindow: contextWindow,
+ maxTokens: maxTokens,
+ };
+
+ return ollamaModel;
+ } catch (err) {
+ console.error(`Failed to fetch details for model ${model.name}:`, err);
+ return null;
+ }
+ })
+ .filter((m) => m !== null);
+
+ const results = await Promise.all(ollamaModelPromises);
+ this.ollamaModels = results.filter((m): m is Model => m !== null);
+ } catch (err) {
+ // Ollama not available or other error - silently ignore
+ console.debug("Ollama not available:", err);
+ this.ollamaError = err instanceof Error ? err.message : String(err);
+ }
+ }
+
+ private formatTokens(tokens: number): string {
+ if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(0)}M`;
+ if (tokens >= 1000) return `${(tokens / 1000).toFixed(0)}`;
+ return String(tokens);
+ }
+
+ private handleSelect(model: Model) {
+ if (model) {
+ this.onSelectCallback?.(model);
+ this.close();
+ }
+ }
+
+ private getFilteredModels(): Array<{ provider: string; id: string; model: any }> {
+ // Collect all models from all providers
+ const allModels: Array<{ provider: string; id: string; model: any }> = [];
+ for (const [provider, providerData] of Object.entries(MODELS)) {
+ for (const [modelId, model] of Object.entries(providerData)) {
+ allModels.push({ provider, id: modelId, model });
+ }
+ }
+
+ // Add Ollama models
+ for (const ollamaModel of this.ollamaModels) {
+ allModels.push({
+ id: ollamaModel.id,
+ provider: "ollama",
+ model: ollamaModel,
+ });
+ }
+
+ // Filter models based on search and capability filters
+ let filteredModels = allModels;
+
+ // Apply search filter
+ if (this.searchQuery) {
+ filteredModels = filteredModels.filter(({ provider, id, model }) => {
+ const searchTokens = this.searchQuery.split(/\s+/).filter((t) => t);
+ const searchText = `${provider} ${id} ${model.name}`.toLowerCase();
+ return searchTokens.every((token) => searchText.includes(token));
+ });
+ }
+
+ // Apply capability filters
+ if (this.filterThinking) {
+ filteredModels = filteredModels.filter(({ model }) => model.reasoning);
+ }
+ if (this.filterVision) {
+ filteredModels = filteredModels.filter(({ model }) => model.input.includes("image"));
+ }
+
+ // Sort: current model first, then by provider
+ filteredModels.sort((a, b) => {
+ const aIsCurrent = this.currentModel?.id === a.model.id;
+ const bIsCurrent = this.currentModel?.id === b.model.id;
+ if (aIsCurrent && !bIsCurrent) return -1;
+ if (!aIsCurrent && bIsCurrent) return 1;
+ return a.provider.localeCompare(b.provider);
+ });
+
+ return filteredModels;
+ }
+
+ private scrollToSelected() {
+ requestAnimationFrame(() => {
+ const scrollContainer = this.scrollContainerRef.value;
+ const selectedElement = scrollContainer?.querySelectorAll("[data-model-item]")[
+ this.selectedIndex
+ ] as HTMLElement;
+ if (selectedElement) {
+ selectedElement.scrollIntoView({ block: "nearest", behavior: "smooth" });
+ }
+ });
+ }
+
+ protected override renderContent(): TemplateResult {
+ const filteredModels = this.getFilteredModels();
+
+ return html`
+
+
+ ${DialogHeader({ title: i18n("Select Model") })}
+ ${Input({
+ placeholder: i18n("Search models..."),
+ value: this.searchQuery,
+ inputRef: this.searchInputRef,
+ onInput: (e: Event) => {
+ this.searchQuery = (e.target as HTMLInputElement).value;
+ this.selectedIndex = 0;
+ // Reset scroll position when search changes
+ if (this.scrollContainerRef.value) {
+ this.scrollContainerRef.value.scrollTop = 0;
+ }
+ },
+ })}
+
+ ${Button({
+ variant: this.filterThinking ? "default" : "secondary",
+ size: "sm",
+ onClick: () => {
+ this.filterThinking = !this.filterThinking;
+ this.selectedIndex = 0;
+ if (this.scrollContainerRef.value) {
+ this.scrollContainerRef.value.scrollTop = 0;
+ }
+ },
+ className: "rounded-full",
+ children: html`${icon(Brain, "sm")} ${i18n("Thinking")} `,
+ })}
+ ${Button({
+ variant: this.filterVision ? "default" : "secondary",
+ size: "sm",
+ onClick: () => {
+ this.filterVision = !this.filterVision;
+ this.selectedIndex = 0;
+ if (this.scrollContainerRef.value) {
+ this.scrollContainerRef.value.scrollTop = 0;
+ }
+ },
+ className: "rounded-full",
+ children: html`${icon(ImageIcon, "sm")} ${i18n("Vision")} `,
+ })}
+
+
+
+
+
+ ${filteredModels.map(({ provider, id, model }, index) => {
+ // Check if this is the current model by comparing IDs
+ const isCurrent = this.currentModel?.id === model.id;
+ const isSelected = index === this.selectedIndex;
+ return html`
+
this.handleSelect(model)}
+ @mouseenter=${() => {
+ // Only update selection in mouse mode
+ if (this.navigationMode === "mouse") {
+ this.selectedIndex = index;
+ }
+ }}
+ >
+
+
+ ${id}
+ ${isCurrent ? html`✓ ` : ""}
+
+ ${Badge(provider, "outline")}
+
+
+
+ ${icon(Brain, "sm")}
+ ${icon(ImageIcon, "sm")}
+ ${this.formatTokens(model.contextWindow)}K/${this.formatTokens(model.maxTokens)}K
+
+
${formatModelCost(model.cost)}
+
+
+ `;
+ })}
+
+ `;
+ }
+}
diff --git a/packages/web-ui/src/index.ts b/packages/web-ui/src/index.ts
new file mode 100644
index 00000000..0d3c0151
--- /dev/null
+++ b/packages/web-ui/src/index.ts
@@ -0,0 +1,55 @@
+// Main chat interface
+export { ChatPanel } from "./ChatPanel.js";
+
+// Components
+export { AgentInterface } from "./components/AgentInterface.js";
+export { AttachmentTile } from "./components/AttachmentTile.js";
+export { ConsoleBlock } from "./components/ConsoleBlock.js";
+export { Input } from "./components/Input.js";
+export { MessageEditor } from "./components/MessageEditor.js";
+export { MessageList } from "./components/MessageList.js";
+// Message components
+export { AssistantMessage, ToolMessage, UserMessage } from "./components/Messages.js";
+export {
+ type SandboxFile,
+ SandboxIframe,
+ type SandboxResult,
+ type SandboxUrlProvider,
+} from "./components/SandboxedIframe.js";
+export { StreamingMessageContainer } from "./components/StreamingMessageContainer.js";
+export { ApiKeysDialog } from "./dialogs/ApiKeysDialog.js";
+export { AttachmentOverlay } from "./dialogs/AttachmentOverlay.js";
+// Dialogs
+export { ModelSelector } from "./dialogs/ModelSelector.js";
+
+// State management
+export { AgentSession } from "./state/agent-session.js";
+export type { KeyStore, StorageAdapter } from "./state/KeyStore.js";
+export { KeyStoreImpl, keyStore } from "./state/KeyStore.js";
+
+// Transports
+export { DirectTransport } from "./state/transports/DirectTransport.js";
+export { ProxyTransport } from "./state/transports/ProxyTransport.js";
+export type { ProxyAssistantMessageEvent } from "./state/transports/proxy-types.js";
+export type { AgentRunConfig, AgentTransport } from "./state/transports/types.js";
+// Artifacts
+export { ArtifactElement } from "./tools/artifacts/ArtifactElement.js";
+export { type Artifact, ArtifactsPanel, type ArtifactsParams } from "./tools/artifacts/artifacts.js";
+export { HtmlArtifact } from "./tools/artifacts/HtmlArtifact.js";
+export { MarkdownArtifact } from "./tools/artifacts/MarkdownArtifact.js";
+export { SvgArtifact } from "./tools/artifacts/SvgArtifact.js";
+export { TextArtifact } from "./tools/artifacts/TextArtifact.js";
+// Tools
+export { getToolRenderer, registerToolRenderer, renderToolParams, renderToolResult } from "./tools/index.js";
+export { BashRenderer } from "./tools/renderers/BashRenderer.js";
+export { CalculateRenderer } from "./tools/renderers/CalculateRenderer.js";
+// Tool renderers
+export { DefaultRenderer } from "./tools/renderers/DefaultRenderer.js";
+export { GetCurrentTimeRenderer } from "./tools/renderers/GetCurrentTimeRenderer.js";
+export type { ToolRenderer } from "./tools/types.js";
+export type { Attachment } from "./utils/attachment-utils.js";
+// Utils
+export { loadAttachment } from "./utils/attachment-utils.js";
+export { clearAuthToken, getAuthToken } from "./utils/auth-token.js";
+export { formatCost, formatModelCost, formatTokenCount, formatUsage } from "./utils/format.js";
+export { i18n, setLanguage } from "./utils/i18n.js";
diff --git a/packages/web-ui/src/state/KeyStore.ts b/packages/web-ui/src/state/KeyStore.ts
new file mode 100644
index 00000000..c0700c02
--- /dev/null
+++ b/packages/web-ui/src/state/KeyStore.ts
@@ -0,0 +1,96 @@
+import { getProviders } from "@mariozechner/pi-ai";
+
+/**
+ * Generic storage adapter interface
+ */
+export interface StorageAdapter {
+ get(key: string): Promise;
+ set(key: string, value: string): Promise;
+ remove(key: string): Promise;
+ getAll(): Promise>;
+}
+
+/**
+ * Interface for API key storage
+ */
+export interface KeyStore {
+ getKey(provider: string): Promise;
+ setKey(provider: string, key: string): Promise;
+ removeKey(provider: string): Promise;
+ getAllKeys(): Promise>; // provider -> isConfigured
+}
+
+/**
+ * Default localStorage implementation for web
+ */
+class LocalStorageAdapter implements StorageAdapter {
+ async get(key: string): Promise {
+ return localStorage.getItem(key);
+ }
+
+ async set(key: string, value: string): Promise {
+ localStorage.setItem(key, value);
+ }
+
+ async remove(key: string): Promise {
+ localStorage.removeItem(key);
+ }
+
+ async getAll(): Promise> {
+ const result: Record = {};
+ for (let i = 0; i < localStorage.length; i++) {
+ const key = localStorage.key(i);
+ if (key) {
+ const value = localStorage.getItem(key);
+ if (value) result[key] = value;
+ }
+ }
+ return result;
+ }
+}
+
+/**
+ * Generic KeyStore implementation
+ */
+class GenericKeyStore implements KeyStore {
+ private readonly prefix = "apiKey_";
+ private readonly storage: StorageAdapter;
+
+ constructor(storage?: StorageAdapter) {
+ this.storage = storage || new LocalStorageAdapter();
+ }
+
+ async getKey(provider: string): Promise {
+ const key = `${this.prefix}${provider}`;
+ return await this.storage.get(key);
+ }
+
+ async setKey(provider: string, key: string): Promise {
+ const storageKey = `${this.prefix}${provider}`;
+ await this.storage.set(storageKey, key);
+ }
+
+ async removeKey(provider: string): Promise {
+ const key = `${this.prefix}${provider}`;
+ await this.storage.remove(key);
+ }
+
+ async getAllKeys(): Promise> {
+ const providers = getProviders();
+ const allStorage = await this.storage.getAll();
+ const result: Record = {};
+
+ for (const provider of providers) {
+ const key = `${this.prefix}${provider}`;
+ result[provider] = !!allStorage[key];
+ }
+
+ return result;
+ }
+}
+
+// Export singleton instance (uses localStorage by default)
+export const keyStore = new GenericKeyStore();
+
+// Export class for custom storage implementations
+export { GenericKeyStore as KeyStoreImpl };
diff --git a/packages/web-ui/src/state/agent-session.ts b/packages/web-ui/src/state/agent-session.ts
new file mode 100644
index 00000000..208b2730
--- /dev/null
+++ b/packages/web-ui/src/state/agent-session.ts
@@ -0,0 +1,311 @@
+import type { Context } from "@mariozechner/pi-ai";
+import {
+ type AgentTool,
+ type AssistantMessage as AssistantMessageType,
+ getModel,
+ type ImageContent,
+ type Message,
+ type Model,
+ type TextContent,
+} from "@mariozechner/pi-ai";
+import type { AppMessage } from "../components/Messages.js";
+import type { Attachment } from "../utils/attachment-utils.js";
+import { DirectTransport } from "./transports/DirectTransport.js";
+import { ProxyTransport } from "./transports/ProxyTransport.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;
+ systemPrompt: string;
+ model: Model | null;
+ thinkingLevel: ThinkingLevel;
+ tools: AgentTool[];
+ messages: AppMessage[];
+ isStreaming: boolean;
+ streamMessage: Message | null;
+ pendingToolCalls: Set;
+ error?: string;
+}
+
+export type AgentSessionEvent =
+ | { type: "state-update"; state: AgentSessionState }
+ | { type: "error-no-model" }
+ | { type: "error-no-api-key"; provider: string };
+
+export type TransportMode = "direct" | "proxy";
+
+export interface AgentSessionOptions {
+ initialState?: Partial;
+ messagePreprocessor?: (messages: AppMessage[]) => Promise;
+ debugListener?: (entry: DebugLogEntry) => void;
+ transportMode?: TransportMode;
+ authTokenProvider?: () => Promise;
+}
+
+export class AgentSession {
+ private _state: AgentSessionState = {
+ id: "default",
+ systemPrompt: "",
+ model: getModel("google", "gemini-2.5-flash-lite-preview-06-17"),
+ thinkingLevel: "off",
+ tools: [],
+ messages: [],
+ isStreaming: false,
+ streamMessage: null,
+ pendingToolCalls: new Set(),
+ error: undefined,
+ };
+ private listeners = new Set<(e: AgentSessionEvent) => void>();
+ private abortController?: AbortController;
+ private transport: AgentTransport;
+ private messagePreprocessor?: (messages: AppMessage[]) => Promise;
+ private debugListener?: (entry: DebugLogEntry) => void;
+
+ constructor(opts: AgentSessionOptions = {}) {
+ this._state = { ...this._state, ...opts.initialState };
+ this.messagePreprocessor = opts.messagePreprocessor;
+ this.debugListener = opts.debugListener;
+
+ const mode = opts.transportMode || "direct";
+
+ if (mode === "proxy") {
+ this.transport = new ProxyTransport(async () => this.preprocessMessages());
+ } else {
+ this.transport = new DirectTransport(async () => this.preprocessMessages());
+ }
+ }
+
+ private async preprocessMessages(): Promise {
+ 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 {
+ return this._state;
+ }
+
+ subscribe(fn: (e: AgentSessionEvent) => void): () => void {
+ this.listeners.add(fn);
+ fn({ type: "state-update", state: this._state });
+ return () => this.listeners.delete(fn);
+ }
+
+ // Mutators
+ setSystemPrompt(v: string) {
+ this.patch({ systemPrompt: v });
+ }
+ setModel(m: Model | null) {
+ this.patch({ model: m });
+ }
+ setThinkingLevel(l: ThinkingLevel) {
+ this.patch({ thinkingLevel: l });
+ }
+ setTools(t: AgentTool[]) {
+ this.patch({ tools: t });
+ }
+ replaceMessages(ms: AppMessage[]) {
+ this.patch({ messages: ms.slice() });
+ }
+ appendMessage(m: AppMessage) {
+ this.patch({ messages: [...this._state.messages, m] });
+ }
+ clearMessages() {
+ this.patch({ messages: [] });
+ }
+
+ abort() {
+ this.abortController?.abort();
+ }
+
+ async prompt(input: string, attachments?: Attachment[]) {
+ const model = this._state.model;
+ if (!model) {
+ this.emit({ type: "error-no-model" });
+ return;
+ }
+
+ // Build user message with attachments
+ const content: Array = [{ type: "text", text: input }];
+ if (attachments?.length) {
+ for (const a of attachments) {
+ if (a.type === "image") {
+ content.push({ type: "image", data: a.content, mimeType: a.mimeType });
+ } else if (a.type === "document" && a.extractedText) {
+ content.push({
+ type: "text",
+ text: `\n\n[Document: ${a.fileName}]\n${a.extractedText}`,
+ isDocument: true,
+ } as TextContent);
+ }
+ }
+ }
+
+ const userMessage: AppMessage = {
+ role: "user",
+ content,
+ attachments: attachments?.length ? attachments : undefined,
+ };
+
+ this.abortController = new AbortController();
+ this.patch({ isStreaming: true, streamMessage: null, error: undefined });
+
+ const reasoning =
+ this._state.thinkingLevel === "off"
+ ? undefined
+ : this._state.thinkingLevel === "minimal"
+ ? "low"
+ : this._state.thinkingLevel;
+ const cfg: AgentRunConfig = {
+ systemPrompt: this._state.systemPrompt,
+ tools: this._state.tools,
+ model,
+ reasoning,
+ };
+
+ try {
+ 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)) {
+ switch (ev.type) {
+ case "turn_start": {
+ turnStart = performance.now();
+ // Build request context snapshot
+ const existing = this._state.messages as Message[];
+ const ctx: Context = {
+ systemPrompt: this._state.systemPrompt,
+ messages: [...existing],
+ tools: this._state.tools,
+ };
+ turnDebug = {
+ timestamp: new Date().toISOString(),
+ request: {
+ provider: cfg.model.provider,
+ model: cfg.model.id,
+ context: { ...ctx },
+ },
+ sseEvents: [],
+ };
+ break;
+ }
+ case "message_start":
+ case "message_update": {
+ partial = ev.message;
+ // Collect SSE-like events for debug (drop heavy partial)
+ if (ev.type === "message_update" && ev.assistantMessageEvent && turnDebug) {
+ const copy: any = { ...ev.assistantMessageEvent };
+ if (copy && "partial" in copy) delete copy.partial;
+ turnDebug.sseEvents.push(JSON.stringify(copy));
+ if (!turnDebug.ttft) turnDebug.ttft = performance.now() - turnStart;
+ }
+ this.patch({ streamMessage: ev.message });
+ break;
+ }
+ case "message_end": {
+ partial = null;
+ this.appendMessage(ev.message as AppMessage);
+ this.patch({ streamMessage: null });
+ if (turnDebug) {
+ if (ev.message.role !== "assistant" && ev.message.role !== "toolResult") {
+ turnDebug.request.context.messages.push(ev.message);
+ }
+ if (ev.message.role === "assistant") turnDebug.response = ev.message as any;
+ }
+ break;
+ }
+ case "tool_execution_start": {
+ const s = new Set(this._state.pendingToolCalls);
+ s.add(ev.toolCallId);
+ this.patch({ pendingToolCalls: s });
+ break;
+ }
+ case "tool_execution_end": {
+ const s = new Set(this._state.pendingToolCalls);
+ s.delete(ev.toolCallId);
+ this.patch({ pendingToolCalls: s });
+ break;
+ }
+ case "turn_end": {
+ // finalize current turn
+ if (turnDebug) {
+ turnDebug.totalTime = performance.now() - turnStart;
+ this.debugListener?.(turnDebug);
+ turnDebug = null;
+ }
+ break;
+ }
+ case "agent_end": {
+ this.patch({ streamMessage: null });
+ break;
+ }
+ }
+ }
+
+ if (partial && partial.role === "assistant" && partial.content.length > 0) {
+ const onlyEmpty = !partial.content.some(
+ (c) =>
+ (c.type === "thinking" && c.thinking.trim().length > 0) ||
+ (c.type === "text" && c.text.trim().length > 0) ||
+ (c.type === "toolCall" && c.name.trim().length > 0),
+ );
+ if (!onlyEmpty) {
+ this.appendMessage(partial as AppMessage);
+ } else {
+ if (this.abortController?.signal.aborted) {
+ throw new Error("Request was aborted");
+ }
+ }
+ }
+ } catch (err: any) {
+ if (String(err?.message || err) === "no-api-key") {
+ this.emit({ type: "error-no-api-key", provider: model.provider });
+ } else {
+ const msg: AssistantMessageType = {
+ role: "assistant",
+ content: [{ type: "text", text: "" }],
+ api: model.api,
+ provider: model.provider,
+ model: model.id,
+ usage: {
+ input: 0,
+ output: 0,
+ cacheRead: 0,
+ cacheWrite: 0,
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
+ },
+ stopReason: this.abortController?.signal.aborted ? "aborted" : "error",
+ errorMessage: err?.message || String(err),
+ };
+ this.appendMessage(msg as AppMessage);
+ this.patch({ error: err?.message || String(err) });
+ }
+ } finally {
+ this.patch({ isStreaming: false, streamMessage: null, pendingToolCalls: new Set() });
+ this.abortController = undefined;
+ }
+ {
+ const { systemPrompt, model, messages } = this._state;
+ console.log("final state:", { systemPrompt, model, messages });
+ }
+ }
+
+ private patch(p: Partial): void {
+ this._state = { ...this._state, ...p };
+ this.emit({ type: "state-update", state: this._state });
+ }
+
+ private emit(e: AgentSessionEvent) {
+ for (const listener of this.listeners) {
+ listener(e);
+ }
+ }
+}
diff --git a/packages/web-ui/src/state/transports/DirectTransport.ts b/packages/web-ui/src/state/transports/DirectTransport.ts
new file mode 100644
index 00000000..ecd6e734
--- /dev/null
+++ b/packages/web-ui/src/state/transports/DirectTransport.ts
@@ -0,0 +1,32 @@
+import { type AgentContext, agentLoop, type Message, type PromptConfig, type UserMessage } from "@mariozechner/pi-ai";
+import { keyStore } from "../KeyStore.js";
+import type { AgentRunConfig, AgentTransport } from "./types.js";
+
+export class DirectTransport implements AgentTransport {
+ constructor(private readonly getMessages: () => Promise) {}
+
+ async *run(userMessage: Message, cfg: AgentRunConfig, signal?: AbortSignal) {
+ // Get API key from KeyStore
+ const apiKey = await keyStore.getKey(cfg.model.provider);
+ if (!apiKey) {
+ throw new Error("no-api-key");
+ }
+
+ const context: AgentContext = {
+ systemPrompt: cfg.systemPrompt,
+ messages: await this.getMessages(),
+ tools: cfg.tools,
+ };
+
+ const pc: PromptConfig = {
+ model: cfg.model,
+ reasoning: cfg.reasoning,
+ apiKey,
+ };
+
+ // Yield events from agentLoop
+ for await (const ev of agentLoop(userMessage as unknown as UserMessage, context, pc, signal)) {
+ yield ev;
+ }
+ }
+}
diff --git a/packages/web-ui/src/state/transports/ProxyTransport.ts b/packages/web-ui/src/state/transports/ProxyTransport.ts
new file mode 100644
index 00000000..16781e07
--- /dev/null
+++ b/packages/web-ui/src/state/transports/ProxyTransport.ts
@@ -0,0 +1,359 @@
+import type {
+ AgentContext,
+ Api,
+ AssistantMessage,
+ AssistantMessageEvent,
+ Context,
+ Message,
+ Model,
+ PromptConfig,
+ SimpleStreamOptions,
+ ToolCall,
+ UserMessage,
+} from "@mariozechner/pi-ai";
+import { agentLoop } from "@mariozechner/pi-ai";
+import { AssistantMessageEventStream } from "@mariozechner/pi-ai/dist/utils/event-stream.js";
+import { parseStreamingJson } from "@mariozechner/pi-ai/dist/utils/json-parse.js";
+import { clearAuthToken, getAuthToken } from "../../utils/auth-token.js";
+import { i18n } from "../../utils/i18n.js";
+import type { ProxyAssistantMessageEvent } from "./proxy-types.js";
+import type { AgentRunConfig, AgentTransport } from "./types.js";
+
+/**
+ * Stream function that proxies through a server instead of calling providers directly.
+ * The server strips the partial field from delta events to reduce bandwidth.
+ * We reconstruct the partial message client-side.
+ */
+function streamSimpleProxy(
+ model: Model,
+ context: Context,
+ options: SimpleStreamOptions & { authToken: string },
+ proxyUrl: string,
+): AssistantMessageEventStream {
+ const stream = new AssistantMessageEventStream();
+
+ (async () => {
+ // Initialize the partial message that we'll build up from events
+ const partial: AssistantMessage = {
+ role: "assistant",
+ stopReason: "stop",
+ content: [],
+ api: model.api,
+ provider: model.provider,
+ model: model.id,
+ usage: {
+ input: 0,
+ output: 0,
+ cacheRead: 0,
+ cacheWrite: 0,
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
+ },
+ };
+
+ let reader: ReadableStreamDefaultReader | undefined;
+
+ // Set up abort handler to cancel the reader
+ const abortHandler = () => {
+ if (reader) {
+ reader.cancel("Request aborted by user").catch(() => {});
+ }
+ };
+
+ if (options.signal) {
+ options.signal.addEventListener("abort", abortHandler);
+ }
+
+ try {
+ const response = await fetch(`${proxyUrl}/api/stream`, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${options.authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ model,
+ context,
+ options: {
+ temperature: options.temperature,
+ maxTokens: options.maxTokens,
+ reasoning: options.reasoning,
+ // Don't send apiKey or signal - those are added server-side
+ },
+ }),
+ signal: options.signal,
+ });
+
+ if (!response.ok) {
+ let errorMessage = `Proxy error: ${response.status} ${response.statusText}`;
+ try {
+ const errorData = await response.json();
+ if (errorData.error) {
+ errorMessage = `Proxy error: ${errorData.error}`;
+ }
+ } catch {
+ // Couldn't parse error response, use default message
+ }
+ throw new Error(errorMessage);
+ }
+
+ // Parse SSE stream
+ reader = response.body!.getReader();
+ const decoder = new TextDecoder();
+ let buffer = "";
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ // Check if aborted after reading
+ if (options.signal?.aborted) {
+ throw new Error("Request aborted by user");
+ }
+
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split("\n");
+ buffer = lines.pop() || "";
+
+ for (const line of lines) {
+ if (line.startsWith("data: ")) {
+ const data = line.slice(6).trim();
+ if (data) {
+ const proxyEvent = JSON.parse(data) as ProxyAssistantMessageEvent;
+ let event: AssistantMessageEvent | undefined;
+
+ // Handle different event types
+ // Server sends events with partial for non-delta events,
+ // and without partial for delta events
+ switch (proxyEvent.type) {
+ case "start":
+ event = { type: "start", partial };
+ break;
+
+ case "text_start":
+ partial.content[proxyEvent.contentIndex] = {
+ type: "text",
+ text: "",
+ };
+ event = { type: "text_start", contentIndex: proxyEvent.contentIndex, partial };
+ break;
+
+ case "text_delta": {
+ const content = partial.content[proxyEvent.contentIndex];
+ if (content?.type === "text") {
+ content.text += proxyEvent.delta;
+ event = {
+ type: "text_delta",
+ contentIndex: proxyEvent.contentIndex,
+ delta: proxyEvent.delta,
+ partial,
+ };
+ } else {
+ throw new Error("Received text_delta for non-text content");
+ }
+ break;
+ }
+ case "text_end": {
+ const content = partial.content[proxyEvent.contentIndex];
+ if (content?.type === "text") {
+ content.textSignature = proxyEvent.contentSignature;
+ event = {
+ type: "text_end",
+ contentIndex: proxyEvent.contentIndex,
+ content: content.text,
+ partial,
+ };
+ } else {
+ throw new Error("Received text_end for non-text content");
+ }
+ break;
+ }
+
+ case "thinking_start":
+ partial.content[proxyEvent.contentIndex] = {
+ type: "thinking",
+ thinking: "",
+ };
+ event = { type: "thinking_start", contentIndex: proxyEvent.contentIndex, partial };
+ break;
+
+ case "thinking_delta": {
+ const content = partial.content[proxyEvent.contentIndex];
+ if (content?.type === "thinking") {
+ content.thinking += proxyEvent.delta;
+ event = {
+ type: "thinking_delta",
+ contentIndex: proxyEvent.contentIndex,
+ delta: proxyEvent.delta,
+ partial,
+ };
+ } else {
+ throw new Error("Received thinking_delta for non-thinking content");
+ }
+ break;
+ }
+
+ case "thinking_end": {
+ const content = partial.content[proxyEvent.contentIndex];
+ if (content?.type === "thinking") {
+ content.thinkingSignature = proxyEvent.contentSignature;
+ event = {
+ type: "thinking_end",
+ contentIndex: proxyEvent.contentIndex,
+ content: content.thinking,
+ partial,
+ };
+ } else {
+ throw new Error("Received thinking_end for non-thinking content");
+ }
+ break;
+ }
+
+ case "toolcall_start":
+ partial.content[proxyEvent.contentIndex] = {
+ type: "toolCall",
+ id: proxyEvent.id,
+ name: proxyEvent.toolName,
+ arguments: {},
+ partialJson: "",
+ } satisfies ToolCall & { partialJson: string } as ToolCall;
+ event = { type: "toolcall_start", contentIndex: proxyEvent.contentIndex, partial };
+ break;
+
+ case "toolcall_delta": {
+ const content = partial.content[proxyEvent.contentIndex];
+ if (content?.type === "toolCall") {
+ (content as any).partialJson += proxyEvent.delta;
+ content.arguments = parseStreamingJson((content as any).partialJson) || {};
+ event = {
+ type: "toolcall_delta",
+ contentIndex: proxyEvent.contentIndex,
+ delta: proxyEvent.delta,
+ partial,
+ };
+ partial.content[proxyEvent.contentIndex] = { ...content }; // Trigger reactivity
+ } else {
+ throw new Error("Received toolcall_delta for non-toolCall content");
+ }
+ break;
+ }
+
+ case "toolcall_end": {
+ const content = partial.content[proxyEvent.contentIndex];
+ if (content?.type === "toolCall") {
+ delete (content as any).partialJson;
+ event = {
+ type: "toolcall_end",
+ contentIndex: proxyEvent.contentIndex,
+ toolCall: content,
+ partial,
+ };
+ }
+ break;
+ }
+
+ case "done":
+ partial.stopReason = proxyEvent.reason;
+ partial.usage = proxyEvent.usage;
+ event = { type: "done", reason: proxyEvent.reason, message: partial };
+ break;
+
+ case "error":
+ partial.stopReason = proxyEvent.reason;
+ partial.errorMessage = proxyEvent.errorMessage;
+ partial.usage = proxyEvent.usage;
+ event = { type: "error", reason: proxyEvent.reason, error: partial };
+ break;
+
+ default: {
+ // Exhaustive check
+ const _exhaustiveCheck: never = proxyEvent;
+ console.warn(`Unhandled event type: ${(proxyEvent as any).type}`);
+ break;
+ }
+ }
+
+ // Push the event to stream
+ if (event) {
+ stream.push(event);
+ } else {
+ throw new Error("Failed to create event from proxy event");
+ }
+ }
+ }
+ }
+ }
+
+ // Check if aborted after reading
+ if (options.signal?.aborted) {
+ throw new Error("Request aborted by user");
+ }
+
+ stream.end();
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ if (errorMessage.toLowerCase().includes("proxy") && errorMessage.includes("Unauthorized")) {
+ clearAuthToken();
+ }
+ partial.stopReason = options.signal?.aborted ? "aborted" : "error";
+ partial.errorMessage = errorMessage;
+ stream.push({
+ type: "error",
+ reason: partial.stopReason,
+ error: partial,
+ } satisfies AssistantMessageEvent);
+ stream.end();
+ } finally {
+ // Clean up abort handler
+ if (options.signal) {
+ options.signal.removeEventListener("abort", abortHandler);
+ }
+ }
+ })();
+
+ return stream;
+}
+
+// Proxy transport executes the turn using a remote proxy server
+export class ProxyTransport implements AgentTransport {
+ // Hardcoded proxy URL for now - will be made configurable later
+ private readonly proxyUrl = "https://genai.mariozechner.at";
+
+ constructor(private readonly getMessages: () => Promise) {}
+
+ async *run(userMessage: Message, cfg: AgentRunConfig, signal?: AbortSignal) {
+ const authToken = await getAuthToken();
+ if (!authToken) {
+ throw new Error(i18n("Auth token is required for proxy transport"));
+ }
+
+ // Use proxy - no local API key needed
+ const streamFn = (model: Model, context: Context, options?: SimpleStreamOptions) => {
+ return streamSimpleProxy(
+ model,
+ context,
+ {
+ ...options,
+ authToken,
+ },
+ this.proxyUrl,
+ );
+ };
+
+ const context: AgentContext = {
+ systemPrompt: cfg.systemPrompt,
+ messages: await this.getMessages(),
+ tools: cfg.tools,
+ };
+
+ const pc: PromptConfig = {
+ model: cfg.model,
+ reasoning: cfg.reasoning,
+ };
+
+ // Yield events from the upstream agentLoop iterator
+ // Pass streamFn as the 5th parameter to use proxy
+ for await (const ev of agentLoop(userMessage as unknown as UserMessage, context, pc, signal, streamFn as any)) {
+ yield ev;
+ }
+ }
+}
diff --git a/packages/web-ui/src/state/transports/index.ts b/packages/web-ui/src/state/transports/index.ts
new file mode 100644
index 00000000..1d91e36f
--- /dev/null
+++ b/packages/web-ui/src/state/transports/index.ts
@@ -0,0 +1,3 @@
+export * from "./DirectTransport.js";
+export * from "./ProxyTransport.js";
+export * from "./types.js";
diff --git a/packages/web-ui/src/state/transports/proxy-types.ts b/packages/web-ui/src/state/transports/proxy-types.ts
new file mode 100644
index 00000000..94d4dbf9
--- /dev/null
+++ b/packages/web-ui/src/state/transports/proxy-types.ts
@@ -0,0 +1,15 @@
+import type { StopReason, Usage } from "@mariozechner/pi-ai";
+
+export type ProxyAssistantMessageEvent =
+ | { type: "start" }
+ | { type: "text_start"; contentIndex: number }
+ | { type: "text_delta"; contentIndex: number; delta: string }
+ | { type: "text_end"; contentIndex: number; contentSignature?: string }
+ | { type: "thinking_start"; contentIndex: number }
+ | { type: "thinking_delta"; contentIndex: number; delta: string }
+ | { type: "thinking_end"; contentIndex: number; contentSignature?: string }
+ | { type: "toolcall_start"; contentIndex: number; id: string; toolName: string }
+ | { type: "toolcall_delta"; contentIndex: number; delta: string }
+ | { type: "toolcall_end"; contentIndex: number }
+ | { type: "done"; reason: Extract; usage: Usage }
+ | { type: "error"; reason: Extract; errorMessage: string; usage: Usage };
diff --git a/packages/web-ui/src/state/transports/types.ts b/packages/web-ui/src/state/transports/types.ts
new file mode 100644
index 00000000..8f432ce6
--- /dev/null
+++ b/packages/web-ui/src/state/transports/types.ts
@@ -0,0 +1,16 @@
+import type { AgentEvent, AgentTool, Message, Model } from "@mariozechner/pi-ai";
+
+// The minimal configuration needed to run a turn.
+export interface AgentRunConfig {
+ systemPrompt: string;
+ tools: AgentTool[];
+ model: Model;
+ reasoning?: "low" | "medium" | "high";
+}
+
+// Events yielded by transports must match the @mariozechner/pi-ai prompt() events.
+// 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; // passthrough of AgentEvent from upstream
+}
diff --git a/packages/web-ui/src/state/types.ts b/packages/web-ui/src/state/types.ts
new file mode 100644
index 00000000..c5513941
--- /dev/null
+++ b/packages/web-ui/src/state/types.ts
@@ -0,0 +1,11 @@
+import type { AssistantMessage, Context } from "@mariozechner/pi-ai";
+
+export interface DebugLogEntry {
+ timestamp: string;
+ request: { provider: string; model: string; context: Context };
+ response?: AssistantMessage;
+ error?: unknown;
+ sseEvents: string[];
+ ttft?: number;
+ totalTime?: number;
+}
diff --git a/packages/web-ui/src/tools/artifacts/ArtifactElement.ts b/packages/web-ui/src/tools/artifacts/ArtifactElement.ts
new file mode 100644
index 00000000..f9f0b839
--- /dev/null
+++ b/packages/web-ui/src/tools/artifacts/ArtifactElement.ts
@@ -0,0 +1,15 @@
+import { LitElement, type TemplateResult } from "lit";
+
+export abstract class ArtifactElement extends LitElement {
+ public filename = "";
+ public displayTitle = "";
+
+ protected override createRenderRoot(): HTMLElement | DocumentFragment {
+ return this; // light DOM for shared styles
+ }
+
+ public abstract get content(): string;
+ public abstract set content(value: string);
+
+ abstract getHeaderButtons(): TemplateResult | HTMLElement;
+}
diff --git a/packages/web-ui/src/tools/artifacts/HtmlArtifact.ts b/packages/web-ui/src/tools/artifacts/HtmlArtifact.ts
new file mode 100644
index 00000000..4fc175d2
--- /dev/null
+++ b/packages/web-ui/src/tools/artifacts/HtmlArtifact.ts
@@ -0,0 +1,221 @@
+import { CopyButton, DownloadButton, PreviewCodeToggle } from "@mariozechner/mini-lit";
+import hljs from "highlight.js";
+import { html } from "lit";
+import { customElement, property, state } from "lit/decorators.js";
+import { createRef, type Ref, ref } from "lit/directives/ref.js";
+import { unsafeHTML } from "lit/directives/unsafe-html.js";
+import type { SandboxIframe } from "../../components/SandboxedIframe.js";
+import type { Attachment } from "../../utils/attachment-utils.js";
+import { i18n } from "../../utils/i18n.js";
+import "../../components/SandboxedIframe.js";
+import { ArtifactElement } from "./ArtifactElement.js";
+
+@customElement("html-artifact")
+export class HtmlArtifact extends ArtifactElement {
+ @property() override filename = "";
+ @property({ attribute: false }) override displayTitle = "";
+ @property({ attribute: false }) attachments: Attachment[] = [];
+ @property({ attribute: false }) sandboxUrlProvider?: () => string;
+
+ private _content = "";
+ private logs: Array<{ type: "log" | "error"; text: string }> = [];
+
+ // Refs for DOM elements
+ private sandboxIframeRef: Ref = createRef();
+ private consoleLogsRef: Ref = createRef();
+ private consoleButtonRef: Ref = createRef();
+
+ // Store message handler so we can remove it
+ private messageHandler?: (e: MessageEvent) => void;
+
+ @state() private viewMode: "preview" | "code" = "preview";
+ @state() private consoleOpen = false;
+
+ private setViewMode(mode: "preview" | "code") {
+ this.viewMode = mode;
+ }
+
+ public getHeaderButtons() {
+ const toggle = new PreviewCodeToggle();
+ toggle.mode = this.viewMode;
+ toggle.addEventListener("mode-change", (e: Event) => {
+ this.setViewMode((e as CustomEvent).detail);
+ });
+
+ const copyButton = new CopyButton();
+ copyButton.text = this._content;
+ copyButton.title = i18n("Copy HTML");
+ copyButton.showText = false;
+
+ return html`
+
+ ${toggle}
+ ${copyButton}
+ ${DownloadButton({ content: this._content, filename: this.filename, mimeType: "text/html", title: i18n("Download HTML") })}
+
+ `;
+ }
+
+ override set content(value: string) {
+ const oldValue = this._content;
+ this._content = value;
+ if (oldValue !== value) {
+ // Reset logs when content changes
+ this.logs = [];
+ if (this.consoleLogsRef.value) {
+ this.consoleLogsRef.value.innerHTML = "";
+ }
+ this.requestUpdate();
+ // Execute content in sandbox if it exists
+ if (this.sandboxIframeRef.value && value) {
+ this.updateConsoleButton();
+ this.executeContent(value);
+ }
+ }
+ }
+
+ private executeContent(html: string) {
+ const sandbox = this.sandboxIframeRef.value;
+ if (!sandbox) return;
+
+ // Configure sandbox URL provider if provided (for browser extensions)
+ if (this.sandboxUrlProvider) {
+ sandbox.sandboxUrlProvider = this.sandboxUrlProvider;
+ }
+
+ // Remove previous message handler if it exists
+ if (this.messageHandler) {
+ window.removeEventListener("message", this.messageHandler);
+ }
+
+ const sandboxId = `artifact-${this.filename}`;
+
+ // Set up message listener to collect logs
+ this.messageHandler = (e: MessageEvent) => {
+ if (e.data.sandboxId !== sandboxId) return;
+
+ if (e.data.type === "console") {
+ this.logs.push({
+ type: e.data.method === "error" ? "error" : "log",
+ text: e.data.text,
+ });
+ this.updateConsoleButton();
+ }
+ };
+ window.addEventListener("message", this.messageHandler);
+
+ // Load content (iframe persists, doesn't get removed)
+ sandbox.loadContent(sandboxId, html, this.attachments);
+ }
+
+ override get content(): string {
+ return this._content;
+ }
+
+ override disconnectedCallback() {
+ super.disconnectedCallback();
+ // Clean up message handler when element is removed from DOM
+ if (this.messageHandler) {
+ window.removeEventListener("message", this.messageHandler);
+ this.messageHandler = undefined;
+ }
+ }
+
+ override firstUpdated() {
+ // Execute initial content
+ if (this._content && this.sandboxIframeRef.value) {
+ this.executeContent(this._content);
+ }
+ }
+
+ override updated(changedProperties: Map) {
+ super.updated(changedProperties);
+ // If we have content but haven't executed yet (e.g., during reconstruction),
+ // execute when the iframe ref becomes available
+ if (this._content && this.sandboxIframeRef.value && this.logs.length === 0) {
+ this.executeContent(this._content);
+ }
+ }
+
+ private updateConsoleButton() {
+ const button = this.consoleButtonRef.value;
+ if (!button) return;
+
+ const errorCount = this.logs.filter((l) => l.type === "error").length;
+ const text =
+ errorCount > 0
+ ? `${i18n("console")} ${errorCount} errors `
+ : `${i18n("console")} (${this.logs.length})`;
+ button.innerHTML = `${text} ${this.consoleOpen ? "▼" : "▶"} `;
+ }
+
+ private toggleConsole() {
+ this.consoleOpen = !this.consoleOpen;
+ this.requestUpdate();
+
+ // Populate console logs if opening
+ if (this.consoleOpen) {
+ requestAnimationFrame(() => {
+ if (this.consoleLogsRef.value) {
+ // Populate with existing logs
+ this.consoleLogsRef.value.innerHTML = "";
+ this.logs.forEach((log) => {
+ const logEl = document.createElement("div");
+ logEl.className = `text-xs font-mono ${log.type === "error" ? "text-destructive" : "text-muted-foreground"}`;
+ logEl.textContent = `[${log.type}] ${log.text}`;
+ this.consoleLogsRef.value!.appendChild(logEl);
+ });
+ }
+ });
+ }
+ }
+
+ public getLogs(): string {
+ if (this.logs.length === 0) return i18n("No logs for {filename}").replace("{filename}", this.filename);
+ return this.logs.map((l) => `[${l.type}] ${l.text}`).join("\n");
+ }
+
+ override render() {
+ return html`
+
+
+
+
+
+ ${
+ this.logs.length > 0
+ ? html`
+
+
this.toggleConsole()}
+ class="w-full px-3 py-1 text-xs text-left hover:bg-muted flex items-center justify-between"
+ ${ref(this.consoleButtonRef)}
+ >
+ ${i18n("console")}
+ ${
+ this.logs.filter((l) => l.type === "error").length > 0
+ ? html`${this.logs.filter((l) => l.type === "error").length} errors `
+ : `(${this.logs.length})`
+ }
+ ${this.consoleOpen ? "▼" : "▶"}
+
+ ${this.consoleOpen ? html`
` : ""}
+
+ `
+ : ""
+ }
+
+
+
+
+
${unsafeHTML(
+ hljs.highlight(this._content, { language: "html" }).value,
+ )}
+
+
+
+ `;
+ }
+}
diff --git a/packages/web-ui/src/tools/artifacts/MarkdownArtifact.ts b/packages/web-ui/src/tools/artifacts/MarkdownArtifact.ts
new file mode 100644
index 00000000..07b6181b
--- /dev/null
+++ b/packages/web-ui/src/tools/artifacts/MarkdownArtifact.ts
@@ -0,0 +1,81 @@
+import { CopyButton, DownloadButton, PreviewCodeToggle } from "@mariozechner/mini-lit";
+import hljs from "highlight.js";
+import { html } from "lit";
+import { customElement, property, state } from "lit/decorators.js";
+import { unsafeHTML } from "lit/directives/unsafe-html.js";
+import { i18n } from "../../utils/i18n.js";
+import "@mariozechner/mini-lit/dist/MarkdownBlock.js";
+import { ArtifactElement } from "./ArtifactElement.js";
+
+@customElement("markdown-artifact")
+export class MarkdownArtifact extends ArtifactElement {
+ @property() override filename = "";
+ @property({ attribute: false }) override displayTitle = "";
+
+ private _content = "";
+ override get content(): string {
+ return this._content;
+ }
+ override set content(value: string) {
+ this._content = value;
+ this.requestUpdate();
+ }
+
+ @state() private viewMode: "preview" | "code" = "preview";
+
+ protected override createRenderRoot(): HTMLElement | DocumentFragment {
+ return this; // light DOM
+ }
+
+ private setViewMode(mode: "preview" | "code") {
+ this.viewMode = mode;
+ }
+
+ public getHeaderButtons() {
+ const toggle = new PreviewCodeToggle();
+ toggle.mode = this.viewMode;
+ toggle.addEventListener("mode-change", (e: Event) => {
+ this.setViewMode((e as CustomEvent).detail);
+ });
+
+ const copyButton = new CopyButton();
+ copyButton.text = this._content;
+ copyButton.title = i18n("Copy Markdown");
+ copyButton.showText = false;
+
+ return html`
+
+ ${toggle}
+ ${copyButton}
+ ${DownloadButton({
+ content: this._content,
+ filename: this.filename,
+ mimeType: "text/markdown",
+ title: i18n("Download Markdown"),
+ })}
+
+ `;
+ }
+
+ override render() {
+ return html`
+
+
+ ${
+ this.viewMode === "preview"
+ ? html`
`
+ : html`
${unsafeHTML(
+ hljs.highlight(this.content, { language: "markdown", ignoreIllegals: true }).value,
+ )}`
+ }
+
+
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "markdown-artifact": MarkdownArtifact;
+ }
+}
diff --git a/packages/web-ui/src/tools/artifacts/SvgArtifact.ts b/packages/web-ui/src/tools/artifacts/SvgArtifact.ts
new file mode 100644
index 00000000..51c24236
--- /dev/null
+++ b/packages/web-ui/src/tools/artifacts/SvgArtifact.ts
@@ -0,0 +1,77 @@
+import { CopyButton, DownloadButton, PreviewCodeToggle } from "@mariozechner/mini-lit";
+import hljs from "highlight.js";
+import { html } from "lit";
+import { customElement, property, state } from "lit/decorators.js";
+import { unsafeHTML } from "lit/directives/unsafe-html.js";
+import { i18n } from "../../utils/i18n.js";
+import { ArtifactElement } from "./ArtifactElement.js";
+
+@customElement("svg-artifact")
+export class SvgArtifact extends ArtifactElement {
+ @property() override filename = "";
+ @property({ attribute: false }) override displayTitle = "";
+
+ private _content = "";
+ override get content(): string {
+ return this._content;
+ }
+ override set content(value: string) {
+ this._content = value;
+ this.requestUpdate();
+ }
+
+ @state() private viewMode: "preview" | "code" = "preview";
+
+ protected override createRenderRoot(): HTMLElement | DocumentFragment {
+ return this; // light DOM
+ }
+
+ private setViewMode(mode: "preview" | "code") {
+ this.viewMode = mode;
+ }
+
+ public getHeaderButtons() {
+ const toggle = new PreviewCodeToggle();
+ toggle.mode = this.viewMode;
+ toggle.addEventListener("mode-change", (e: Event) => {
+ this.setViewMode((e as CustomEvent).detail);
+ });
+
+ const copyButton = new CopyButton();
+ copyButton.text = this._content;
+ copyButton.title = i18n("Copy SVG");
+ copyButton.showText = false;
+
+ return html`
+
+ ${toggle}
+ ${copyButton}
+ ${DownloadButton({ content: this._content, filename: this.filename, mimeType: "image/svg+xml", title: i18n("Download SVG") })}
+
+ `;
+ }
+
+ override render() {
+ return html`
+
+
+ ${
+ this.viewMode === "preview"
+ ? html`
+ ${unsafeHTML(this.content.replace(/
)/i, (_m, p1) => ``
+ : html`${unsafeHTML(
+ hljs.highlight(this.content, { language: "xml", ignoreIllegals: true }).value,
+ )} `
+ }
+
+
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "svg-artifact": SvgArtifact;
+ }
+}
diff --git a/packages/web-ui/src/tools/artifacts/TextArtifact.ts b/packages/web-ui/src/tools/artifacts/TextArtifact.ts
new file mode 100644
index 00000000..b7899316
--- /dev/null
+++ b/packages/web-ui/src/tools/artifacts/TextArtifact.ts
@@ -0,0 +1,148 @@
+import { CopyButton, DownloadButton } from "@mariozechner/mini-lit";
+import hljs from "highlight.js";
+import { html } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import { unsafeHTML } from "lit/directives/unsafe-html.js";
+import { i18n } from "../../utils/i18n.js";
+import { ArtifactElement } from "./ArtifactElement.js";
+
+// Known code file extensions for highlighting
+const CODE_EXTENSIONS = [
+ "js",
+ "javascript",
+ "ts",
+ "typescript",
+ "jsx",
+ "tsx",
+ "py",
+ "python",
+ "java",
+ "c",
+ "cpp",
+ "cs",
+ "php",
+ "rb",
+ "ruby",
+ "go",
+ "rust",
+ "swift",
+ "kotlin",
+ "scala",
+ "dart",
+ "html",
+ "css",
+ "scss",
+ "sass",
+ "less",
+ "json",
+ "xml",
+ "yaml",
+ "yml",
+ "toml",
+ "sql",
+ "sh",
+ "bash",
+ "ps1",
+ "bat",
+ "r",
+ "matlab",
+ "julia",
+ "lua",
+ "perl",
+ "vue",
+ "svelte",
+];
+
+@customElement("text-artifact")
+export class TextArtifact extends ArtifactElement {
+ @property() override filename = "";
+ @property({ attribute: false }) override displayTitle = "";
+
+ private _content = "";
+ override get content(): string {
+ return this._content;
+ }
+ override set content(value: string) {
+ this._content = value;
+ this.requestUpdate();
+ }
+
+ protected override createRenderRoot(): HTMLElement | DocumentFragment {
+ return this; // light DOM
+ }
+
+ private isCode(): boolean {
+ const ext = this.filename.split(".").pop()?.toLowerCase() || "";
+ return CODE_EXTENSIONS.includes(ext);
+ }
+
+ private getLanguageFromExtension(ext: string): string {
+ const languageMap: Record
= {
+ js: "javascript",
+ ts: "typescript",
+ py: "python",
+ rb: "ruby",
+ yml: "yaml",
+ ps1: "powershell",
+ bat: "batch",
+ };
+ return languageMap[ext] || ext;
+ }
+
+ private getMimeType(): string {
+ const ext = this.filename.split(".").pop()?.toLowerCase() || "";
+ if (ext === "svg") return "image/svg+xml";
+ if (ext === "md" || ext === "markdown") return "text/markdown";
+ return "text/plain";
+ }
+
+ public getHeaderButtons() {
+ const copyButton = new CopyButton();
+ copyButton.text = this.content;
+ copyButton.title = i18n("Copy");
+ copyButton.showText = false;
+
+ return html`
+
+ ${copyButton}
+ ${DownloadButton({
+ content: this.content,
+ filename: this.filename,
+ mimeType: this.getMimeType(),
+ title: i18n("Download"),
+ })}
+
+ `;
+ }
+
+ override render() {
+ const isCode = this.isCode();
+ const ext = this.filename.split(".").pop() || "";
+ return html`
+
+
+ ${
+ isCode
+ ? html`
+
${unsafeHTML(
+ hljs.highlight(this.content, {
+ language: this.getLanguageFromExtension(ext.toLowerCase()),
+ ignoreIllegals: true,
+ }).value,
+ )}
+ `
+ : html`
${this.content} `
+ }
+
+
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "text-artifact": TextArtifact;
+ }
+}
diff --git a/packages/web-ui/src/tools/artifacts/artifacts.ts b/packages/web-ui/src/tools/artifacts/artifacts.ts
new file mode 100644
index 00000000..cc10d5ac
--- /dev/null
+++ b/packages/web-ui/src/tools/artifacts/artifacts.ts
@@ -0,0 +1,888 @@
+import { Button, Diff, icon } from "@mariozechner/mini-lit";
+import { type AgentTool, type Message, StringEnum, type ToolCall, type ToolResultMessage } from "@mariozechner/pi-ai";
+import { type Static, Type } from "@sinclair/typebox";
+import { html, LitElement, type TemplateResult } from "lit";
+import { customElement, property, state } from "lit/decorators.js";
+import { createRef, type Ref, ref } from "lit/directives/ref.js";
+import { X } from "lucide";
+import type { Attachment } from "../../utils/attachment-utils.js";
+import { i18n } from "../../utils/i18n.js";
+import type { ToolRenderer } from "../types.js";
+import type { ArtifactElement } from "./ArtifactElement.js";
+import { HtmlArtifact } from "./HtmlArtifact.js";
+import { MarkdownArtifact } from "./MarkdownArtifact.js";
+import { SvgArtifact } from "./SvgArtifact.js";
+import { TextArtifact } from "./TextArtifact.js";
+import "@mariozechner/mini-lit/dist/MarkdownBlock.js";
+import "@mariozechner/mini-lit/dist/CodeBlock.js";
+
+// Simple artifact model
+export interface Artifact {
+ filename: string;
+ title: string;
+ content: string;
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+// JSON-schema friendly parameters object (LLM-facing)
+const artifactsParamsSchema = Type.Object({
+ command: StringEnum(["create", "update", "rewrite", "get", "delete", "logs"], {
+ description: "The operation to perform",
+ }),
+ filename: Type.String({ description: "Filename including extension (e.g., 'index.html', 'script.js')" }),
+ title: Type.Optional(Type.String({ description: "Display title for the tab (defaults to filename)" })),
+ content: Type.Optional(Type.String({ description: "File content" })),
+ old_str: Type.Optional(Type.String({ description: "String to replace (for update command)" })),
+ new_str: Type.Optional(Type.String({ description: "Replacement string (for update command)" })),
+});
+export type ArtifactsParams = Static;
+
+// Minimal helper to render plain text outputs consistently
+function plainOutput(text: string): TemplateResult {
+ return html`${text}
`;
+}
+
+@customElement("artifacts-panel")
+export class ArtifactsPanel extends LitElement implements ToolRenderer {
+ @state() private _artifacts = new Map();
+ @state() private _activeFilename: string | null = null;
+
+ // Programmatically managed artifact elements
+ private artifactElements = new Map();
+ private contentRef: Ref = createRef();
+
+ // External provider for attachments (decouples panel from AgentInterface)
+ @property({ attribute: false }) attachmentsProvider?: () => Attachment[];
+ // Sandbox URL provider for browser extensions (optional)
+ @property({ attribute: false }) sandboxUrlProvider?: () => string;
+ // Callbacks
+ @property({ attribute: false }) onArtifactsChange?: () => void;
+ @property({ attribute: false }) onClose?: () => void;
+ @property({ attribute: false }) onOpen?: () => void;
+ // Collapsed mode: hides panel content but can show a floating reopen pill
+ @property({ type: Boolean }) collapsed = false;
+ // Overlay mode: when true, panel renders full-screen overlay (mobile)
+ @property({ type: Boolean }) overlay = false;
+
+ // Public getter for artifacts
+ get artifacts() {
+ return this._artifacts;
+ }
+
+ protected override createRenderRoot(): HTMLElement | DocumentFragment {
+ return this; // light DOM for shared styles
+ }
+
+ override connectedCallback(): void {
+ super.connectedCallback();
+ this.style.display = "block";
+ this.style.height = "100%";
+ // Reattach existing artifact elements when panel is re-inserted into the DOM
+ requestAnimationFrame(() => {
+ const container = this.contentRef.value;
+ if (!container) return;
+ // Ensure we have an active filename
+ if (!this._activeFilename && this._artifacts.size > 0) {
+ this._activeFilename = Array.from(this._artifacts.keys())[0];
+ }
+ this.artifactElements.forEach((element, name) => {
+ if (!element.parentElement) container.appendChild(element);
+ element.style.display = name === this._activeFilename ? "block" : "none";
+ });
+ });
+ }
+
+ override disconnectedCallback() {
+ super.disconnectedCallback();
+ // Do not tear down artifact elements; keep them to restore on next mount
+ }
+
+ // Helper to determine file type from extension
+ private getFileType(filename: string): "html" | "svg" | "markdown" | "text" {
+ const ext = filename.split(".").pop()?.toLowerCase();
+ if (ext === "html") return "html";
+ if (ext === "svg") return "svg";
+ if (ext === "md" || ext === "markdown") return "markdown";
+ return "text";
+ }
+
+ // Helper to determine language for syntax highlighting
+ private getLanguageFromFilename(filename?: string): string {
+ if (!filename) return "text";
+ const ext = filename.split(".").pop()?.toLowerCase();
+ const languageMap: Record = {
+ js: "javascript",
+ jsx: "javascript",
+ ts: "typescript",
+ tsx: "typescript",
+ html: "html",
+ css: "css",
+ scss: "scss",
+ json: "json",
+ py: "python",
+ md: "markdown",
+ svg: "xml",
+ xml: "xml",
+ yaml: "yaml",
+ yml: "yaml",
+ sh: "bash",
+ bash: "bash",
+ sql: "sql",
+ java: "java",
+ c: "c",
+ cpp: "cpp",
+ cs: "csharp",
+ go: "go",
+ rs: "rust",
+ php: "php",
+ rb: "ruby",
+ swift: "swift",
+ kt: "kotlin",
+ r: "r",
+ };
+ return languageMap[ext || ""] || "text";
+ }
+
+ // Get or create artifact element
+ private getOrCreateArtifactElement(filename: string, content: string, title: string): ArtifactElement {
+ let element = this.artifactElements.get(filename);
+
+ if (!element) {
+ const type = this.getFileType(filename);
+ if (type === "html") {
+ element = new HtmlArtifact();
+ (element as HtmlArtifact).attachments = this.attachmentsProvider?.() || [];
+ if (this.sandboxUrlProvider) {
+ (element as HtmlArtifact).sandboxUrlProvider = this.sandboxUrlProvider;
+ }
+ } else if (type === "svg") {
+ element = new SvgArtifact();
+ } else if (type === "markdown") {
+ element = new MarkdownArtifact();
+ } else {
+ element = new TextArtifact();
+ }
+ element.filename = filename;
+ element.displayTitle = title;
+ element.content = content;
+ element.style.display = "none";
+ element.style.height = "100%";
+
+ // Store element
+ this.artifactElements.set(filename, element);
+
+ // Add to DOM - try immediately if container exists, otherwise schedule
+ const newElement = element;
+ if (this.contentRef.value) {
+ this.contentRef.value.appendChild(newElement);
+ } else {
+ requestAnimationFrame(() => {
+ if (this.contentRef.value && !newElement.parentElement) {
+ this.contentRef.value.appendChild(newElement);
+ }
+ });
+ }
+ } else {
+ // Just update content
+ element.content = content;
+ element.displayTitle = title;
+ if (element instanceof HtmlArtifact) {
+ element.attachments = this.attachmentsProvider?.() || [];
+ }
+ }
+
+ return element;
+ }
+
+ // Show/hide artifact elements
+ private showArtifact(filename: string) {
+ // Ensure the active element is in the DOM
+ requestAnimationFrame(() => {
+ this.artifactElements.forEach((element, name) => {
+ if (this.contentRef.value && !element.parentElement) {
+ this.contentRef.value.appendChild(element);
+ }
+ element.style.display = name === filename ? "block" : "none";
+ });
+ });
+ this._activeFilename = filename;
+ this.requestUpdate(); // Only for tab bar update
+ }
+
+ // Open panel and focus an artifact tab by filename
+ private openArtifact(filename: string) {
+ if (this._artifacts.has(filename)) {
+ this.showArtifact(filename);
+ // Ask host to open panel (AgentInterface demo listens to onOpen)
+ this.onOpen?.();
+ }
+ }
+
+ // Build the AgentTool (no details payload; return only output strings)
+ public get tool(): AgentTool {
+ return {
+ label: "Artifacts",
+ name: "artifacts",
+ description: `Creates and manages file artifacts. Each artifact is a file with a filename and content.
+
+IMPORTANT: Always prefer updating existing files over creating new ones. Check available files first.
+
+Commands:
+1. create: Create a new file
+ - filename: Name with extension (required, e.g., 'index.html', 'script.js', 'README.md')
+ - title: Display name for the tab (optional, defaults to filename)
+ - content: File content (required)
+
+2. update: Update part of an existing file
+ - filename: File to update (required)
+ - old_str: Exact string to replace (required)
+ - new_str: Replacement string (required)
+
+3. rewrite: Completely replace a file's content
+ - filename: File to rewrite (required)
+ - content: New content (required)
+ - title: Optionally update display title
+
+4. get: Retrieve the full content of a file
+ - filename: File to retrieve (required)
+ - Returns the complete file content
+
+5. delete: Delete a file
+ - filename: File to delete (required)
+
+6. logs: Get console logs and errors (HTML files only)
+ - filename: HTML file to get logs for (required)
+ - Returns all console output and runtime errors
+
+For text/html artifacts with attachments:
+- HTML artifacts automatically have access to user attachments via JavaScript
+- Available global functions in HTML artifacts:
+ * listFiles() - Returns array of {id, fileName, mimeType, size} for all attachments
+ * readTextFile(attachmentId) - Returns text content of attachment (for CSV, JSON, text files)
+ * readBinaryFile(attachmentId) - Returns Uint8Array of binary data (for images, Excel, etc.)
+- Example HTML artifact that processes a CSV attachment:
+
+
+For text/html artifacts:
+- Must be a single self-contained file
+- External scripts: Use CDNs like https://esm.sh, https://unpkg.com, or https://cdnjs.cloudflare.com
+- Preferred: Use https://esm.sh for npm packages (e.g., https://esm.sh/three for Three.js)
+- For ES modules, use:
+- For Three.js specifically: import from 'https://esm.sh/three' or 'https://esm.sh/three@0.160.0'
+- For addons: import from 'https://esm.sh/three/examples/jsm/controls/OrbitControls.js'
+- No localStorage/sessionStorage - use in-memory variables only
+- CSS should be included inline
+- CRITICAL REMINDER FOR HTML ARTIFACTS:
+ - ALWAYS set a background color inline in \n\n\n \n
Hello, World! \n
This is a minimal HTML5 page.
\n
\n\n