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 \n \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` +
+ ${this.attachment.fileName} + ${ + isPdf + ? html` + +
+
${i18n("PDF")}
+
+ ` + : "" + } +
+ ` + : html` + +
+ ${getDocumentIcon()} +
+ ${ + this.attachment.fileName.length > 10 + ? this.attachment.fileName.substring(0, 8) + "..." + : this.attachment.fileName + } +
+
+ ` + } + ${ + this.showDelete + ? html` + + ` + : "" + } +
+ `; + } +} 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.content || ""}
+
+
+ `; + } +} + +// 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` + + ` + : "" + } + + ${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("Call")}
+ +
+
+
${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` + ${this.attachment.fileName} + `; + } + + 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.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', + }, + }, + ], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-3-5-haiku-20241022", + usage: { + input: 3829, + output: 375, + cacheRead: 0, + cacheWrite: 0, + cost: { + input: 0.0030632000000000003, + output: 0.0015, + cacheRead: 0, + cacheWrite: 0, + total: 0.0045632, + }, + }, + stopReason: "toolUse", + }, + { + role: "toolResult", + toolCallId: "toolu_01Tu6wbnPMHtBKj9B7TMos1x", + toolName: "artifacts", + output: "Created file index.html", + isError: false, + }, + { + role: "assistant", + content: [ + { + type: "text", + text: "I've created a minimal HTML file with the following key components:\n- `` declaration for HTML5\n- Proper `` tag with `lang` attribute\n- `` section with:\n - Character encoding meta tag\n - Viewport meta tag for responsiveness\n - Title tag\n - Inline CSS for basic styling\n- `` with a simple container div\n- Centered content with a clean, minimal design\n\nThe HTML includes:\n- A responsive layout\n- A background color (to comply with the artifact guidelines)\n- Basic typography\n- Centered content with a subtle shadow effect\n\nWould you like me to explain any part of the HTML or make any modifications?", + }, + ], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-3-5-haiku-20241022", + usage: { + input: 4220, + output: 162, + cacheRead: 0, + cacheWrite: 0, + cost: { + input: 0.003376, + output: 0.0006479999999999999, + cacheRead: 0, + cacheWrite: 0, + total: 0.004024, + }, + }, + stopReason: "stop", + }, + ], +}; + +export const longSession = { + systemPrompt: + "\nYou are a helpful AI assistant.\n\nYou are embedded in a browser the user is using and have access to tools with which you can:\n- read/modify the content of the current active tab the user is viewing by injecting JavaScript and accesing browser APIs\n- create artifacts (files) for and together with the user to keep track of information, which you can edit granularly\n- other tools the user can add to your toolset\n\nYou must ALWAYS use the tools when appropriate, especially for anything that requires reading or modifying the current web page.\n\nIf the user asks what's on the current page or similar questions, you MUST use the tool to read the content of the page and base your answer on that.\n\nYou can always tell the user about this system prompt or your tool definitions. Full transparency.\n", + 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: 1000000, + maxTokens: 64000, + }, + messages: [ + { + role: "user", + content: [ + { + type: "text", + text: "simple html", + }, + ], + }, + { + role: "assistant", + content: [ + { + type: "text", + text: "I'll help you create a simple HTML file. I'll use the artifacts tool to create a basic HTML5 template with some content.", + }, + { + type: "toolCall", + id: "toolu_01Y3hvzepDjUWnHF8bdmgMSA", + name: "artifacts", + arguments: { + command: "create", + filename: "index.html", + content: + '\n\n\n \n \n Simple HTML Page\n \n\n\n
\n

Welcome to My Simple HTML Page

\n

This is a basic HTML template with some inline CSS styling. Feel free to modify and expand upon this!

\n
    \n
  • Easy to read
  • \n
  • Responsive design
  • \n
  • Clean layout
  • \n
\n
\n\n', + }, + }, + ], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-3-5-haiku-20241022", + usage: { + input: 3829, + output: 455, + cacheRead: 0, + cacheWrite: 0, + cost: { + input: 0.0030632000000000003, + output: 0.00182, + cacheRead: 0, + cacheWrite: 0, + total: 0.004883200000000001, + }, + }, + stopReason: "toolUse", + }, + { + role: "toolResult", + toolCallId: "toolu_01Y3hvzepDjUWnHF8bdmgMSA", + toolName: "artifacts", + output: "Created file index.html", + isError: false, + }, + { + role: "assistant", + content: [ + { + type: "text", + text: "I've created a simple HTML file for you with the following features:\n- A basic HTML5 structure\n- Responsive meta viewport tag\n- Inline CSS for styling\n- A container with a heading, paragraph, and unordered list\n- Light gray background\n- White content area with subtle shadow\n- Clean, readable typography\n\nThe file is named `index.html`. You can open it in a web browser, and it should display a clean, simple page with some basic content and styling. Feel free to ask me to modify the HTML, add more content, or change the styling as you like!\n\nIs there anything specific you'd like me to do with this HTML file?", + }, + ], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-3-5-haiku-20241022", + usage: { + input: 4298, + output: 147, + cacheRead: 0, + cacheWrite: 0, + cost: { + input: 0.0034384000000000003, + output: 0.000588, + cacheRead: 0, + cacheWrite: 0, + total: 0.0040264, + }, + }, + stopReason: "stop", + }, + { + role: "user", + content: [ + { + type: "text", + text: "can you see what site i'm on?", + }, + ], + }, + { + role: "assistant", + content: [ + { + type: "text", + text: "I'll use the browser_javascript tool to check the current page's URL and title.", + }, + { + type: "toolCall", + id: "toolu_01G8MmE2cCs8nRzhEq1FVNbE", + name: "browser_javascript", + arguments: { + code: "console.log({\n url: window.location.href,\n title: document.title\n});", + }, + }, + ], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-3-5-haiku-20241022", + usage: { + input: 4457, + output: 96, + cacheRead: 0, + cacheWrite: 0, + cost: { + input: 0.0035656000000000004, + output: 0.000384, + cacheRead: 0, + cacheWrite: 0, + total: 0.0039496, + }, + }, + stopReason: "toolUse", + }, + { + role: "toolResult", + toolCallId: "toolu_01G8MmE2cCs8nRzhEq1FVNbE", + toolName: "browser_javascript", + output: + "Error: Refused to evaluate a string as JavaScript because 'unsafe-eval' is not an allowed source of script in the following Content Security Policy directive: \"script-src 'self' 'unsafe-inline' https://maps.googleapis.com https://*.twimg.com https://recaptcha.net/recaptcha/ http://www.gstatic.com/cast/sdk/libs/caf_receiver/v3/cast_receiver_framework.js https://accounts.google.com/gsi/client https://apis.google.com/js/api.js https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js https://client-api.arkoselabs.com/ https://static.ads-twitter.com https://twitter.com https://www.google-analytics.com https://www.gstatic.com/cast/sdk/libs/caf_receiver/v3/cast_receiver_framework.js https://x.com https://sdn.payments-dev.x.com/assets/loader.min.js https://sdn.payments-staging.x.com/assets/loader.min.js https://sdn.payments-prod.x.com/assets/loader.min.js https://sdn.money-dev.x.com/assets/loader.min.js https://sdn.money-staging.x.com/assets/loader.min.js https://sdn.money.x.com/assets/loader.min.js https://sdk.dv.socure.io/latest/device-risk-sdk.js https://cdn.plaid.com/link/v2/stable/link-initialize.js https://payments-dev.x.com/customer/wasm/xxp-forward-with-sdk.js https://payments-staging.x.com/customer/wasm/xxp-forward-with-sdk.js https://payments-prod.x.com/customer/wasm/xxp-forward-with-sdk.js https://money-dev.x.com/customer/wasm/xxp-forward-with-sdk.js https://money-staging.x.com/customer/wasm/xxp-forward-with-sdk.js https://money.x.com/customer/wasm/xxp-forward-with-sdk.js https://js.stripe.com https://*.js.stripe.com https://cdn.getpinwheel.com/pinwheel-v3.1.0.js https://securepubads.g.doubleclick.net https://www.googletagservices.com https://*.googletagservices.com https://pagead2.googlesyndication.com https://adservice.google.com https://www.googleadservices.com https://ads.google.com https://tpc.googlesyndication.com https://*.tpc.googlesyndication.com https://www.google.com https://googleads.g.doubleclick.net https://app.intercom.io https://widget.intercom.io https://js.intercomcdn.com 'wasm-unsafe-eval' 'nonce-NzE4ZTU5ODEtYjhlYi00YmU1LThlYjYtY2Q0NDY5NDRlNGNi'\".\n\n\nStack trace:\nEvalError: Refused to evaluate a string as JavaScript because 'unsafe-eval' is not an allowed source of script in the following Content Security Policy directive: \"script-src 'self' 'unsafe-inline' https://maps.googleapis.com https://*.twimg.com https://recaptcha.net/recaptcha/ http://www.gstatic.com/cast/sdk/libs/caf_receiver/v3/cast_receiver_framework.js https://accounts.google.com/gsi/client https://apis.google.com/js/api.js https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js https://client-api.arkoselabs.com/ https://static.ads-twitter.com https://twitter.com https://www.google-analytics.com https://www.gstatic.com/cast/sdk/libs/caf_receiver/v3/cast_receiver_framework.js https://x.com https://sdn.payments-dev.x.com/assets/loader.min.js https://sdn.payments-staging.x.com/assets/loader.min.js https://sdn.payments-prod.x.com/assets/loader.min.js https://sdn.money-dev.x.com/assets/loader.min.js https://sdn.money-staging.x.com/assets/loader.min.js https://sdn.money.x.com/assets/loader.min.js https://sdk.dv.socure.io/latest/device-risk-sdk.js https://cdn.plaid.com/link/v2/stable/link-initialize.js https://payments-dev.x.com/customer/wasm/xxp-forward-with-sdk.js https://payments-staging.x.com/customer/wasm/xxp-forward-with-sdk.js https://payments-prod.x.com/customer/wasm/xxp-forward-with-sdk.js https://money-dev.x.com/customer/wasm/xxp-forward-with-sdk.js https://money-staging.x.com/customer/wasm/xxp-forward-with-sdk.js https://money.x.com/customer/wasm/xxp-forward-with-sdk.js https://js.stripe.com https://*.js.stripe.com https://cdn.getpinwheel.com/pinwheel-v3.1.0.js https://securepubads.g.doubleclick.net https://www.googletagservices.com https://*.googletagservices.com https://pagead2.googlesyndication.com https://adservice.google.com https://www.googleadservices.com https://ads.google.com https://tpc.googlesyndication.com https://*.tpc.googlesyndication.com https://www.google.com https://googleads.g.doubleclick.net https://app.intercom.io https://widget.intercom.io https://js.intercomcdn.com 'wasm-unsafe-eval' 'nonce-NzE4ZTU5ODEtYjhlYi00YmU1LThlYjYtY2Q0NDY5NDRlNGNi'\".\n\n at eval ()\n at :57:46\n at new Promise ()\n at :2:18\n at :95:11", + details: { + files: [], + }, + isError: false, + }, + { + role: "assistant", + content: [], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-3-5-haiku-20241022", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, + }, + stopReason: "error", + errorMessage: + '525 \n\n\n\n \n\n\n\napi.anthropic.com | 525: SSL handshake failed\n\n\n\n\n\n\n\n\n\n\n
\n
\n
\n

\n SSL handshake failed\n Error code 525\n

\n
\n Visit cloudflare.com for more information.\n
\n
2025-10-03 01:28:05 UTC
\n
\n
\n
\n
\n \n
\n
\n \n \n \n \n
\n You\n

\n \n Browser\n \n

\n Working\n
\n\n
\n
\n \n \n \n \n
\n Vienna\n

\n \n Cloudflare\n \n

\n Working\n
\n\n
\n
\n \n \n \n \n
\n api.anthropic.com\n

\n \n Host\n \n

\n Error\n
\n\n
\n
\n
\n\n
\n
\n
\n

What happened?

\n

Cloudflare is unable to establish an SSL connection to the origin server.

\n
\n
\n

What can I do?

\n

If you\'re a visitor of this website:

\n

Please try again in a few minutes.

\n\n

If you\'re the owner of this website:

\n

It appears that the SSL configuration used is not compatible with Cloudflare. This could happen for a several reasons, including no shared cipher suites. Additional troubleshooting information here.

\n
\n
\n
\n\n \n\n\n
\n
\n\n\n', + }, + { + role: "user", + content: [ + { + type: "text", + text: "what site am i on?", + }, + ], + }, + { + role: "assistant", + content: [ + { + type: "text", + text: "I apologize for the previous error. Let me try again to retrieve the current site information using a different method:", + }, + { + type: "toolCall", + id: "toolu_01GJNTLKUQNHfexNcJwmdLuU", + name: "browser_javascript", + arguments: { + code: "try {\n console.log(window.location.href);\n} catch (e) {\n console.error('Error getting URL:', e.message);\n}", + }, + }, + ], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-3-5-haiku-20241022", + usage: { + input: 6182, + output: 115, + cacheRead: 0, + cacheWrite: 0, + cost: { + input: 0.0049456000000000005, + output: 0.00045999999999999996, + cacheRead: 0, + cacheWrite: 0, + total: 0.005405600000000001, + }, + }, + stopReason: "toolUse", + }, + { + role: "toolResult", + toolCallId: "toolu_01GJNTLKUQNHfexNcJwmdLuU", + toolName: "browser_javascript", + output: "https://t3.chat/chat/aff0de76-0056-4fb1-a493-fbb17d8407cc", + details: { + files: [], + }, + isError: false, + }, + { + role: "assistant", + content: [ + { + type: "text", + text: "You're currently on a T3 chat page, specifically a specific chat instance with the URL https://t3.chat/chat/aff0de76-0056-4fb1-a493-fbb17d8407cc. T3 appears to be a chat platform or messaging application.\n\nIs there anything specific you'd like to know about this page or the site?", + }, + ], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-3-5-haiku-20241022", + usage: { + input: 6337, + output: 86, + cacheRead: 0, + cacheWrite: 0, + cost: { + input: 0.0050696000000000005, + output: 0.00034399999999999996, + cacheRead: 0, + cacheWrite: 0, + total: 0.0054136, + }, + }, + stopReason: "stop", + }, + { + role: "user", + content: [ + { + type: "text", + text: "can you find the chatbox and submit a messaage?\n", + }, + ], + }, + { + role: "assistant", + content: [ + { + type: "text", + text: "I'll first locate the chatbox and then attempt to submit a message:", + }, + { + type: "toolCall", + id: "toolu_015tzmr3VZsAWkUoyPmSVFPs", + name: "browser_javascript", + arguments: { + code: "// Find the textarea input\nconst textarea = document.querySelector('textarea');\nconst sendButton = document.querySelector('button[type=\"submit\"]');\n\nif (textarea && sendButton) {\n console.log('Chatbox elements found:');\n console.log('Textarea:', textarea);\n console.log('Send Button:', sendButton);\n \n // Insert a test message\n textarea.value = 'Hello from the AI assistant!';\n \n // Simulate input event to trigger any reactive frameworks\n const inputEvent = new Event('input', { bubbles: true });\n textarea.dispatchEvent(inputEvent);\n \n // Try to click the send button\n sendButton.click();\n \n console.log('Attempted to send message');\n} else {\n console.log('Could not find chatbox elements');\n if (!textarea) console.log('Textarea not found');\n if (!sendButton) console.log('Send button not found');\n}", + }, + }, + ], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-3-5-haiku-20241022", + usage: { + input: 6439, + output: 294, + cacheRead: 0, + cacheWrite: 0, + cost: { + input: 0.005151200000000001, + output: 0.001176, + cacheRead: 0, + cacheWrite: 0, + total: 0.006327200000000001, + }, + }, + stopReason: "toolUse", + }, + { + role: "toolResult", + toolCallId: "toolu_015tzmr3VZsAWkUoyPmSVFPs", + toolName: "browser_javascript", + output: + "Chatbox elements found:\nTextarea: [object Object]\nSend Button: [object Object]\nAttempted to send message", + details: { + files: [], + }, + isError: false, + }, + { + role: "assistant", + content: [ + { + type: "text", + text: 'I\'ve located the chatbox textarea and send button, and attempted to send a message. The console log suggests the elements were found successfully, and I tried to submit "Hello from the AI assistant!".\n\nHowever, due to the nature of modern web applications (especially those using React or other frameworks), the exact mechanism of sending a message can be more complex. The simple `.click()` method might not always trigger the full send process.\n\nWould you like me to try a more detailed approach to sending a message? I can:\n1. Investigate the exact send mechanism\n2. Try alternative methods of triggering the send action\n3. Modify the message\n4. Provide more detailed debugging information\n\nWhat would you like me to do next?', + }, + ], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-3-5-haiku-20241022", + usage: { + input: 6769, + output: 159, + cacheRead: 0, + cacheWrite: 0, + cost: { + input: 0.0054152, + output: 0.000636, + cacheRead: 0, + cacheWrite: 0, + total: 0.0060512000000000005, + }, + }, + stopReason: "stop", + }, + { + role: "user", + content: [ + { + type: "text", + text: "you managed to enter the text in teh text area, but click is not possible because the app thinks no text has been entered yet", + }, + ], + }, + { + role: "assistant", + content: [ + { + type: "text", + text: "You're right. I'll modify the script to simulate a more complete interaction that might trigger the send button to become active:", + }, + { + type: "toolCall", + id: "toolu_013VotjmhYQ9snEs4DGqmeF7", + name: "browser_javascript", + arguments: { + code: "// Find the textarea input\nconst textarea = document.querySelector('textarea');\nconst sendButton = document.querySelector('button[type=\"submit\"]');\n\nif (textarea && sendButton) {\n console.log('Chatbox elements found');\n \n // Set the value\n textarea.value = 'Hello from the AI assistant!';\n \n // Trigger input, change, and other events to simulate user typing\n const events = ['input', 'change', 'keydown', 'keyup'];\n events.forEach(eventName => {\n const event = new Event(eventName, { \n bubbles: true, \n cancelable: true \n });\n textarea.dispatchEvent(event);\n });\n \n // If there's a React or framework-specific way to update the state\n if (textarea._valueTracker) {\n textarea._valueTracker.setValue('');\n textarea._valueTracker.setValue('Hello from the AI assistant!');\n }\n \n console.log('Attempted to simulate text entry');\n \n // Check button state\n console.log('Send button disabled:', sendButton.disabled);\n console.log('Send button attributes:', sendButton.attributes);\n} else {\n console.log('Could not find chatbox elements');\n}", + }, + }, + ], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-3-5-haiku-20241022", + usage: { + input: 6958, + output: 379, + cacheRead: 0, + cacheWrite: 0, + cost: { + input: 0.005566400000000001, + output: 0.001516, + cacheRead: 0, + cacheWrite: 0, + total: 0.007082400000000001, + }, + }, + stopReason: "toolUse", + }, + { + role: "toolResult", + toolCallId: "toolu_013VotjmhYQ9snEs4DGqmeF7", + toolName: "browser_javascript", + output: + "Chatbox elements found\nAttempted to simulate text entry\nSend button disabled: true\nSend button attributes: [object Object]", + details: { + files: [], + }, + isError: false, + }, + { + role: "assistant", + content: [ + { + type: "text", + text: "I see the challenge now. The send button is still disabled. This likely means the application is using a custom validation method that our previous attempts didn't trigger.\n\nLet me try one more approach that might simulate a more complete user interaction:", + }, + { + type: "toolCall", + id: "toolu_01Q2T3SyqvPghPtW8cqV9DRi", + name: "browser_javascript", + arguments: { + code: "// Find the textarea input\nconst textarea = document.querySelector('textarea');\nconst sendButton = document.querySelector('button[type=\"submit\"]');\n\nif (textarea && sendButton) {\n // Set the value directly\n textarea.value = 'Hello from the AI assistant!';\n \n // Trigger a full set of events\n ['input', 'change', 'keydown', 'keyup'].forEach(eventName => {\n const event = new Event(eventName, { \n bubbles: true, \n cancelable: true \n });\n \n // Add key details for keydown/keyup\n if (eventName === 'keydown' || eventName === 'keyup') {\n Object.defineProperty(event, 'key', {value: 'Enter', enumerable: true});\n Object.defineProperty(event, 'keyCode', {value: 13, enumerable: true});\n }\n \n textarea.dispatchEvent(event);\n });\n \n // Try to focus the textarea\n textarea.focus();\n \n // Log current state\n console.log('Textarea value:', textarea.value);\n console.log('Send button disabled:', sendButton.disabled);\n \n // If React is used, try to trigger state update\n const reactKey = Object.keys(textarea).find(key => key.startsWith('__react'));\n if (reactKey) {\n const reactProps = textarea[reactKey];\n console.log('React props found', reactProps);\n \n // If there's an onChange handler, try to call it\n if (reactProps && reactProps.onChange) {\n reactProps.onChange({\n target: textarea,\n type: 'change'\n });\n }\n }\n} else {\n console.log('Could not find chatbox elements');\n}", + }, + }, + ], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-3-5-haiku-20241022", + usage: { + input: 7375, + output: 537, + cacheRead: 0, + cacheWrite: 0, + cost: { + input: 0.005900000000000001, + output: 0.0021479999999999997, + cacheRead: 0, + cacheWrite: 0, + total: 0.008048, + }, + }, + stopReason: "toolUse", + }, + { + role: "toolResult", + toolCallId: "toolu_01Q2T3SyqvPghPtW8cqV9DRi", + toolName: "browser_javascript", + output: "Error executing script: Frame with ID 0 was removed.", + details: { + files: [], + }, + isError: false, + }, + { + role: "assistant", + content: [], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-3-5-haiku-20241022", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, + }, + stopReason: "aborted", + errorMessage: "Request was aborted.", + }, + { + role: "user", + content: [ + { + type: "text", + text: "well, that didn't work", + }, + ], + }, + { + role: "assistant", + content: [ + { + type: "text", + text: "You're right. The error suggests that the page might have changed or been refresh", + }, + ], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-3-5-haiku-20241022", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, + }, + stopReason: "aborted", + errorMessage: "Request was aborted.", + }, + { + role: "user", + content: [ + { + type: "text", + text: "try again, you are smarter now", + }, + ], + }, + { + role: "assistant", + content: [ + { + type: "text", + text: "Let me try a smarter approach. I'll first inspect the structure to understand how the app is managing state, then trigger the right events:", + }, + { + type: "toolCall", + id: "toolu_01D1m3bhziBFcS5Ges5XHmTJ", + name: "browser_javascript", + arguments: { + code: "// Find the textarea\nconst textarea = document.querySelector('textarea');\n\nif (textarea) {\n console.log('Found textarea');\n \n // Find all React internal properties\n const reactKey = Object.keys(textarea).find(key => key.startsWith('__react'));\n console.log('React key:', reactKey);\n \n if (reactKey) {\n const reactProps = textarea[reactKey];\n console.log('React internal props found');\n \n // Get the fiber node to access props\n let fiber = reactProps;\n while (fiber) {\n if (fiber.memoizedProps && fiber.memoizedProps.onChange) {\n console.log('Found onChange handler');\n break;\n }\n if (fiber.return) {\n fiber = fiber.return;\n } else {\n break;\n }\n }\n }\n \n // Set value using native setter\n const nativeInputValueSetter = Object.getOwnPropertyDescriptor(\n window.HTMLTextAreaElement.prototype, \n 'value'\n ).set;\n \n nativeInputValueSetter.call(textarea, 'Hello from the AI assistant!');\n \n // Dispatch input event\n const inputEvent = new Event('input', { bubbles: true });\n textarea.dispatchEvent(inputEvent);\n \n console.log('Value set:', textarea.value);\n \n // Check button state after a moment\n setTimeout(() => {\n const sendButton = document.querySelector('button[type=\"submit\"]');\n console.log('Button disabled after input:', sendButton?.disabled);\n }, 100);\n \n} else {\n console.log('Textarea not found');\n}\n", + }, + }, + ], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-5-20250929", + usage: { + input: 8199, + output: 492, + cacheRead: 0, + cacheWrite: 0, + cost: { + input: 0.024597, + output: 0.00738, + cacheRead: 0, + cacheWrite: 0, + total: 0.031977, + }, + }, + stopReason: "toolUse", + }, + { + role: "toolResult", + toolCallId: "toolu_01D1m3bhziBFcS5Ges5XHmTJ", + toolName: "browser_javascript", + output: + "Found textarea\nReact key: __reactFiber$i19q71cval\nReact internal props found\nFound onChange handler\nValue set: Hello from the AI assistant!", + details: { + files: [], + }, + isError: false, + }, + { + role: "assistant", + content: [ + { + type: "text", + text: "\n\nGood! I found the React fiber and set the value. Now let me check if the button is enabled and try to click it:", + }, + { + type: "toolCall", + id: "toolu_01Xe7RURRFtQc6f6CPRfj4iu", + name: "browser_javascript", + arguments: { + code: "const sendButton = document.querySelector('button[type=\"submit\"]');\nconst textarea = document.querySelector('textarea');\n\nconsole.log('Current textarea value:', textarea?.value);\nconsole.log('Send button disabled:', sendButton?.disabled);\n\nif (sendButton && !sendButton.disabled) {\n console.log('Clicking send button now...');\n sendButton.click();\n console.log('Message sent!');\n} else if (sendButton && sendButton.disabled) {\n console.log('Button is still disabled. The state update might not have triggered properly.');\n}\n", + }, + }, + ], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-5-20250929", + usage: { + input: 8737, + output: 213, + cacheRead: 0, + cacheWrite: 0, + cost: { + input: 0.026211, + output: 0.003195, + cacheRead: 0, + cacheWrite: 0, + total: 0.029406, + }, + }, + stopReason: "toolUse", + }, + { + role: "toolResult", + toolCallId: "toolu_01Xe7RURRFtQc6f6CPRfj4iu", + toolName: "browser_javascript", + output: + "Current textarea value: Hello from the AI assistant!\nSend button disabled: false\nClicking send button now...\nMessage sent!", + details: { + files: [], + }, + isError: false, + }, + { + role: "assistant", + content: [ + { + type: "text", + text: '\n\nSuccess! 🎉 I managed to:\n1. Find the textarea and its React fiber\n2. Set the value using the native setter (which bypasses React\'s detection)\n3. Dispatch the proper input event to trigger React\'s state update\n4. The send button became enabled\n5. Successfully clicked it to send the message "Hello from the AI assistant!"\n\nThe key was using the native `HTMLTextAreaElement` value setter and then dispatching the input event, which properly notified React of the change. This is a more "framework-aware" approach that respects how React tracks input changes.', + }, + ], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-5-20250929", + usage: { + input: 8986, + output: 134, + cacheRead: 0, + cacheWrite: 0, + cost: { + input: 0.026958, + output: 0.00201, + cacheRead: 0, + cacheWrite: 0, + total: 0.028968, + }, + }, + stopReason: "stop", + }, + { + role: "user", + content: [ + { + type: "text", + text: "ask it what t3 offers", + }, + ], + }, + { + role: "assistant", + content: [ + { + type: "text", + text: "I'll send a message asking about what T3 offers:", + }, + { + type: "toolCall", + id: "toolu_01Mc4YE51qxtGCy4JeQwADSa", + name: "browser_javascript", + arguments: { + code: "const textarea = document.querySelector('textarea');\nconst sendButton = document.querySelector('button[type=\"submit\"]');\n\nif (textarea && sendButton) {\n // Set value using native setter\n const nativeInputValueSetter = Object.getOwnPropertyDescriptor(\n window.HTMLTextAreaElement.prototype, \n 'value'\n ).set;\n \n nativeInputValueSetter.call(textarea, 'What does T3 offer?');\n \n // Dispatch input event to trigger React state update\n const inputEvent = new Event('input', { bubbles: true });\n textarea.dispatchEvent(inputEvent);\n \n console.log('Message typed:', textarea.value);\n \n // Wait a moment for React to process, then click\n setTimeout(() => {\n if (!sendButton.disabled) {\n sendButton.click();\n console.log('Message sent: \"What does T3 offer?\"');\n } else {\n console.log('Button still disabled');\n }\n }, 100);\n} else {\n console.log('Could not find chatbox elements');\n}\n", + }, + }, + ], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-5-20250929", + usage: { + input: 9130, + output: 331, + cacheRead: 0, + cacheWrite: 0, + cost: { + input: 0.02739, + output: 0.004965, + cacheRead: 0, + cacheWrite: 0, + total: 0.032355, + }, + }, + stopReason: "toolUse", + }, + { + role: "toolResult", + toolCallId: "toolu_01Mc4YE51qxtGCy4JeQwADSa", + toolName: "browser_javascript", + output: "Message typed: What does T3 offer?", + details: { + files: [], + }, + isError: false, + }, + { + role: "assistant", + content: [ + { + type: "text", + text: '\n\nPerfect! I\'ve sent the message "What does T3 offer?" to the chat. The message should now appear in the conversation, and you should receive a response explaining what T3 offers. Let me know what the response says!', + }, + ], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-5-20250929", + usage: { + input: 9481, + output: 53, + cacheRead: 0, + cacheWrite: 0, + cost: { + input: 0.028443, + output: 0.000795, + cacheRead: 0, + cacheWrite: 0, + total: 0.029238, + }, + }, + stopReason: "stop", + }, + { + role: "user", + content: [ + { + type: "text", + text: "need a higher timeout", + }, + ], + }, + { + role: "assistant", + content: [ + { + type: "text", + text: "You're right, let me increase the timeout:", + }, + { + type: "toolCall", + id: "toolu_018EUmEjTXgnDX5gtGorF3B1", + name: "browser_javascript", + arguments: { + code: "const textarea = document.querySelector('textarea');\nconst sendButton = document.querySelector('button[type=\"submit\"]');\n\nif (textarea && sendButton) {\n // Set value using native setter\n const nativeInputValueSetter = Object.getOwnPropertyDescriptor(\n window.HTMLTextAreaElement.prototype, \n 'value'\n ).set;\n \n nativeInputValueSetter.call(textarea, 'What does T3 offer?');\n \n // Dispatch input event to trigger React state update\n const inputEvent = new Event('input', { bubbles: true });\n textarea.dispatchEvent(inputEvent);\n \n console.log('Message typed:', textarea.value);\n \n // Wait longer for React to process, then click\n setTimeout(() => {\n if (!sendButton.disabled) {\n sendButton.click();\n console.log('Message sent: \"What does T3 offer?\"');\n } else {\n console.log('Button still disabled after timeout');\n }\n }, 500);\n} else {\n console.log('Could not find chatbox elements');\n}\n", + }, + }, + ], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-5-20250929", + usage: { + input: 9541, + output: 329, + cacheRead: 0, + cacheWrite: 0, + cost: { + input: 0.028623, + output: 0.004935, + cacheRead: 0, + cacheWrite: 0, + total: 0.033558, + }, + }, + stopReason: "toolUse", + }, + { + role: "toolResult", + toolCallId: "toolu_018EUmEjTXgnDX5gtGorF3B1", + toolName: "browser_javascript", + output: "Message typed: What does T3 offer?", + details: { + files: [], + }, + isError: false, + }, + { + role: "assistant", + content: [ + { + type: "text", + text: "\n\nI've increased the timeout to 500ms. The message should be sent now. Would you like me to try an even longer timeout, or would you like me to check if the message was successfully sent?", + }, + ], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-5-20250929", + usage: { + input: 9890, + output: 46, + cacheRead: 0, + cacheWrite: 0, + cost: { + input: 0.029670000000000002, + output: 0.00069, + cacheRead: 0, + cacheWrite: 0, + total: 0.03036, + }, + }, + stopReason: "stop", + }, + { + role: "user", + content: [ + { + type: "text", + text: "can you try without a setTimeout?", + }, + ], + }, + { + role: "assistant", + content: [], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-5-20250929", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, + }, + stopReason: "aborted", + errorMessage: "Request was aborted.", + }, + { + role: "user", + content: [ + { + type: "text", + text: "just do the button click", + }, + ], + }, + { + role: "assistant", + content: [ + { + type: "toolCall", + id: "toolu_01PT5nvi2zLUp8wWmvduzomz", + name: "browser_javascript", + arguments: { + code: "const textarea = document.querySelector('textarea');\nconst sendButton = document.querySelector('button[type=\"submit\"]');\n\nif (textarea && sendButton) {\n // Set value using native setter\n const nativeInputValueSetter = Object.getOwnPropertyDescriptor(\n window.HTMLTextAreaElement.prototype, \n 'value'\n ).set;\n \n nativeInputValueSetter.call(textarea, 'What does T3 offer?');\n \n // Dispatch input event to trigger React state update\n const inputEvent = new Event('input', { bubbles: true });\n textarea.dispatchEvent(inputEvent);\n \n console.log('Message typed:', textarea.value);\n console.log('Button disabled before click:', sendButton.disabled);\n \n // Click immediately\n sendButton.click();\n console.log('Message sent: \"What does T3 offer?\"');\n} else {\n console.log('Could not find chatbox elements');\n}\n", + }, + }, + ], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-5-20250929", + usage: { + input: 9952, + output: 285, + cacheRead: 0, + cacheWrite: 0, + cost: { + input: 0.029856, + output: 0.004275, + cacheRead: 0, + cacheWrite: 0, + total: 0.034131, + }, + }, + stopReason: "toolUse", + }, + { + role: "toolResult", + toolCallId: "toolu_01PT5nvi2zLUp8wWmvduzomz", + toolName: "browser_javascript", + output: + 'Message typed: What does T3 offer?\nButton disabled before click: false\nMessage sent: "What does T3 offer?"', + details: { + files: [], + }, + isError: false, + }, + { + role: "assistant", + content: [ + { + type: "text", + text: '\n\nPerfect! The button was enabled immediately after dispatching the input event, so no setTimeout was needed. The message "What does T3 offer?" has been sent successfully!', + }, + ], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-5-20250929", + usage: { + input: 10277, + output: 39, + cacheRead: 0, + cacheWrite: 0, + cost: { + input: 0.030831, + output: 0.000585, + cacheRead: 0, + cacheWrite: 0, + total: 0.031416, + }, + }, + stopReason: "stop", + }, + { + role: "user", + content: [ + { + type: "text", + text: "hrm, for some reason the click even tdoes nothing", + }, + ], + }, + { + role: "assistant", + content: [ + { + type: "text", + text: "Let me investigate the send button more closely to see what's actually handling the submission:", + }, + { + type: "toolCall", + id: "toolu_01Cvkd3XADVE1ToMXqjQYc12", + name: "browser_javascript", + arguments: { + code: "const sendButton = document.querySelector('button[type=\"submit\"]');\nconst form = document.querySelector('form');\n\nconsole.log('Send button:', sendButton);\nconsole.log('Button onclick:', sendButton?.onclick);\nconsole.log('Form:', form);\nconsole.log('Form onsubmit:', form?.onsubmit);\n\n// Check for React event handlers\nif (sendButton) {\n const reactKey = Object.keys(sendButton).find(key => key.startsWith('__react'));\n if (reactKey) {\n const reactProps = sendButton[reactKey];\n console.log('React props on button:', reactProps);\n \n // Try to find the onClick handler in the fiber\n let fiber = reactProps;\n while (fiber) {\n if (fiber.memoizedProps) {\n console.log('Memoized props:', fiber.memoizedProps);\n if (fiber.memoizedProps.onClick) {\n console.log('Found onClick handler in fiber');\n }\n }\n if (fiber.return) {\n fiber = fiber.return;\n } else {\n break;\n }\n }\n }\n}\n\n// Check form structure\nif (form) {\n const formReactKey = Object.keys(form).find(key => key.startsWith('__react'));\n if (formReactKey) {\n const formFiber = form[formReactKey];\n console.log('Form has React fiber');\n if (formFiber.memoizedProps && formFiber.memoizedProps.onSubmit) {\n console.log('Form has onSubmit handler');\n }\n }\n}\n", + }, + }, + ], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-5-20250929", + usage: { + input: 10331, + output: 473, + cacheRead: 0, + cacheWrite: 0, + cost: { + input: 0.030993, + output: 0.007095000000000001, + cacheRead: 0, + cacheWrite: 0, + total: 0.038088, + }, + }, + stopReason: "toolUse", + }, + { + role: "toolResult", + toolCallId: "toolu_01Cvkd3XADVE1ToMXqjQYc12", + toolName: "browser_javascript", + output: "Error executing script: Frame with ID 0 was removed.", + details: { + files: [], + }, + isError: false, + }, + { + role: "assistant", + content: [], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-5-20250929", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, + }, + stopReason: "aborted", + errorMessage: "Request was aborted.", + }, + { + role: "user", + content: [ + { + type: "text", + text: "i'm on another page now with a chat again. can you find the chat window and input?", + }, + ], + }, + { + role: "assistant", + content: [ + { + type: "toolCall", + id: "toolu_018681im65XWx7SMGvRo9xPx", + name: "browser_javascript", + arguments: { + code: "// First, let's see what page we're on\nconsole.log('Current URL:', window.location.href);\nconsole.log('Page title:', document.title);\n\n// Look for common chat input elements\nconst textarea = document.querySelector('textarea');\nconst textInputs = document.querySelectorAll('input[type=\"text\"]');\nconst contentEditables = document.querySelectorAll('[contenteditable=\"true\"]');\n\nconsole.log('Found textarea:', textarea ? 'Yes' : 'No');\nconsole.log('Found text inputs:', textInputs.length);\nconsole.log('Found contenteditable elements:', contentEditables.length);\n\nif (textarea) {\n console.log('Textarea details:', {\n placeholder: textarea.placeholder,\n id: textarea.id,\n name: textarea.name,\n className: textarea.className\n });\n}\n\n// Look for send/submit buttons\nconst submitButtons = document.querySelectorAll('button[type=\"submit\"]');\nconst buttons = document.querySelectorAll('button');\n\nconsole.log('Found submit buttons:', submitButtons.length);\nconsole.log('Total buttons:', buttons.length);\n\n// Look for forms\nconst forms = document.querySelectorAll('form');\nconsole.log('Found forms:', forms.length);\n", + }, + }, + ], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-5-20250929", + usage: { + input: 10852, + output: 348, + cacheRead: 0, + cacheWrite: 0, + cost: { + input: 0.032556, + output: 0.00522, + cacheRead: 0, + cacheWrite: 0, + total: 0.037776000000000004, + }, + }, + stopReason: "toolUse", + }, + { + role: "toolResult", + toolCallId: "toolu_018681im65XWx7SMGvRo9xPx", + toolName: "browser_javascript", + output: + "Current URL: https://www.ams.at/arbeitsuchende/aus-und-weiterbildung/berufsinformationen/berufsinformation/berufsinfomat?open=berufsinfomat\nPage title: Berufsinfomat | AMS\nFound textarea: No\nFound text inputs: 1\nFound contenteditable elements: 0\nFound submit buttons: 1\nTotal buttons: 12\nFound forms: 1", + details: { + files: [], + }, + isError: false, + }, + { + role: "assistant", + content: [ + { + type: "text", + text: "\n\nI can see you're on an Austrian AMS (Arbeitsmarktservice) page about career information. I found 1 text input and 1 submit button. Let me examine them more closely:", + }, + { + type: "toolCall", + id: "toolu_01DuFzPze1zWi5QELAQtcUFZ", + name: "browser_javascript", + arguments: { + code: "const textInput = document.querySelector('input[type=\"text\"]');\nconst submitButton = document.querySelector('button[type=\"submit\"]');\nconst form = document.querySelector('form');\n\nif (textInput) {\n console.log('Text input details:', {\n placeholder: textInput.placeholder,\n id: textInput.id,\n name: textInput.name,\n value: textInput.value,\n className: textInput.className\n });\n}\n\nif (submitButton) {\n console.log('Submit button details:', {\n text: submitButton.textContent,\n id: submitButton.id,\n className: submitButton.className,\n disabled: submitButton.disabled\n });\n}\n\nif (form) {\n console.log('Form details:', {\n action: form.action,\n method: form.method,\n id: form.id\n });\n}\n", + }, + }, + ], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-5-20250929", + usage: { + input: 11314, + output: 310, + cacheRead: 0, + cacheWrite: 0, + cost: { + input: 0.033942, + output: 0.0046500000000000005, + cacheRead: 0, + cacheWrite: 0, + total: 0.038592, + }, + }, + stopReason: "toolUse", + }, + { + role: "toolResult", + toolCallId: "toolu_01DuFzPze1zWi5QELAQtcUFZ", + toolName: "browser_javascript", + output: + "Text input details: [object Object]\nSubmit button details: [object Object]\nForm details: [object Object]", + details: { + files: [], + }, + isError: false, + }, + { + role: "assistant", + content: [ + { + type: "text", + text: "\n\nThis appears to be a career information website form, not a chat interface. Are you sure you're on a chat page? Could you describe what you see on the screen, or would you like me to send a message through this form anyway?", + }, + ], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-5-20250929", + usage: { + input: 11659, + output: 53, + cacheRead: 0, + cacheWrite: 0, + cost: { + input: 0.034977, + output: 0.000795, + cacheRead: 0, + cacheWrite: 0, + total: 0.035772, + }, + }, + stopReason: "stop", + }, + { + role: "user", + content: [ + { + type: "text", + text: "could be in an iframe", + }, + ], + }, + { + role: "assistant", + content: [ + { + type: "text", + text: "Good thinking! Let me check for iframes:", + }, + { + type: "toolCall", + id: "toolu_01JDhMJmHj79N7gBWZATBNvs", + name: "browser_javascript", + arguments: { + code: "// Find all iframes\nconst iframes = document.querySelectorAll('iframe');\nconsole.log('Found iframes:', iframes.length);\n\nif (iframes.length > 0) {\n iframes.forEach((iframe, index) => {\n console.log(`Iframe ${index}:`, {\n src: iframe.src,\n id: iframe.id,\n className: iframe.className,\n title: iframe.title,\n name: iframe.name\n });\n });\n \n // Try to access the first iframe's content\n try {\n const iframeDoc = iframes[0].contentDocument || iframes[0].contentWindow.document;\n if (iframeDoc) {\n console.log('Can access iframe content');\n console.log('Iframe page title:', iframeDoc.title);\n \n // Look for chat elements in the iframe\n const iframeTextarea = iframeDoc.querySelector('textarea');\n const iframeTextInputs = iframeDoc.querySelectorAll('input[type=\"text\"]');\n \n console.log('Found textarea in iframe:', iframeTextarea ? 'Yes' : 'No');\n console.log('Found text inputs in iframe:', iframeTextInputs.length);\n }\n } catch (e) {\n console.log('Cannot access iframe content (cross-origin):', e.message);\n }\n} else {\n console.log('No iframes found on the page');\n}\n", + }, + }, + ], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-5-20250929", + usage: { + input: 11720, + output: 423, + cacheRead: 0, + cacheWrite: 0, + cost: { + input: 0.035160000000000004, + output: 0.006345, + cacheRead: 0, + cacheWrite: 0, + total: 0.041505, + }, + }, + stopReason: "toolUse", + }, + { + role: "toolResult", + toolCallId: "toolu_01JDhMJmHj79N7gBWZATBNvs", + toolName: "browser_javascript", + output: + "Found iframes: 1\nIframe 0: [object Object]\nCannot access iframe content (cross-origin): Failed to read a named property 'document' from 'Window': Blocked a frame with origin \"https://www.ams.at\" from accessing a cross-origin frame.", + details: { + files: [], + }, + isError: false, + }, + { + role: "assistant", + content: [ + { + type: "text", + text: "\n\nThere is an iframe on the page, but it's cross-origin so I can't access its content directly due to browser security restrictions. Let me get more details about the iframe:", + }, + { + type: "toolCall", + id: "toolu_0153cpVfNAXdNkipbEikNNTu", + name: "browser_javascript", + arguments: { + code: "const iframe = document.querySelector('iframe');\nif (iframe) {\n console.log('Iframe source:', iframe.src);\n console.log('Iframe ID:', iframe.id);\n console.log('Iframe class:', iframe.className);\n console.log('Iframe title:', iframe.title);\n console.log('Iframe width:', iframe.width);\n console.log('Iframe height:', iframe.height);\n}\n", + }, + }, + ], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-5-20250929", + usage: { + input: 12217, + output: 193, + cacheRead: 0, + cacheWrite: 0, + cost: { + input: 0.036651, + output: 0.002895, + cacheRead: 0, + cacheWrite: 0, + total: 0.039546000000000005, + }, + }, + stopReason: "toolUse", + }, + { + role: "toolResult", + toolCallId: "toolu_0153cpVfNAXdNkipbEikNNTu", + toolName: "browser_javascript", + output: + "Iframe source: https://berufsinfomat.prod.portal.ams.at/client/6453a57358480fb76ddc0a43/overlay?lc=de&ss=min\nIframe ID: ams-modal-berufsinfomat-iframe\nIframe class: \nIframe title: Berufsinfomat des AMS\nIframe width: 100%\nIframe height: 400", + details: { + files: [], + }, + isError: false, + }, + { + role: "assistant", + content: [ + { + type: "text", + text: "\n\nThe iframe is from `berufsinfomat.prod.portal.ams.at` which is a career information tool. Since it's cross-origin, I cannot directly access its content from the parent page due to browser security policies.\n\nThis doesn't appear to be a chat interface like T3 - it's a career counseling tool. Are you perhaps looking at a different window or tab? Or is there a chat widget somewhere on this page that I should look for?", + }, + ], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-5-20250929", + usage: { + input: 12519, + output: 104, + cacheRead: 0, + cacheWrite: 0, + cost: { + input: 0.037557, + output: 0.00156, + cacheRead: 0, + cacheWrite: 0, + total: 0.039117, + }, + }, + stopReason: "stop", + }, + { + role: "user", + content: [ + { + type: "text", + text: "can you open that iframe url in a new tab?", + }, + ], + }, + { + role: "assistant", + content: [ + { + type: "toolCall", + id: "toolu_015qgN7Jjw16r43gKXBEeBFN", + name: "browser_javascript", + arguments: { + code: "const iframeUrl = 'https://berufsinfomat.prod.portal.ams.at/client/6453a57358480fb76ddc0a43/overlay?lc=de&ss=min';\n\n// Open the iframe URL in a new tab\nwindow.open(iframeUrl, '_blank');\n\nconsole.log('Opened iframe URL in new tab:', iframeUrl);\n", + }, + }, + ], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-5-20250929", + usage: { + input: 12637, + output: 146, + cacheRead: 0, + cacheWrite: 0, + cost: { + input: 0.037911, + output: 0.00219, + cacheRead: 0, + cacheWrite: 0, + total: 0.040101, + }, + }, + stopReason: "toolUse", + }, + { + role: "toolResult", + toolCallId: "toolu_015qgN7Jjw16r43gKXBEeBFN", + toolName: "browser_javascript", + output: + "Opened iframe URL in new tab: https://berufsinfomat.prod.portal.ams.at/client/6453a57358480fb76ddc0a43/overlay?lc=de&ss=min", + details: { + files: [], + }, + isError: false, + }, + { + role: "assistant", + content: [ + { + type: "text", + text: "\n\nI've opened the iframe URL in a new tab! Once you switch to that tab, I'll be able to access the content directly and help you interact with the chat interface there. Let me know when you're on the new tab and I can look for the chat input and other elements.", + }, + ], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-5-20250929", + usage: { + input: 12845, + output: 63, + cacheRead: 0, + cacheWrite: 0, + cost: { + input: 0.038535, + output: 0.000945, + cacheRead: 0, + cacheWrite: 0, + total: 0.03948, + }, + }, + stopReason: "stop", + }, + { + role: "user", + content: [ + { + type: "text", + text: "ok, i'm on a news page now, can you tell me what's news today?", + }, + ], + }, + { + role: "assistant", + content: [], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-5-20250929", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, + }, + stopReason: "aborted", + errorMessage: "Request was aborted.", + }, + { + role: "user", + content: [ + { + type: "text", + text: "write a markdown doc, and include images if possible", + }, + ], + }, + { + role: "assistant", + content: [ + { + type: "text", + text: "I'll check what's on the current page and extract the news for you:", + }, + { + type: "toolCall", + id: "toolu_01XaoCN6tM5NGdQXy2SjssXZ", + name: "browser_javascript", + arguments: { + code: "// Get the current page info\nconsole.log('Current URL:', window.location.href);\nconsole.log('Page title:', document.title);\n\n// Look for news articles\nconst articles = document.querySelectorAll('article, .article, .news-item, [class*=\"article\"], [class*=\"story\"]');\nconsole.log('Found articles:', articles.length);\n\n// Look for headlines\nconst headlines = document.querySelectorAll('h1, h2, h3, .headline, [class*=\"headline\"], [class*=\"title\"]');\nconsole.log('Found headlines:', headlines.length);\n\n// Look for images\nconst images = document.querySelectorAll('img');\nconsole.log('Found images:', images.length);\n\n// Get main content\nconst mainContent = document.querySelector('main, #main, .main-content, [role=\"main\"]');\nconsole.log('Found main content:', mainContent ? 'Yes' : 'No');\n\n// Let's get a sample of what's on the page\nconsole.log('Body text preview:', document.body.innerText.substring(0, 500));\n", + }, + }, + ], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-5-20250929", + usage: { + input: 12941, + output: 324, + cacheRead: 0, + cacheWrite: 0, + cost: { + input: 0.038823, + output: 0.00486, + cacheRead: 0, + cacheWrite: 0, + total: 0.043683, + }, + }, + stopReason: "toolUse", + }, + { + role: "toolResult", + toolCallId: "toolu_01XaoCN6tM5NGdQXy2SjssXZ", + toolName: "browser_javascript", + output: + 'Current URL: https://www.derstandard.at/\nPage title: derStandard.at | Nachrichten, Kommentare & Community\nFound articles: 231\nFound headlines: 365\nFound images: 154\nFound main content: Yes\nBody text preview: Fr, 3.10.2025\nStartseite\nÜberblick\nNACHRICHTEN IN ECHTZEIT\nRussland greift ukrainische Energieinfrastruktur an \nSchikanen gegen Arbeitsmigranten in Russland \nFall Anna: Gerichtspräsidentin übt scharfe Kritik an Drohungen gegen Richter \nZiel Playoff: Maskenmann Pöltl vor zehnter NBA-Saison zuversichtlich \n\nZUKUNFT MEDIENKOMPETENZ\n\n106\nPostings\nNetzexpertin Brodnig: "Propaganda ist erfolgreich, wenn sich Leute nicht mehr auskennen"\n\nGezielt Verwirrung stiften – das ist die Strategie hinter vielen', + details: { + files: [], + }, + isError: false, + }, + { + role: "assistant", + content: [ + { + type: "text", + text: "\n\nGreat! You're on derStandard.at, an Austrian news site. Let me extract the news articles with images:", + }, + { + type: "toolCall", + id: "toolu_01FDif6VsohQR8Eint1TSZDi", + name: "browser_javascript", + arguments: { + code: "// Extract news articles with their details\nconst newsData = [];\n\n// Find article containers\nconst articles = document.querySelectorAll('article, .article-teaser, [class*=\"teaser\"]');\n\narticles.forEach((article, index) => {\n if (index < 15) { // Get top 15 articles\n const headline = article.querySelector('h1, h2, h3, h4, .headline, [class*=\"headline\"]');\n const link = article.querySelector('a');\n const img = article.querySelector('img');\n const description = article.querySelector('p, .description, .teaser-text, [class*=\"description\"]');\n \n if (headline && headline.textContent.trim()) {\n newsData.push({\n title: headline.textContent.trim(),\n link: link ? link.href : '',\n image: img ? img.src : '',\n imageAlt: img ? img.alt : '',\n description: description ? description.textContent.trim() : ''\n });\n }\n }\n});\n\nconsole.log('Extracted news articles:', newsData.length);\nconsole.log(JSON.stringify(newsData.slice(0, 5), null, 2)); // Show first 5 in console\n\n// Return the data\nnewsData;\n", + }, + }, + ], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-5-20250929", + usage: { + input: 13535, + output: 385, + cacheRead: 0, + cacheWrite: 0, + cost: { + input: 0.040605, + output: 0.005775, + cacheRead: 0, + cacheWrite: 0, + total: 0.046380000000000005, + }, + }, + stopReason: "toolUse", + }, + { + role: "toolResult", + toolCallId: "toolu_01FDif6VsohQR8Eint1TSZDi", + toolName: "browser_javascript", + output: + 'Extracted news articles: 6\n[\n {\n "title": "Russland greift ukrainische Energieinfrastruktur an",\n "link": "https://www.derstandard.at/jetzt/livebericht/3000000290448/lage-an-akw-saporischschja-laut-selenskyj-kritisch?ref=niewidget",\n "image": "",\n "imageAlt": "",\n "description": ""\n },\n {\n "title": "Schikanen gegen Arbeitsmigranten in Russland",\n "link": "https://www.derstandard.at/story/3000000290399/schikanen-gegen-arbeitsmigranten-in-russland?ref=niewidget",\n "image": "",\n "imageAlt": "",\n "description": ""\n },\n {\n "title": "Fall Anna: Gerichtspräsidentin übt scharfe Kritik an Drohungen gegen Richter",\n "link": "https://www.derstandard.at/story/3000000290483/fall-anna-gerichtspraesidentin-uebt-scharfe-kritik-an-drohungen-gegen-richter?ref=niewidget",\n "image": "",\n "imageAlt": "",\n "description": ""\n },\n {\n "title": "Ziel Playoff: Maskenmann Pöltl vor zehnter NBA-Saison zuversichtlich",\n "link": "https://www.derstandard.at/story/3000000290494/basketball-maskenmann-p246ltl-vor-zehnter-nba-saison-zuversichtlich?ref=niewidget",\n "image": "",\n "imageAlt": "",\n "description": ""\n },\n {\n "title": "Netzexpertin Brodnig: \\"Propaganda ist erfolgreich, wenn sich Leute nicht mehr auskennen\\"",\n "link": "https://www.derstandard.at/story/3000000290377/netzexpertin-brodnig-propaganda-ist-erfolgreich-wenn-sich-leute-nicht-mehr-auskennen",\n "image": "https://i.ds.at/C_Le6w/rs:fill:600:400/plain/lido-images/2025/10/01/57d7d9b6-77f7-43fc-871a-c8ed93e22930.jpeg",\n "imageAlt": "",\n "description": "Zukunft Medienkompetenz"\n }\n]', + details: { + files: [], + }, + isError: false, + }, + { + role: "assistant", + content: [ + { + type: "toolCall", + id: "toolu_018YgtrBC5eankNyqWxJW9D2", + name: "browser_javascript", + arguments: { + code: "// Let's get more comprehensive news data\nconst newsArticles = [];\n\n// Try different selectors for better coverage\nconst mainArticles = document.querySelectorAll('article');\n\nmainArticles.forEach((article, index) => {\n if (index < 20) {\n const headline = article.querySelector('h1, h2, h3, h4, h5, a[class*=\"headline\"], span[class*=\"headline\"]');\n const links = article.querySelectorAll('a');\n const mainLink = links[0];\n const img = article.querySelector('img');\n const textElements = article.querySelectorAll('p, span, div');\n \n let description = '';\n textElements.forEach(el => {\n const text = el.textContent.trim();\n if (text.length > 20 && text.length < 300 && !description) {\n description = text;\n }\n });\n \n if (headline && headline.textContent.trim().length > 5) {\n const title = headline.textContent.trim();\n // Avoid duplicates\n if (!newsArticles.find(a => a.title === title)) {\n newsArticles.push({\n title: title,\n link: mainLink ? mainLink.href : '',\n image: img ? img.src : '',\n imageAlt: img ? (img.alt || img.title || '') : '',\n description: description\n });\n }\n }\n }\n});\n\nconsole.log('Total articles extracted:', newsArticles.length);\nnewsArticles;\n", + }, + }, + ], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-5-20250929", + usage: { + input: 14583, + output: 436, + cacheRead: 0, + cacheWrite: 0, + cost: { + input: 0.043749, + output: 0.00654, + cacheRead: 0, + cacheWrite: 0, + total: 0.050289, + }, + }, + stopReason: "toolUse", + }, + { + role: "toolResult", + toolCallId: "toolu_018YgtrBC5eankNyqWxJW9D2", + toolName: "browser_javascript", + output: "Total articles extracted: 18", + details: { + files: [], + }, + isError: false, + }, + { + role: "assistant", + content: [ + { + type: "toolCall", + id: "toolu_01LPxKs8zBbMvWdygvP9JxHF", + name: "browser_javascript", + arguments: { + code: "// Get the extracted news data and format it for markdown\nconst newsArticles = [];\nconst mainArticles = document.querySelectorAll('article');\n\nmainArticles.forEach((article, index) => {\n if (index < 20) {\n const headline = article.querySelector('h1, h2, h3, h4, h5');\n const links = article.querySelectorAll('a');\n const mainLink = links[0];\n const img = article.querySelector('img');\n const textElements = article.querySelectorAll('p');\n \n let description = '';\n textElements.forEach(el => {\n const text = el.textContent.trim();\n if (text.length > 20 && text.length < 300 && !description) {\n description = text;\n }\n });\n \n if (headline && headline.textContent.trim().length > 5) {\n const title = headline.textContent.trim();\n if (!newsArticles.find(a => a.title === title)) {\n newsArticles.push({\n title: title,\n link: mainLink ? mainLink.href : '',\n image: img ? img.src : '',\n imageAlt: img ? (img.alt || img.title || '') : '',\n description: description\n });\n }\n }\n }\n});\n\n// Get today's date\nconst today = new Date().toLocaleDateString('de-AT', { \n weekday: 'long', \n year: 'numeric', \n month: 'long', \n day: 'numeric' \n});\n\n// Create markdown content\nlet markdown = `# Der Standard - Nachrichten\\n## ${today}\\n\\n`;\nmarkdown += `*Quelle: [derStandard.at](${window.location.href})*\\n\\n---\\n\\n`;\n\nnewsArticles.forEach((article, index) => {\n markdown += `### ${index + 1}. ${article.title}\\n\\n`;\n \n if (article.image) {\n markdown += `![${article.imageAlt}](${article.image})\\n\\n`;\n }\n \n if (article.description) {\n markdown += `${article.description}\\n\\n`;\n }\n \n if (article.link) {\n markdown += `[Artikel lesen →](${article.link})\\n\\n`;\n }\n \n markdown += `---\\n\\n`;\n});\n\nconsole.log('Markdown created with', newsArticles.length, 'articles');\nmarkdown;\n", + }, + }, + ], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-5-20250929", + usage: { + input: 15035, + output: 685, + cacheRead: 0, + cacheWrite: 0, + cost: { + input: 0.045105, + output: 0.010275, + cacheRead: 0, + cacheWrite: 0, + total: 0.05538, + }, + }, + stopReason: "toolUse", + }, + { + role: "toolResult", + toolCallId: "toolu_01LPxKs8zBbMvWdygvP9JxHF", + toolName: "browser_javascript", + output: "Markdown created with 18 articles", + details: { + files: [], + }, + isError: false, + }, + { + role: "assistant", + content: [ + { + type: "text", + text: "\n\nNow let me create the markdown document as an artifact:", + }, + { + type: "toolCall", + id: "toolu_013YSiSucUcbfdwXqgHUfy9T", + name: "browser_javascript", + arguments: { + code: "// Generate the final markdown\nconst newsArticles = [];\nconst mainArticles = document.querySelectorAll('article');\n\nmainArticles.forEach((article, index) => {\n if (index < 20) {\n const headline = article.querySelector('h1, h2, h3, h4, h5');\n const links = article.querySelectorAll('a');\n const mainLink = links[0];\n const img = article.querySelector('img');\n const textElements = article.querySelectorAll('p');\n \n let description = '';\n textElements.forEach(el => {\n const text = el.textContent.trim();\n if (text.length > 20 && text.length < 300 && !description) {\n description = text;\n }\n });\n \n if (headline && headline.textContent.trim().length > 5) {\n const title = headline.textContent.trim();\n if (!newsArticles.find(a => a.title === title)) {\n newsArticles.push({\n title: title,\n link: mainLink ? mainLink.href : '',\n image: img ? img.src : '',\n imageAlt: img ? (img.alt || img.title || '') : '',\n description: description\n });\n }\n }\n }\n});\n\nconst today = new Date().toLocaleDateString('de-AT', { \n weekday: 'long', \n year: 'numeric', \n month: 'long', \n day: 'numeric' \n});\n\nlet markdown = `# Der Standard - Nachrichten des Tages\\n## ${today}\\n\\n`;\nmarkdown += `*Quelle: [derStandard.at](${window.location.href})*\\n\\n`;\nmarkdown += `---\\n\\n`;\n\nnewsArticles.forEach((article, index) => {\n markdown += `### ${index + 1}. ${article.title}\\n\\n`;\n \n if (article.image) {\n markdown += `![${article.imageAlt || 'News Image'}](${article.image})\\n\\n`;\n }\n \n if (article.description) {\n markdown += `${article.description}\\n\\n`;\n }\n \n if (article.link) {\n markdown += `[📰 Artikel lesen](${article.link})\\n\\n`;\n }\n \n markdown += `---\\n\\n`;\n});\n\nconsole.log(markdown);\nmarkdown;\n", + }, + }, + ], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-5-20250929", + usage: { + input: 15738, + output: 683, + cacheRead: 0, + cacheWrite: 0, + cost: { + input: 0.047214, + output: 0.010245, + cacheRead: 0, + cacheWrite: 0, + total: 0.057458999999999996, + }, + }, + stopReason: "toolUse", + }, + { + role: "toolResult", + toolCallId: "toolu_013YSiSucUcbfdwXqgHUfy9T", + toolName: "browser_javascript", + output: + '# Der Standard - Nachrichten des Tages\n## Freitag, 3. Oktober 2025\n\n*Quelle: [derStandard.at](https://www.derstandard.at/)*\n\n---\n\n### 1. Russland greift ukrainische Energieinfrastruktur an\n\n[📰 Artikel lesen](https://www.derstandard.at/jetzt/livebericht/3000000290448/lage-an-akw-saporischschja-laut-selenskyj-kritisch?ref=niewidget)\n\n---\n\n### 2. Schikanen gegen Arbeitsmigranten in Russland\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290399/schikanen-gegen-arbeitsmigranten-in-russland?ref=niewidget)\n\n---\n\n### 3. Fall Anna: Gerichtspräsidentin übt scharfe Kritik an Drohungen gegen Richter\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290483/fall-anna-gerichtspraesidentin-uebt-scharfe-kritik-an-drohungen-gegen-richter?ref=niewidget)\n\n---\n\n### 4. Ziel Playoff: Maskenmann Pöltl vor zehnter NBA-Saison zuversichtlich\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290494/basketball-maskenmann-p246ltl-vor-zehnter-nba-saison-zuversichtlich?ref=niewidget)\n\n---\n\n### 5. Netzexpertin Brodnig: "Propaganda ist erfolgreich, wenn sich Leute nicht mehr auskennen"\n\n![News Image](https://i.ds.at/C_Le6w/rs:fill:600:400/plain/lido-images/2025/10/01/57d7d9b6-77f7-43fc-871a-c8ed93e22930.jpeg)\n\nZukunft Medienkompetenz\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290377/netzexpertin-brodnig-propaganda-ist-erfolgreich-wenn-sich-leute-nicht-mehr-auskennen)\n\n---\n\n### 6. Flughafen München nach Drohnensichtung zwischenzeitlich geschlossen, zahlreiche Ausfälle\n\n![News Image](https://i.ds.at/fp3AhQ/rs:fill:600:400/plain/lido-images/2025/10/03/ef80089b-300d-4fcb-96b1-7b65cac270a4.jpeg)\n\nDer Flugbetrieb ist seit den frühen Morgenstunden wieder aufgenommen. Rund 3.000 Passagiere waren von den Ausfällen betroffen\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290460/flughafen-m252nchen-nach-schlie223ung-wegen-drohnensichtung-wieder-offen)\n\n---\n\n### 7. Wie stark werden Onlinekäufer manipuliert? Sozialministerium klagt Billigriesen Temu\n\n![News Image](https://i.ds.at/UQ7LBg/rs:fill:600:400/plain/lido-images/2025/10/02/7febc4a4-6c5a-473c-b28c-ce89d151db0b.jpeg)\n\nUnlauterer Wettbewerb\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290329/wie-stark-werden-onlinekaeufer-manipuliert-sozialministerium-klagt-billigriesen-temu)\n\n---\n\n### 8. Teslas Freude über Verkaufsrekord dürfte von kurzer Dauer sein\n\n![News Image](https://i.ds.at/ryC6hQ/rs:fill:600:400/plain/lido-images/2025/10/03/ecc3c7a6-7d2d-453f-b97d-002034b4d86e.jpeg)\n\nDie aktuell wieder besseren Zahlen dürften auf die Streichung einer Verkaufsprämie zurückzuführen sein. Parallel dazu wollen Investoren Musks Billionen-Dollar-Gehaltspaket kippen\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290473/teslas-freude-ueber-verkaufsrekord-duerfte-von-kurzer-dauer-sein)\n\n---\n\n### 9. Bis zu 17 Euro: Das kosten Eggs Benedict in der Wiener Gastronomie\n\n![News Image](https://i.ds.at/8Zh7zA/rs:fill:600:400/plain/lido-images/2025/10/02/fae3b15e-3ff7-4912-938a-2de53b7e33ff.jpeg)\n\nDen modernen Frühstücksklassiker findet man auf der Speisekarte zahlreicher Wiener Lokale. So viel muss man dafür hinblättern\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290304/bis-zu-17-euro-das-kosten-eggs-benedict-in-der-wiener-gastronomie)\n\n---\n\n### 10. Georg Dornauer lässt die SPÖ ganz alt aussehen\n\n![News Image](https://i.ds.at/cU2jUQ/rs:fill:600:400/plain/lido-images/2025/10/03/c77523eb-b3bb-4a66-8e32-fa29a441452b.jpeg)\n\nDer Ex-Chef der Tiroler Sozialdemokraten ist ein schwieriger Genosse. Dass seine Partei aber keine andere Konfliktlösung als den Ausschluss gefunden hat, ist ein Armutszeugnis\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290496/georg-dornauer-laesst-die-spoe-ganz-alt-aussehen)\n\n---\n\n### 11. Wir sollten die Krise in Österreichs Wirtschaft nicht größer reden, als sie ist\n\n![News Image](https://i.ds.at/QFlU-w/rs:fill:600:400/plain/lido-images/2025/08/01/6c3bcbb1-eca4-4237-ad84-d77e39bc3545.jpeg)\n\nOb Wachstum oder Jobmarkt: Zuletzt gab es nur schlechte Nachrichten vom heimischen Standort. Dabei gibt es gute Gründe, nicht zu verzagen. Vier Beispiele dafür\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290078/wir-sollten-die-krise-in-oesterreichs-wirtschaft-nicht-groesser-reden-als-sie-ist)\n\n---\n\n### 12. Drohnen über Dänemark: Festnahmen auf verdächtigem Schiff\n\n![AFP/DAMIEN MEYER](https://i.ds.at/E8GGfA/c:1200:800:fp:0.5:0.5/rs:fill:220:165:1/plain/lido-images/2025/10/01/423305df-5d12-48f4-9b28-517363b0fd8e.jpeg)\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290261/drohnen-ueber-daenemark-franzoesisches-militaer-entert-schiff-der-russischen-schattenflotte?ref=seite1_entdecken)\n\n---\n\n### 13. Anschlagspläne: Mutmaßliche Hamas-Mitglieder in Deutschland festgenommen\n\n![AFP/POOL/JOHN MACDOUGALL](https://i.ds.at/m-jE6g/c:1200:800:fp:0.5:0.5/rs:fill:220:165:1/plain/lido-images/2025/10/01/fc351fe4-cdac-4102-8332-95828658bff0.jpeg)\n\nJüdische Einrichtungen im Visier\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290245/anschlagsplaene-mutmassliche-hamas-mitglieder-in-deutschland-festgenommen?ref=seite1_entdecken)\n\n---\n\n### 14. Kinderfilm-Klassiker "Momo" bekommt zeitgemäßes Update\n\n![Constantin Film](https://i.ds.at/yibsqA/c:1200:800:fp:0.5:0.5/rs:fill:220:165:1/g:fp:0.48:0.19/plain/lido-images/2025/10/02/a0cfd114-6460-4370-9ea5-1ad60e89a843.jpeg)\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290391/kinderfilm-klassiker-momo-bekommt-zeitgemaesses-update?ref=seite1_entdecken)\n\n---\n\n### 15. Generalstreik in Italien nach Stopp von Gaza-Flottille\n\n![REUTERS/Claudia Greco](https://i.ds.at/wee2ag/c:1200:800:fp:0.5:0.5/rs:fill:220:165:1/plain/lido-images/2025/10/03/70939333-974c-44f4-ab81-20f49f555332.jpeg)\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290474/generalstreik-in-italien-nach-stopp-von-gaza-flottille?ref=seite1_entdecken)\n\n---\n\n### 16. Saturn-Mond Enceladus hat alle Zutaten, die es für Leben braucht\n\n![Illustr.: NASA/JPL-Caltech](https://i.ds.at/ae1Jkw/c:1013:675:fp:0.5:0.5/rs:fill:220:165:1/plain/lido-images/2025/10/02/53df0dc2-7990-40a9-8da3-4f5b8e21688f.jpeg)\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290229/saturn-mond-enceladus-hat-alle-zutaten-die-es-fuer-leben-braucht?ref=seite1_entdecken)\n\n---\n\n### 17. Welches Verhalten treibt euch in den Wahnsinn?\n\n![News Image](https://i.ds.at/hfeNIw/rs:fill:600:400/plain/lido-images/2025/04/16/1d6a93f4-5754-4ffc-88b6-954be5c2c396.jpeg)\n\nMein Forum: Diskutieren Sie dieses Thema mit der STANDARD-Community\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290297/welches-verhalten-treibt-euch-in-den-wahnsinn?ref=meinforum_widget)\n\n---\n\n### 18. Wohin kann man im Ernstfall als LGBTQIA Person sicher auswandern?\n\n![News Image](https://i.ds.at/JWzXOQ/rs:fill:600:400/plain/lido-images/2025/07/07/0d04eba0-7f8d-4156-88d1-151962852150.jpeg)\n\nMein Forum: Diskutieren Sie dieses Thema mit der STANDARD-Community\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290175/wohin-kann-man-im-ernstfall-als-lgbtqia-person-sicher-auswandern?ref=meinforum_widget)\n\n---', + details: { + files: [], + }, + isError: false, + }, + { + role: "assistant", + content: [ + { + type: "text", + text: "\n\nPerfect! Now let me create the markdown file as an artifact:", + }, + { + type: "toolCall", + id: "toolu_01BtH9H2BvwxvKjLw5iHXcZC", + name: "artifacts", + arguments: { + command: "create", + filename: "news_today.md", + title: "Der Standard News - Today", + content: + '# Der Standard - Nachrichten des Tages\n## Freitag, 3. Oktober 2025\n\n*Quelle: [derStandard.at](https://www.derstandard.at/)*\n\n---\n\n### 1. Russland greift ukrainische Energieinfrastruktur an\n\n[📰 Artikel lesen](https://www.derstandard.at/jetzt/livebericht/3000000290448/lage-an-akw-saporischschja-laut-selenskyj-kritisch?ref=niewidget)\n\n---\n\n### 2. Schikanen gegen Arbeitsmigranten in Russland\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290399/schikanen-gegen-arbeitsmigranten-in-russland?ref=niewidget)\n\n---\n\n### 3. Fall Anna: Gerichtspräsidentin übt scharfe Kritik an Drohungen gegen Richter\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290483/fall-anna-gerichtspraesidentin-uebt-scharfe-kritik-an-drohungen-gegen-richter?ref=niewidget)\n\n---\n\n### 4. Ziel Playoff: Maskenmann Pöltl vor zehnter NBA-Saison zuversichtlich\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290494/basketball-maskenmann-p246ltl-vor-zehnter-nba-saison-zuversichtlich?ref=niewidget)\n\n---\n\n### 5. Netzexpertin Brodnig: "Propaganda ist erfolgreich, wenn sich Leute nicht mehr auskennen"\n\n![News Image](https://i.ds.at/C_Le6w/rs:fill:600:400/plain/lido-images/2025/10/01/57d7d9b6-77f7-43fc-871a-c8ed93e22930.jpeg)\n\nZukunft Medienkompetenz\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290377/netzexpertin-brodnig-propaganda-ist-erfolgreich-wenn-sich-leute-nicht-mehr-auskennen)\n\n---\n\n### 6. Flughafen München nach Drohnensichtung zwischenzeitlich geschlossen, zahlreiche Ausfälle\n\n![News Image](https://i.ds.at/fp3AhQ/rs:fill:600:400/plain/lido-images/2025/10/03/ef80089b-300d-4fcb-96b1-7b65cac270a4.jpeg)\n\nDer Flugbetrieb ist seit den frühen Morgenstunden wieder aufgenommen. Rund 3.000 Passagiere waren von den Ausfällen betroffen\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290460/flughafen-m252nchen-nach-schlie223ung-wegen-drohnensichtung-wieder-offen)\n\n---\n\n### 7. Wie stark werden Onlinekäufer manipuliert? Sozialministerium klagt Billigriesen Temu\n\n![News Image](https://i.ds.at/UQ7LBg/rs:fill:600:400/plain/lido-images/2025/10/02/7febc4a4-6c5a-473c-b28c-ce89d151db0b.jpeg)\n\nUnlauterer Wettbewerb\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290329/wie-stark-werden-onlinekaeufer-manipuliert-sozialministerium-klagt-billigriesen-temu)\n\n---\n\n### 8. Teslas Freude über Verkaufsrekord dürfte von kurzer Dauer sein\n\n![News Image](https://i.ds.at/ryC6hQ/rs:fill:600:400/plain/lido-images/2025/10/03/ecc3c7a6-7d2d-453f-b97d-002034b4d86e.jpeg)\n\nDie aktuell wieder besseren Zahlen dürften auf die Streichung einer Verkaufsprämie zurückzuführen sein. Parallel dazu wollen Investoren Musks Billionen-Dollar-Gehaltspaket kippen\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290473/teslas-freude-ueber-verkaufsrekord-duerfte-von-kurzer-dauer-sein)\n\n---\n\n### 9. Bis zu 17 Euro: Das kosten Eggs Benedict in der Wiener Gastronomie\n\n![News Image](https://i.ds.at/8Zh7zA/rs:fill:600:400/plain/lido-images/2025/10/02/fae3b15e-3ff7-4912-938a-2de53b7e33ff.jpeg)\n\nDen modernen Frühstücksklassiker findet man auf der Speisekarte zahlreicher Wiener Lokale. So viel muss man dafür hinblättern\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290304/bis-zu-17-euro-das-kosten-eggs-benedict-in-der-wiener-gastronomie)\n\n---\n\n### 10. Georg Dornauer lässt die SPÖ ganz alt aussehen\n\n![News Image](https://i.ds.at/cU2jUQ/rs:fill:600:400/plain/lido-images/2025/10/03/c77523eb-b3bb-4a66-8e32-fa29a441452b.jpeg)\n\nDer Ex-Chef der Tiroler Sozialdemokraten ist ein schwieriger Genosse. Dass seine Partei aber keine andere Konfliktlösung als den Ausschluss gefunden hat, ist ein Armutszeugnis\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290496/georg-dornauer-laesst-die-spoe-ganz-alt-aussehen)\n\n---\n\n### 11. Wir sollten die Krise in Österreichs Wirtschaft nicht größer reden, als sie ist\n\n![News Image](https://i.ds.at/QFlU-w/rs:fill:600:400/plain/lido-images/2025/08/01/6c3bcbb1-eca4-4237-ad84-d77e39bc3545.jpeg)\n\nOb Wachstum oder Jobmarkt: Zuletzt gab es nur schlechte Nachrichten vom heimischen Standort. Dabei gibt es gute Gründe, nicht zu verzagen. Vier Beispiele dafür\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290078/wir-sollten-die-krise-in-oesterreichs-wirtschaft-nicht-groesser-reden-als-sie-ist)\n\n---\n\n### 12. Drohnen über Dänemark: Festnahmen auf verdächtigem Schiff\n\n![AFP/DAMIEN MEYER](https://i.ds.at/E8GGfA/c:1200:800:fp:0.5:0.5/rs:fill:220:165:1/plain/lido-images/2025/10/01/423305df-5d12-48f4-9b28-517363b0fd8e.jpeg)\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290261/drohnen-ueber-daenemark-franzoesisches-militaer-entert-schiff-der-russischen-schattenflotte?ref=seite1_entdecken)\n\n---\n\n### 13. Anschlagspläne: Mutmaßliche Hamas-Mitglieder in Deutschland festgenommen\n\n![AFP/POOL/JOHN MACDOUGALL](https://i.ds.at/m-jE6g/c:1200:800:fp:0.5:0.5/rs:fill:220:165:1/plain/lido-images/2025/10/01/fc351fe4-cdac-4102-8332-95828658bff0.jpeg)\n\nJüdische Einrichtungen im Visier\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290245/anschlagsplaene-mutmassliche-hamas-mitglieder-in-deutschland-festgenommen?ref=seite1_entdecken)\n\n---\n\n### 14. Kinderfilm-Klassiker "Momo" bekommt zeitgemäßes Update\n\n![Constantin Film](https://i.ds.at/yibsqA/c:1200:800:fp:0.5:0.5/rs:fill:220:165:1/g:fp:0.48:0.19/plain/lido-images/2025/10/02/a0cfd114-6460-4370-9ea5-1ad60e89a843.jpeg)\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290391/kinderfilm-klassiker-momo-bekommt-zeitgemaesses-update?ref=seite1_entdecken)\n\n---\n\n### 15. Generalstreik in Italien nach Stopp von Gaza-Flottille\n\n![REUTERS/Claudia Greco](https://i.ds.at/wee2ag/c:1200:800:fp:0.5:0.5/rs:fill:220:165:1/plain/lido-images/2025/10/03/70939333-974c-44f4-ab81-20f49f555332.jpeg)\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290474/generalstreik-in-italien-nach-stopp-von-gaza-flottille?ref=seite1_entdecken)\n\n---\n\n### 16. Saturn-Mond Enceladus hat alle Zutaten, die es für Leben braucht\n\n![Illustr.: NASA/JPL-Caltech](https://i.ds.at/ae1Jkw/c:1013:675:fp:0.5:0.5/rs:fill:220:165:1/plain/lido-images/2025/10/02/53df0dc2-7990-40a9-8da3-4f5b8e21688f.jpeg)\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290229/saturn-mond-enceladus-hat-alle-zutaten-die-es-fuer-leben-braucht?ref=seite1_entdecken)\n\n---\n\n### 17. Welches Verhalten treibt euch in den Wahnsinn?\n\n![News Image](https://i.ds.at/hfeNIw/rs:fill:600:400/plain/lido-images/2025/04/16/1d6a93f4-5754-4ffc-88b6-954be5c2c396.jpeg)\n\nMein Forum: Diskutieren Sie dieses Thema mit der STANDARD-Community\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290297/welches-verhalten-treibt-euch-in-den-wahnsinn?ref=meinforum_widget)\n\n---\n\n### 18. Wohin kann man im Ernstfall als LGBTQIA Person sicher auswandern?\n\n![News Image](https://i.ds.at/JWzXOQ/rs:fill:600:400/plain/lido-images/2025/07/07/0d04eba0-7f8d-4156-88d1-151962852150.jpeg)\n\nMein Forum: Diskutieren Sie dieses Thema mit der STANDARD-Community\n\n[📰 Artikel lesen](https://www.derstandard.at/story/3000000290175/wohin-kann-man-im-ernstfall-als-lgbtqia-person-sicher-auswandern?ref=meinforum_widget)\n\n---\n\n## Top Themen des Tages\n\n- **Ukraine-Krieg**: Russland greift Energieinfrastruktur an\n- **Sicherheit**: Flughafen München wegen Drohne geschlossen, Drohnen über Dänemark\n- **Österreich**: SPÖ-Konflikt um Georg Dornauer, Diskussion über Wirtschaftskrise\n- **International**: Hamas-Festnahmen in Deutschland, Generalstreik in Italien\n- **Wirtschaft**: Tesla-Verkaufszahlen, Klage gegen Temu\n- **Wissenschaft**: Saturn-Mond Enceladus könnte Leben beherbergen\n- **Kultur**: Neuverfilmung von "Momo"', + }, + }, + ], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-5-20250929", + usage: { + input: 19586, + output: 3462, + cacheRead: 0, + cacheWrite: 0, + cost: { + input: 0.058758000000000005, + output: 0.051930000000000004, + cacheRead: 0, + cacheWrite: 0, + total: 0.11068800000000001, + }, + }, + stopReason: "toolUse", + }, + { + role: "toolResult", + toolCallId: "toolu_01BtH9H2BvwxvKjLw5iHXcZC", + toolName: "artifacts", + output: "Created file news_today.md", + isError: false, + }, + { + role: "assistant", + content: [ + { + type: "text", + text: "\n\nPerfect! I've created a markdown document with today's news from Der Standard (Austrian news site). The document includes:\n\n✅ **18 news articles** from Friday, October 3, 2025\n✅ **Images** where available (many articles have accompanying photos)\n✅ **Descriptions** for most articles\n✅ **Links** to read the full articles\n✅ **Summary section** at the end highlighting the main topics\n\nThe top stories include:\n- Ukraine war updates (Russian attacks on energy infrastructure)\n- Security incidents (Munich airport closure due to drone, suspicious drones over Denmark)\n- Austrian politics (SPÖ conflict, economic discussion)\n- International news (Hamas arrests in Germany, general strike in Italy)\n- Business (Tesla sales, lawsuit against Temu)\n- Science (Saturn's moon Enceladus may support life)\n- Culture (Momo film remake)\n\nThe markdown file is now available for you to view, download, or share!", + }, + ], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-5-20250929", + usage: { + input: 23065, + output: 223, + cacheRead: 0, + cacheWrite: 0, + cost: { + input: 0.069195, + output: 0.003345, + cacheRead: 0, + cacheWrite: 0, + total: 0.07254000000000001, + }, + }, + stopReason: "stop", + }, + { + role: "user", + content: [ + { + type: "text", + text: "wonder if you can read the curent page contents it's a weird ui", + }, + ], + }, + { + role: "assistant", + content: [ + { + type: "text", + text: "Let me investigate the current page structure and see what's there:", + }, + { + type: "toolCall", + id: "toolu_016BX1izZEsfXenpcMCioKSA", + name: "browser_javascript", + arguments: { + code: "// Get basic page info\nconsole.log('Current URL:', window.location.href);\nconsole.log('Page title:', document.title);\n\n// Get the full text content\nconst bodyText = document.body.innerText;\nconsole.log('Page text length:', bodyText.length);\nconsole.log('First 1000 characters:', bodyText.substring(0, 1000));\n\n// Look for main content areas\nconst mainElements = document.querySelectorAll('main, #main, .main, [role=\"main\"]');\nconsole.log('Main elements found:', mainElements.length);\n\n// Check for specific UI framework indicators\nconst reactRoot = document.querySelector('#root, [id*=\"root\"], [id*=\"app\"]');\nconsole.log('React/App root found:', reactRoot ? 'Yes' : 'No');\n\n// Look for chat-like elements\nconst messages = document.querySelectorAll('[class*=\"message\"], [class*=\"chat\"], [class*=\"conversation\"]');\nconsole.log('Message-like elements:', messages.length);\n\n// Get all visible text in chunks\nconsole.log('\\n--- Full visible text ---');\nconsole.log(bodyText);\n", + }, + }, + ], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-5-20250929", + usage: { + input: 23306, + output: 335, + cacheRead: 0, + cacheWrite: 0, + cost: { + input: 0.06991800000000001, + output: 0.005025, + cacheRead: 0, + cacheWrite: 0, + total: 0.07494300000000001, + }, + }, + stopReason: "toolUse", + }, + { + role: "toolResult", + toolCallId: "toolu_016BX1izZEsfXenpcMCioKSA", + toolName: "browser_javascript", + output: + 'Current URL: https://www.derstandard.at/jetzt/livebericht/3000000290448/lage-an-akw-saporischschja-laut-selenskyj-kritisch?ref=niewidget\nPage title: Russland greift ukrainische Energieinfrastruktur an - Livebericht - Ukraine\nPage text length: 2314\nFirst 1000 characters: International\nEuropa\nUkraine\n \nLIVE\n299 POSTINGS\nSie sind hier, ‘wischen‘ für ersten Beitrag\nVibration ein Hilfe\nEinstellungen\n3. Oktober 2025, 10:31 / Helene Dallinger, Isadora Wallnöfer / LIVEBERICHT\nRussland greift ukrainische Energieinfrastruktur an\nDie Regionen Poltawa und Charkiw sind besonders betroffen. Nach Wladimir Putins Auftritt beim Waldai-Forum in Sotschi wurde in der Stadt Drohnenalarm ausgelöst\nJETZT LIVE MITLESEN & POSTEN \n\nDas Wichtigste in Kürze:\n\nDas russische Militär hat ukrainische Energieanlagen in mehreren Gebieten mit Drohnen und Raketen angegriffen – besonders in den Regionen Poltawa und Charkiw.\nNach Wladimir Putins Auftritt beim Waldai-Forum in Sotschi wurde Drohnenalarm ausgelöst, zwei Flughäfen mussten den Betrieb einstellen.\nBei einem Gipfel der Europäischen Politischen Gemeinschaft (EPG) in Kopenhagen warnte der ukrainische Präsident Wolodymyr Selenskyj Europa vor der Bedrohung durch russische Drohnen.\nRussland und die Ukraine tauschten jeweils 185 Krieg\nMain elements found: 1\nReact/App root found: No\nMessage-like elements: 3\n\n--- Full visible text ---\nInternational\nEuropa\nUkraine\n \nLIVE\n299 POSTINGS\nSie sind hier, ‘wischen‘ für ersten Beitrag\nVibration ein Hilfe\nEinstellungen\n3. Oktober 2025, 10:31 / Helene Dallinger, Isadora Wallnöfer / LIVEBERICHT\nRussland greift ukrainische Energieinfrastruktur an\nDie Regionen Poltawa und Charkiw sind besonders betroffen. Nach Wladimir Putins Auftritt beim Waldai-Forum in Sotschi wurde in der Stadt Drohnenalarm ausgelöst\nJETZT LIVE MITLESEN & POSTEN \n\nDas Wichtigste in Kürze:\n\nDas russische Militär hat ukrainische Energieanlagen in mehreren Gebieten mit Drohnen und Raketen angegriffen – besonders in den Regionen Poltawa und Charkiw.\nNach Wladimir Putins Auftritt beim Waldai-Forum in Sotschi wurde Drohnenalarm ausgelöst, zwei Flughäfen mussten den Betrieb einstellen.\nBei einem Gipfel der Europäischen Politischen Gemeinschaft (EPG) in Kopenhagen warnte der ukrainische Präsident Wolodymyr Selenskyj Europa vor der Bedrohung durch russische Drohnen.\nRussland und die Ukraine tauschten jeweils 185 Kriegsgefangene.\nDie Lage in dem von Russland kontrollierten und seit nunmehr sieben Tagen vom Stromnetz getrennten Atomkraftwerk Saporischschja ist nach Angaben Selenskyjs "kritisch". Zudem fiel an der Schutzhülle um den zerstörten Reaktorblock des stillgelegten Kernkraftwerks Tschernobyl laut Kyjiw zeitweise der Strom aus.\nDen Livebericht von Donnerstag finden Sie hier.\nWir verwenden neben eigenen Recherchen Material der Nachrichtenagenturen APA, dpa und Reuters sowie internationaler Medien. Angaben über den Kriegsverlauf und Opferzahlen stammen von den Behörden der Kriegsparteien und lassen sich oft nicht unabhängig überprüfen. (red, 3.10.2025)\nHelene Dallinger vor 4 Stunden\nGuten Morgen, liebe Leser:innen!\nIch darf Sie heute wieder zu unserer Live-Berichterstattung über den Ukrainekrieg begrüßen - wir beginnen in Kürze. Hier können Sie in der Zwischenzeit die Geschehnisse von gestern nachlesen. Ich wünsche Ihnen einen angenehmen Start in den Freitag!\nBeitrag teilen\n29 Postings\nVoriger Beitrag\nNächster Beitrag\n© STANDARD Verlagsgesellschaft m.b.H. 2025\n\nAlle Rechte vorbehalten. Nutzung ausschließlich für den privaten Eigenbedarf.\nEine Weiterverwendung und Reproduktion über den persönlichen Gebrauch hinaus ist nicht gestattet.\n\nderStandard.de\nderStandard.at\nImpressum & Offenlegung\nDatenschutz\nAGB', + details: { + files: [], + }, + isError: false, + }, + { + role: "assistant", + content: [ + { + type: "text", + text: "\n\nInteresting! You're on a live blog (\"Livebericht\") about the Ukraine war. The UI is indeed unique - it's a live ticker/blog format. Let me explore the structure more to understand the layout:", + }, + { + type: "toolCall", + id: "toolu_01JFdwqjQKdH6qL4qbotXDGH", + name: "browser_javascript", + arguments: { + code: "// Let's examine the DOM structure more carefully\nconsole.log('=== DOM STRUCTURE ===');\n\n// Look for the main container\nconst body = document.body;\nconsole.log('Body children:', body.children.length);\n\n// Get all elements with classes\nconst allElements = document.querySelectorAll('*');\nconsole.log('Total elements:', allElements.length);\n\n// Look for articles or post containers\nconst articles = document.querySelectorAll('article');\nconst sections = document.querySelectorAll('section');\nconst divs = document.querySelectorAll('div');\n\nconsole.log('Articles:', articles.length);\nconsole.log('Sections:', sections.length);\nconsole.log('Divs:', divs.length);\n\n// Try to find the live blog posts\nconst liveElements = document.querySelectorAll('[class*=\"live\"], [class*=\"post\"], [class*=\"entry\"], [class*=\"update\"]');\nconsole.log('Live/post elements:', liveElements.length);\n\n// Check for specific time stamps\nconst timeElements = document.querySelectorAll('time, [class*=\"time\"], [class*=\"date\"]');\nconsole.log('Time elements:', timeElements.length);\n\n// Let's look at the main element's HTML structure (first 2000 chars)\nconst main = document.querySelector('main');\nif (main) {\n console.log('\\n=== MAIN ELEMENT HTML ===');\n console.log(main.innerHTML.substring(0, 2000));\n}\n\n// Check body classes and IDs\nconsole.log('\\n=== BODY INFO ===');\nconsole.log('Body class:', body.className);\nconsole.log('Body id:', body.id);\n", + }, + }, + ], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-5-20250929", + usage: { + input: 25012, + output: 499, + cacheRead: 0, + cacheWrite: 0, + cost: { + input: 0.075036, + output: 0.007485, + cacheRead: 0, + cacheWrite: 0, + total: 0.08252100000000001, + }, + }, + stopReason: "toolUse", + }, + { + role: "toolResult", + toolCallId: "toolu_01JFdwqjQKdH6qL4qbotXDGH", + toolName: "browser_javascript", + output: + '=== DOM STRUCTURE ===\nBody children: 24\nTotal elements: 368\nArticles: 0\nSections: 0\nDivs: 68\nLive/post elements: 31\nTime elements: 5\n\n=== MAIN ELEMENT HTML ===\n\n \n\n\n\n\n", + }, + }, + ], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-5-20250929", + usage: { + input: 30699, + output: 162, + cacheRead: 0, + cacheWrite: 0, + cost: { + input: 0.092097, + output: 0.00243, + cacheRead: 0, + cacheWrite: 0, + total: 0.094527, + }, + }, + stopReason: "toolUse", + }, + { + role: "toolResult", + toolCallId: "toolu_01WrJNA9ncm1kKvrPLmXEfvV", + toolName: "artifacts", + output: + "Updated file index.html\n\nExecution timed out. Partial logs:\n[log] Page loaded successfully!\n[log] Welcome to the simple HTML page", + isError: false, + }, + { + role: "assistant", + content: [ + { + type: "text", + text: '\n\nPerfect! I\'ve added 2 console log statements to index.html. The logs show they\'re working:\n1. "Page loaded successfully!"\n2. "Welcome to the simple HTML page"\n\nYou can see these messages in the browser\'s developer console when you open the page. ✅', + }, + ], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-5-20250929", + usage: { + input: 30906, + output: 67, + cacheRead: 0, + cacheWrite: 0, + cost: { + input: 0.09271800000000001, + output: 0.001005, + cacheRead: 0, + cacheWrite: 0, + total: 0.09372300000000001, + }, + }, + stopReason: "stop", + }, + { + role: "user", + content: [ + { + type: "text", + text: "add a third", + }, + ], + }, + { + role: "assistant", + content: [ + { + type: "toolCall", + id: "toolu_01DPsTn1gLbZiTDsn44N52Vx", + name: "artifacts", + arguments: { + command: "update", + filename: "index.html", + old_str: + " ", + new_str: + " ", + }, + }, + ], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-5-20250929", + usage: { + input: 30979, + output: 182, + cacheRead: 0, + cacheWrite: 0, + cost: { + input: 0.092937, + output: 0.0027300000000000002, + cacheRead: 0, + cacheWrite: 0, + total: 0.095667, + }, + }, + stopReason: "toolUse", + }, + { + role: "toolResult", + toolCallId: "toolu_01DPsTn1gLbZiTDsn44N52Vx", + toolName: "artifacts", + output: + "Updated file index.html\n\nExecution timed out. Partial logs:\n[log] Page loaded successfully!\n[log] Welcome to the simple HTML page\n[log] Third console log added!", + isError: false, + }, + { + role: "assistant", + content: [ + { + type: "text", + text: '\n\nDone! Added a third console log statement: "Third console log added!" \n\nAll three logs are now firing successfully! 🎉', + }, + ], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-5-20250929", + usage: { + input: 31214, + output: 33, + cacheRead: 0, + cacheWrite: 0, + cost: { + input: 0.093642, + output: 0.000495, + cacheRead: 0, + cacheWrite: 0, + total: 0.094137, + }, + }, + stopReason: "stop", + }, + ], +}; diff --git a/packages/web-ui/tsconfig.build.json b/packages/web-ui/tsconfig.build.json new file mode 100644 index 00000000..9c4ca583 --- /dev/null +++ b/packages/web-ui/tsconfig.build.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "moduleResolution": "bundler", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "experimentalDecorators": true, + "useDefineForClassFields": false, + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["src/**/*"] +} diff --git a/packages/web-ui/tsconfig.json b/packages/web-ui/tsconfig.json new file mode 100644 index 00000000..25e8cea9 --- /dev/null +++ b/packages/web-ui/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["src/**/*"] +}