diff --git a/.husky/pre-commit b/.husky/pre-commit
index a0951d8..31df8a4 100755
--- a/.husky/pre-commit
+++ b/.husky/pre-commit
@@ -42,7 +42,7 @@ fi
RUN_BROWSER_SMOKE=0
for file in $STAGED_FILES; do
case "$file" in
- packages/ai/*|packages/web-ui/*|package.json|package-lock.json)
+ packages/ai/*|package.json|package-lock.json)
RUN_BROWSER_SMOKE=1
break
;;
diff --git a/biome.json b/biome.json
index 0d85d71..3791404 100644
--- a/biome.json
+++ b/biome.json
@@ -27,12 +27,9 @@
"includes": [
"packages/*/src/**/*.ts",
"packages/*/test/**/*.ts",
- "packages/web-ui/src/**/*.ts",
- "packages/web-ui/example/**/*.ts",
"!**/node_modules/**/*",
"!**/test-sessions.ts",
"!**/models.generated.ts",
- "!packages/web-ui/src/app.css",
"!!**/node_modules"
]
}
diff --git a/package-lock.json b/package-lock.json
index 8caf810..b928aa2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,8 +8,7 @@
"name": "pi",
"version": "0.0.3",
"workspaces": [
- "packages/*",
- "packages/web-ui/example"
+ "packages/*"
],
"dependencies": {
"@mariozechner/jiti": "^2.6.5",
@@ -1761,10 +1760,6 @@
"resolved": "packages/tui",
"link": true
},
- "node_modules/@mariozechner/pi-web-ui": {
- "resolved": "packages/web-ui",
- "link": true
- },
"node_modules/@mistralai/mistralai": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-1.14.1.tgz",
@@ -7062,10 +7057,6 @@
"resolved": "packages/pi-teams",
"link": true
},
- "node_modules/pi-web-ui-example": {
- "resolved": "packages/web-ui/example",
- "link": true
- },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -9343,48 +9334,6 @@
"type": "opencollective",
"url": "https://opencollective.com/express"
}
- },
- "packages/web-ui": {
- "name": "@mariozechner/pi-web-ui",
- "version": "0.56.2",
- "license": "MIT",
- "dependencies": {
- "@lmstudio/sdk": "^1.5.0",
- "@mariozechner/pi-ai": "^0.56.2",
- "@mariozechner/pi-tui": "^0.56.2",
- "docx-preview": "^0.3.7",
- "jszip": "^3.10.1",
- "lucide": "^0.544.0",
- "ollama": "^0.6.0",
- "pdfjs-dist": "5.4.394",
- "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
- },
- "devDependencies": {
- "@mariozechner/mini-lit": "^0.2.0",
- "@tailwindcss/cli": "^4.0.0-beta.14",
- "concurrently": "^9.2.1",
- "typescript": "^5.7.3"
- },
- "peerDependencies": {
- "@mariozechner/mini-lit": "^0.2.0",
- "lit": "^3.3.1"
- }
- },
- "packages/web-ui/example": {
- "name": "pi-web-ui-example",
- "version": "1.44.2",
- "dependencies": {
- "@mariozechner/mini-lit": "^0.2.0",
- "@mariozechner/pi-ai": "file:../../ai",
- "@mariozechner/pi-web-ui": "file:../",
- "@tailwindcss/vite": "^4.1.17",
- "lit": "^3.3.1",
- "lucide": "^0.544.0"
- },
- "devDependencies": {
- "typescript": "^5.7.3",
- "vite": "^7.1.6"
- }
}
}
}
diff --git a/package.json b/package.json
index b536b53..f31a7b4 100644
--- a/package.json
+++ b/package.json
@@ -11,15 +11,14 @@
"url": "git+https://github.com/getcompanion-ai/co-mono.git"
},
"workspaces": [
- "packages/*",
- "packages/web-ui/example"
+ "packages/*"
],
"scripts": {
"clean": "npm run clean --workspaces",
- "build": "cd packages/tui && npm run build && cd ../ai && npm run build && cd ../agent && npm run build && cd ../coding-agent && npm run build && cd ../web-ui && npm run build",
- "dev": "concurrently --names \"ai,agent,coding-agent,web-ui,tui\" --prefix-colors \"cyan,yellow,red,green,magenta\" \"cd packages/ai && npm run dev\" \"cd packages/agent && npm run dev\" \"cd packages/coding-agent && npm run dev\" \"cd packages/web-ui && npm run dev\" \"cd packages/tui && npm run dev\"",
- "dev:tsc": "concurrently --names \"ai,web-ui\" --prefix-colors \"cyan,green\" \"cd packages/ai && npm run dev:tsc\" \"cd packages/web-ui && npm run dev:tsc\"",
- "check": "biome check --write --error-on-warnings . && tsgo --noEmit && npm run check:browser-smoke && cd packages/web-ui && npm run check",
+ "build": "cd packages/tui && npm run build && cd ../ai && npm run build && cd ../agent && npm run build && cd ../coding-agent && npm run build",
+ "dev": "concurrently --names \"ai,agent,coding-agent,tui\" --prefix-colors \"cyan,yellow,red,magenta\" \"cd packages/ai && npm run dev\" \"cd packages/agent && npm run dev\" \"cd packages/coding-agent && npm run dev\" \"cd packages/tui && npm run dev\"",
+ "dev:tsc": "cd packages/ai && npm run dev:tsc",
+ "check": "biome check --write --error-on-warnings . && tsgo --noEmit && npm run check:browser-smoke",
"check:browser-smoke": "sh -c 'esbuild scripts/browser-smoke-entry.ts --bundle --platform=browser --format=esm --log-limit=0 --outfile=/tmp/pi-browser-smoke.js > /tmp/pi-browser-smoke-errors.log 2>&1 || { echo \"Browser smoke check failed. See /tmp/pi-browser-smoke-errors.log\"; exit 1; }'",
"test": "npm run test --workspaces --if-present",
"version:patch": "npm version patch -ws --no-git-tag-version && node scripts/sync-versions.js && shx rm -rf node_modules packages/*/node_modules package-lock.json && npm install",
diff --git a/packages/web-ui/CHANGELOG.md b/packages/web-ui/CHANGELOG.md
deleted file mode 100644
index 6e5a989..0000000
--- a/packages/web-ui/CHANGELOG.md
+++ /dev/null
@@ -1,287 +0,0 @@
-# Changelog
-
-## [Unreleased]
-
-## [0.56.2] - 2026-03-05
-
-## [0.56.1] - 2026-03-05
-
-## [0.56.0] - 2026-03-04
-
-## [0.55.4] - 2026-03-02
-
-## [0.55.3] - 2026-02-27
-
-## [0.55.2] - 2026-02-27
-
-## [0.55.1] - 2026-02-26
-
-## [0.55.0] - 2026-02-24
-
-## [0.54.2] - 2026-02-23
-
-## [0.54.1] - 2026-02-22
-
-## [0.54.0] - 2026-02-19
-
-## [0.53.1] - 2026-02-19
-
-## [0.53.0] - 2026-02-17
-
-## [0.52.12] - 2026-02-13
-
-## [0.52.11] - 2026-02-13
-
-## [0.52.10] - 2026-02-12
-
-### Fixed
-
-- Made model selector search case-insensitive by normalizing query tokens, fixing auto-capitalized mobile input filtering ([#1443](https://github.com/badlogic/pi-mono/issues/1443))
-
-## [0.52.9] - 2026-02-08
-
-## [0.52.8] - 2026-02-07
-
-## [0.52.7] - 2026-02-06
-
-## [0.52.6] - 2026-02-05
-
-## [0.52.5] - 2026-02-05
-
-## [0.52.4] - 2026-02-05
-
-## [0.52.3] - 2026-02-05
-
-## [0.52.2] - 2026-02-05
-
-## [0.52.1] - 2026-02-05
-
-## [0.52.0] - 2026-02-05
-
-## [0.51.6] - 2026-02-04
-
-## [0.51.5] - 2026-02-04
-
-## [0.51.4] - 2026-02-03
-
-## [0.51.3] - 2026-02-03
-
-## [0.51.2] - 2026-02-03
-
-## [0.51.1] - 2026-02-02
-
-## [0.51.0] - 2026-02-01
-
-## [0.50.9] - 2026-02-01
-
-## [0.50.8] - 2026-02-01
-
-## [0.50.7] - 2026-01-31
-
-## [0.50.6] - 2026-01-30
-
-## [0.50.5] - 2026-01-30
-
-## [0.50.3] - 2026-01-29
-
-## [0.50.2] - 2026-01-29
-
-### Added
-
-- Exported `CustomProviderCard`, `ProviderKeyInput`, `AbortedMessage`, and `ToolMessageDebugView` components for custom UIs ([#1015](https://github.com/badlogic/pi-mono/issues/1015))
-
-## [0.50.1] - 2026-01-26
-
-## [0.50.0] - 2026-01-26
-
-## [0.49.3] - 2026-01-22
-
-### Changed
-
-- Updated tsgo to 7.0.0-dev.20260120.1 for decorator support ([#873](https://github.com/badlogic/pi-mono/issues/873))
-
-## [0.49.2] - 2026-01-19
-
-## [0.49.1] - 2026-01-18
-
-## [0.49.0] - 2026-01-17
-
-## [0.48.0] - 2026-01-16
-
-## [0.47.0] - 2026-01-16
-
-## [0.46.0] - 2026-01-15
-
-## [0.45.7] - 2026-01-13
-
-## [0.45.6] - 2026-01-13
-
-## [0.45.5] - 2026-01-13
-
-## [0.45.4] - 2026-01-13
-
-## [0.45.3] - 2026-01-13
-
-## [0.45.2] - 2026-01-13
-
-## [0.45.1] - 2026-01-13
-
-## [0.45.0] - 2026-01-13
-
-## [0.44.0] - 2026-01-12
-
-## [0.43.0] - 2026-01-11
-
-## [0.42.5] - 2026-01-11
-
-## [0.42.4] - 2026-01-10
-
-## [0.42.3] - 2026-01-10
-
-## [0.42.2] - 2026-01-10
-
-## [0.42.1] - 2026-01-09
-
-## [0.42.0] - 2026-01-09
-
-## [0.41.0] - 2026-01-09
-
-## [0.40.1] - 2026-01-09
-
-## [0.40.0] - 2026-01-08
-
-## [0.39.1] - 2026-01-08
-
-## [0.39.0] - 2026-01-08
-
-## [0.38.0] - 2026-01-08
-
-## [0.37.8] - 2026-01-07
-
-## [0.37.7] - 2026-01-07
-
-## [0.37.6] - 2026-01-06
-
-## [0.37.5] - 2026-01-06
-
-## [0.37.4] - 2026-01-06
-
-## [0.37.3] - 2026-01-06
-
-## [0.37.2] - 2026-01-05
-
-## [0.37.1] - 2026-01-05
-
-## [0.37.0] - 2026-01-05
-
-## [0.36.0] - 2026-01-05
-
-## [0.35.0] - 2026-01-05
-
-## [0.34.2] - 2026-01-04
-
-## [0.34.1] - 2026-01-04
-
-## [0.34.0] - 2026-01-04
-
-## [0.33.0] - 2026-01-04
-
-## [0.32.3] - 2026-01-03
-
-## [0.32.2] - 2026-01-03
-
-## [0.32.1] - 2026-01-03
-
-## [0.32.0] - 2026-01-03
-
-## [0.31.1] - 2026-01-02
-
-## [0.31.0] - 2026-01-02
-
-### Breaking Changes
-
-- **Agent class moved to `@mariozechner/pi-agent-core`**: The `Agent` class, `AgentState`, and related types are no longer exported from this package. Import them from `@mariozechner/pi-agent-core` instead.
-
-- **Transport abstraction removed**: `ProviderTransport`, `AppTransport`, `AgentTransport` interface, and related types have been removed. The `Agent` class now uses `streamFn` for custom streaming.
-
-- **`AppMessage` renamed to `AgentMessage`**: Now imported from `@mariozechner/pi-agent-core`. Custom message types use declaration merging on `CustomAgentMessages` interface.
-
-- **`UserMessageWithAttachments` is now a custom message type**: Has `role: "user-with-attachments"` instead of `role: "user"`. Use `isUserMessageWithAttachments()` type guard.
-
-- **`CustomMessages` interface removed**: Use declaration merging on `CustomAgentMessages` from `@mariozechner/pi-agent-core` instead.
-
-- **`agent.appendMessage()` removed**: Use `agent.queueMessage()` instead.
-
-- **Agent event types changed**: `AgentInterface` now handles new event types from `@mariozechner/pi-agent-core`: `message_start`, `message_end`, `message_update`, `turn_start`, `turn_end`, `agent_start`, `agent_end`.
-
-### Added
-
-- **`defaultConvertToLlm`**: Default message transformer that handles `UserMessageWithAttachments` and `ArtifactMessage`. Apps can extend this for custom message types.
-
-- **`convertAttachments`**: Utility to convert `Attachment[]` to LLM content blocks (images and extracted document text).
-
-- **`isUserMessageWithAttachments` / `isArtifactMessage`**: Type guard functions for custom message types.
-
-- **`createStreamFn`**: Creates a stream function with CORS proxy support. Reads proxy settings on each call for dynamic configuration.
-
-- **Default `streamFn` and `getApiKey`**: `AgentInterface` now sets sensible defaults if not provided:
- - `streamFn`: Uses `createStreamFn` with proxy settings from storage
- - `getApiKey`: Reads from `providerKeys` storage
-
-- **Proxy utilities exported**: `applyProxyIfNeeded`, `shouldUseProxyForProvider`, `isCorsError`, `createStreamFn`
-
-### Removed
-
-- `Agent` class (moved to `@mariozechner/pi-agent-core`)
-- `ProviderTransport` class
-- `AppTransport` class
-- `AgentTransport` interface
-- `AgentRunConfig` type
-- `ProxyAssistantMessageEvent` type
-- `test-sessions.ts` example file
-
-### Migration Guide
-
-**Before (0.30.x):**
-
-```typescript
-import { Agent, ProviderTransport, type AppMessage } from '@mariozechner/pi-web-ui';
-
-const agent = new Agent({
- transport: new ProviderTransport(),
- messageTransformer: (messages: AppMessage[]) => messages.filter(...)
-});
-```
-
-**After:**
-
-```typescript
-import { Agent, type AgentMessage } from "@mariozechner/pi-agent-core";
-import { defaultConvertToLlm } from "@mariozechner/pi-web-ui";
-
-const agent = new Agent({
- convertToLlm: (messages: AgentMessage[]) => {
- // Extend defaultConvertToLlm for custom types
- return defaultConvertToLlm(messages);
- },
-});
-// AgentInterface will set streamFn and getApiKey defaults automatically
-```
-
-**Custom message types:**
-
-```typescript
-// Before: declaration merging on CustomMessages
-declare module "@mariozechner/pi-web-ui" {
- interface CustomMessages {
- "my-message": MyMessage;
- }
-}
-
-// After: declaration merging on CustomAgentMessages
-declare module "@mariozechner/pi-agent-core" {
- interface CustomAgentMessages {
- "my-message": MyMessage;
- }
-}
-```
diff --git a/packages/web-ui/README.md b/packages/web-ui/README.md
deleted file mode 100644
index 28c08b3..0000000
--- a/packages/web-ui/README.md
+++ /dev/null
@@ -1,650 +0,0 @@
-# @mariozechner/pi-web-ui
-
-Reusable web UI components for building AI chat interfaces powered by [@mariozechner/pi-ai](../ai) and [@mariozechner/pi-agent-core](../agent).
-
-Built with [mini-lit](https://github.com/badlogic/mini-lit) web components and Tailwind CSS v4.
-
-## Features
-
-- **Chat UI**: Complete interface with message history, streaming, and tool execution
-- **Tools**: JavaScript REPL, document extraction, and artifacts (HTML, SVG, Markdown, etc.)
-- **Attachments**: PDF, DOCX, XLSX, PPTX, images with preview and text extraction
-- **Artifacts**: Interactive HTML, SVG, Markdown with sandboxed execution
-- **Storage**: IndexedDB-backed storage for sessions, API keys, and settings
-- **CORS Proxy**: Automatic proxy handling for browser environments
-- **Custom Providers**: Support for Ollama, LM Studio, vLLM, and OpenAI-compatible APIs
-
-## Installation
-
-```bash
-npm install @mariozechner/pi-web-ui @mariozechner/pi-agent-core @mariozechner/pi-ai
-```
-
-## Quick Start
-
-See the [example](./example) directory for a complete working application.
-
-```typescript
-import { Agent } from "@mariozechner/pi-agent-core";
-import { getModel } from "@mariozechner/pi-ai";
-import {
- ChatPanel,
- AppStorage,
- IndexedDBStorageBackend,
- ProviderKeysStore,
- SessionsStore,
- SettingsStore,
- setAppStorage,
- defaultConvertToLlm,
- ApiKeyPromptDialog,
-} from "@mariozechner/pi-web-ui";
-import "@mariozechner/pi-web-ui/app.css";
-
-// Set up storage
-const settings = new SettingsStore();
-const providerKeys = new ProviderKeysStore();
-const sessions = new SessionsStore();
-
-const backend = new IndexedDBStorageBackend({
- dbName: "my-app",
- version: 1,
- stores: [
- settings.getConfig(),
- providerKeys.getConfig(),
- sessions.getConfig(),
- SessionsStore.getMetadataConfig(),
- ],
-});
-
-settings.setBackend(backend);
-providerKeys.setBackend(backend);
-sessions.setBackend(backend);
-
-const storage = new AppStorage(
- settings,
- providerKeys,
- sessions,
- undefined,
- backend,
-);
-setAppStorage(storage);
-
-// Create agent
-const agent = new Agent({
- initialState: {
- systemPrompt: "You are a helpful assistant.",
- model: getModel("anthropic", "claude-sonnet-4-5-20250929"),
- thinkingLevel: "off",
- messages: [],
- tools: [],
- },
- convertToLlm: defaultConvertToLlm,
-});
-
-// Create chat panel
-const chatPanel = new ChatPanel();
-await chatPanel.setAgent(agent, {
- onApiKeyRequired: (provider) => ApiKeyPromptDialog.prompt(provider),
-});
-
-document.body.appendChild(chatPanel);
-```
-
-## Architecture
-
-```
-┌─────────────────────────────────────────────────────┐
-│ ChatPanel │
-│ ┌─────────────────────┐ ┌─────────────────────┐ │
-│ │ AgentInterface │ │ ArtifactsPanel │ │
-│ │ (messages, input) │ │ (HTML, SVG, MD) │ │
-│ └─────────────────────┘ └─────────────────────┘ │
-└─────────────────────────────────────────────────────┘
- │
- ▼
-┌─────────────────────────────────────────────────────┐
-│ Agent (from pi-agent-core) │
-│ - State management (messages, model, tools) │
-│ - Event emission (agent_start, message_update, ...) │
-│ - Tool execution │
-└─────────────────────────────────────────────────────┘
- │
- ▼
-┌─────────────────────────────────────────────────────┐
-│ AppStorage │
-│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
-│ │ Settings │ │ Provider │ │ Sessions │ │
-│ │ Store │ │Keys Store│ │ Store │ │
-│ └──────────┘ └──────────┘ └──────────┘ │
-│ │ │
-│ IndexedDBStorageBackend │
-└─────────────────────────────────────────────────────┘
-```
-
-## Components
-
-### ChatPanel
-
-High-level chat interface with built-in artifacts panel.
-
-```typescript
-const chatPanel = new ChatPanel();
-await chatPanel.setAgent(agent, {
- // Prompt for API key when needed
- onApiKeyRequired: async (provider) => ApiKeyPromptDialog.prompt(provider),
-
- // Hook before sending messages
- onBeforeSend: async () => {
- /* save draft, etc. */
- },
-
- // Handle cost display click
- onCostClick: () => {
- /* show cost breakdown */
- },
-
- // Custom sandbox URL for browser extensions
- sandboxUrlProvider: () => chrome.runtime.getURL("sandbox.html"),
-
- // Add custom tools
- toolsFactory: (
- agent,
- agentInterface,
- artifactsPanel,
- runtimeProvidersFactory,
- ) => {
- const replTool = createJavaScriptReplTool();
- replTool.runtimeProvidersFactory = runtimeProvidersFactory;
- return [replTool];
- },
-});
-```
-
-### AgentInterface
-
-Lower-level chat interface for custom layouts.
-
-```typescript
-const chat = document.createElement("agent-interface") as AgentInterface;
-chat.session = agent;
-chat.enableAttachments = true;
-chat.enableModelSelector = true;
-chat.enableThinkingSelector = true;
-chat.onApiKeyRequired = async (provider) => {
- /* ... */
-};
-chat.onBeforeSend = async () => {
- /* ... */
-};
-```
-
-Properties:
-
-- `session`: Agent instance
-- `enableAttachments`: Show attachment button (default: true)
-- `enableModelSelector`: Show model selector (default: true)
-- `enableThinkingSelector`: Show thinking level selector (default: true)
-- `showThemeToggle`: Show theme toggle (default: false)
-
-### Agent (from pi-agent-core)
-
-```typescript
-import { Agent } from '@mariozechner/pi-agent-core';
-
-const agent = new Agent({
- initialState: {
- model: getModel('anthropic', 'claude-sonnet-4-5-20250929'),
- systemPrompt: 'You are helpful.',
- thinkingLevel: 'off',
- messages: [],
- tools: [],
- },
- convertToLlm: defaultConvertToLlm,
-});
-
-// Events
-agent.subscribe((event) => {
- switch (event.type) {
- case 'agent_start': // Agent loop started
- case 'agent_end': // Agent loop finished
- case 'turn_start': // LLM call started
- case 'turn_end': // LLM call finished
- case 'message_start':
- case 'message_update': // Streaming update
- case 'message_end':
- break;
- }
-});
-
-// Send message
-await agent.prompt('Hello!');
-await agent.prompt({ role: 'user-with-attachments', content: 'Check this', attachments, timestamp: Date.now() });
-
-// Control
-agent.abort();
-agent.setModel(newModel);
-agent.setThinkingLevel('medium');
-agent.setTools([...]);
-agent.queueMessage(customMessage);
-```
-
-## Message Types
-
-### UserMessageWithAttachments
-
-User message with file attachments:
-
-```typescript
-const message: UserMessageWithAttachments = {
- role: "user-with-attachments",
- content: "Analyze this document",
- attachments: [pdfAttachment],
- timestamp: Date.now(),
-};
-
-// Type guard
-if (isUserMessageWithAttachments(msg)) {
- console.log(msg.attachments);
-}
-```
-
-### ArtifactMessage
-
-For session persistence of artifacts:
-
-```typescript
-const artifact: ArtifactMessage = {
- role: "artifact",
- action: "create", // or 'update', 'delete'
- filename: "chart.html",
- content: "
...
",
- timestamp: new Date().toISOString(),
-};
-
-// Type guard
-if (isArtifactMessage(msg)) {
- console.log(msg.filename);
-}
-```
-
-### Custom Message Types
-
-Extend via declaration merging:
-
-```typescript
-interface SystemNotification {
- role: "system-notification";
- message: string;
- level: "info" | "warning" | "error";
- timestamp: string;
-}
-
-declare module "@mariozechner/pi-agent-core" {
- interface CustomAgentMessages {
- "system-notification": SystemNotification;
- }
-}
-
-// Register renderer
-registerMessageRenderer("system-notification", {
- render: (msg) => html`${msg.message}
`,
-});
-
-// Extend convertToLlm
-function myConvertToLlm(messages: AgentMessage[]): Message[] {
- const processed = messages.map((m) => {
- if (m.role === "system-notification") {
- return {
- role: "user",
- content: `${m.message} `,
- timestamp: Date.now(),
- };
- }
- return m;
- });
- return defaultConvertToLlm(processed);
-}
-```
-
-## Message Transformer
-
-`convertToLlm` transforms app messages to LLM-compatible format:
-
-```typescript
-import {
- defaultConvertToLlm,
- convertAttachments,
-} from "@mariozechner/pi-web-ui";
-
-// defaultConvertToLlm handles:
-// - UserMessageWithAttachments → user message with image/text content blocks
-// - ArtifactMessage → filtered out (UI-only)
-// - Standard messages (user, assistant, toolResult) → passed through
-```
-
-## Tools
-
-### JavaScript REPL
-
-Execute JavaScript in a sandboxed browser environment:
-
-```typescript
-import { createJavaScriptReplTool } from "@mariozechner/pi-web-ui";
-
-const replTool = createJavaScriptReplTool();
-
-// Configure runtime providers for artifact/attachment access
-replTool.runtimeProvidersFactory = () => [
- new AttachmentsRuntimeProvider(attachments),
- new ArtifactsRuntimeProvider(artifactsPanel, agent, true), // read-write
-];
-
-agent.setTools([replTool]);
-```
-
-### Extract Document
-
-Extract text from documents at URLs:
-
-```typescript
-import { createExtractDocumentTool } from "@mariozechner/pi-web-ui";
-
-const extractTool = createExtractDocumentTool();
-extractTool.corsProxyUrl = "https://corsproxy.io/?";
-
-agent.setTools([extractTool]);
-```
-
-### Artifacts Tool
-
-Built into ArtifactsPanel, supports: HTML, SVG, Markdown, text, JSON, images, PDF, DOCX, XLSX.
-
-```typescript
-const artifactsPanel = new ArtifactsPanel();
-artifactsPanel.agent = agent;
-
-// The tool is available as artifactsPanel.tool
-agent.setTools([artifactsPanel.tool]);
-```
-
-### Custom Tool Renderers
-
-```typescript
-import {
- registerToolRenderer,
- type ToolRenderer,
-} from "@mariozechner/pi-web-ui";
-
-const myRenderer: ToolRenderer = {
- render(params, result, isStreaming) {
- return {
- content: html`...
`,
- isCustom: false, // true = no card wrapper
- };
- },
-};
-
-registerToolRenderer("my_tool", myRenderer);
-```
-
-## Storage
-
-### Setup
-
-```typescript
-import {
- AppStorage,
- IndexedDBStorageBackend,
- SettingsStore,
- ProviderKeysStore,
- SessionsStore,
- CustomProvidersStore,
- setAppStorage,
- getAppStorage,
-} from "@mariozechner/pi-web-ui";
-
-// Create stores
-const settings = new SettingsStore();
-const providerKeys = new ProviderKeysStore();
-const sessions = new SessionsStore();
-const customProviders = new CustomProvidersStore();
-
-// Create backend with all store configs
-const backend = new IndexedDBStorageBackend({
- dbName: "my-app",
- version: 1,
- stores: [
- settings.getConfig(),
- providerKeys.getConfig(),
- sessions.getConfig(),
- SessionsStore.getMetadataConfig(),
- customProviders.getConfig(),
- ],
-});
-
-// Wire stores to backend
-settings.setBackend(backend);
-providerKeys.setBackend(backend);
-sessions.setBackend(backend);
-customProviders.setBackend(backend);
-
-// Create and set global storage
-const storage = new AppStorage(
- settings,
- providerKeys,
- sessions,
- customProviders,
- backend,
-);
-setAppStorage(storage);
-```
-
-### SettingsStore
-
-Key-value settings:
-
-```typescript
-await storage.settings.set("proxy.enabled", true);
-await storage.settings.set("proxy.url", "https://proxy.example.com");
-const enabled = await storage.settings.get("proxy.enabled");
-```
-
-### ProviderKeysStore
-
-API keys by provider:
-
-```typescript
-await storage.providerKeys.set("anthropic", "sk-ant-...");
-const key = await storage.providerKeys.get("anthropic");
-const providers = await storage.providerKeys.list();
-```
-
-### SessionsStore
-
-Chat sessions with metadata:
-
-```typescript
-// Save session
-await storage.sessions.save(sessionData, metadata);
-
-// Load session
-const data = await storage.sessions.get(sessionId);
-const metadata = await storage.sessions.getMetadata(sessionId);
-
-// List sessions (sorted by lastModified)
-const allMetadata = await storage.sessions.getAllMetadata();
-
-// Update title
-await storage.sessions.updateTitle(sessionId, "New Title");
-
-// Delete
-await storage.sessions.delete(sessionId);
-```
-
-### CustomProvidersStore
-
-Custom LLM providers:
-
-```typescript
-const provider: CustomProvider = {
- id: crypto.randomUUID(),
- name: "My Ollama",
- type: "ollama",
- baseUrl: "http://localhost:11434",
-};
-
-await storage.customProviders.set(provider);
-const all = await storage.customProviders.getAll();
-```
-
-## Attachments
-
-Load and process files:
-
-```typescript
-import { loadAttachment, type Attachment } from "@mariozechner/pi-web-ui";
-
-// From File input
-const file = inputElement.files[0];
-const attachment = await loadAttachment(file);
-
-// From URL
-const attachment = await loadAttachment("https://example.com/doc.pdf");
-
-// From ArrayBuffer
-const attachment = await loadAttachment(arrayBuffer, "document.pdf");
-
-// Attachment structure
-interface Attachment {
- id: string;
- type: "image" | "document";
- fileName: string;
- mimeType: string;
- size: number;
- content: string; // base64 encoded
- extractedText?: string; // For documents
- preview?: string; // base64 preview image
-}
-```
-
-Supported formats: PDF, DOCX, XLSX, PPTX, images, text files.
-
-## CORS Proxy
-
-For browser environments with CORS restrictions:
-
-```typescript
-import {
- createStreamFn,
- shouldUseProxyForProvider,
- isCorsError,
-} from "@mariozechner/pi-web-ui";
-
-// AgentInterface auto-configures proxy from settings
-// For manual setup:
-agent.streamFn = createStreamFn(async () => {
- const enabled = await storage.settings.get("proxy.enabled");
- return enabled ? await storage.settings.get("proxy.url") : undefined;
-});
-
-// Providers requiring proxy:
-// - zai: always
-// - anthropic: only OAuth tokens (sk-ant-oat-*)
-```
-
-## Dialogs
-
-### SettingsDialog
-
-```typescript
-import {
- SettingsDialog,
- ProvidersModelsTab,
- ProxyTab,
- ApiKeysTab,
-} from "@mariozechner/pi-web-ui";
-
-SettingsDialog.open([
- new ProvidersModelsTab(), // Custom providers + model list
- new ProxyTab(), // CORS proxy settings
- new ApiKeysTab(), // API keys per provider
-]);
-```
-
-### SessionListDialog
-
-```typescript
-import { SessionListDialog } from "@mariozechner/pi-web-ui";
-
-SessionListDialog.open(
- async (sessionId) => {
- /* load session */
- },
- (deletedId) => {
- /* handle deletion */
- },
-);
-```
-
-### ApiKeyPromptDialog
-
-```typescript
-import { ApiKeyPromptDialog } from "@mariozechner/pi-web-ui";
-
-const success = await ApiKeyPromptDialog.prompt("anthropic");
-```
-
-### ModelSelector
-
-```typescript
-import { ModelSelector } from "@mariozechner/pi-web-ui";
-
-ModelSelector.open(currentModel, (selectedModel) => {
- agent.setModel(selectedModel);
-});
-```
-
-## Styling
-
-Import the pre-built CSS:
-
-```typescript
-import "@mariozechner/pi-web-ui/app.css";
-```
-
-Or use Tailwind with custom config:
-
-```css
-@import "@mariozechner/mini-lit/themes/claude.css";
-@tailwind base;
-@tailwind components;
-@tailwind utilities;
-```
-
-## Internationalization
-
-```typescript
-import { i18n, setLanguage, translations } from "@mariozechner/pi-web-ui";
-
-// Add translations
-translations.de = {
- "Loading...": "Laden...",
- "No sessions yet": "Noch keine Sitzungen",
-};
-
-setLanguage("de");
-console.log(i18n("Loading...")); // "Laden..."
-```
-
-## Examples
-
-- [example/](./example) - Complete web app with sessions, artifacts, custom messages
-- [sitegeist](https://sitegeist.ai) - Browser extension using pi-web-ui
-
-## Known Issues
-
-- **PersistentStorageDialog**: Currently broken
-
-## License
-
-MIT
diff --git a/packages/web-ui/example/.gitignore b/packages/web-ui/example/.gitignore
deleted file mode 100644
index 0ca39c0..0000000
--- a/packages/web-ui/example/.gitignore
+++ /dev/null
@@ -1,3 +0,0 @@
-node_modules
-dist
-.DS_Store
diff --git a/packages/web-ui/example/README.md b/packages/web-ui/example/README.md
deleted file mode 100644
index 475c308..0000000
--- a/packages/web-ui/example/README.md
+++ /dev/null
@@ -1,61 +0,0 @@
-# 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/badlogic/mini-lit)
diff --git a/packages/web-ui/example/index.html b/packages/web-ui/example/index.html
deleted file mode 100644
index e462448..0000000
--- a/packages/web-ui/example/index.html
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
-
- Pi Web UI - Example
-
-
-
-
-
-
-
diff --git a/packages/web-ui/example/package.json b/packages/web-ui/example/package.json
deleted file mode 100644
index accceb5..0000000
--- a/packages/web-ui/example/package.json
+++ /dev/null
@@ -1,25 +0,0 @@
-{
- "name": "pi-web-ui-example",
- "version": "1.44.2",
- "private": true,
- "type": "module",
- "scripts": {
- "dev": "vite",
- "build": "vite build",
- "preview": "vite preview",
- "check": "tsgo --noEmit",
- "clean": "shx rm -rf dist"
- },
- "dependencies": {
- "@mariozechner/mini-lit": "^0.2.0",
- "@mariozechner/pi-ai": "file:../../ai",
- "@mariozechner/pi-web-ui": "file:../",
- "@tailwindcss/vite": "^4.1.17",
- "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
deleted file mode 100644
index 695386b..0000000
--- a/packages/web-ui/example/src/app.css
+++ /dev/null
@@ -1 +0,0 @@
-@import "../../dist/app.css";
diff --git a/packages/web-ui/example/src/custom-messages.ts b/packages/web-ui/example/src/custom-messages.ts
deleted file mode 100644
index c12427f..0000000
--- a/packages/web-ui/example/src/custom-messages.ts
+++ /dev/null
@@ -1,104 +0,0 @@
-import { Alert } from "@mariozechner/mini-lit/dist/Alert.js";
-import type { Message } from "@mariozechner/pi-ai";
-import type { AgentMessage, MessageRenderer } from "@mariozechner/pi-web-ui";
-import {
- defaultConvertToLlm,
- registerMessageRenderer,
-} from "@mariozechner/pi-web-ui";
-import { html } from "lit";
-
-// ============================================================================
-// 1. EXTEND AppMessage TYPE VIA DECLARATION MERGING
-// ============================================================================
-
-// Define custom message types
-export interface SystemNotificationMessage {
- role: "system-notification";
- message: string;
- variant: "default" | "destructive";
- timestamp: string;
-}
-
-// Extend CustomAgentMessages interface via declaration merging
-// This must target pi-agent-core where CustomAgentMessages is defined
-declare module "@mariozechner/pi-agent-core" {
- interface CustomAgentMessages {
- "system-notification": SystemNotificationMessage;
- }
-}
-
-// ============================================================================
-// 2. CREATE CUSTOM RENDERER (TYPED TO SystemNotificationMessage)
-// ============================================================================
-
-const systemNotificationRenderer: MessageRenderer = {
- render: (notification) => {
- // notification is fully typed as SystemNotificationMessage!
- return html`
-
- ${Alert({
- variant: notification.variant,
- children: html`
-
-
${notification.message}
-
- ${new Date(notification.timestamp).toLocaleTimeString()}
-
-
- `,
- })}
-
- `;
- },
-};
-
-// ============================================================================
-// 3. REGISTER RENDERER
-// ============================================================================
-
-export function registerCustomMessageRenderers() {
- registerMessageRenderer("system-notification", systemNotificationRenderer);
-}
-
-// ============================================================================
-// 4. HELPER TO CREATE CUSTOM MESSAGES
-// ============================================================================
-
-export function createSystemNotification(
- message: string,
- variant: "default" | "destructive" = "default",
-): SystemNotificationMessage {
- return {
- role: "system-notification",
- message,
- variant,
- timestamp: new Date().toISOString(),
- };
-}
-
-// ============================================================================
-// 5. CUSTOM MESSAGE TRANSFORMER
-// ============================================================================
-
-/**
- * Custom message transformer that extends defaultConvertToLlm.
- * Handles system-notification messages by converting them to user messages.
- */
-export function customConvertToLlm(messages: AgentMessage[]): Message[] {
- // First, handle our custom system-notification type
- const processed = messages.map((m): AgentMessage => {
- if (m.role === "system-notification") {
- const notification = m as SystemNotificationMessage;
- // Convert to user message with tags
- return {
- role: "user",
- content: `${notification.message} `,
- timestamp: Date.now(),
- };
- }
- return m;
- });
-
- // Then use defaultConvertToLlm for standard handling
- return defaultConvertToLlm(processed);
-}
diff --git a/packages/web-ui/example/src/main.ts b/packages/web-ui/example/src/main.ts
deleted file mode 100644
index 2a658dc..0000000
--- a/packages/web-ui/example/src/main.ts
+++ /dev/null
@@ -1,473 +0,0 @@
-import "@mariozechner/mini-lit/dist/ThemeToggle.js";
-import { Agent, type AgentMessage } from "@mariozechner/pi-agent-core";
-import { getModel } from "@mariozechner/pi-ai";
-import {
- type AgentState,
- ApiKeyPromptDialog,
- AppStorage,
- ChatPanel,
- CustomProvidersStore,
- createJavaScriptReplTool,
- IndexedDBStorageBackend,
- // PersistentStorageDialog, // TODO: Fix - currently broken
- ProviderKeysStore,
- ProvidersModelsTab,
- ProxyTab,
- SessionListDialog,
- SessionsStore,
- SettingsDialog,
- SettingsStore,
- setAppStorage,
-} from "@mariozechner/pi-web-ui";
-import { html, render } from "lit";
-import { Bell, History, Plus, Settings } from "lucide";
-import "./app.css";
-import { icon } from "@mariozechner/mini-lit";
-import { Button } from "@mariozechner/mini-lit/dist/Button.js";
-import { Input } from "@mariozechner/mini-lit/dist/Input.js";
-import {
- createSystemNotification,
- customConvertToLlm,
- registerCustomMessageRenderers,
-} from "./custom-messages.js";
-
-// Register custom message renderers
-registerCustomMessageRenderers();
-
-// Create stores
-const settings = new SettingsStore();
-const providerKeys = new ProviderKeysStore();
-const sessions = new SessionsStore();
-const customProviders = new CustomProvidersStore();
-
-// Gather configs
-const configs = [
- settings.getConfig(),
- SessionsStore.getMetadataConfig(),
- providerKeys.getConfig(),
- customProviders.getConfig(),
- sessions.getConfig(),
-];
-
-// Create backend
-const backend = new IndexedDBStorageBackend({
- dbName: "pi-web-ui-example",
- version: 2, // Incremented for custom-providers store
- stores: configs,
-});
-
-// Wire backend to stores
-settings.setBackend(backend);
-providerKeys.setBackend(backend);
-customProviders.setBackend(backend);
-sessions.setBackend(backend);
-
-// Create and set app storage
-const storage = new AppStorage(
- settings,
- providerKeys,
- sessions,
- customProviders,
- backend,
-);
-setAppStorage(storage);
-
-let currentSessionId: string | undefined;
-let currentTitle = "";
-let isEditingTitle = false;
-let agent: Agent;
-let chatPanel: ChatPanel;
-let agentUnsubscribe: (() => void) | undefined;
-
-const generateTitle = (messages: AgentMessage[]): string => {
- const firstUserMsg = messages.find(
- (m) => m.role === "user" || m.role === "user-with-attachments",
- );
- if (
- !firstUserMsg ||
- (firstUserMsg.role !== "user" &&
- firstUserMsg.role !== "user-with-attachments")
- )
- return "";
-
- let text = "";
- const content = firstUserMsg.content;
-
- if (typeof content === "string") {
- text = content;
- } else {
- const textBlocks = content.filter((c: any) => c.type === "text");
- text = textBlocks.map((c: any) => c.text || "").join(" ");
- }
-
- text = text.trim();
- if (!text) return "";
-
- const sentenceEnd = text.search(/[.!?]/);
- if (sentenceEnd > 0 && sentenceEnd <= 50) {
- return text.substring(0, sentenceEnd + 1);
- }
- return text.length <= 50 ? text : `${text.substring(0, 47)}...`;
-};
-
-const shouldSaveSession = (messages: AgentMessage[]): boolean => {
- const hasUserMsg = messages.some(
- (m: any) => m.role === "user" || m.role === "user-with-attachments",
- );
- const hasAssistantMsg = messages.some((m: any) => m.role === "assistant");
- return hasUserMsg && hasAssistantMsg;
-};
-
-const saveSession = async () => {
- if (!storage.sessions || !currentSessionId || !agent || !currentTitle) return;
-
- const state = agent.state;
- if (!shouldSaveSession(state.messages)) return;
-
- try {
- // Create session data
- const sessionData = {
- id: currentSessionId,
- title: currentTitle,
- model: state.model!,
- thinkingLevel: state.thinkingLevel,
- messages: state.messages,
- createdAt: new Date().toISOString(),
- lastModified: new Date().toISOString(),
- };
-
- // Create session metadata
- const metadata = {
- id: currentSessionId,
- title: currentTitle,
- createdAt: sessionData.createdAt,
- lastModified: sessionData.lastModified,
- messageCount: state.messages.length,
- usage: {
- input: 0,
- output: 0,
- cacheRead: 0,
- cacheWrite: 0,
- totalTokens: 0,
- cost: {
- input: 0,
- output: 0,
- cacheRead: 0,
- cacheWrite: 0,
- total: 0,
- },
- },
- modelId: state.model?.id || null,
- thinkingLevel: state.thinkingLevel,
- preview: generateTitle(state.messages),
- };
-
- await storage.sessions.save(sessionData, metadata);
- } catch (err) {
- console.error("Failed to save session:", err);
- }
-};
-
-const updateUrl = (sessionId: string) => {
- const url = new URL(window.location.href);
- url.searchParams.set("session", sessionId);
- window.history.replaceState({}, "", url);
-};
-
-const createAgent = async (initialState?: Partial) => {
- if (agentUnsubscribe) {
- agentUnsubscribe();
- }
-
- agent = new Agent({
- initialState: initialState || {
- systemPrompt: `You are a helpful AI assistant with access to various tools.
-
-Available tools:
-- JavaScript REPL: Execute JavaScript code in a sandboxed browser environment (can do calculations, get time, process data, create visualizations, etc.)
-- Artifacts: Create interactive HTML, SVG, Markdown, and text artifacts
-
-Feel free to use these tools when needed to provide accurate and helpful responses.`,
- model: getModel("anthropic", "claude-sonnet-4-5-20250929"),
- thinkingLevel: "off",
- messages: [],
- tools: [],
- },
- // Custom transformer: convert custom messages to LLM-compatible format
- convertToLlm: customConvertToLlm,
- });
-
- agentUnsubscribe = agent.subscribe((event: any) => {
- if (event.type === "state-update") {
- const messages = event.state.messages;
-
- // Generate title after first successful response
- if (!currentTitle && shouldSaveSession(messages)) {
- currentTitle = generateTitle(messages);
- }
-
- // Create session ID on first successful save
- if (!currentSessionId && shouldSaveSession(messages)) {
- currentSessionId = crypto.randomUUID();
- updateUrl(currentSessionId);
- }
-
- // Auto-save
- if (currentSessionId) {
- saveSession();
- }
-
- renderApp();
- }
- });
-
- await chatPanel.setAgent(agent, {
- onApiKeyRequired: async (provider: string) => {
- return await ApiKeyPromptDialog.prompt(provider);
- },
- toolsFactory: (
- _agent,
- _agentInterface,
- _artifactsPanel,
- runtimeProvidersFactory,
- ) => {
- // Create javascript_repl tool with access to attachments + artifacts
- const replTool = createJavaScriptReplTool();
- replTool.runtimeProvidersFactory = runtimeProvidersFactory;
- return [replTool];
- },
- });
-};
-
-const loadSession = async (sessionId: string): Promise => {
- if (!storage.sessions) return false;
-
- const sessionData = await storage.sessions.get(sessionId);
- if (!sessionData) {
- console.error("Session not found:", sessionId);
- return false;
- }
-
- currentSessionId = sessionId;
- const metadata = await storage.sessions.getMetadata(sessionId);
- currentTitle = metadata?.title || "";
-
- await createAgent({
- model: sessionData.model,
- thinkingLevel: sessionData.thinkingLevel,
- messages: sessionData.messages,
- tools: [],
- });
-
- updateUrl(sessionId);
- renderApp();
- return true;
-};
-
-const newSession = () => {
- const url = new URL(window.location.href);
- url.search = "";
- window.location.href = url.toString();
-};
-
-// ============================================================================
-// RENDER
-// ============================================================================
-const renderApp = () => {
- const app = document.getElementById("app");
- if (!app) return;
-
- const appHtml = html`
-
-
-
-
- ${Button({
- variant: "ghost",
- size: "sm",
- children: icon(History, "sm"),
- onClick: () => {
- SessionListDialog.open(
- async (sessionId) => {
- await loadSession(sessionId);
- },
- (deletedSessionId) => {
- // Only reload if the current session was deleted
- if (deletedSessionId === currentSessionId) {
- newSession();
- }
- },
- );
- },
- title: "Sessions",
- })}
- ${Button({
- variant: "ghost",
- size: "sm",
- children: icon(Plus, "sm"),
- onClick: newSession,
- title: "New Session",
- })}
- ${currentTitle
- ? isEditingTitle
- ? html`
- ${Input({
- type: "text",
- value: currentTitle,
- className: "text-sm w-64",
- onChange: async (e: Event) => {
- const newTitle = (
- e.target as HTMLInputElement
- ).value.trim();
- if (
- newTitle &&
- newTitle !== currentTitle &&
- storage.sessions &&
- currentSessionId
- ) {
- await storage.sessions.updateTitle(
- currentSessionId,
- newTitle,
- );
- currentTitle = newTitle;
- }
- isEditingTitle = false;
- renderApp();
- },
- onKeyDown: async (e: KeyboardEvent) => {
- if (e.key === "Enter") {
- const newTitle = (
- e.target as HTMLInputElement
- ).value.trim();
- if (
- newTitle &&
- newTitle !== currentTitle &&
- storage.sessions &&
- currentSessionId
- ) {
- await storage.sessions.updateTitle(
- currentSessionId,
- newTitle,
- );
- currentTitle = newTitle;
- }
- isEditingTitle = false;
- renderApp();
- } else if (e.key === "Escape") {
- isEditingTitle = false;
- renderApp();
- }
- },
- })}
-
`
- : html`
{
- isEditingTitle = true;
- renderApp();
- requestAnimationFrame(() => {
- const input = app?.querySelector(
- 'input[type="text"]',
- ) as HTMLInputElement;
- if (input) {
- input.focus();
- input.select();
- }
- });
- }}
- title="Click to edit title"
- >
- ${currentTitle}
- `
- : html`
Pi Web UI Example `}
-
-
- ${Button({
- variant: "ghost",
- size: "sm",
- children: icon(Bell, "sm"),
- onClick: () => {
- // Demo: Inject custom message (will appear on next agent run)
- if (agent) {
- agent.steer(
- createSystemNotification(
- "This is a custom message! It appears in the UI but is never sent to the LLM.",
- ),
- );
- }
- },
- title: "Demo: Add Custom Notification",
- })}
-
- ${Button({
- variant: "ghost",
- size: "sm",
- children: icon(Settings, "sm"),
- onClick: () =>
- SettingsDialog.open([new ProvidersModelsTab(), new ProxyTab()]),
- title: "Settings",
- })}
-
-
-
-
- ${chatPanel}
-
- `;
-
- render(appHtml, app);
-};
-
-// ============================================================================
-// INIT
-// ============================================================================
-async function initApp() {
- const app = document.getElementById("app");
- if (!app) throw new Error("App container not found");
-
- // Show loading
- render(
- html`
-
- `,
- app,
- );
-
- // TODO: Fix PersistentStorageDialog - currently broken
- // Request persistent storage
- // if (storage.sessions) {
- // await PersistentStorageDialog.request();
- // }
-
- // Create ChatPanel
- chatPanel = new ChatPanel();
-
- // Check for session in URL
- const urlParams = new URLSearchParams(window.location.search);
- const sessionIdFromUrl = urlParams.get("session");
-
- if (sessionIdFromUrl) {
- const loaded = await loadSession(sessionIdFromUrl);
- if (!loaded) {
- // Session doesn't exist, redirect to new session
- newSession();
- return;
- }
- } else {
- await createAgent();
- }
-
- renderApp();
-}
-
-initApp();
diff --git a/packages/web-ui/example/tsconfig.json b/packages/web-ui/example/tsconfig.json
deleted file mode 100644
index 0844934..0000000
--- a/packages/web-ui/example/tsconfig.json
+++ /dev/null
@@ -1,23 +0,0 @@
-{
- "compilerOptions": {
- "target": "ES2022",
- "module": "ES2022",
- "lib": ["ES2022", "DOM", "DOM.Iterable"],
- "moduleResolution": "bundler",
- "paths": {
- "*": ["./*"],
- "@mariozechner/pi-agent-core": ["../../agent/dist/index.d.ts"],
- "@mariozechner/pi-ai": ["../../ai/dist/index.d.ts"],
- "@mariozechner/pi-tui": ["../../tui/dist/index.d.ts"],
- "@mariozechner/pi-web-ui": ["../dist/index.d.ts"]
- },
- "strict": true,
- "skipLibCheck": true,
- "esModuleInterop": true,
- "allowSyntheticDefaultImports": true,
- "experimentalDecorators": true,
- "useDefineForClassFields": false
- },
- "include": ["src/**/*"],
- "exclude": ["../src"]
-}
diff --git a/packages/web-ui/example/vite.config.ts b/packages/web-ui/example/vite.config.ts
deleted file mode 100644
index 2f4c184..0000000
--- a/packages/web-ui/example/vite.config.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-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
deleted file mode 100644
index c4387b1..0000000
--- a/packages/web-ui/package.json
+++ /dev/null
@@ -1,51 +0,0 @@
-{
- "name": "@mariozechner/pi-web-ui",
- "version": "0.56.2",
- "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": "shx rm -rf dist",
- "build": "tsgo -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 --preserveWatchOutput\" \"tailwindcss -i ./src/app.css -o ./dist/app.css --watch\" \"npm run dev --prefix example\"",
- "dev:tsc": "concurrently --names \"build\" --prefix-colors \"cyan\" \"tsc -p tsconfig.build.json --watch --preserveWatchOutput\" \"tailwindcss -i ./src/app.css -o ./dist/app.css --watch\"",
- "check": "biome check --write --error-on-warnings . && tsc --noEmit && cd example && biome check --write --error-on-warnings . && tsc --noEmit"
- },
- "dependencies": {
- "@lmstudio/sdk": "^1.5.0",
- "@mariozechner/pi-ai": "^0.56.2",
- "@mariozechner/pi-tui": "^0.56.2",
- "docx-preview": "^0.3.7",
- "jszip": "^3.10.1",
- "lucide": "^0.544.0",
- "ollama": "^0.6.0",
- "pdfjs-dist": "5.4.394",
- "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
- },
- "peerDependencies": {
- "@mariozechner/mini-lit": "^0.2.0",
- "lit": "^3.3.1"
- },
- "devDependencies": {
- "@mariozechner/mini-lit": "^0.2.0",
- "@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/scripts/count-prompt-tokens.ts b/packages/web-ui/scripts/count-prompt-tokens.ts
deleted file mode 100644
index fbf15af..0000000
--- a/packages/web-ui/scripts/count-prompt-tokens.ts
+++ /dev/null
@@ -1,91 +0,0 @@
-#!/usr/bin/env tsx
-/**
- * Count tokens in system prompts using Anthropic's token counter API
- */
-
-import * as prompts from "../src/prompts/prompts.js";
-
-const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
-
-if (!ANTHROPIC_API_KEY) {
- console.error("Error: ANTHROPIC_API_KEY environment variable not set");
- process.exit(1);
-}
-
-interface TokenCountResponse {
- input_tokens: number;
-}
-
-async function countTokens(text: string): Promise {
- const response = await fetch(
- "https://api.anthropic.com/v1/messages/count_tokens",
- {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- "x-api-key": ANTHROPIC_API_KEY,
- "anthropic-version": "2023-06-01",
- },
- body: JSON.stringify({
- model: "claude-3-5-sonnet-20241022",
- messages: [
- {
- role: "user",
- content: text,
- },
- ],
- }),
- },
- );
-
- if (!response.ok) {
- const error = await response.text();
- throw new Error(`API error: ${response.status} ${error}`);
- }
-
- const data = (await response.json()) as TokenCountResponse;
- return data.input_tokens;
-}
-
-async function main() {
- console.log("Counting tokens in prompts...\n");
-
- const promptsToCount: Array<{ name: string; content: string }> = [
- {
- name: "ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RW",
- content: prompts.ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RW,
- },
- {
- name: "ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RO",
- content: prompts.ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RO,
- },
- {
- name: "ATTACHMENTS_RUNTIME_DESCRIPTION",
- content: prompts.ATTACHMENTS_RUNTIME_DESCRIPTION,
- },
- {
- name: "JAVASCRIPT_REPL_TOOL_DESCRIPTION (without runtime providers)",
- content: prompts.JAVASCRIPT_REPL_TOOL_DESCRIPTION([]),
- },
- {
- name: "ARTIFACTS_TOOL_DESCRIPTION (without runtime providers)",
- content: prompts.ARTIFACTS_TOOL_DESCRIPTION([]),
- },
- ];
-
- let total = 0;
-
- for (const prompt of promptsToCount) {
- try {
- const tokens = await countTokens(prompt.content);
- total += tokens;
- console.log(`${prompt.name}: ${tokens.toLocaleString()} tokens`);
- } catch (error) {
- console.error(`Error counting tokens for ${prompt.name}:`, error);
- }
- }
-
- console.log(`\nTotal: ${total.toLocaleString()} tokens`);
-}
-
-main();
diff --git a/packages/web-ui/src/ChatPanel.ts b/packages/web-ui/src/ChatPanel.ts
deleted file mode 100644
index e073594..0000000
--- a/packages/web-ui/src/ChatPanel.ts
+++ /dev/null
@@ -1,239 +0,0 @@
-import { Badge } from "@mariozechner/mini-lit/dist/Badge.js";
-import { html, LitElement } from "lit";
-import { customElement, state } from "lit/decorators.js";
-import "./components/AgentInterface.js";
-import type { Agent, AgentTool } from "@mariozechner/pi-agent-core";
-import type { AgentInterface } from "./components/AgentInterface.js";
-import { ArtifactsRuntimeProvider } from "./components/sandbox/ArtifactsRuntimeProvider.js";
-import { AttachmentsRuntimeProvider } from "./components/sandbox/AttachmentsRuntimeProvider.js";
-import type { SandboxRuntimeProvider } from "./components/sandbox/SandboxRuntimeProvider.js";
-import {
- ArtifactsPanel,
- ArtifactsToolRenderer,
-} from "./tools/artifacts/index.js";
-import { registerToolRenderer } from "./tools/renderer-registry.js";
-import type { Attachment } from "./utils/attachment-utils.js";
-import { i18n } from "./utils/i18n.js";
-
-const BREAKPOINT = 800; // px - switch between overlay and side-by-side
-
-@customElement("pi-chat-panel")
-export class ChatPanel extends LitElement {
- @state() public agent?: Agent;
- @state() public agentInterface?: AgentInterface;
- @state() public artifactsPanel?: ArtifactsPanel;
- @state() private hasArtifacts = false;
- @state() private artifactCount = 0;
- @state() private showArtifactsPanel = false;
- @state() private windowWidth = 0;
-
- private resizeHandler = () => {
- this.windowWidth = window.innerWidth;
- this.requestUpdate();
- };
-
- createRenderRoot() {
- return this;
- }
-
- override connectedCallback() {
- super.connectedCallback();
- this.windowWidth = window.innerWidth; // Set initial width after connection
- window.addEventListener("resize", this.resizeHandler);
- this.style.display = "flex";
- this.style.flexDirection = "column";
- this.style.height = "100%";
- this.style.minHeight = "0";
- // Update width after initial render
- requestAnimationFrame(() => {
- this.windowWidth = window.innerWidth;
- this.requestUpdate();
- });
- }
-
- override disconnectedCallback() {
- super.disconnectedCallback();
- window.removeEventListener("resize", this.resizeHandler);
- }
-
- async setAgent(
- agent: Agent,
- config?: {
- onApiKeyRequired?: (provider: string) => Promise;
- onBeforeSend?: () => void | Promise;
- onCostClick?: () => void;
- sandboxUrlProvider?: () => string;
- toolsFactory?: (
- agent: Agent,
- agentInterface: AgentInterface,
- artifactsPanel: ArtifactsPanel,
- runtimeProvidersFactory: () => SandboxRuntimeProvider[],
- ) => AgentTool[];
- },
- ) {
- this.agent = agent;
-
- // Create AgentInterface
- this.agentInterface = document.createElement(
- "agent-interface",
- ) as AgentInterface;
- this.agentInterface.session = agent;
- this.agentInterface.enableAttachments = true;
- this.agentInterface.enableModelSelector = true;
- this.agentInterface.enableThinkingSelector = true;
- this.agentInterface.showThemeToggle = false;
- this.agentInterface.onApiKeyRequired = config?.onApiKeyRequired;
- this.agentInterface.onBeforeSend = config?.onBeforeSend;
- this.agentInterface.onCostClick = config?.onCostClick;
-
- // Set up artifacts panel
- this.artifactsPanel = new ArtifactsPanel();
- this.artifactsPanel.agent = agent; // Pass agent for HTML artifact runtime providers
- if (config?.sandboxUrlProvider) {
- this.artifactsPanel.sandboxUrlProvider = config.sandboxUrlProvider;
- }
- // Register the standalone tool renderer (not the panel itself)
- registerToolRenderer(
- "artifacts",
- new ArtifactsToolRenderer(this.artifactsPanel),
- );
-
- // Runtime providers factory for REPL tools (read-write access)
- const runtimeProvidersFactory = () => {
- const attachments: Attachment[] = [];
- for (const message of this.agent!.state.messages) {
- if (message.role === "user-with-attachments") {
- message.attachments?.forEach((a) => {
- attachments.push(a);
- });
- }
- }
- const providers: SandboxRuntimeProvider[] = [];
-
- // Add attachments provider if there are attachments
- if (attachments.length > 0) {
- providers.push(new AttachmentsRuntimeProvider(attachments));
- }
-
- // Add artifacts provider with read-write access (for REPL)
- providers.push(
- new ArtifactsRuntimeProvider(this.artifactsPanel!, this.agent!, true),
- );
-
- return providers;
- };
-
- this.artifactsPanel.onArtifactsChange = () => {
- const count = this.artifactsPanel?.artifacts?.size ?? 0;
- const created = count > this.artifactCount;
- this.hasArtifacts = count > 0;
- this.artifactCount = count;
- if (this.hasArtifacts && created) {
- this.showArtifactsPanel = true;
- }
- this.requestUpdate();
- };
-
- this.artifactsPanel.onClose = () => {
- this.showArtifactsPanel = false;
- this.requestUpdate();
- };
-
- this.artifactsPanel.onOpen = () => {
- this.showArtifactsPanel = true;
- this.requestUpdate();
- };
-
- // Set tools on the agent
- // Pass runtimeProvidersFactory so consumers can configure their own REPL tools
- const additionalTools =
- config?.toolsFactory?.(
- agent,
- this.agentInterface,
- this.artifactsPanel,
- runtimeProvidersFactory,
- ) || [];
- const tools = [this.artifactsPanel.tool, ...additionalTools];
- this.agent.setTools(tools);
-
- // Reconstruct artifacts from existing messages
- // Temporarily disable the onArtifactsChange callback to prevent auto-opening on load
- const originalCallback = this.artifactsPanel.onArtifactsChange;
- this.artifactsPanel.onArtifactsChange = undefined;
- await this.artifactsPanel.reconstructFromMessages(
- this.agent.state.messages,
- );
- this.artifactsPanel.onArtifactsChange = originalCallback;
-
- this.hasArtifacts = this.artifactsPanel.artifacts.size > 0;
- this.artifactCount = this.artifactsPanel.artifacts.size;
-
- this.requestUpdate();
- }
-
- render() {
- if (!this.agent || !this.agentInterface) {
- return html``;
- }
-
- const isMobile = this.windowWidth < BREAKPOINT;
-
- // Set panel props
- if (this.artifactsPanel) {
- this.artifactsPanel.collapsed = !this.showArtifactsPanel;
- this.artifactsPanel.overlay = isMobile;
- }
-
- return html`
-
-
- ${this.agentInterface}
-
-
-
- ${this.hasArtifacts && !this.showArtifactsPanel
- ? html`
-
{
- this.showArtifactsPanel = true;
- this.requestUpdate();
- }}
- title=${i18n("Show artifacts")}
- >
- ${Badge(html`
-
- ${i18n("Artifacts")}
- ${this.artifactCount}
-
- `)}
-
- `
- : ""}
-
-
- ${this.artifactsPanel}
-
-
- `;
- }
-}
diff --git a/packages/web-ui/src/app.css b/packages/web-ui/src/app.css
deleted file mode 100644
index c8ddc30..0000000
--- a/packages/web-ui/src/app.css
+++ /dev/null
@@ -1,68 +0,0 @@
-/* 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);
-}
-
-/* Fix cursor for dialog close buttons */
-.fixed.inset-0 button[aria-label*="Close"],
-.fixed.inset-0 button[type="button"] {
- cursor: pointer;
-}
-
-/* Shimmer animation for thinking text */
-@keyframes shimmer {
- 0% {
- background-position: -200% 0;
- }
- 100% {
- background-position: 200% 0;
- }
-}
-
-.animate-shimmer {
- animation: shimmer 2s ease-in-out infinite;
-}
-
-/* User message with fancy pill styling */
-.user-message-container {
- transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
- position: relative;
- background: linear-gradient(135deg, rgba(217, 79, 0, 0.12), rgba(255, 107, 0, 0.12), rgba(212, 165, 0, 0.12));
- border: 1px solid rgba(255, 107, 0, 0.25);
- backdrop-filter: blur(10px);
- max-width: 100%;
-}
diff --git a/packages/web-ui/src/components/AgentInterface.ts b/packages/web-ui/src/components/AgentInterface.ts
deleted file mode 100644
index 98eda8b..0000000
--- a/packages/web-ui/src/components/AgentInterface.ts
+++ /dev/null
@@ -1,428 +0,0 @@
-import {
- streamSimple,
- type ToolResultMessage,
- type Usage,
-} from "@mariozechner/pi-ai";
-import { html, LitElement } from "lit";
-import { customElement, property, query } from "lit/decorators.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 { getAppStorage } from "../storage/app-storage.js";
-import "./StreamingMessageContainer.js";
-import type { Agent, AgentEvent } from "@mariozechner/pi-agent-core";
-import type { Attachment } from "../utils/attachment-utils.js";
-import { formatUsage } from "../utils/format.js";
-import { i18n } from "../utils/i18n.js";
-import { createStreamFn } from "../utils/proxy-utils.js";
-import type { UserMessageWithAttachments } from "./Messages.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?: Agent;
- @property({ type: Boolean }) enableAttachments = true;
- @property({ type: Boolean }) enableModelSelector = true;
- @property({ type: Boolean }) enableThinkingSelector = true;
- @property({ type: Boolean }) showThemeToggle = false;
- // Optional custom API key prompt handler - if not provided, uses default dialog
- @property({ attribute: false }) onApiKeyRequired?: (
- provider: string,
- ) => Promise;
- // Optional callback called before sending a message
- @property({ attribute: false }) onBeforeSend?: () => void | Promise;
- // Optional callback called before executing a tool call - return false to prevent execution
- @property({ attribute: false }) onBeforeToolCall?: (
- toolName: string,
- args: any,
- ) => boolean | Promise;
- // Optional callback called when cost display is clicked
- @property({ attribute: false }) onCostClick?: () => void;
-
- // 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();
- }
-
- public setAutoScroll(enabled: boolean) {
- this._autoScroll = enabled;
- }
-
- protected override createRenderRoot(): HTMLElement | DocumentFragment {
- return this;
- }
-
- override willUpdate(changedProperties: Map) {
- super.willUpdate(changedProperties);
-
- // Re-subscribe when session property changes
- if (changedProperties.has("session")) {
- this.setupSessionSubscription();
- }
- }
-
- 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();
- }
-
- 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;
-
- // Set default streamFn with proxy support if not already set
- if (this.session.streamFn === streamSimple) {
- this.session.streamFn = createStreamFn(async () => {
- const enabled =
- await getAppStorage().settings.get("proxy.enabled");
- return enabled
- ? (await getAppStorage().settings.get("proxy.url")) ||
- undefined
- : undefined;
- });
- }
-
- // Set default getApiKey if not already set
- if (!this.session.getApiKey) {
- this.session.getApiKey = async (provider: string) => {
- const key = await getAppStorage().providerKeys.get(provider);
- return key ?? undefined;
- };
- }
-
- this._unsubscribeSession = this.session.subscribe(
- async (ev: AgentEvent) => {
- switch (ev.type) {
- case "message_start":
- case "message_end":
- case "turn_start":
- case "turn_end":
- case "agent_start":
- this.requestUpdate();
- break;
- case "agent_end":
- // Clear streaming container when agent finishes
- if (this._streamingContainer) {
- this._streamingContainer.isStreaming = false;
- this._streamingContainer.setMessage(null, true);
- }
- this.requestUpdate();
- break;
- case "message_update":
- if (this._streamingContainer) {
- const isStreaming = this.session?.state.isStreaming || false;
- this._streamingContainer.isStreaming = isStreaming;
- this._streamingContainer.setMessage(ev.message, !isStreaming);
- }
- this.requestUpdate();
- break;
- }
- },
- );
- }
-
- 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;
- const apiKey = await getAppStorage().providerKeys.get(provider);
-
- // If no API key, prompt for it
- if (!apiKey) {
- if (!this.onApiKeyRequired) {
- console.error(
- "No API key configured and no onApiKeyRequired handler set",
- );
- return;
- }
-
- const success = await this.onApiKeyRequired(provider);
-
- // If still no API key, abort the send
- if (!success) {
- return;
- }
- }
-
- // Call onBeforeSend hook before sending
- if (this.onBeforeSend) {
- await this.onBeforeSend();
- }
-
- // 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
-
- // Compose message with attachments if any
- if (attachments && attachments.length > 0) {
- const message: UserMessageWithAttachments = {
- role: "user-with-attachments",
- content: input,
- attachments,
- timestamp: Date.now(),
- };
- await this.session?.prompt(message);
- } else {
- await this.session?.prompt(input);
- }
- }
-
- 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}
- .onCostClick=${this.onCostClick}
- >
-
-
-
-
- `;
- }
-
- 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,
- totalTokens: 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
- ? this.onCostClick
- ? 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.enableThinkingSelector
- ? (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
deleted file mode 100644
index 7139a2e..0000000
--- a/packages/web-ui/src/components/AttachmentTile.ts
+++ /dev/null
@@ -1,107 +0,0 @@
-import { icon } from "@mariozechner/mini-lit/dist/icons.js";
-import { LitElement } from "lit";
-import { customElement, property } from "lit/decorators.js";
-import { html } from "lit/html.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 isExcel =
- this.attachment.mimeType?.includes("spreadsheetml") ||
- this.attachment.fileName.toLowerCase().endsWith(".xlsx") ||
- this.attachment.fileName.toLowerCase().endsWith(".xls");
-
- // Choose the appropriate icon
- const getDocumentIcon = () => {
- if (isExcel) return icon(FileSpreadsheet, "md");
- return icon(FileText, "md");
- };
-
- return html`
-
- ${hasPreview
- ? html`
-
-
- ${isPdf
- ? html`
-
-
- `
- : ""}
-
- `
- : html`
-
-
- ${getDocumentIcon()}
-
- ${this.attachment.fileName.length > 10
- ? `${this.attachment.fileName.substring(0, 8)}...`
- : this.attachment.fileName}
-
-
- `}
- ${this.showDelete
- ? html`
-
{
- e.stopPropagation();
- this.onDelete?.();
- }}
- class="absolute -top-1 -right-1 w-5 h-5 bg-background hover:bg-muted text-muted-foreground hover:text-foreground rounded-full flex items-center justify-center opacity-100 hover:opacity-100 [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover:opacity-100 transition-opacity border border-input shadow-sm"
- title="${i18n("Remove")}"
- >
- ${icon(X, "xs")}
-
- `
- : ""}
-
- `;
- }
-}
diff --git a/packages/web-ui/src/components/ConsoleBlock.ts b/packages/web-ui/src/components/ConsoleBlock.ts
deleted file mode 100644
index ffcb329..0000000
--- a/packages/web-ui/src/components/ConsoleBlock.ts
+++ /dev/null
@@ -1,80 +0,0 @@
-import { icon } from "@mariozechner/mini-lit";
-import { LitElement } from "lit";
-import { property, state } from "lit/decorators.js";
-import { html } from "lit/html.js";
-import { Check, Copy } from "lucide";
-import { i18n } from "../utils/i18n.js";
-
-export class ConsoleBlock extends LitElement {
- @property() content: string = "";
- @property() variant: "default" | "error" = "default";
- @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() {
- const isError = this.variant === "error";
- const textClass = isError ? "text-destructive" : "text-foreground";
-
- return html`
-
-
- ${i18n("console")}
- this.copy()}
- class="flex items-center gap-1 px-2 py-0.5 text-xs rounded hover:bg-accent text-muted-foreground hover:text-accent-foreground transition-colors"
- title="${i18n("Copy output")}"
- >
- ${this.copied ? icon(Check, "sm") : icon(Copy, "sm")}
- ${this.copied ? html`${i18n("Copied!")} ` : ""}
-
-
-
-
- `;
- }
-}
-
-// Register custom element
-if (!customElements.get("console-block")) {
- customElements.define("console-block", ConsoleBlock);
-}
diff --git a/packages/web-ui/src/components/CustomProviderCard.ts b/packages/web-ui/src/components/CustomProviderCard.ts
deleted file mode 100644
index ee6edd4..0000000
--- a/packages/web-ui/src/components/CustomProviderCard.ts
+++ /dev/null
@@ -1,99 +0,0 @@
-import { i18n } from "@mariozechner/mini-lit";
-import { Button } from "@mariozechner/mini-lit/dist/Button.js";
-import { html, LitElement, type TemplateResult } from "lit";
-import { customElement, property } from "lit/decorators.js";
-import type { CustomProvider } from "../storage/stores/custom-providers-store.js";
-
-@customElement("custom-provider-card")
-export class CustomProviderCard extends LitElement {
- @property({ type: Object }) provider!: CustomProvider;
- @property({ type: Boolean }) isAutoDiscovery = false;
- @property({ type: Object }) status?: {
- modelCount: number;
- status: "connected" | "disconnected" | "checking";
- };
- @property() onRefresh?: (provider: CustomProvider) => void;
- @property() onEdit?: (provider: CustomProvider) => void;
- @property() onDelete?: (provider: CustomProvider) => void;
-
- protected createRenderRoot() {
- return this;
- }
-
- private renderStatus(): TemplateResult {
- if (!this.isAutoDiscovery) {
- return html`
-
- ${i18n("Models")}: ${this.provider.models?.length || 0}
-
- `;
- }
-
- if (!this.status) return html``;
-
- const statusIcon =
- this.status.status === "connected"
- ? html`● `
- : this.status.status === "checking"
- ? html`● `
- : html`● `;
-
- const statusText =
- this.status.status === "connected"
- ? `${this.status.modelCount} ${i18n("models")}`
- : this.status.status === "checking"
- ? i18n("Checking...")
- : i18n("Disconnected");
-
- return html`
-
- ${statusIcon} ${statusText}
-
- `;
- }
-
- render(): TemplateResult {
- return html`
-
-
-
-
- ${this.provider.name}
-
-
- ${this.provider.type}
- ${this.provider.baseUrl ? html` • ${this.provider.baseUrl}` : ""}
-
- ${this.renderStatus()}
-
-
- ${this.isAutoDiscovery && this.onRefresh
- ? Button({
- onClick: () => this.onRefresh?.(this.provider),
- variant: "ghost",
- size: "sm",
- children: i18n("Refresh"),
- })
- : ""}
- ${this.onEdit
- ? Button({
- onClick: () => this.onEdit?.(this.provider),
- variant: "ghost",
- size: "sm",
- children: i18n("Edit"),
- })
- : ""}
- ${this.onDelete
- ? Button({
- onClick: () => this.onDelete?.(this.provider),
- variant: "ghost",
- size: "sm",
- children: i18n("Delete"),
- })
- : ""}
-
-
-
- `;
- }
-}
diff --git a/packages/web-ui/src/components/ExpandableSection.ts b/packages/web-ui/src/components/ExpandableSection.ts
deleted file mode 100644
index 1282921..0000000
--- a/packages/web-ui/src/components/ExpandableSection.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-import { icon } from "@mariozechner/mini-lit";
-import { html, LitElement, type TemplateResult } from "lit";
-import { customElement, property, state } from "lit/decorators.js";
-import { ChevronDown, ChevronRight } from "lucide";
-
-/**
- * Reusable expandable section component for tool renderers.
- * Captures children in connectedCallback and re-renders them in the details area.
- */
-@customElement("expandable-section")
-export class ExpandableSection extends LitElement {
- @property() summary!: string;
- @property({ type: Boolean }) defaultExpanded = false;
- @state() private expanded = false;
- private capturedChildren: Node[] = [];
-
- protected createRenderRoot() {
- return this; // light DOM
- }
-
- override connectedCallback() {
- super.connectedCallback();
- // Capture children before first render
- this.capturedChildren = Array.from(this.childNodes);
- // Clear children (we'll re-insert them in render)
- this.innerHTML = "";
- this.expanded = this.defaultExpanded;
- }
-
- override render(): TemplateResult {
- return html`
-
-
{
- this.expanded = !this.expanded;
- }}
- class="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors w-full text-left"
- >
- ${icon(this.expanded ? ChevronDown : ChevronRight, "sm")}
- ${this.summary}
-
- ${this.expanded
- ? html`
${this.capturedChildren}
`
- : ""}
-
- `;
- }
-}
diff --git a/packages/web-ui/src/components/Input.ts b/packages/web-ui/src/components/Input.ts
deleted file mode 100644
index 1e6a0e3..0000000
--- a/packages/web-ui/src/components/Input.ts
+++ /dev/null
@@ -1,128 +0,0 @@
-import {
- type BaseComponentProps,
- fc,
-} from "@mariozechner/mini-lit/dist/mini.js";
-import { html } from "lit";
-import { type Ref, ref } from "lit/directives/ref.js";
-import { i18n } from "../utils/i18n.js";
-
-export type InputType =
- | "text"
- | "email"
- | "password"
- | "number"
- | "url"
- | "tel"
- | "search";
-export type InputSize = "sm" | "md" | "lg";
-
-export interface InputProps extends BaseComponentProps {
- type?: InputType;
- size?: InputSize;
- value?: string;
- placeholder?: string;
- label?: string;
- error?: string;
- disabled?: boolean;
- required?: boolean;
- name?: string;
- autocomplete?: string;
- min?: number;
- max?: number;
- step?: number;
- inputRef?: Ref;
- onInput?: (e: Event) => void;
- onChange?: (e: Event) => void;
- onKeyDown?: (e: KeyboardEvent) => void;
- onKeyUp?: (e: KeyboardEvent) => void;
-}
-
-export const Input = fc(
- ({
- type = "text",
- size = "md",
- value = "",
- placeholder = "",
- label = "",
- error = "",
- disabled = false,
- required = false,
- name = "",
- autocomplete = "",
- min,
- max,
- step,
- inputRef,
- onInput,
- onChange,
- onKeyDown,
- onKeyUp,
- className = "",
- }) => {
- const sizeClasses = {
- sm: "h-8 px-3 py-1 text-sm",
- md: "h-9 px-3 py-1 text-sm md:text-sm",
- lg: "h-10 px-4 py-1 text-base",
- };
-
- const baseClasses =
- "flex w-full min-w-0 rounded-md border bg-transparent text-foreground shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium";
- const interactionClasses =
- "placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground";
- const focusClasses =
- "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]";
- const darkClasses = "dark:bg-input/30";
- const stateClasses = error
- ? "border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40"
- : "border-input";
- const disabledClasses =
- "disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50";
-
- const handleInput = (e: Event) => {
- onInput?.(e);
- };
-
- const handleChange = (e: Event) => {
- onChange?.(e);
- };
-
- return html`
-
- ${label
- ? html`
-
- ${label}
- ${required
- ? html`${i18n("*")} `
- : ""}
-
- `
- : ""}
-
- ${error
- ? html`${error} `
- : ""}
-
- `;
- },
-);
diff --git a/packages/web-ui/src/components/MessageEditor.ts b/packages/web-ui/src/components/MessageEditor.ts
deleted file mode 100644
index a8f2e02..0000000
--- a/packages/web-ui/src/components/MessageEditor.ts
+++ /dev/null
@@ -1,444 +0,0 @@
-import { icon } from "@mariozechner/mini-lit";
-import { Button } from "@mariozechner/mini-lit/dist/Button.js";
-import {
- Select,
- type SelectOption,
-} from "@mariozechner/mini-lit/dist/Select.js";
-import type { Model } from "@mariozechner/pi-ai";
-import { html, LitElement } from "lit";
-import { customElement, property, state } from "lit/decorators.js";
-import { createRef, ref } from "lit/directives/ref.js";
-import { Brain, Loader2, Paperclip, Send, Sparkles, Square } from "lucide";
-import { type Attachment, loadAttachment } from "../utils/attachment-utils.js";
-import { i18n } from "../utils/i18n.js";
-import "./AttachmentTile.js";
-import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
-
-@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);
- }
-
- @property() isStreaming = false;
- @property() currentModel?: Model;
- @property() thinkingLevel: ThinkingLevel = "off";
- @property() showAttachmentButton = true;
- @property() showModelSelector = true;
- @property() showThinkingSelector = true;
- @property() onInput?: (value: string) => void;
- @property() onSend?: (input: string, attachments: Attachment[]) => void;
- @property() onAbort?: () => void;
- @property() onModelSelect?: () => void;
- @property() onThinkingChange?: (
- level: "off" | "minimal" | "low" | "medium" | "high",
- ) => 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;
- @state() isDragging = 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;
- 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 handlePaste = async (e: ClipboardEvent) => {
- const items = e.clipboardData?.items;
- if (!items) return;
-
- const imageFiles: File[] = [];
-
- // Check for image items in clipboard
- for (let i = 0; i < items.length; i++) {
- const item = items[i];
- if (item.type.startsWith("image/")) {
- const file = item.getAsFile();
- if (file) {
- imageFiles.push(file);
- }
- }
- }
-
- // If we found images, process them
- if (imageFiles.length > 0) {
- e.preventDefault(); // Prevent default paste behavior
-
- if (imageFiles.length + this.attachments.length > this.maxFiles) {
- alert(`Maximum ${this.maxFiles} files allowed`);
- return;
- }
-
- this.processingFiles = true;
- const newAttachments: Attachment[] = [];
-
- for (const file of imageFiles) {
- try {
- if (file.size > this.maxFileSize) {
- alert(
- `Image 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 pasted image:", error);
- alert(`Failed to process pasted image: ${String(error)}`);
- }
- }
-
- this.attachments = [...this.attachments, ...newAttachments];
- this.onFilesChange?.(this.attachments);
- this.processingFiles = false;
- }
- };
-
- 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 handleDragOver = (e: DragEvent) => {
- e.preventDefault();
- e.stopPropagation();
- if (!this.isDragging) {
- this.isDragging = true;
- }
- };
-
- private handleDragLeave = (e: DragEvent) => {
- e.preventDefault();
- e.stopPropagation();
- // Only set isDragging to false if we're leaving the entire component
- const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
- const x = e.clientX;
- const y = e.clientY;
- if (
- x <= rect.left ||
- x >= rect.right ||
- y <= rect.top ||
- y >= rect.bottom
- ) {
- this.isDragging = false;
- }
- };
-
- private handleDrop = async (e: DragEvent) => {
- e.preventDefault();
- e.stopPropagation();
- this.isDragging = false;
-
- const files = Array.from(e.dataTransfer?.files || []);
- if (files.length === 0) return;
-
- if (files.length + this.attachments.length > this.maxFiles) {
- alert(`Maximum ${this.maxFiles} files allowed`);
- 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;
- };
-
- override firstUpdated() {
- const textarea = this.textareaRef.value;
- if (textarea) {
- textarea.focus();
- }
- }
-
- override render() {
- // Check if current model supports thinking/reasoning
- const model = this.currentModel;
- const supportsThinking = model?.reasoning === true; // Models with reasoning:true support thinking
-
- return html`
-
-
- ${this.isDragging
- ? html`
-
-
- ${i18n("Drop files here")}
-
-
- `
- : ""}
-
-
- ${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"),
- })}
- `
- : ""}
- ${supportsThinking && this.showThinkingSelector
- ? html`
- ${Select({
- value: this.thinkingLevel,
- placeholder: i18n("Off"),
- options: [
- {
- value: "off",
- label: i18n("Off"),
- icon: icon(Brain, "sm"),
- },
- {
- value: "minimal",
- label: i18n("Minimal"),
- icon: icon(Brain, "sm"),
- },
- {
- value: "low",
- label: i18n("Low"),
- icon: icon(Brain, "sm"),
- },
- {
- value: "medium",
- label: i18n("Medium"),
- icon: icon(Brain, "sm"),
- },
- {
- value: "high",
- label: i18n("High"),
- icon: icon(Brain, "sm"),
- },
- ] as SelectOption[],
- onChange: (value: string) => {
- this.onThinkingChange?.(
- value as "off" | "minimal" | "low" | "medium" | "high",
- );
- },
- width: "80px",
- size: "sm",
- variant: "ghost",
- fitContent: true,
- })}
- `
- : ""}
-
-
-
-
- ${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
deleted file mode 100644
index 58d3062..0000000
--- a/packages/web-ui/src/components/MessageList.ts
+++ /dev/null
@@ -1,98 +0,0 @@
-import type { AgentMessage, AgentTool } from "@mariozechner/pi-agent-core";
-import type {
- AssistantMessage as AssistantMessageType,
- ToolResultMessage as ToolResultMessageType,
-} from "@mariozechner/pi-ai";
-import { html, LitElement, type TemplateResult } from "lit";
-import { property } from "lit/decorators.js";
-import { repeat } from "lit/directives/repeat.js";
-import { renderMessage } from "./message-renderer-registry.js";
-
-export class MessageList extends LitElement {
- @property({ type: Array }) messages: AgentMessage[] = [];
- @property({ type: Array }) tools: AgentTool[] = [];
- @property({ type: Object }) pendingToolCalls?: Set;
- @property({ type: Boolean }) isStreaming: boolean = false;
- @property({ attribute: false }) onCostClick?: () => void;
-
- 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) {
- // Skip artifact messages - they're for session persistence only, not UI display
- if (msg.role === "artifact") {
- continue;
- }
-
- // Try custom renderer first
- const customTemplate = renderMessage(msg);
- if (customTemplate) {
- items.push({ key: `msg:${index}`, template: customTemplate });
- index++;
- continue;
- }
-
- // Fall back to built-in renderers
- if (msg.role === "user" || msg.role === "user-with-attachments") {
- 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
- // Skip unknown roles
- }
- }
- 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
deleted file mode 100644
index df5256f..0000000
--- a/packages/web-ui/src/components/Messages.ts
+++ /dev/null
@@ -1,436 +0,0 @@
-import type {
- AssistantMessage as AssistantMessageType,
- ImageContent,
- TextContent,
- ToolCall,
- ToolResultMessage as ToolResultMessageType,
- UserMessage as UserMessageType,
-} from "@mariozechner/pi-ai";
-import { html, LitElement, type TemplateResult } from "lit";
-import { customElement, property } from "lit/decorators.js";
-import { renderTool } from "../tools/index.js";
-import type { Attachment } from "../utils/attachment-utils.js";
-import { formatUsage } from "../utils/format.js";
-import { i18n } from "../utils/i18n.js";
-import "./ThinkingBlock.js";
-import type { AgentTool } from "@mariozechner/pi-agent-core";
-
-export type UserMessageWithAttachments = {
- role: "user-with-attachments";
- content: string | (TextContent | ImageContent)[];
- timestamp: number;
- attachments?: Attachment[];
-};
-
-// Artifact message type for session persistence
-export interface ArtifactMessage {
- role: "artifact";
- action: "create" | "update" | "delete";
- filename: string;
- content?: string;
- title?: string;
- timestamp: string;
-}
-
-declare module "@mariozechner/pi-agent-core" {
- interface CustomAgentMessages {
- "user-with-attachments": UserMessageWithAttachments;
- artifact: ArtifactMessage;
- }
-}
-
-@customElement("user-message")
-export class UserMessage extends LitElement {
- @property({ type: Object }) message!:
- | UserMessageWithAttachments
- | UserMessageType;
-
- 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.role === "user-with-attachments" &&
- 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<
- string,
- ToolResultMessageType
- >;
- @property({ type: Boolean }) isStreaming: boolean = false;
- @property({ type: Boolean }) hidePendingToolCalls = false;
- @property({ attribute: false }) onCostClick?: () => void;
-
- 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);
- // Skip rendering pending tool calls when hidePendingToolCalls is true
- // (used to prevent duplication when StreamingMessageContainer is showing them)
- if (this.hidePendingToolCalls && pending && !result) {
- continue;
- }
- // A tool call is aborted if the message was aborted and there's no result for this tool call
- const aborted = this.message.stopReason === "aborted" && !result;
- orderedParts.push(
- html` `,
- );
- }
- }
- }
-
- return html`
-
- ${orderedParts.length
- ? html`
${orderedParts}
`
- : ""}
- ${this.message.usage && !this.isStreaming
- ? this.onCostClick
- ? html`
-
- ${formatUsage(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: Object }) result?: ToolResultMessageType;
- @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 textOutput =
- this.result?.content
- ?.filter((c) => c.type === "text")
- .map((c: any) => c.text)
- .join("\n") || "";
- const output = this.pretty(textOutput);
- 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;
-
- protected override createRenderRoot(): HTMLElement | DocumentFragment {
- return this;
- }
-
- override connectedCallback(): void {
- super.connectedCallback();
- this.style.display = "block";
- }
-
- override render() {
- const toolName = this.tool?.name || this.toolCall.name;
-
- // Render tool content (renderer handles errors and styling)
- const result: ToolResultMessageType | undefined = this.aborted
- ? {
- role: "toolResult",
- isError: true,
- content: [],
- toolCallId: this.toolCall.id,
- toolName: this.toolCall.name,
- timestamp: Date.now(),
- }
- : this.result;
- const renderResult = renderTool(
- toolName,
- this.toolCall.arguments,
- result,
- !this.aborted && (this.isStreaming || this.pending),
- );
-
- // Handle custom rendering (no card wrapper)
- if (renderResult.isCustom) {
- return renderResult.content;
- }
-
- // Default: wrap in card
- return html`
-
- ${renderResult.content}
-
- `;
- }
-}
-
-@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")} `;
- }
-}
-
-// ============================================================================
-// Default Message Transformer
-// ============================================================================
-
-import type { AgentMessage } from "@mariozechner/pi-agent-core";
-import type { Message } from "@mariozechner/pi-ai";
-
-/**
- * Convert attachments to content blocks for LLM.
- * - Images become ImageContent blocks
- * - Documents with extractedText become TextContent blocks with filename header
- */
-export function convertAttachments(
- attachments: Attachment[],
-): (TextContent | ImageContent)[] {
- const content: (TextContent | ImageContent)[] = [];
- for (const attachment of attachments) {
- if (attachment.type === "image") {
- content.push({
- type: "image",
- data: attachment.content,
- mimeType: attachment.mimeType,
- } as ImageContent);
- } else if (attachment.type === "document" && attachment.extractedText) {
- content.push({
- type: "text",
- text: `\n\n[Document: ${attachment.fileName}]\n${attachment.extractedText}`,
- } as TextContent);
- }
- }
- return content;
-}
-
-/**
- * Check if a message is a UserMessageWithAttachments.
- */
-export function isUserMessageWithAttachments(
- msg: AgentMessage,
-): msg is UserMessageWithAttachments {
- return (msg as UserMessageWithAttachments).role === "user-with-attachments";
-}
-
-/**
- * Check if a message is an ArtifactMessage.
- */
-export function isArtifactMessage(msg: AgentMessage): msg is ArtifactMessage {
- return (msg as ArtifactMessage).role === "artifact";
-}
-
-/**
- * Default convertToLlm for web-ui apps.
- *
- * Handles:
- * - UserMessageWithAttachments: converts to user message with content blocks
- * - ArtifactMessage: filtered out (UI-only, for session reconstruction)
- * - Standard LLM messages (user, assistant, toolResult): passed through
- */
-export function defaultConvertToLlm(messages: AgentMessage[]): Message[] {
- return messages
- .filter((m) => {
- // Filter out artifact messages - they're for session reconstruction only
- if (isArtifactMessage(m)) {
- return false;
- }
- return true;
- })
- .map((m): Message | null => {
- // Convert user-with-attachments to user message with content blocks
- if (isUserMessageWithAttachments(m)) {
- const textContent: (TextContent | ImageContent)[] =
- typeof m.content === "string"
- ? [{ type: "text", text: m.content }]
- : [...m.content];
-
- if (m.attachments) {
- textContent.push(...convertAttachments(m.attachments));
- }
-
- return {
- role: "user",
- content: textContent,
- timestamp: m.timestamp,
- } as Message;
- }
-
- // Pass through standard LLM roles
- if (
- m.role === "user" ||
- m.role === "assistant" ||
- m.role === "toolResult"
- ) {
- return m as Message;
- }
-
- // Filter out unknown message types
- return null;
- })
- .filter((m): m is Message => m !== null);
-}
diff --git a/packages/web-ui/src/components/ProviderKeyInput.ts b/packages/web-ui/src/components/ProviderKeyInput.ts
deleted file mode 100644
index e4ccc71..0000000
--- a/packages/web-ui/src/components/ProviderKeyInput.ts
+++ /dev/null
@@ -1,165 +0,0 @@
-import { i18n } from "@mariozechner/mini-lit";
-import { Badge } from "@mariozechner/mini-lit/dist/Badge.js";
-import { Button } from "@mariozechner/mini-lit/dist/Button.js";
-import { type Context, complete, getModel } from "@mariozechner/pi-ai";
-import { html, LitElement } from "lit";
-import { customElement, property, state } from "lit/decorators.js";
-import { getAppStorage } from "../storage/app-storage.js";
-import { applyProxyIfNeeded } from "../utils/proxy-utils.js";
-import { Input } from "./Input.js";
-
-// Test models for each provider
-const TEST_MODELS: Record = {
- anthropic: "claude-3-5-haiku-20241022",
- openai: "gpt-4o-mini",
- google: "gemini-2.5-flash",
- groq: "openai/gpt-oss-20b",
- openrouter: "z-ai/glm-4.6",
- "vercel-ai-gateway": "anthropic/claude-opus-4.5",
- cerebras: "gpt-oss-120b",
- xai: "grok-4-fast-non-reasoning",
- zai: "glm-4.5-air",
-};
-
-@customElement("provider-key-input")
-export class ProviderKeyInput extends LitElement {
- @property() provider = "";
- @state() private keyInput = "";
- @state() private testing = false;
- @state() private failed = false;
- @state() private hasKey = false;
- @state() private inputChanged = false;
-
- protected createRenderRoot() {
- return this;
- }
-
- override async connectedCallback() {
- super.connectedCallback();
- await this.checkKeyStatus();
- }
-
- private async checkKeyStatus() {
- try {
- const key = await getAppStorage().providerKeys.get(this.provider);
- this.hasKey = !!key;
- } catch (error) {
- console.error("Failed to check key status:", error);
- }
- }
-
- private async testApiKey(provider: string, apiKey: string): Promise {
- try {
- const modelId = TEST_MODELS[provider];
- // Returning true here for Ollama and friends. Can' know which model to use for testing
- if (!modelId) return true;
-
- let model = getModel(provider as any, modelId);
- if (!model) return false;
-
- // Get proxy URL from settings (if available)
- const proxyEnabled =
- await getAppStorage().settings.get("proxy.enabled");
- const proxyUrl = await getAppStorage().settings.get("proxy.url");
-
- // Apply proxy only if this provider/key combination requires it
- model = applyProxyIfNeeded(
- model,
- apiKey,
- proxyEnabled ? proxyUrl || undefined : undefined,
- );
-
- const context: Context = {
- messages: [
- { role: "user", content: "Reply with: ok", timestamp: Date.now() },
- ],
- };
-
- const result = await complete(model, context, {
- apiKey,
- maxTokens: 200,
- } as any);
-
- return result.stopReason === "stop";
- } catch (error) {
- console.error(`API key test failed for ${provider}:`, error);
- return false;
- }
- }
-
- private async saveKey() {
- if (!this.keyInput) return;
-
- this.testing = true;
- this.failed = false;
-
- const success = await this.testApiKey(this.provider, this.keyInput);
-
- this.testing = false;
-
- if (success) {
- try {
- await getAppStorage().providerKeys.set(this.provider, this.keyInput);
- this.hasKey = true;
- this.inputChanged = false;
- this.requestUpdate();
- } catch (error) {
- console.error("Failed to save API key:", error);
- this.failed = true;
- setTimeout(() => {
- this.failed = false;
- this.requestUpdate();
- }, 5000);
- }
- } else {
- this.failed = true;
- setTimeout(() => {
- this.failed = false;
- this.requestUpdate();
- }, 5000);
- }
- }
-
- render() {
- return html`
-
-
- ${this.provider}
- ${this.testing
- ? Badge({ children: i18n("Testing..."), variant: "secondary" })
- : this.hasKey
- ? html`✓ `
- : ""}
- ${this.failed
- ? Badge({ children: i18n("✗ Invalid"), variant: "destructive" })
- : ""}
-
-
- ${Input({
- type: "password",
- placeholder: this.hasKey ? "••••••••••••" : i18n("Enter API key"),
- value: this.keyInput,
- onInput: (e: Event) => {
- this.keyInput = (e.target as HTMLInputElement).value;
- this.inputChanged = true;
- this.requestUpdate();
- },
- className: "flex-1",
- })}
- ${Button({
- onClick: () => this.saveKey(),
- variant: "default",
- size: "sm",
- disabled:
- !this.keyInput ||
- this.testing ||
- (this.hasKey && !this.inputChanged),
- children: i18n("Save"),
- })}
-
-
- `;
- }
-}
diff --git a/packages/web-ui/src/components/SandboxedIframe.ts b/packages/web-ui/src/components/SandboxedIframe.ts
deleted file mode 100644
index 4ac92dd..0000000
--- a/packages/web-ui/src/components/SandboxedIframe.ts
+++ /dev/null
@@ -1,672 +0,0 @@
-import { LitElement } from "lit";
-import { customElement, property } from "lit/decorators.js";
-import { ConsoleRuntimeProvider } from "./sandbox/ConsoleRuntimeProvider.js";
-import { RuntimeMessageBridge } from "./sandbox/RuntimeMessageBridge.js";
-import {
- type MessageConsumer,
- RUNTIME_MESSAGE_ROUTER,
-} from "./sandbox/RuntimeMessageRouter.js";
-import type { SandboxRuntimeProvider } from "./sandbox/SandboxRuntimeProvider.js";
-
-export interface SandboxFile {
- fileName: string;
- content: string | Uint8Array;
- mimeType: string;
-}
-
-export interface SandboxResult {
- success: boolean;
- console: Array<{ type: string; text: string }>;
- files?: SandboxFile[];
- error?: { message: string; stack: string };
- returnValue?: any;
-}
-
-/**
- * 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;
-
-/**
- * Configuration for prepareHtmlDocument
- */
-export interface PrepareHtmlOptions {
- /** True if this is an HTML artifact (inject into existing HTML), false if REPL (wrap in HTML) */
- isHtmlArtifact: boolean;
- /** True if this is a standalone download (no runtime bridge, no navigation interceptor) */
- isStandalone?: boolean;
-}
-
-/**
- * Escape HTML special sequences in code to prevent premature tag closure
- * @param code Code that will be injected into in user code to prevent premature tag closure
- const escapedUserCode = escapeScriptContent(userCode);
-
- return `
-
-
- ${runtime}
-
-
-
-
-`;
- }
- }
-
- /**
- * Generate runtime script from providers
- * @param sandboxId Unique sandbox ID
- * @param providers Runtime providers
- * @param isStandalone If true, skip runtime bridge and navigation interceptor (for standalone downloads)
- */
- private getRuntimeScript(
- sandboxId: string,
- providers: SandboxRuntimeProvider[] = [],
- isStandalone: boolean = false,
- ): string {
- // Collect all data from providers
- const allData: Record = {};
- for (const provider of providers) {
- Object.assign(allData, provider.getData());
- }
-
- // Generate bridge code (skip if standalone)
- const bridgeCode = isStandalone
- ? ""
- : RuntimeMessageBridge.generateBridgeCode({
- context: "sandbox-iframe",
- sandboxId,
- });
-
- // Collect all runtime functions - pass sandboxId as string literal
- const runtimeFunctions: string[] = [];
- for (const provider of providers) {
- runtimeFunctions.push(
- `(${provider.getRuntime().toString()})(${JSON.stringify(sandboxId)});`,
- );
- }
-
- // Build script with HTML escaping
- // Escape to prevent premature tag closure in HTML parser
- const dataInjection = Object.entries(allData)
- .map(([key, value]) => {
- const jsonStr = JSON.stringify(value).replace(
- /<\/script/gi,
- "<\\/script",
- );
- return `window.${key} = ${jsonStr};`;
- })
- .join("\n");
-
- // TODO the font-size is needed, as chrome seems to inject a stylesheet into iframes
- // found in an extension context like sidepanel, setting body { font-size: 75% }. It's
- // definitely not our code doing that.
- // See https://stackoverflow.com/questions/71480433/chrome-is-injecting-some-stylesheet-in-popup-ui-which-reduces-the-font-size-to-7
-
- // Navigation interceptor (only if NOT standalone)
- const navigationInterceptor = isStandalone
- ? ""
- : `
-// Navigation interceptor: prevent all navigation and open externally
-(function() {
- // Intercept link clicks
- document.addEventListener('click', function(e) {
- const link = e.target.closest('a');
- if (link && link.href) {
- // Check if it's an external link (not javascript: or #hash)
- if (link.href.startsWith('http://') || link.href.startsWith('https://')) {
- e.preventDefault();
- e.stopPropagation();
- window.parent.postMessage({ type: 'open-external-url', url: link.href }, '*');
- }
- }
- }, true);
-
- // Intercept form submissions
- document.addEventListener('submit', function(e) {
- const form = e.target;
- if (form && form.action) {
- e.preventDefault();
- e.stopPropagation();
- window.parent.postMessage({ type: 'open-external-url', url: form.action }, '*');
- }
- }, true);
-
- // Prevent window.location changes (only if not already redefined)
- try {
- const originalLocation = window.location;
- Object.defineProperty(window, 'location', {
- get: function() { return originalLocation; },
- set: function(url) {
- window.parent.postMessage({ type: 'open-external-url', url: url.toString() }, '*');
- }
- });
- } catch (e) {
- // Already defined, skip
- }
-})();
-`;
-
- return `
-`;
- }
-}
diff --git a/packages/web-ui/src/components/StreamingMessageContainer.ts b/packages/web-ui/src/components/StreamingMessageContainer.ts
deleted file mode 100644
index 7bc531e..0000000
--- a/packages/web-ui/src/components/StreamingMessageContainer.ts
+++ /dev/null
@@ -1,112 +0,0 @@
-import type { AgentMessage, AgentTool } from "@mariozechner/pi-agent-core";
-import type { ToolResultMessage } from "@mariozechner/pi-ai";
-import { html, 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;
- @property({ attribute: false }) onCostClick?: () => void;
-
- @state() private _message: AgentMessage | null = null;
- private _pendingMessage: AgentMessage | 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: AgentMessage | 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" || msg.role === "user-with-attachments") {
- // 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/components/ThinkingBlock.ts b/packages/web-ui/src/components/ThinkingBlock.ts
deleted file mode 100644
index aac40e3..0000000
--- a/packages/web-ui/src/components/ThinkingBlock.ts
+++ /dev/null
@@ -1,53 +0,0 @@
-import { icon } from "@mariozechner/mini-lit";
-import { html, LitElement } from "lit";
-import { customElement, property, state } from "lit/decorators.js";
-import { ChevronRight } from "lucide";
-
-@customElement("thinking-block")
-export class ThinkingBlock extends LitElement {
- @property() content!: string;
- @property({ type: Boolean }) isStreaming = false;
- @state() private isExpanded = false;
-
- protected override createRenderRoot(): HTMLElement | DocumentFragment {
- return this;
- }
-
- override connectedCallback(): void {
- super.connectedCallback();
- this.style.display = "block";
- }
-
- private toggleExpanded() {
- this.isExpanded = !this.isExpanded;
- }
-
- override render() {
- const shimmerClasses = this.isStreaming
- ? "animate-shimmer bg-gradient-to-r from-muted-foreground via-foreground to-muted-foreground bg-[length:200%_100%] bg-clip-text text-transparent"
- : "";
-
- return html`
-
-
- ${this.isExpanded
- ? html` `
- : ""}
-
- `;
- }
-}
diff --git a/packages/web-ui/src/components/message-renderer-registry.ts b/packages/web-ui/src/components/message-renderer-registry.ts
deleted file mode 100644
index d593f56..0000000
--- a/packages/web-ui/src/components/message-renderer-registry.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import type { AgentMessage } from "@mariozechner/pi-agent-core";
-import type { TemplateResult } from "lit";
-
-// Extract role type from AppMessage union
-export type MessageRole = AgentMessage["role"];
-
-// Generic message renderer typed to specific message type
-export interface MessageRenderer {
- render(message: TMessage): TemplateResult;
-}
-
-// Registry of custom message renderers by role
-const messageRenderers = new Map>();
-
-export function registerMessageRenderer(
- role: TRole,
- renderer: MessageRenderer>,
-): void {
- messageRenderers.set(role, renderer);
-}
-
-export function getMessageRenderer(
- role: MessageRole,
-): MessageRenderer | undefined {
- return messageRenderers.get(role);
-}
-
-export function renderMessage(
- message: AgentMessage,
-): TemplateResult | undefined {
- return messageRenderers.get(message.role)?.render(message);
-}
diff --git a/packages/web-ui/src/components/sandbox/ArtifactsRuntimeProvider.ts b/packages/web-ui/src/components/sandbox/ArtifactsRuntimeProvider.ts
deleted file mode 100644
index 7277cdb..0000000
--- a/packages/web-ui/src/components/sandbox/ArtifactsRuntimeProvider.ts
+++ /dev/null
@@ -1,239 +0,0 @@
-import {
- ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RO,
- ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RW,
-} from "../../prompts/prompts.js";
-import type { SandboxRuntimeProvider } from "./SandboxRuntimeProvider.js";
-
-// Define minimal interface for ArtifactsPanel to avoid circular dependencies
-interface ArtifactsPanelLike {
- artifacts: Map;
- tool: {
- execute(
- toolCallId: string,
- args: { command: string; filename: string; content?: string },
- ): Promise;
- };
-}
-
-interface AgentLike {
- appendMessage(message: any): void;
-}
-
-/**
- * Artifacts Runtime Provider
- *
- * Provides programmatic access to session artifacts from sandboxed code.
- * Allows code to create, read, update, and delete artifacts dynamically.
- * Supports both online (extension) and offline (downloaded HTML) modes.
- */
-export class ArtifactsRuntimeProvider implements SandboxRuntimeProvider {
- constructor(
- private artifactsPanel: ArtifactsPanelLike,
- private agent?: AgentLike,
- private readWrite: boolean = true,
- ) {}
-
- getData(): Record {
- // Inject artifact snapshot for offline mode
- const snapshot: Record = {};
- this.artifactsPanel.artifacts.forEach((artifact, filename) => {
- snapshot[filename] = artifact.content;
- });
- return { artifacts: snapshot };
- }
-
- getRuntime(): (sandboxId: string) => void {
- // This function will be stringified, so no external references!
- return (_sandboxId: string) => {
- // Auto-parse/stringify for .json files
- const isJsonFile = (filename: string) => filename.endsWith(".json");
-
- (window as any).listArtifacts = async (): Promise => {
- // Online: ask extension
- if ((window as any).sendRuntimeMessage) {
- const response = await (window as any).sendRuntimeMessage({
- type: "artifact-operation",
- action: "list",
- });
- if (!response.success) throw new Error(response.error);
- return response.result;
- }
- // Offline: return snapshot keys
- else {
- return Object.keys((window as any).artifacts || {});
- }
- };
-
- (window as any).getArtifact = async (filename: string): Promise => {
- let content: string;
-
- // Online: ask extension
- if ((window as any).sendRuntimeMessage) {
- const response = await (window as any).sendRuntimeMessage({
- type: "artifact-operation",
- action: "get",
- filename,
- });
- if (!response.success) throw new Error(response.error);
- content = response.result;
- }
- // Offline: read snapshot
- else {
- if (!(window as any).artifacts?.[filename]) {
- throw new Error(`Artifact not found (offline mode): ${filename}`);
- }
- content = (window as any).artifacts[filename];
- }
-
- // Auto-parse .json files
- if (isJsonFile(filename)) {
- try {
- return JSON.parse(content);
- } catch (e) {
- throw new Error(`Failed to parse JSON from ${filename}: ${e}`);
- }
- }
- return content;
- };
-
- (window as any).createOrUpdateArtifact = async (
- filename: string,
- content: any,
- mimeType?: string,
- ): Promise => {
- if (!(window as any).sendRuntimeMessage) {
- throw new Error(
- "Cannot create/update artifacts in offline mode (read-only)",
- );
- }
-
- let finalContent = content;
- // Auto-stringify .json files
- if (isJsonFile(filename) && typeof content !== "string") {
- finalContent = JSON.stringify(content, null, 2);
- } else if (typeof content !== "string") {
- finalContent = JSON.stringify(content, null, 2);
- }
-
- const response = await (window as any).sendRuntimeMessage({
- type: "artifact-operation",
- action: "createOrUpdate",
- filename,
- content: finalContent,
- mimeType,
- });
- if (!response.success) throw new Error(response.error);
- };
-
- (window as any).deleteArtifact = async (
- filename: string,
- ): Promise => {
- if (!(window as any).sendRuntimeMessage) {
- throw new Error(
- "Cannot delete artifacts in offline mode (read-only)",
- );
- }
-
- const response = await (window as any).sendRuntimeMessage({
- type: "artifact-operation",
- action: "delete",
- filename,
- });
- if (!response.success) throw new Error(response.error);
- };
- };
- }
-
- async handleMessage(
- message: any,
- respond: (response: any) => void,
- ): Promise {
- if (message.type !== "artifact-operation") {
- return;
- }
-
- const { action, filename, content } = message;
-
- try {
- switch (action) {
- case "list": {
- const filenames = Array.from(this.artifactsPanel.artifacts.keys());
- respond({ success: true, result: filenames });
- break;
- }
-
- case "get": {
- const artifact = this.artifactsPanel.artifacts.get(filename);
- if (!artifact) {
- respond({
- success: false,
- error: `Artifact not found: ${filename}`,
- });
- } else {
- respond({ success: true, result: artifact.content });
- }
- break;
- }
-
- case "createOrUpdate": {
- try {
- const exists = this.artifactsPanel.artifacts.has(filename);
- const command = exists ? "rewrite" : "create";
- const action = exists ? "update" : "create";
-
- await this.artifactsPanel.tool.execute("", {
- command,
- filename,
- content,
- });
- this.agent?.appendMessage({
- role: "artifact",
- action,
- filename,
- content,
- ...(action === "create" && { title: filename }),
- timestamp: new Date().toISOString(),
- });
- respond({ success: true });
- } catch (err: any) {
- respond({ success: false, error: err.message });
- }
- break;
- }
-
- case "delete": {
- try {
- await this.artifactsPanel.tool.execute("", {
- command: "delete",
- filename,
- });
- this.agent?.appendMessage({
- role: "artifact",
- action: "delete",
- filename,
- timestamp: new Date().toISOString(),
- });
- respond({ success: true });
- } catch (err: any) {
- respond({ success: false, error: err.message });
- }
- break;
- }
-
- default:
- respond({
- success: false,
- error: `Unknown artifact action: ${action}`,
- });
- }
- } catch (error: any) {
- respond({ success: false, error: error.message });
- }
- }
-
- getDescription(): string {
- return this.readWrite
- ? ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RW
- : ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RO;
- }
-}
diff --git a/packages/web-ui/src/components/sandbox/AttachmentsRuntimeProvider.ts b/packages/web-ui/src/components/sandbox/AttachmentsRuntimeProvider.ts
deleted file mode 100644
index f783bde..0000000
--- a/packages/web-ui/src/components/sandbox/AttachmentsRuntimeProvider.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-import { ATTACHMENTS_RUNTIME_DESCRIPTION } from "../../prompts/prompts.js";
-import type { Attachment } from "../../utils/attachment-utils.js";
-import type { SandboxRuntimeProvider } from "./SandboxRuntimeProvider.js";
-
-/**
- * Attachments Runtime Provider
- *
- * OPTIONAL provider that provides file access APIs to sandboxed code.
- * Only needed when attachments are present.
- * Attachments are read-only snapshot data - no messaging needed.
- */
-export class AttachmentsRuntimeProvider implements SandboxRuntimeProvider {
- constructor(private attachments: Attachment[]) {}
-
- getData(): Record {
- const attachmentsData = this.attachments.map((a) => ({
- id: a.id,
- fileName: a.fileName,
- mimeType: a.mimeType,
- size: a.size,
- content: a.content,
- extractedText: a.extractedText,
- }));
-
- return { attachments: attachmentsData };
- }
-
- getRuntime(): (sandboxId: string) => void {
- // This function will be stringified, so no external references!
- // These functions read directly from window.attachments
- // Works both online AND offline (no messaging needed!)
- return (_sandboxId: string) => {
- (window as any).listAttachments = () =>
- ((window as any).attachments || []).map((a: any) => ({
- id: a.id,
- fileName: a.fileName,
- mimeType: a.mimeType,
- size: a.size,
- }));
-
- (window as any).readTextAttachment = (attachmentId: string) => {
- const a = ((window as any).attachments || []).find(
- (x: any) => x.id === attachmentId,
- );
- if (!a) throw new Error(`Attachment not found: ${attachmentId}`);
- if (a.extractedText) return a.extractedText;
- try {
- return atob(a.content);
- } catch {
- throw new Error(`Failed to decode text content for: ${attachmentId}`);
- }
- };
-
- (window as any).readBinaryAttachment = (attachmentId: string) => {
- const a = ((window as any).attachments || []).find(
- (x: any) => x.id === attachmentId,
- );
- if (!a) throw new Error(`Attachment not found: ${attachmentId}`);
- const bin = atob(a.content);
- const bytes = new Uint8Array(bin.length);
- for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
- return bytes;
- };
- };
- }
-
- getDescription(): string {
- return ATTACHMENTS_RUNTIME_DESCRIPTION;
- }
-}
diff --git a/packages/web-ui/src/components/sandbox/ConsoleRuntimeProvider.ts b/packages/web-ui/src/components/sandbox/ConsoleRuntimeProvider.ts
deleted file mode 100644
index 8fb43b2..0000000
--- a/packages/web-ui/src/components/sandbox/ConsoleRuntimeProvider.ts
+++ /dev/null
@@ -1,197 +0,0 @@
-import type { SandboxRuntimeProvider } from "./SandboxRuntimeProvider.js";
-
-export interface ConsoleLog {
- type: "log" | "warn" | "error" | "info";
- text: string;
- args?: unknown[];
-}
-
-/**
- * Console Runtime Provider
- *
- * REQUIRED provider that should always be included first.
- * Provides console capture, error handling, and execution lifecycle management.
- * Collects console output for retrieval by caller.
- */
-export class ConsoleRuntimeProvider implements SandboxRuntimeProvider {
- private logs: ConsoleLog[] = [];
- private completionError: { message: string; stack: string } | null = null;
- private completed = false;
-
- getData(): Record {
- // No data needed
- return {};
- }
-
- getDescription(): string {
- return "";
- }
-
- getRuntime(): (sandboxId: string) => void {
- return (_sandboxId: string) => {
- // Store truly original console methods on first wrap only
- // This prevents accumulation of wrapper functions across multiple executions
- if (!(window as any).__originalConsole) {
- (window as any).__originalConsole = {
- log: console.log.bind(console),
- error: console.error.bind(console),
- warn: console.warn.bind(console),
- info: console.info.bind(console),
- };
- }
-
- // Always use the truly original console, not the current (possibly wrapped) one
- const originalConsole = (window as any).__originalConsole;
-
- // Track pending send promises to wait for them in onCompleted
- const pendingSends: Promise[] = [];
-
- ["log", "error", "warn", "info"].forEach((method) => {
- (console as any)[method] = (...args: any[]) => {
- const text = args
- .map((arg) => {
- try {
- return typeof arg === "object"
- ? JSON.stringify(arg)
- : String(arg);
- } catch {
- return String(arg);
- }
- })
- .join(" ");
-
- // Always log locally too (using truly original console)
- (originalConsole as any)[method].apply(console, args);
-
- // Send immediately and track the promise (only in extension context)
- if ((window as any).sendRuntimeMessage) {
- const sendPromise = (window as any)
- .sendRuntimeMessage({
- type: "console",
- method,
- text,
- args,
- })
- .catch(() => {});
- pendingSends.push(sendPromise);
- }
- };
- });
-
- // Register completion callback to wait for all pending sends
- if ((window as any).onCompleted) {
- (window as any).onCompleted(async (_success: boolean) => {
- // Wait for all pending console sends to complete
- if (pendingSends.length > 0) {
- await Promise.all(pendingSends);
- }
- });
- }
-
- // Track errors for HTML artifacts
- let lastError: { message: string; stack: string } | null = null;
-
- // Error handlers - track errors but don't log them
- // (they'll be shown via execution-error message)
- window.addEventListener("error", (e) => {
- const text = `${e.error?.stack || e.message || String(e)} at line ${e.lineno || "?"}:${e.colno || "?"}`;
-
- lastError = {
- message: e.error?.message || e.message || String(e),
- stack: e.error?.stack || text,
- };
- });
-
- window.addEventListener("unhandledrejection", (e) => {
- const text = `Unhandled promise rejection: ${e.reason?.message || e.reason || "Unknown error"}`;
-
- lastError = {
- message:
- e.reason?.message ||
- String(e.reason) ||
- "Unhandled promise rejection",
- stack: e.reason?.stack || text,
- };
- });
-
- // Expose complete() method for user code to call
- let completionSent = false;
- (window as any).complete = async (
- error?: { message: string; stack: string },
- returnValue?: any,
- ) => {
- if (completionSent) return;
- completionSent = true;
-
- const finalError = error || lastError;
-
- if ((window as any).sendRuntimeMessage) {
- if (finalError) {
- await (window as any).sendRuntimeMessage({
- type: "execution-error",
- error: finalError,
- });
- } else {
- await (window as any).sendRuntimeMessage({
- type: "execution-complete",
- returnValue,
- });
- }
- }
- };
- };
- }
-
- async handleMessage(
- message: any,
- respond: (response: any) => void,
- ): Promise {
- if (message.type === "console") {
- // Collect console output
- this.logs.push({
- type:
- message.method === "error"
- ? "error"
- : message.method === "warn"
- ? "warn"
- : message.method === "info"
- ? "info"
- : "log",
- text: message.text,
- args: message.args,
- });
- // Acknowledge receipt
- respond({ success: true });
- }
- }
-
- /**
- * Get collected console logs
- */
- getLogs(): ConsoleLog[] {
- return this.logs;
- }
-
- /**
- * Get completion status
- */
- isCompleted(): boolean {
- return this.completed;
- }
-
- /**
- * Get completion error if any
- */
- getCompletionError(): { message: string; stack: string } | null {
- return this.completionError;
- }
-
- /**
- * Reset state for reuse
- */
- reset(): void {
- this.logs = [];
- this.completionError = null;
- this.completed = false;
- }
-}
diff --git a/packages/web-ui/src/components/sandbox/FileDownloadRuntimeProvider.ts b/packages/web-ui/src/components/sandbox/FileDownloadRuntimeProvider.ts
deleted file mode 100644
index 3f0ca71..0000000
--- a/packages/web-ui/src/components/sandbox/FileDownloadRuntimeProvider.ts
+++ /dev/null
@@ -1,121 +0,0 @@
-import type { SandboxRuntimeProvider } from "./SandboxRuntimeProvider.js";
-
-export interface DownloadableFile {
- fileName: string;
- content: string | Uint8Array;
- mimeType: string;
-}
-
-/**
- * File Download Runtime Provider
- *
- * Provides returnDownloadableFile() for creating user downloads.
- * Files returned this way are NOT accessible to the LLM later (one-time download).
- * Works both online (sends to extension) and offline (triggers browser download directly).
- * Collects files for retrieval by caller.
- */
-export class FileDownloadRuntimeProvider implements SandboxRuntimeProvider {
- private files: DownloadableFile[] = [];
-
- getData(): Record {
- // No data needed
- return {};
- }
-
- getRuntime(): (sandboxId: string) => void {
- return (_sandboxId: string) => {
- (window as any).returnDownloadableFile = async (
- fileName: string,
- content: any,
- mimeType?: string,
- ) => {
- let finalContent: any, finalMimeType: string;
-
- if (content instanceof Blob) {
- const arrayBuffer = await content.arrayBuffer();
- finalContent = new Uint8Array(arrayBuffer);
- finalMimeType =
- mimeType || content.type || "application/octet-stream";
- if (!mimeType && !content.type) {
- throw new Error(
- "returnDownloadableFile: MIME type is required for Blob content. Please provide a mimeType parameter (e.g., 'image/png').",
- );
- }
- } else if (content instanceof Uint8Array) {
- finalContent = content;
- if (!mimeType) {
- throw new Error(
- "returnDownloadableFile: MIME type is required for Uint8Array content. Please provide a mimeType parameter (e.g., 'image/png').",
- );
- }
- finalMimeType = mimeType;
- } else if (typeof content === "string") {
- finalContent = content;
- finalMimeType = mimeType || "text/plain";
- } else {
- finalContent = JSON.stringify(content, null, 2);
- finalMimeType = mimeType || "application/json";
- }
-
- // Send to extension if in extension context (online mode)
- if ((window as any).sendRuntimeMessage) {
- const response = await (window as any).sendRuntimeMessage({
- type: "file-returned",
- fileName,
- content: finalContent,
- mimeType: finalMimeType,
- });
- if (response.error) throw new Error(response.error);
- } else {
- // Offline mode: trigger browser download directly
- const blob = new Blob(
- [finalContent instanceof Uint8Array ? finalContent : finalContent],
- {
- type: finalMimeType,
- },
- );
- const url = URL.createObjectURL(blob);
- const a = document.createElement("a");
- a.href = url;
- a.download = fileName;
- a.click();
- URL.revokeObjectURL(url);
- }
- };
- };
- }
-
- async handleMessage(
- message: any,
- respond: (response: any) => void,
- ): Promise {
- if (message.type === "file-returned") {
- // Collect file for caller
- this.files.push({
- fileName: message.fileName,
- content: message.content,
- mimeType: message.mimeType,
- });
-
- respond({ success: true });
- }
- }
-
- /**
- * Get collected files
- */
- getFiles(): DownloadableFile[] {
- return this.files;
- }
-
- /**
- * Reset state for reuse
- */
- reset(): void {
- this.files = [];
- }
-
- getDescription(): string {
- return "returnDownloadableFile(filename, content, mimeType?) - Create downloadable file for user (one-time download, not accessible later)";
- }
-}
diff --git a/packages/web-ui/src/components/sandbox/RuntimeMessageBridge.ts b/packages/web-ui/src/components/sandbox/RuntimeMessageBridge.ts
deleted file mode 100644
index b364241..0000000
--- a/packages/web-ui/src/components/sandbox/RuntimeMessageBridge.ts
+++ /dev/null
@@ -1,82 +0,0 @@
-/**
- * Generates sendRuntimeMessage() function for injection into execution contexts.
- * Provides unified messaging API that works in both sandbox iframe and user script contexts.
- */
-
-export type MessageType = "request-response" | "fire-and-forget";
-
-export interface RuntimeMessageBridgeOptions {
- context: "sandbox-iframe" | "user-script";
- sandboxId: string;
-}
-
-// biome-ignore lint/complexity/noStaticOnlyClass: fine
-export class RuntimeMessageBridge {
- /**
- * Generate sendRuntimeMessage() function as injectable string.
- * Returns the function source code to be injected into target context.
- */
- static generateBridgeCode(options: RuntimeMessageBridgeOptions): string {
- if (options.context === "sandbox-iframe") {
- return RuntimeMessageBridge.generateSandboxBridge(options.sandboxId);
- } else {
- return RuntimeMessageBridge.generateUserScriptBridge(options.sandboxId);
- }
- }
-
- private static generateSandboxBridge(sandboxId: string): string {
- // Returns stringified function that uses window.parent.postMessage
- return `
-window.__completionCallbacks = [];
-window.sendRuntimeMessage = async (message) => {
- const messageId = 'msg_' + Date.now() + '_' + Math.random().toString(36).substring(2, 9);
-
- return new Promise((resolve, reject) => {
- const handler = (e) => {
- if (e.data.type === 'runtime-response' && e.data.messageId === messageId) {
- window.removeEventListener('message', handler);
- if (e.data.success) {
- resolve(e.data);
- } else {
- reject(new Error(e.data.error || 'Operation failed'));
- }
- }
- };
-
- window.addEventListener('message', handler);
-
- window.parent.postMessage({
- ...message,
- sandboxId: ${JSON.stringify(sandboxId)},
- messageId: messageId
- }, '*');
-
- // Timeout after 30s
- setTimeout(() => {
- window.removeEventListener('message', handler);
- reject(new Error('Runtime message timeout'));
- }, 30000);
- });
-};
-window.onCompleted = (callback) => {
- window.__completionCallbacks.push(callback);
-};
-`.trim();
- }
-
- private static generateUserScriptBridge(sandboxId: string): string {
- // Returns stringified function that uses chrome.runtime.sendMessage
- return `
-window.__completionCallbacks = [];
-window.sendRuntimeMessage = async (message) => {
- return await chrome.runtime.sendMessage({
- ...message,
- sandboxId: ${JSON.stringify(sandboxId)}
- });
-};
-window.onCompleted = (callback) => {
- window.__completionCallbacks.push(callback);
-};
-`.trim();
- }
-}
diff --git a/packages/web-ui/src/components/sandbox/RuntimeMessageRouter.ts b/packages/web-ui/src/components/sandbox/RuntimeMessageRouter.ts
deleted file mode 100644
index 298136a..0000000
--- a/packages/web-ui/src/components/sandbox/RuntimeMessageRouter.ts
+++ /dev/null
@@ -1,239 +0,0 @@
-import type { SandboxRuntimeProvider } from "./SandboxRuntimeProvider.js";
-
-// Type declaration for chrome extension API (when available)
-declare const chrome: any;
-
-/**
- * Message consumer interface - components that want to receive messages from sandboxes
- */
-export interface MessageConsumer {
- /**
- * Handle a message from a sandbox.
- * All consumers receive all messages - decide internally what to handle.
- */
- handleMessage(message: any): Promise;
-}
-
-/**
- * Sandbox context - tracks active sandboxes and their consumers
- */
-interface SandboxContext {
- sandboxId: string;
- iframe: HTMLIFrameElement | null; // null until setSandboxIframe() or null for user scripts
- providers: SandboxRuntimeProvider[];
- consumers: Set;
-}
-
-/**
- * Centralized message router for all runtime communication.
- *
- * This singleton replaces all individual window.addEventListener("message") calls
- * with a single global listener that routes messages to the appropriate handlers.
- * Also handles user script messages from chrome.runtime.onUserScriptMessage.
- *
- * Benefits:
- * - Single global listener instead of multiple independent listeners
- * - Automatic cleanup when sandboxes are destroyed
- * - Support for bidirectional communication (providers) and broadcasting (consumers)
- * - Works with both sandbox iframes and user scripts
- * - Clear lifecycle management
- */
-export class RuntimeMessageRouter {
- private sandboxes = new Map();
- private messageListener: ((e: MessageEvent) => void) | null = null;
- private userScriptMessageListener:
- | ((
- message: any,
- sender: any,
- sendResponse: (response: any) => void,
- ) => boolean)
- | null = null;
-
- /**
- * Register a new sandbox with its runtime providers.
- * Call this BEFORE creating the iframe (for sandbox contexts) or executing user script.
- */
- registerSandbox(
- sandboxId: string,
- providers: SandboxRuntimeProvider[],
- consumers: MessageConsumer[],
- ): void {
- this.sandboxes.set(sandboxId, {
- sandboxId,
- iframe: null, // Will be set via setSandboxIframe() for sandbox contexts
- providers,
- consumers: new Set(consumers),
- });
-
- // Setup global listener if not already done
- this.setupListener();
- }
-
- /**
- * Update the iframe reference for a sandbox.
- * Call this AFTER creating the iframe.
- * This is needed so providers can send responses back to the sandbox.
- */
- setSandboxIframe(sandboxId: string, iframe: HTMLIFrameElement): void {
- const context = this.sandboxes.get(sandboxId);
- if (context) {
- context.iframe = iframe;
- }
- }
-
- /**
- * Unregister a sandbox and remove all its consumers.
- * Call this when the sandbox is destroyed.
- */
- unregisterSandbox(sandboxId: string): void {
- this.sandboxes.delete(sandboxId);
-
- // If no more sandboxes, remove global listeners
- if (this.sandboxes.size === 0) {
- // Remove iframe listener
- if (this.messageListener) {
- window.removeEventListener("message", this.messageListener);
- this.messageListener = null;
- }
-
- // Remove user script listener
- if (
- this.userScriptMessageListener &&
- typeof chrome !== "undefined" &&
- chrome.runtime?.onUserScriptMessage
- ) {
- chrome.runtime.onUserScriptMessage.removeListener(
- this.userScriptMessageListener,
- );
- this.userScriptMessageListener = null;
- }
- }
- }
-
- /**
- * Add a message consumer for a sandbox.
- * Consumers receive broadcast messages (console, execution-complete, etc.)
- */
- addConsumer(sandboxId: string, consumer: MessageConsumer): void {
- const context = this.sandboxes.get(sandboxId);
- if (context) {
- context.consumers.add(consumer);
- }
- }
-
- /**
- * Remove a message consumer from a sandbox.
- */
- removeConsumer(sandboxId: string, consumer: MessageConsumer): void {
- const context = this.sandboxes.get(sandboxId);
- if (context) {
- context.consumers.delete(consumer);
- }
- }
-
- /**
- * Setup the global message listeners (called automatically)
- */
- private setupListener(): void {
- // Setup sandbox iframe listener
- if (!this.messageListener) {
- this.messageListener = async (e: MessageEvent) => {
- const { sandboxId, messageId } = e.data;
- if (!sandboxId) return;
-
- const context = this.sandboxes.get(sandboxId);
- if (!context) {
- return;
- }
-
- // Create respond() function for bidirectional communication
- const respond = (response: any) => {
- context.iframe?.contentWindow?.postMessage(
- {
- type: "runtime-response",
- messageId,
- sandboxId,
- ...response,
- },
- "*",
- );
- };
-
- // 1. Try provider handlers first (for bidirectional comm)
- for (const provider of context.providers) {
- if (provider.handleMessage) {
- await provider.handleMessage(e.data, respond);
- // Don't stop - let consumers also handle the message
- }
- }
-
- // 2. Broadcast to consumers (one-way messages or lifecycle events)
- for (const consumer of context.consumers) {
- await consumer.handleMessage(e.data);
- // Don't stop - let all consumers see the message
- }
- };
-
- window.addEventListener("message", this.messageListener);
- }
-
- // Setup user script message listener
- if (!this.userScriptMessageListener) {
- // Guard: check if we're in extension context
- if (
- typeof chrome === "undefined" ||
- !chrome.runtime?.onUserScriptMessage
- ) {
- return;
- }
-
- this.userScriptMessageListener = (
- message: any,
- _sender: any,
- sendResponse: (response: any) => void,
- ) => {
- const { sandboxId } = message;
- if (!sandboxId) return false;
-
- const context = this.sandboxes.get(sandboxId);
- if (!context) return false;
-
- const respond = (response: any) => {
- sendResponse({
- ...response,
- sandboxId,
- });
- };
-
- // Route to providers (async)
- (async () => {
- // 1. Try provider handlers first (for bidirectional comm)
- for (const provider of context.providers) {
- if (provider.handleMessage) {
- await provider.handleMessage(message, respond);
- // Don't stop - let consumers also handle the message
- }
- }
-
- // 2. Broadcast to consumers (one-way messages or lifecycle events)
- for (const consumer of context.consumers) {
- await consumer.handleMessage(message);
- // Don't stop - let all consumers see the message
- }
- })();
-
- return true; // Indicates async response
- };
-
- chrome.runtime.onUserScriptMessage.addListener(
- this.userScriptMessageListener,
- );
- }
- }
-}
-
-/**
- * Global singleton instance.
- * Import this from wherever you need to interact with the message router.
- */
-export const RUNTIME_MESSAGE_ROUTER = new RuntimeMessageRouter();
diff --git a/packages/web-ui/src/components/sandbox/SandboxRuntimeProvider.ts b/packages/web-ui/src/components/sandbox/SandboxRuntimeProvider.ts
deleted file mode 100644
index 20a3b1a..0000000
--- a/packages/web-ui/src/components/sandbox/SandboxRuntimeProvider.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-/**
- * Interface for providing runtime capabilities to sandboxed iframes.
- * Each provider injects data and runtime functions into the sandbox context.
- */
-export interface SandboxRuntimeProvider {
- /**
- * Returns data to inject into window scope.
- * Keys become window properties (e.g., { attachments: [...] } -> window.attachments)
- */
- getData(): Record;
-
- /**
- * Returns a runtime function that will be stringified and executed in the sandbox.
- * The function receives sandboxId and has access to data from getData() via window.
- *
- * IMPORTANT: This function will be converted to string via .toString() and injected
- * into the sandbox, so it cannot reference external variables or imports.
- */
- getRuntime(): (sandboxId: string) => void;
-
- /**
- * Optional message handler for bidirectional communication.
- * All providers receive all messages - decide internally what to handle.
- *
- * @param message - The message from the sandbox
- * @param respond - Function to send a response back to the sandbox
- */
- handleMessage?(message: any, respond: (response: any) => void): Promise;
-
- /**
- * Optional documentation describing what globals/functions this provider injects.
- * This will be appended to tool descriptions dynamically so the LLM knows what's available.
- */
- getDescription(): string;
-
- /**
- * Optional lifecycle callback invoked when sandbox execution starts.
- * Providers can use this to track abort signals for cancellation of async operations.
- *
- * @param sandboxId - The unique identifier for this sandbox execution
- * @param signal - Optional AbortSignal that will be triggered if execution is cancelled
- */
- onExecutionStart?(sandboxId: string, signal?: AbortSignal): void;
-
- /**
- * Optional lifecycle callback invoked when sandbox execution ends (success, error, or abort).
- * Providers can use this to clean up any resources associated with the sandbox.
- *
- * @param sandboxId - The unique identifier for this sandbox execution
- */
- onExecutionEnd?(sandboxId: string): void;
-}
diff --git a/packages/web-ui/src/dialogs/ApiKeyPromptDialog.ts b/packages/web-ui/src/dialogs/ApiKeyPromptDialog.ts
deleted file mode 100644
index e1d1c86..0000000
--- a/packages/web-ui/src/dialogs/ApiKeyPromptDialog.ts
+++ /dev/null
@@ -1,78 +0,0 @@
-import { customElement, state } from "lit/decorators.js";
-import "../components/ProviderKeyInput.js";
-import {
- DialogContent,
- DialogHeader,
-} from "@mariozechner/mini-lit/dist/Dialog.js";
-import { DialogBase } from "@mariozechner/mini-lit/dist/DialogBase.js";
-import { html } from "lit";
-import { getAppStorage } from "../storage/app-storage.js";
-import { i18n } from "../utils/i18n.js";
-
-@customElement("api-key-prompt-dialog")
-export class ApiKeyPromptDialog extends DialogBase {
- @state() private provider = "";
-
- private resolvePromise?: (success: boolean) => void;
- private unsubscribe?: () => void;
-
- protected modalWidth = "min(500px, 90vw)";
- protected modalHeight = "auto";
-
- static async prompt(provider: string): Promise {
- const dialog = new ApiKeyPromptDialog();
- dialog.provider = provider;
- dialog.open();
-
- return new Promise((resolve) => {
- dialog.resolvePromise = resolve;
- });
- }
-
- override async connectedCallback() {
- super.connectedCallback();
-
- // Poll for key existence - when key is added, resolve and close
- const checkInterval = setInterval(async () => {
- const hasKey = !!(await getAppStorage().providerKeys.get(this.provider));
- if (hasKey) {
- clearInterval(checkInterval);
- if (this.resolvePromise) {
- this.resolvePromise(true);
- this.resolvePromise = undefined;
- }
- this.close();
- }
- }, 500);
-
- this.unsubscribe = () => clearInterval(checkInterval);
- }
-
- override disconnectedCallback() {
- super.disconnectedCallback();
- if (this.unsubscribe) {
- this.unsubscribe();
- this.unsubscribe = undefined;
- }
- }
-
- override close() {
- super.close();
- if (this.resolvePromise) {
- this.resolvePromise(false);
- }
- }
-
- protected override renderContent() {
- return html`
- ${DialogContent({
- children: html`
- ${DialogHeader({
- title: i18n("API Key Required"),
- })}
-
- `,
- })}
- `;
- }
-}
diff --git a/packages/web-ui/src/dialogs/AttachmentOverlay.ts b/packages/web-ui/src/dialogs/AttachmentOverlay.ts
deleted file mode 100644
index 1282cc1..0000000
--- a/packages/web-ui/src/dialogs/AttachmentOverlay.ts
+++ /dev/null
@@ -1,677 +0,0 @@
-import "@mariozechner/mini-lit/dist/ModeToggle.js";
-import { icon } from "@mariozechner/mini-lit";
-import { Button } from "@mariozechner/mini-lit/dist/Button.js";
-import { renderAsync } from "docx-preview";
-import { html, LitElement } from "lit";
-import { state } from "lit/decorators.js";
-import { Download, X } from "lucide";
-import * as pdfjsLib from "pdfjs-dist";
-import * as XLSX from "xlsx";
-import type { Attachment } from "../utils/attachment-utils.js";
-import { i18n } from "../utils/i18n.js";
-
-type FileType = "image" | "pdf" | "docx" | "pptx" | "excel" | "text";
-
-export class AttachmentOverlay extends LitElement {
- @state() private attachment?: Attachment;
- @state() private showExtractedText = false;
- @state() private error: string | null = null;
-
- // Track current loading task to cancel if needed
- private currentLoadingTask: any = null;
- private onCloseCallback?: () => void;
- private boundHandleKeyDown?: (e: KeyboardEvent) => void;
-
- protected override createRenderRoot(): HTMLElement | DocumentFragment {
- return this;
- }
-
- static open(attachment: Attachment, onClose?: () => void) {
- const overlay = new AttachmentOverlay();
- overlay.attachment = attachment;
- overlay.onCloseCallback = onClose;
- document.body.appendChild(overlay);
- overlay.setupEventListeners();
- }
-
- private setupEventListeners() {
- this.boundHandleKeyDown = (e: KeyboardEvent) => {
- if (e.key === "Escape") {
- this.close();
- }
- };
- window.addEventListener("keydown", this.boundHandleKeyDown);
- }
-
- private close() {
- this.cleanup();
- if (this.boundHandleKeyDown) {
- window.removeEventListener("keydown", this.boundHandleKeyDown);
- }
- this.onCloseCallback?.();
- this.remove();
- }
-
- private getFileType(): FileType {
- if (!this.attachment) return "text";
-
- if (this.attachment.type === "image") return "image";
- if (this.attachment.mimeType === "application/pdf") return "pdf";
- if (this.attachment.mimeType?.includes("wordprocessingml")) return "docx";
- if (
- this.attachment.mimeType?.includes("presentationml") ||
- this.attachment.fileName.toLowerCase().endsWith(".pptx")
- )
- return "pptx";
- if (
- this.attachment.mimeType?.includes("spreadsheetml") ||
- this.attachment.mimeType?.includes("ms-excel") ||
- this.attachment.fileName.toLowerCase().endsWith(".xlsx") ||
- this.attachment.fileName.toLowerCase().endsWith(".xls")
- )
- return "excel";
-
- return "text";
- }
-
- private getFileTypeLabel(): string {
- const type = this.getFileType();
- switch (type) {
- case "pdf":
- return i18n("PDF");
- case "docx":
- return i18n("Document");
- case "pptx":
- return i18n("Presentation");
- case "excel":
- return i18n("Spreadsheet");
- default:
- return "";
- }
- }
-
- private handleBackdropClick = () => {
- this.close();
- };
-
- private handleDownload = () => {
- if (!this.attachment) return;
-
- // Create a blob from the base64 content
- const byteCharacters = atob(this.attachment.content);
- const byteNumbers = new Array(byteCharacters.length);
- for (let i = 0; i < byteCharacters.length; i++) {
- byteNumbers[i] = byteCharacters.charCodeAt(i);
- }
- const byteArray = new Uint8Array(byteNumbers);
- const blob = new Blob([byteArray], { type: this.attachment.mimeType });
-
- // Create download link
- const url = URL.createObjectURL(blob);
- const a = document.createElement("a");
- a.href = url;
- a.download = this.attachment.fileName;
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- URL.revokeObjectURL(url);
- };
-
- private cleanup() {
- this.showExtractedText = false;
- this.error = null;
- // Cancel any loading PDF task when closing
- if (this.currentLoadingTask) {
- this.currentLoadingTask.destroy();
- this.currentLoadingTask = null;
- }
- }
-
- override render() {
- if (!this.attachment) return html``;
-
- return html`
-
-
-
-
e.stopPropagation()}
- >
-
-
- ${this.attachment.fileName}
-
-
- ${this.renderToggle()}
- ${Button({
- variant: "ghost",
- size: "icon",
- onClick: this.handleDownload,
- children: icon(Download, "sm"),
- className: "h-8 w-8",
- })}
- ${Button({
- variant: "ghost",
- size: "icon",
- onClick: () => this.close(),
- children: icon(X, "sm"),
- className: "h-8 w-8",
- })}
-
-
-
-
-
-
e.stopPropagation()}
- >
- ${this.renderContent()}
-
-
- `;
- }
-
- private renderToggle() {
- if (!this.attachment) return html``;
-
- const fileType = this.getFileType();
- const hasExtractedText = !!this.attachment.extractedText;
- const showToggle =
- fileType !== "image" &&
- fileType !== "text" &&
- fileType !== "pptx" &&
- hasExtractedText;
-
- if (!showToggle) return html``;
-
- const fileTypeLabel = this.getFileTypeLabel();
-
- return html`
- ) => {
- e.stopPropagation();
- this.showExtractedText = e.detail.index === 1;
- this.error = null;
- }}
- >
- `;
- }
-
- private renderContent() {
- if (!this.attachment) return html``;
-
- // Error state
- if (this.error) {
- return html`
-
-
${i18n("Error loading file")}
-
${this.error}
-
- `;
- }
-
- // Content based on file type
- return this.renderFileContent();
- }
-
- private renderFileContent() {
- if (!this.attachment) return html``;
-
- const fileType = this.getFileType();
-
- // Show extracted text if toggled
- if (this.showExtractedText && fileType !== "image") {
- return html`
-
-
-${this.attachment.extractedText || i18n("No text content available")}
-
- `;
- }
-
- // Render based on file type
- switch (fileType) {
- case "image": {
- const imageUrl = `data:${this.attachment.mimeType};base64,${this.attachment.content}`;
- return html`
-
- `;
- }
-
- case "pdf":
- return html`
-
- `;
-
- case "docx":
- return html`
-
- `;
-
- case "excel":
- return html`
-
- `;
-
- case "pptx":
- return html`
-
- `;
-
- default:
- return html`
-
-
-${this.attachment.extractedText || i18n("No content available")}
-
- `;
- }
- }
-
- override async updated(changedProperties: Map) {
- super.updated(changedProperties);
-
- // Only process if we need to render the actual file (not extracted text)
- if (
- (changedProperties.has("attachment") ||
- changedProperties.has("showExtractedText")) &&
- this.attachment &&
- !this.showExtractedText &&
- !this.error
- ) {
- const fileType = this.getFileType();
-
- switch (fileType) {
- case "pdf":
- await this.renderPdf();
- break;
- case "docx":
- await this.renderDocx();
- break;
- case "excel":
- await this.renderExcel();
- break;
- case "pptx":
- await this.renderExtractedText();
- break;
- }
- }
- }
-
- private async renderPdf() {
- const container = this.querySelector("#pdf-container");
- if (!container || !this.attachment) return;
-
- let pdf: any = null;
-
- try {
- // Convert base64 to ArrayBuffer
- const arrayBuffer = this.base64ToArrayBuffer(this.attachment.content);
-
- // Cancel any existing loading task
- if (this.currentLoadingTask) {
- this.currentLoadingTask.destroy();
- }
-
- // Load the PDF
- this.currentLoadingTask = pdfjsLib.getDocument({ data: arrayBuffer });
- pdf = await this.currentLoadingTask.promise;
- this.currentLoadingTask = null;
-
- // Clear container and add wrapper
- container.innerHTML = "";
- const wrapper = document.createElement("div");
- wrapper.className = "";
- container.appendChild(wrapper);
-
- // Render all pages
- for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) {
- const page = await pdf.getPage(pageNum);
-
- // Create a container for each page
- const pageContainer = document.createElement("div");
- pageContainer.className = "mb-4 last:mb-0";
-
- // Create canvas for this page
- const canvas = document.createElement("canvas");
- const context = canvas.getContext("2d");
-
- // Set scale for reasonable resolution
- const viewport = page.getViewport({ scale: 1.5 });
- canvas.height = viewport.height;
- canvas.width = viewport.width;
-
- // Style the canvas
- canvas.className =
- "w-full max-w-full h-auto block mx-auto bg-white rounded shadow-sm border border-border";
-
- // Fill white background for proper PDF rendering
- if (context) {
- context.fillStyle = "white";
- context.fillRect(0, 0, canvas.width, canvas.height);
- }
-
- // Render page
- await page.render({
- canvasContext: context!,
- viewport: viewport,
- canvas: canvas,
- }).promise;
-
- pageContainer.appendChild(canvas);
-
- // Add page separator for multi-page documents
- if (pageNum < pdf.numPages) {
- const separator = document.createElement("div");
- separator.className = "h-px bg-border my-4";
- pageContainer.appendChild(separator);
- }
-
- wrapper.appendChild(pageContainer);
- }
- } catch (error: any) {
- console.error("Error rendering PDF:", error);
- this.error = error?.message || i18n("Failed to load PDF");
- } finally {
- if (pdf) {
- pdf.destroy();
- }
- }
- }
-
- private async renderDocx() {
- const container = this.querySelector("#docx-container");
- if (!container || !this.attachment) return;
-
- try {
- // Convert base64 to ArrayBuffer
- const arrayBuffer = this.base64ToArrayBuffer(this.attachment.content);
-
- // Clear container first
- container.innerHTML = "";
-
- // Create a wrapper div for the document
- const wrapper = document.createElement("div");
- wrapper.className = "docx-wrapper-custom";
- container.appendChild(wrapper);
-
- // Render the DOCX file into the wrapper
- await renderAsync(arrayBuffer, wrapper as HTMLElement, undefined, {
- className: "docx",
- inWrapper: true,
- ignoreWidth: true, // Let it be responsive
- ignoreHeight: false,
- ignoreFonts: false,
- breakPages: true,
- ignoreLastRenderedPageBreak: true,
- experimental: false,
- trimXmlDeclaration: true,
- useBase64URL: false,
- renderHeaders: true,
- renderFooters: true,
- renderFootnotes: true,
- renderEndnotes: true,
- });
-
- // Apply custom styles to match theme and fix sizing
- const style = document.createElement("style");
- style.textContent = `
- #docx-container {
- padding: 0;
- }
-
- #docx-container .docx-wrapper-custom {
- max-width: 100%;
- overflow-x: auto;
- }
-
- #docx-container .docx-wrapper {
- max-width: 100% !important;
- margin: 0 !important;
- background: transparent !important;
- padding: 0em !important;
- }
-
- #docx-container .docx-wrapper > section.docx {
- box-shadow: none !important;
- border: none !important;
- border-radius: 0 !important;
- margin: 0 !important;
- padding: 2em !important;
- background: white !important;
- color: black !important;
- max-width: 100% !important;
- width: 100% !important;
- min-width: 0 !important;
- overflow-x: auto !important;
- }
-
- /* Fix tables and wide content */
- #docx-container table {
- max-width: 100% !important;
- width: auto !important;
- overflow-x: auto !important;
- display: block !important;
- }
-
- #docx-container img {
- max-width: 100% !important;
- height: auto !important;
- }
-
- /* Fix paragraphs and text */
- #docx-container p,
- #docx-container span,
- #docx-container div {
- max-width: 100% !important;
- word-wrap: break-word !important;
- overflow-wrap: break-word !important;
- }
-
- /* Hide page breaks in web view */
- #docx-container .docx-page-break {
- display: none !important;
- }
- `;
- container.appendChild(style);
- } catch (error: any) {
- console.error("Error rendering DOCX:", error);
- this.error = error?.message || i18n("Failed to load document");
- }
- }
-
- private async renderExcel() {
- const container = this.querySelector("#excel-container");
- if (!container || !this.attachment) return;
-
- try {
- // Convert base64 to ArrayBuffer
- const arrayBuffer = this.base64ToArrayBuffer(this.attachment.content);
-
- // Read the workbook
- const workbook = XLSX.read(arrayBuffer, { type: "array" });
-
- // Clear container
- container.innerHTML = "";
- const wrapper = document.createElement("div");
- wrapper.className = "overflow-auto h-full flex flex-col";
- container.appendChild(wrapper);
-
- // Create tabs for multiple sheets
- if (workbook.SheetNames.length > 1) {
- const tabContainer = document.createElement("div");
- tabContainer.className =
- "flex gap-2 mb-4 border-b border-border sticky top-0 bg-card z-10";
-
- const sheetContents: HTMLElement[] = [];
-
- workbook.SheetNames.forEach((sheetName, index) => {
- // Create tab button
- const tab = document.createElement("button");
- tab.textContent = sheetName;
- tab.className =
- index === 0
- ? "px-4 py-2 text-sm font-medium border-b-2 border-primary text-primary"
- : "px-4 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:border-b-2 hover:border-border transition-colors";
-
- // Create sheet content
- const sheetDiv = document.createElement("div");
- sheetDiv.style.display = index === 0 ? "flex" : "none";
- sheetDiv.className = "flex-1 overflow-auto";
- sheetDiv.appendChild(
- this.renderExcelSheet(workbook.Sheets[sheetName], sheetName),
- );
- sheetContents.push(sheetDiv);
-
- // Tab click handler
- tab.onclick = () => {
- // Update tab styles
- tabContainer.querySelectorAll("button").forEach((btn, btnIndex) => {
- if (btnIndex === index) {
- btn.className =
- "px-4 py-2 text-sm font-medium border-b-2 border-primary text-primary";
- } else {
- btn.className =
- "px-4 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:border-b-2 hover:border-border transition-colors";
- }
- });
- // Show/hide sheets
- sheetContents.forEach((content, contentIndex) => {
- content.style.display = contentIndex === index ? "flex" : "none";
- });
- };
-
- tabContainer.appendChild(tab);
- });
-
- wrapper.appendChild(tabContainer);
- sheetContents.forEach((content) => {
- wrapper.appendChild(content);
- });
- } else {
- // Single sheet
- const sheetName = workbook.SheetNames[0];
- wrapper.appendChild(
- this.renderExcelSheet(workbook.Sheets[sheetName], sheetName),
- );
- }
- } catch (error: any) {
- console.error("Error rendering Excel:", error);
- this.error = error?.message || i18n("Failed to load spreadsheet");
- }
- }
-
- private renderExcelSheet(worksheet: any, sheetName: string): HTMLElement {
- const sheetDiv = document.createElement("div");
-
- // Generate HTML table
- const htmlTable = XLSX.utils.sheet_to_html(worksheet, {
- id: `sheet-${sheetName}`,
- });
- const tempDiv = document.createElement("div");
- tempDiv.innerHTML = htmlTable;
-
- // Find and style the table
- const table = tempDiv.querySelector("table");
- if (table) {
- table.className = "w-full border-collapse text-foreground";
-
- // Style all cells
- table.querySelectorAll("td, th").forEach((cell) => {
- const cellEl = cell as HTMLElement;
- cellEl.className = "border border-border px-3 py-2 text-sm text-left";
- });
-
- // Style header row
- const headerCells = table.querySelectorAll("thead th, tr:first-child td");
- if (headerCells.length > 0) {
- headerCells.forEach((th) => {
- const thEl = th as HTMLElement;
- thEl.className =
- "border border-border px-3 py-2 text-sm font-semibold bg-muted text-foreground sticky top-0";
- });
- }
-
- // Alternate row colors
- table.querySelectorAll("tbody tr:nth-child(even)").forEach((row) => {
- const rowEl = row as HTMLElement;
- rowEl.className = "bg-muted/30";
- });
-
- sheetDiv.appendChild(table);
- }
-
- return sheetDiv;
- }
-
- private base64ToArrayBuffer(base64: string): ArrayBuffer {
- const binaryString = atob(base64);
- const bytes = new Uint8Array(binaryString.length);
- for (let i = 0; i < binaryString.length; i++) {
- bytes[i] = binaryString.charCodeAt(i);
- }
- return bytes.buffer;
- }
-
- private async renderExtractedText() {
- const container = this.querySelector("#pptx-container");
- if (!container || !this.attachment) return;
-
- try {
- // Display the extracted text content
- container.innerHTML = "";
- const wrapper = document.createElement("div");
- wrapper.className = "p-6 overflow-auto";
-
- // Create a pre element to preserve formatting
- const pre = document.createElement("pre");
- pre.className = "whitespace-pre-wrap text-sm text-foreground font-mono";
- pre.textContent =
- this.attachment.extractedText || i18n("No text content available");
-
- wrapper.appendChild(pre);
- container.appendChild(wrapper);
- } catch (error: any) {
- console.error("Error rendering extracted text:", error);
- this.error = error?.message || i18n("Failed to display text content");
- }
- }
-}
-
-// Register the custom element only once
-if (!customElements.get("attachment-overlay")) {
- customElements.define("attachment-overlay", AttachmentOverlay);
-}
diff --git a/packages/web-ui/src/dialogs/CustomProviderDialog.ts b/packages/web-ui/src/dialogs/CustomProviderDialog.ts
deleted file mode 100644
index 9f46beb..0000000
--- a/packages/web-ui/src/dialogs/CustomProviderDialog.ts
+++ /dev/null
@@ -1,306 +0,0 @@
-import { i18n } from "@mariozechner/mini-lit";
-import { Button } from "@mariozechner/mini-lit/dist/Button.js";
-import { DialogBase } from "@mariozechner/mini-lit/dist/DialogBase.js";
-import { Input } from "@mariozechner/mini-lit/dist/Input.js";
-import { Label } from "@mariozechner/mini-lit/dist/Label.js";
-import { Select } from "@mariozechner/mini-lit/dist/Select.js";
-import type { Model } from "@mariozechner/pi-ai";
-import { html, type TemplateResult } from "lit";
-import { state } from "lit/decorators.js";
-import { getAppStorage } from "../storage/app-storage.js";
-import type {
- CustomProvider,
- CustomProviderType,
-} from "../storage/stores/custom-providers-store.js";
-import { discoverModels } from "../utils/model-discovery.js";
-
-export class CustomProviderDialog extends DialogBase {
- private provider?: CustomProvider;
- private initialType?: CustomProviderType;
- private onSaveCallback?: () => void;
-
- @state() private name = "";
- @state() private type: CustomProviderType = "openai-completions";
- @state() private baseUrl = "";
- @state() private apiKey = "";
- @state() private testing = false;
- @state() private testError = "";
- @state() private discoveredModels: Model[] = [];
-
- protected modalWidth = "min(800px, 90vw)";
- protected modalHeight = "min(700px, 90vh)";
-
- static async open(
- provider: CustomProvider | undefined,
- initialType: CustomProviderType | undefined,
- onSave?: () => void,
- ) {
- const dialog = new CustomProviderDialog();
- dialog.provider = provider;
- dialog.initialType = initialType;
- dialog.onSaveCallback = onSave;
- document.body.appendChild(dialog);
- dialog.initializeFromProvider();
- dialog.open();
- dialog.requestUpdate();
- }
-
- private initializeFromProvider() {
- if (this.provider) {
- this.name = this.provider.name;
- this.type = this.provider.type;
- this.baseUrl = this.provider.baseUrl;
- this.apiKey = this.provider.apiKey || "";
- this.discoveredModels = this.provider.models || [];
- } else {
- this.name = "";
- this.type = this.initialType || "openai-completions";
- this.baseUrl = "";
- this.updateDefaultBaseUrl();
- this.apiKey = "";
- this.discoveredModels = [];
- }
- this.testError = "";
- this.testing = false;
- }
-
- private updateDefaultBaseUrl() {
- if (this.baseUrl) return;
-
- const defaults: Record = {
- ollama: "http://localhost:11434",
- "llama.cpp": "http://localhost:8080",
- vllm: "http://localhost:8000",
- lmstudio: "http://localhost:1234",
- "openai-completions": "",
- "openai-responses": "",
- "anthropic-messages": "",
- };
-
- this.baseUrl = defaults[this.type] || "";
- }
-
- private isAutoDiscoveryType(): boolean {
- return (
- this.type === "ollama" ||
- this.type === "llama.cpp" ||
- this.type === "vllm" ||
- this.type === "lmstudio"
- );
- }
-
- private async testConnection() {
- if (!this.isAutoDiscoveryType()) return;
-
- this.testing = true;
- this.testError = "";
- this.discoveredModels = [];
-
- try {
- const models = await discoverModels(
- this.type as "ollama" | "llama.cpp" | "vllm" | "lmstudio",
- this.baseUrl,
- this.apiKey || undefined,
- );
-
- this.discoveredModels = models.map((model) => ({
- ...model,
- provider: this.name || this.type,
- }));
-
- this.testError = "";
- } catch (error) {
- this.testError = error instanceof Error ? error.message : String(error);
- this.discoveredModels = [];
- } finally {
- this.testing = false;
- this.requestUpdate();
- }
- }
-
- private async save() {
- if (!this.name || !this.baseUrl) {
- alert(i18n("Please fill in all required fields"));
- return;
- }
-
- try {
- const storage = getAppStorage();
-
- const provider: CustomProvider = {
- id: this.provider?.id || crypto.randomUUID(),
- name: this.name,
- type: this.type,
- baseUrl: this.baseUrl,
- apiKey: this.apiKey || undefined,
- models: this.isAutoDiscoveryType()
- ? undefined
- : this.provider?.models || [],
- };
-
- await storage.customProviders.set(provider);
-
- if (this.onSaveCallback) {
- this.onSaveCallback();
- }
- this.close();
- } catch (error) {
- console.error("Failed to save provider:", error);
- alert(i18n("Failed to save provider"));
- }
- }
-
- protected override renderContent(): TemplateResult {
- const providerTypes = [
- { value: "ollama", label: "Ollama (auto-discovery)" },
- { value: "llama.cpp", label: "llama.cpp (auto-discovery)" },
- { value: "vllm", label: "vLLM (auto-discovery)" },
- { value: "lmstudio", label: "LM Studio (auto-discovery)" },
- { value: "openai-completions", label: "OpenAI Completions Compatible" },
- { value: "openai-responses", label: "OpenAI Responses Compatible" },
- { value: "anthropic-messages", label: "Anthropic Messages Compatible" },
- ];
-
- return html`
-
-
-
- ${this.provider ? i18n("Edit Provider") : i18n("Add Provider")}
-
-
-
-
-
-
- ${Label({
- htmlFor: "provider-name",
- children: i18n("Provider Name"),
- })}
- ${Input({
- value: this.name,
- placeholder: i18n("e.g., My Ollama Server"),
- onInput: (e: Event) => {
- this.name = (e.target as HTMLInputElement).value;
- this.requestUpdate();
- },
- })}
-
-
-
- ${Label({
- htmlFor: "provider-type",
- children: i18n("Provider Type"),
- })}
- ${Select({
- value: this.type,
- options: providerTypes.map((pt) => ({
- value: pt.value,
- label: pt.label,
- })),
- onChange: (value: string) => {
- this.type = value as CustomProviderType;
- this.baseUrl = "";
- this.updateDefaultBaseUrl();
- this.requestUpdate();
- },
- width: "100%",
- })}
-
-
-
- ${Label({ htmlFor: "base-url", children: i18n("Base URL") })}
- ${Input({
- value: this.baseUrl,
- placeholder: i18n("e.g., http://localhost:11434"),
- onInput: (e: Event) => {
- this.baseUrl = (e.target as HTMLInputElement).value;
- this.requestUpdate();
- },
- })}
-
-
-
- ${Label({
- htmlFor: "api-key",
- children: i18n("API Key (Optional)"),
- })}
- ${Input({
- type: "password",
- value: this.apiKey,
- placeholder: i18n("Leave empty if not required"),
- onInput: (e: Event) => {
- this.apiKey = (e.target as HTMLInputElement).value;
- this.requestUpdate();
- },
- })}
-
-
- ${this.isAutoDiscoveryType()
- ? html`
-
- ${Button({
- onClick: () => this.testConnection(),
- variant: "outline",
- disabled: this.testing || !this.baseUrl,
- children: this.testing
- ? i18n("Testing...")
- : i18n("Test Connection"),
- })}
- ${this.testError
- ? html`
-
- ${this.testError}
-
- `
- : ""}
- ${this.discoveredModels.length > 0
- ? html`
-
- ${i18n("Discovered")}
- ${this.discoveredModels.length} ${i18n("models")}:
-
- ${this.discoveredModels
- .slice(0, 5)
- .map((model) => html`${model.name} `)}
- ${this.discoveredModels.length > 5
- ? html`
- ...${i18n("and")}
- ${this.discoveredModels.length - 5}
- ${i18n("more")}
- `
- : ""}
-
-
- `
- : ""}
-
- `
- : html`
- ${i18n(
- "For manual provider types, add models after saving the provider.",
- )}
-
`}
-
-
-
-
- ${Button({
- onClick: () => this.close(),
- variant: "ghost",
- children: i18n("Cancel"),
- })}
- ${Button({
- onClick: () => this.save(),
- variant: "default",
- disabled: !this.name || !this.baseUrl,
- children: i18n("Save"),
- })}
-
-
- `;
- }
-}
-
-customElements.define("custom-provider-dialog", CustomProviderDialog);
diff --git a/packages/web-ui/src/dialogs/ModelSelector.ts b/packages/web-ui/src/dialogs/ModelSelector.ts
deleted file mode 100644
index 898c31c..0000000
--- a/packages/web-ui/src/dialogs/ModelSelector.ts
+++ /dev/null
@@ -1,367 +0,0 @@
-import { icon } from "@mariozechner/mini-lit";
-import { Badge } from "@mariozechner/mini-lit/dist/Badge.js";
-import { Button } from "@mariozechner/mini-lit/dist/Button.js";
-import { DialogHeader } from "@mariozechner/mini-lit/dist/Dialog.js";
-import { DialogBase } from "@mariozechner/mini-lit/dist/DialogBase.js";
-import {
- getModels,
- getProviders,
- type Model,
- modelsAreEqual,
-} from "@mariozechner/pi-ai";
-import { html, type PropertyValues, type TemplateResult } 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 { Input } from "../components/Input.js";
-import { getAppStorage } from "../storage/app-storage.js";
-import type { AutoDiscoveryProviderType } from "../storage/stores/custom-providers-store.js";
-import { formatModelCost } from "../utils/format.js";
-import { i18n } from "../utils/i18n.js";
-import { discoverModels } from "../utils/model-discovery.js";
-
-@customElement("agent-model-selector")
-export class ModelSelector extends DialogBase {
- @state() currentModel: Model | null = null;
- @state() searchQuery = "";
- @state() filterThinking = false;
- @state() filterVision = false;
- @state() customProvidersLoading = false;
- @state() selectedIndex = 0;
- @state() private navigationMode: "mouse" | "keyboard" = "mouse";
- @state() private customProviderModels: Model[] = [];
-
- 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.loadCustomProviders();
- }
-
- 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 loadCustomProviders() {
- this.customProvidersLoading = true;
- const allCustomModels: Model[] = [];
-
- try {
- const storage = getAppStorage();
- const customProviders = await storage.customProviders.getAll();
-
- // Load models from custom providers
- for (const provider of customProviders) {
- const isAutoDiscovery: boolean =
- provider.type === "ollama" ||
- provider.type === "llama.cpp" ||
- provider.type === "vllm" ||
- provider.type === "lmstudio";
-
- if (isAutoDiscovery) {
- try {
- const models = await discoverModels(
- provider.type as AutoDiscoveryProviderType,
- provider.baseUrl,
- provider.apiKey,
- );
-
- const modelsWithProvider = models.map((model) => ({
- ...model,
- provider: provider.name,
- }));
-
- allCustomModels.push(...modelsWithProvider);
- } catch (error) {
- console.debug(
- `Failed to load models from ${provider.name}:`,
- error,
- );
- }
- } else if (provider.models) {
- // Manual provider - models already defined
- allCustomModels.push(...provider.models);
- }
- }
- } catch (error) {
- console.error("Failed to load custom providers:", error);
- } finally {
- this.customProviderModels = allCustomModels;
- this.customProvidersLoading = false;
- this.requestUpdate();
- }
- }
-
- 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 known providers
- const allModels: Array<{ provider: string; id: string; model: any }> = [];
- const knownProviders = getProviders();
-
- for (const provider of knownProviders) {
- const models = getModels(provider as any);
- for (const model of models) {
- allModels.push({ provider, id: model.id, model });
- }
- }
-
- // Add custom provider models
- for (const model of this.customProviderModels) {
- allModels.push({ provider: model.provider, id: model.id, model });
- }
-
- // 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
- .toLowerCase()
- .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 = modelsAreEqual(this.currentModel, a.model);
- const bIsCurrent = modelsAreEqual(this.currentModel, b.model);
- 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) => {
- const isCurrent = modelsAreEqual(this.currentModel, model);
- 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/dialogs/PersistentStorageDialog.ts b/packages/web-ui/src/dialogs/PersistentStorageDialog.ts
deleted file mode 100644
index 875682c..0000000
--- a/packages/web-ui/src/dialogs/PersistentStorageDialog.ts
+++ /dev/null
@@ -1,178 +0,0 @@
-import { Button } from "@mariozechner/mini-lit/dist/Button.js";
-import {
- DialogContent,
- DialogHeader,
-} from "@mariozechner/mini-lit/dist/Dialog.js";
-import { DialogBase } from "@mariozechner/mini-lit/dist/DialogBase.js";
-import { html } from "lit";
-import { customElement, state } from "lit/decorators.js";
-import { i18n } from "../utils/i18n.js";
-
-@customElement("persistent-storage-dialog")
-export class PersistentStorageDialog extends DialogBase {
- @state() private requesting = false;
-
- private resolvePromise?: (userApproved: boolean) => void;
-
- protected modalWidth = "min(500px, 90vw)";
- protected modalHeight = "auto";
-
- /**
- * Request persistent storage permission.
- * Returns true if browser granted persistent storage, false otherwise.
- */
- static async request(): Promise {
- // Check if already persisted
- if (navigator.storage?.persisted) {
- const alreadyPersisted = await navigator.storage.persisted();
- if (alreadyPersisted) {
- console.log("✓ Persistent storage already granted");
- return true;
- }
- }
-
- // Show dialog and wait for user response
- const dialog = new PersistentStorageDialog();
- dialog.open();
-
- const userApproved = await new Promise((resolve) => {
- dialog.resolvePromise = resolve;
- });
-
- if (!userApproved) {
- console.warn("⚠ User declined persistent storage - sessions may be lost");
- return false;
- }
-
- // User approved, request from browser
- if (!navigator.storage?.persist) {
- console.warn("⚠ Persistent storage API not available");
- return false;
- }
-
- try {
- const granted = await navigator.storage.persist();
- if (granted) {
- console.log(
- "✓ Persistent storage granted - sessions will be preserved",
- );
- } else {
- console.warn(
- "⚠ Browser denied persistent storage - sessions may be lost under storage pressure",
- );
- }
- return granted;
- } catch (error) {
- console.error("Failed to request persistent storage:", error);
- return false;
- }
- }
-
- private handleGrant() {
- if (this.resolvePromise) {
- this.resolvePromise(true);
- this.resolvePromise = undefined;
- }
- this.close();
- }
-
- private handleDeny() {
- if (this.resolvePromise) {
- this.resolvePromise(false);
- this.resolvePromise = undefined;
- }
- this.close();
- }
-
- override close() {
- super.close();
- if (this.resolvePromise) {
- this.resolvePromise(false);
- }
- }
-
- protected override renderContent() {
- return html`
- ${DialogContent({
- children: html`
- ${DialogHeader({
- title: i18n("Storage Permission Required"),
- description: i18n(
- "This app needs persistent storage to save your conversations",
- ),
- })}
-
-
-
-
-
-
- ${i18n("Why is this needed?")}
-
-
- ${i18n(
- "Without persistent storage, your browser may delete saved conversations when it needs disk space. Granting this permission ensures your chat history is preserved.",
- )}
-
-
-
-
-
-
${i18n("What this means:")}
-
-
- ${i18n(
- "Your conversations will be saved locally in your browser",
- )}
-
-
- ${i18n(
- "Data will not be deleted automatically to free up space",
- )}
-
-
- ${i18n("You can still manually clear data at any time")}
-
- ${i18n("No data is sent to external servers")}
-
-
-
-
-
- ${Button({
- variant: "outline",
- onClick: () => this.handleDeny(),
- disabled: this.requesting,
- children: i18n("Continue Anyway"),
- })}
- ${Button({
- variant: "default",
- onClick: () => this.handleGrant(),
- disabled: this.requesting,
- children: this.requesting
- ? i18n("Requesting...")
- : i18n("Grant Permission"),
- })}
-
- `,
- })}
- `;
- }
-}
diff --git a/packages/web-ui/src/dialogs/ProvidersModelsTab.ts b/packages/web-ui/src/dialogs/ProvidersModelsTab.ts
deleted file mode 100644
index 7638974..0000000
--- a/packages/web-ui/src/dialogs/ProvidersModelsTab.ts
+++ /dev/null
@@ -1,249 +0,0 @@
-import { i18n } from "@mariozechner/mini-lit";
-import { Select } from "@mariozechner/mini-lit/dist/Select.js";
-import { getProviders } from "@mariozechner/pi-ai";
-import { html, type TemplateResult } from "lit";
-import { customElement, state } from "lit/decorators.js";
-import "../components/CustomProviderCard.js";
-import "../components/ProviderKeyInput.js";
-import { getAppStorage } from "../storage/app-storage.js";
-import type {
- AutoDiscoveryProviderType,
- CustomProvider,
- CustomProviderType,
-} from "../storage/stores/custom-providers-store.js";
-import { discoverModels } from "../utils/model-discovery.js";
-import { CustomProviderDialog } from "./CustomProviderDialog.js";
-import { SettingsTab } from "./SettingsDialog.js";
-
-@customElement("providers-models-tab")
-export class ProvidersModelsTab extends SettingsTab {
- @state() private customProviders: CustomProvider[] = [];
- @state() private providerStatus: Map<
- string,
- { modelCount: number; status: "connected" | "disconnected" | "checking" }
- > = new Map();
-
- override async connectedCallback() {
- super.connectedCallback();
- await this.loadCustomProviders();
- }
-
- private async loadCustomProviders() {
- try {
- const storage = getAppStorage();
- this.customProviders = await storage.customProviders.getAll();
-
- // Check status for auto-discovery providers
- for (const provider of this.customProviders) {
- const isAutoDiscovery =
- provider.type === "ollama" ||
- provider.type === "llama.cpp" ||
- provider.type === "vllm" ||
- provider.type === "lmstudio";
- if (isAutoDiscovery) {
- this.checkProviderStatus(provider);
- }
- }
- } catch (error) {
- console.error("Failed to load custom providers:", error);
- }
- }
-
- getTabName(): string {
- return "Providers & Models";
- }
-
- private async checkProviderStatus(provider: CustomProvider) {
- this.providerStatus.set(provider.id, { modelCount: 0, status: "checking" });
- this.requestUpdate();
-
- try {
- const models = await discoverModels(
- provider.type as AutoDiscoveryProviderType,
- provider.baseUrl,
- provider.apiKey,
- );
-
- this.providerStatus.set(provider.id, {
- modelCount: models.length,
- status: "connected",
- });
- } catch (_error) {
- this.providerStatus.set(provider.id, {
- modelCount: 0,
- status: "disconnected",
- });
- }
- this.requestUpdate();
- }
-
- private renderKnownProviders(): TemplateResult {
- const providers = getProviders();
-
- return html`
-
-
-
- Cloud Providers
-
-
- Cloud LLM providers with predefined models. API keys are stored
- locally in your browser.
-
-
-
- ${providers.map(
- (provider) => html`
-
- `,
- )}
-
-
- `;
- }
-
- private renderCustomProviders(): TemplateResult {
- const isAutoDiscovery = (type: string) =>
- type === "ollama" ||
- type === "llama.cpp" ||
- type === "vllm" ||
- type === "lmstudio";
-
- return html`
-
-
-
-
- Custom Providers
-
-
- User-configured servers with auto-discovered or manually defined
- models.
-
-
- ${Select({
- placeholder: i18n("Add Provider"),
- options: [
- { value: "ollama", label: "Ollama" },
- { value: "llama.cpp", label: "llama.cpp" },
- { value: "vllm", label: "vLLM" },
- { value: "lmstudio", label: "LM Studio" },
- {
- value: "openai-completions",
- label: i18n("OpenAI Completions Compatible"),
- },
- {
- value: "openai-responses",
- label: i18n("OpenAI Responses Compatible"),
- },
- {
- value: "anthropic-messages",
- label: i18n("Anthropic Messages Compatible"),
- },
- ],
- onChange: (value: string) =>
- this.addCustomProvider(value as CustomProviderType),
- variant: "outline",
- size: "sm",
- })}
-
-
- ${this.customProviders.length === 0
- ? html`
-
- No custom providers configured. Click 'Add Provider' to get
- started.
-
- `
- : html`
-
- ${this.customProviders.map(
- (provider) => html`
-
- this.refreshProvider(p)}
- .onEdit=${(p: CustomProvider) => this.editProvider(p)}
- .onDelete=${(p: CustomProvider) => this.deleteProvider(p)}
- >
- `,
- )}
-
- `}
-
- `;
- }
-
- private async addCustomProvider(type: CustomProviderType) {
- await CustomProviderDialog.open(undefined, type, async () => {
- await this.loadCustomProviders();
- this.requestUpdate();
- });
- }
-
- private async editProvider(provider: CustomProvider) {
- await CustomProviderDialog.open(provider, undefined, async () => {
- await this.loadCustomProviders();
- this.requestUpdate();
- });
- }
-
- private async refreshProvider(provider: CustomProvider) {
- this.providerStatus.set(provider.id, { modelCount: 0, status: "checking" });
- this.requestUpdate();
-
- try {
- const models = await discoverModels(
- provider.type as AutoDiscoveryProviderType,
- provider.baseUrl,
- provider.apiKey,
- );
-
- this.providerStatus.set(provider.id, {
- modelCount: models.length,
- status: "connected",
- });
- this.requestUpdate();
-
- console.log(`Refreshed ${models.length} models from ${provider.name}`);
- } catch (error) {
- this.providerStatus.set(provider.id, {
- modelCount: 0,
- status: "disconnected",
- });
- this.requestUpdate();
-
- console.error(`Failed to refresh provider ${provider.name}:`, error);
- alert(
- `Failed to refresh provider: ${error instanceof Error ? error.message : String(error)}`,
- );
- }
- }
-
- private async deleteProvider(provider: CustomProvider) {
- if (!confirm("Are you sure you want to delete this provider?")) {
- return;
- }
-
- try {
- const storage = getAppStorage();
- await storage.customProviders.delete(provider.id);
- await this.loadCustomProviders();
- this.requestUpdate();
- } catch (error) {
- console.error("Failed to delete provider:", error);
- }
- }
-
- render(): TemplateResult {
- return html`
-
- ${this.renderKnownProviders()}
-
- ${this.renderCustomProviders()}
-
- `;
- }
-}
diff --git a/packages/web-ui/src/dialogs/SessionListDialog.ts b/packages/web-ui/src/dialogs/SessionListDialog.ts
deleted file mode 100644
index 240f188..0000000
--- a/packages/web-ui/src/dialogs/SessionListDialog.ts
+++ /dev/null
@@ -1,179 +0,0 @@
-import {
- DialogContent,
- DialogHeader,
-} from "@mariozechner/mini-lit/dist/Dialog.js";
-import { DialogBase } from "@mariozechner/mini-lit/dist/DialogBase.js";
-import { html } from "lit";
-import { customElement, state } from "lit/decorators.js";
-import { getAppStorage } from "../storage/app-storage.js";
-import type { SessionMetadata } from "../storage/types.js";
-import { formatUsage } from "../utils/format.js";
-import { i18n } from "../utils/i18n.js";
-
-@customElement("session-list-dialog")
-export class SessionListDialog extends DialogBase {
- @state() private sessions: SessionMetadata[] = [];
- @state() private loading = true;
-
- private onSelectCallback?: (sessionId: string) => void;
- private onDeleteCallback?: (sessionId: string) => void;
- private deletedSessions = new Set();
- private closedViaSelection = false;
-
- protected modalWidth = "min(600px, 90vw)";
- protected modalHeight = "min(700px, 90vh)";
-
- static async open(
- onSelect: (sessionId: string) => void,
- onDelete?: (sessionId: string) => void,
- ) {
- const dialog = new SessionListDialog();
- dialog.onSelectCallback = onSelect;
- dialog.onDeleteCallback = onDelete;
- dialog.open();
- await dialog.loadSessions();
- }
-
- private async loadSessions() {
- this.loading = true;
- try {
- const storage = getAppStorage();
- this.sessions = await storage.sessions.getAllMetadata();
- } catch (err) {
- console.error("Failed to load sessions:", err);
- this.sessions = [];
- } finally {
- this.loading = false;
- }
- }
-
- private async handleDelete(sessionId: string, event: Event) {
- event.stopPropagation();
-
- if (!confirm(i18n("Delete this session?"))) {
- return;
- }
-
- try {
- const storage = getAppStorage();
- if (!storage.sessions) return;
-
- await storage.sessions.deleteSession(sessionId);
- await this.loadSessions();
-
- // Track deleted session
- this.deletedSessions.add(sessionId);
- } catch (err) {
- console.error("Failed to delete session:", err);
- }
- }
-
- override close() {
- super.close();
-
- // Only notify about deleted sessions if dialog wasn't closed via selection
- if (
- !this.closedViaSelection &&
- this.onDeleteCallback &&
- this.deletedSessions.size > 0
- ) {
- for (const sessionId of this.deletedSessions) {
- this.onDeleteCallback(sessionId);
- }
- }
- }
-
- private handleSelect(sessionId: string) {
- this.closedViaSelection = true;
- if (this.onSelectCallback) {
- this.onSelectCallback(sessionId);
- }
- this.close();
- }
-
- private formatDate(isoString: string): string {
- const date = new Date(isoString);
- const now = new Date();
- const diff = now.getTime() - date.getTime();
- const days = Math.floor(diff / (1000 * 60 * 60 * 24));
-
- if (days === 0) {
- return i18n("Today");
- } else if (days === 1) {
- return i18n("Yesterday");
- } else if (days < 7) {
- return i18n("{days} days ago").replace("{days}", days.toString());
- } else {
- return date.toLocaleDateString();
- }
- }
-
- protected override renderContent() {
- return html`
- ${DialogContent({
- className: "h-full flex flex-col",
- children: html`
- ${DialogHeader({
- title: i18n("Sessions"),
- description: i18n("Load a previous conversation"),
- })}
-
-
- ${this.loading
- ? html`
- ${i18n("Loading...")}
-
`
- : this.sessions.length === 0
- ? html`
- ${i18n("No sessions yet")}
-
`
- : this.sessions.map(
- (session) => html`
-
this.handleSelect(session.id)}
- >
-
-
- ${session.title}
-
-
- ${this.formatDate(session.lastModified)}
-
-
- ${session.messageCount} ${i18n("messages")} ·
- ${formatUsage(session.usage)}
-
-
-
- this.handleDelete(session.id, e)}
- title=${i18n("Delete")}
- >
-
-
-
-
-
-
-
- `,
- )}
-
- `,
- })}
- `;
- }
-}
diff --git a/packages/web-ui/src/dialogs/SettingsDialog.ts b/packages/web-ui/src/dialogs/SettingsDialog.ts
deleted file mode 100644
index edad4da..0000000
--- a/packages/web-ui/src/dialogs/SettingsDialog.ts
+++ /dev/null
@@ -1,241 +0,0 @@
-import { i18n } from "@mariozechner/mini-lit";
-import {
- Dialog,
- DialogContent,
- DialogHeader,
-} from "@mariozechner/mini-lit/dist/Dialog.js";
-import { Input } from "@mariozechner/mini-lit/dist/Input.js";
-import { Label } from "@mariozechner/mini-lit/dist/Label.js";
-import { Switch } from "@mariozechner/mini-lit/dist/Switch.js";
-import { getProviders } from "@mariozechner/pi-ai";
-import { html, LitElement, type TemplateResult } from "lit";
-import { customElement, property, state } from "lit/decorators.js";
-import "../components/ProviderKeyInput.js";
-import { getAppStorage } from "../storage/app-storage.js";
-
-// Base class for settings tabs
-export abstract class SettingsTab extends LitElement {
- abstract getTabName(): string;
-
- protected createRenderRoot() {
- return this;
- }
-}
-
-// API Keys Tab
-@customElement("api-keys-tab")
-export class ApiKeysTab extends SettingsTab {
- getTabName(): string {
- return i18n("API Keys");
- }
-
- render(): TemplateResult {
- const providers = getProviders();
-
- return html`
-
-
- ${i18n(
- "Configure API keys for LLM providers. Keys are stored locally in your browser.",
- )}
-
- ${providers.map(
- (provider) =>
- html`
`,
- )}
-
- `;
- }
-}
-
-// Proxy Tab
-@customElement("proxy-tab")
-export class ProxyTab extends SettingsTab {
- @state() private proxyEnabled = false;
- @state() private proxyUrl = "http://localhost:3001";
-
- override async connectedCallback() {
- super.connectedCallback();
- // Load proxy settings when tab is connected
- try {
- const storage = getAppStorage();
- const enabled = await storage.settings.get("proxy.enabled");
- const url = await storage.settings.get("proxy.url");
-
- if (enabled !== null) this.proxyEnabled = enabled;
- if (url !== null) this.proxyUrl = url;
- } catch (error) {
- console.error("Failed to load proxy settings:", error);
- }
- }
-
- private async saveProxySettings() {
- try {
- const storage = getAppStorage();
- await storage.settings.set("proxy.enabled", this.proxyEnabled);
- await storage.settings.set("proxy.url", this.proxyUrl);
- } catch (error) {
- console.error("Failed to save proxy settings:", error);
- }
- }
-
- getTabName(): string {
- return i18n("Proxy");
- }
-
- render(): TemplateResult {
- return html`
-
-
- ${i18n(
- "Allows browser-based apps to bypass CORS restrictions when calling LLM providers. Required for Z-AI and Anthropic with OAuth token.",
- )}
-
-
-
- ${i18n("Use CORS Proxy")}
- ${Switch({
- checked: this.proxyEnabled,
- onChange: (checked: boolean) => {
- this.proxyEnabled = checked;
- this.saveProxySettings();
- },
- })}
-
-
-
- ${Label({ children: i18n("Proxy URL") })}
- ${Input({
- type: "text",
- value: this.proxyUrl,
- disabled: !this.proxyEnabled,
- onInput: (e) => {
- this.proxyUrl = (e.target as HTMLInputElement).value;
- },
- onChange: () => this.saveProxySettings(),
- })}
-
- ${i18n(
- "Format: The proxy must accept requests as /?url=",
- )}
-
-
-
- `;
- }
-}
-
-@customElement("settings-dialog")
-export class SettingsDialog extends LitElement {
- @property({ type: Array, attribute: false }) tabs: SettingsTab[] = [];
- @state() private isOpen = false;
- @state() private activeTabIndex = 0;
-
- protected createRenderRoot() {
- return this;
- }
-
- static async open(tabs: SettingsTab[]) {
- const dialog = new SettingsDialog();
- dialog.tabs = tabs;
- dialog.isOpen = true;
- document.body.appendChild(dialog);
- }
-
- private setActiveTab(index: number) {
- this.activeTabIndex = index;
- }
-
- private renderSidebarItem(tab: SettingsTab, index: number): TemplateResult {
- const isActive = this.activeTabIndex === index;
- return html`
- this.setActiveTab(index)}
- >
- ${tab.getTabName()}
-
- `;
- }
-
- private renderMobileTab(tab: SettingsTab, index: number): TemplateResult {
- const isActive = this.activeTabIndex === index;
- return html`
- this.setActiveTab(index)}
- >
- ${tab.getTabName()}
-
- `;
- }
-
- render() {
- if (this.tabs.length === 0) {
- return html``;
- }
-
- return Dialog({
- isOpen: this.isOpen,
- onClose: () => {
- this.isOpen = false;
- this.remove();
- },
- width: "min(1000px, 90vw)",
- height: "min(800px, 90vh)",
- backdropClassName: "bg-black/50 backdrop-blur-sm",
- children: html`
- ${DialogContent({
- className: "h-full p-6",
- children: html`
-
-
-
- ${DialogHeader({ title: i18n("Settings") })}
-
-
-
-
- ${this.tabs.map((tab, index) =>
- this.renderMobileTab(tab, index),
- )}
-
-
-
-
-
-
- ${this.tabs.map((tab, index) =>
- this.renderSidebarItem(tab, index),
- )}
-
-
-
-
- ${this.tabs.map(
- (tab, index) =>
- html`
- ${tab}
-
`,
- )}
-
-
-
- `,
- })}
- `,
- });
- }
-}
diff --git a/packages/web-ui/src/index.ts b/packages/web-ui/src/index.ts
deleted file mode 100644
index 6d675f3..0000000
--- a/packages/web-ui/src/index.ts
+++ /dev/null
@@ -1,167 +0,0 @@
-// Main chat interface
-
-export type {
- Agent,
- AgentMessage,
- AgentState,
- ThinkingLevel,
-} from "@mariozechner/pi-agent-core";
-export type { Model } from "@mariozechner/pi-ai";
-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 { CustomProviderCard } from "./components/CustomProviderCard.js";
-export { ExpandableSection } from "./components/ExpandableSection.js";
-export { Input } from "./components/Input.js";
-export { MessageEditor } from "./components/MessageEditor.js";
-export { MessageList } from "./components/MessageList.js";
-// Message components
-export type {
- ArtifactMessage,
- UserMessageWithAttachments,
-} from "./components/Messages.js";
-export {
- AbortedMessage,
- AssistantMessage,
- convertAttachments,
- defaultConvertToLlm,
- isArtifactMessage,
- isUserMessageWithAttachments,
- ToolMessage,
- ToolMessageDebugView,
- UserMessage,
-} from "./components/Messages.js";
-// Message renderer registry
-export {
- getMessageRenderer,
- type MessageRenderer,
- type MessageRole,
- registerMessageRenderer,
- renderMessage,
-} from "./components/message-renderer-registry.js";
-export { ProviderKeyInput } from "./components/ProviderKeyInput.js";
-export {
- type SandboxFile,
- SandboxIframe,
- type SandboxResult,
- type SandboxUrlProvider,
-} from "./components/SandboxedIframe.js";
-export { StreamingMessageContainer } from "./components/StreamingMessageContainer.js";
-// Sandbox Runtime Providers
-export { ArtifactsRuntimeProvider } from "./components/sandbox/ArtifactsRuntimeProvider.js";
-export { AttachmentsRuntimeProvider } from "./components/sandbox/AttachmentsRuntimeProvider.js";
-export {
- type ConsoleLog,
- ConsoleRuntimeProvider,
-} from "./components/sandbox/ConsoleRuntimeProvider.js";
-export {
- type DownloadableFile,
- FileDownloadRuntimeProvider,
-} from "./components/sandbox/FileDownloadRuntimeProvider.js";
-export { RuntimeMessageBridge } from "./components/sandbox/RuntimeMessageBridge.js";
-export { RUNTIME_MESSAGE_ROUTER } from "./components/sandbox/RuntimeMessageRouter.js";
-export type { SandboxRuntimeProvider } from "./components/sandbox/SandboxRuntimeProvider.js";
-export { ThinkingBlock } from "./components/ThinkingBlock.js";
-export { ApiKeyPromptDialog } from "./dialogs/ApiKeyPromptDialog.js";
-export { AttachmentOverlay } from "./dialogs/AttachmentOverlay.js";
-// Dialogs
-export { ModelSelector } from "./dialogs/ModelSelector.js";
-export { PersistentStorageDialog } from "./dialogs/PersistentStorageDialog.js";
-export { ProvidersModelsTab } from "./dialogs/ProvidersModelsTab.js";
-export { SessionListDialog } from "./dialogs/SessionListDialog.js";
-export {
- ApiKeysTab,
- ProxyTab,
- SettingsDialog,
- SettingsTab,
-} from "./dialogs/SettingsDialog.js";
-// Prompts
-export {
- ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RO,
- ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RW,
- ATTACHMENTS_RUNTIME_DESCRIPTION,
-} from "./prompts/prompts.js";
-// Storage
-export {
- AppStorage,
- getAppStorage,
- setAppStorage,
-} from "./storage/app-storage.js";
-export { IndexedDBStorageBackend } from "./storage/backends/indexeddb-storage-backend.js";
-export { Store } from "./storage/store.js";
-export type {
- AutoDiscoveryProviderType,
- CustomProvider,
- CustomProviderType,
-} from "./storage/stores/custom-providers-store.js";
-export { CustomProvidersStore } from "./storage/stores/custom-providers-store.js";
-export { ProviderKeysStore } from "./storage/stores/provider-keys-store.js";
-export { SessionsStore } from "./storage/stores/sessions-store.js";
-export { SettingsStore } from "./storage/stores/settings-store.js";
-export type {
- IndexConfig,
- IndexedDBConfig,
- SessionData,
- SessionMetadata,
- StorageBackend,
- StorageTransaction,
- StoreConfig,
-} from "./storage/types.js";
-// Artifacts
-export { ArtifactElement } from "./tools/artifacts/ArtifactElement.js";
-export { ArtifactPill } from "./tools/artifacts/ArtifactPill.js";
-export {
- type Artifact,
- ArtifactsPanel,
- type ArtifactsParams,
-} from "./tools/artifacts/artifacts.js";
-export { ArtifactsToolRenderer } from "./tools/artifacts/artifacts-tool-renderer.js";
-export { HtmlArtifact } from "./tools/artifacts/HtmlArtifact.js";
-export { ImageArtifact } from "./tools/artifacts/ImageArtifact.js";
-export { MarkdownArtifact } from "./tools/artifacts/MarkdownArtifact.js";
-export { SvgArtifact } from "./tools/artifacts/SvgArtifact.js";
-export { TextArtifact } from "./tools/artifacts/TextArtifact.js";
-export {
- createExtractDocumentTool,
- extractDocumentTool,
-} from "./tools/extract-document.js";
-// Tools
-export {
- getToolRenderer,
- registerToolRenderer,
- renderTool,
- setShowJsonMode,
-} from "./tools/index.js";
-export {
- createJavaScriptReplTool,
- javascriptReplTool,
-} from "./tools/javascript-repl.js";
-export {
- renderCollapsibleHeader,
- renderHeader,
-} from "./tools/renderer-registry.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, ToolRenderResult } 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, translations } from "./utils/i18n.js";
-export {
- applyProxyIfNeeded,
- createStreamFn,
- isCorsError,
- shouldUseProxyForProvider,
-} from "./utils/proxy-utils.js";
diff --git a/packages/web-ui/src/prompts/prompts.ts b/packages/web-ui/src/prompts/prompts.ts
deleted file mode 100644
index d8983c9..0000000
--- a/packages/web-ui/src/prompts/prompts.ts
+++ /dev/null
@@ -1,286 +0,0 @@
-/**
- * Centralized tool prompts/descriptions.
- * Each prompt is either a string constant or a template function.
- */
-
-// ============================================================================
-// JavaScript REPL Tool
-// ============================================================================
-
-export const JAVASCRIPT_REPL_TOOL_DESCRIPTION = (
- runtimeProviderDescriptions: string[],
-) => `# JavaScript REPL
-
-## Purpose
-Execute JavaScript code in a sandboxed browser environment with full Web APIs.
-
-## When to Use
-- Quick calculations or data transformations
-- Testing JavaScript code snippets in isolation
-- Processing data with libraries (XLSX, CSV, etc.)
-- Creating artifacts from data
-
-## Environment
-- ES2023+ JavaScript (async/await, optional chaining, nullish coalescing, etc.)
-- All browser APIs: DOM, Canvas, WebGL, Fetch, Web Workers, WebSockets, Crypto, etc.
-- Import any npm package: await import('https://esm.run/package-name')
-
-## Common Libraries
-- XLSX: const XLSX = await import('https://esm.run/xlsx');
-- CSV: const Papa = (await import('https://esm.run/papaparse')).default;
-- Chart.js: const Chart = (await import('https://esm.run/chart.js/auto')).default;
-- Three.js: const THREE = await import('https://esm.run/three');
-
-## Persistence between tool calls
-- Objects stored on global scope do not persist between calls.
-- Use artifacts as a key-value JSON object store:
- - Use createOrUpdateArtifact(filename, content) to persist data between calls. JSON objects are auto-stringified.
- - Use listArtifacts() and getArtifact(filename) to read persisted data. JSON files are auto-parsed to objects.
- - Prefer to use a single artifact throughout the session to store intermediate data (e.g. 'data.json').
-
-## Input
-- You have access to the user's attachments via listAttachments(), readTextAttachment(id), and readBinaryAttachment(id)
-- You have access to previously created artifacts via listArtifacts() and getArtifact(filename)
-
-## Output
-- All console.log() calls are captured for you to inspect. The user does not see these logs.
-- Create artifacts for file results (images, JSON, CSV, etc.) which persiste throughout the
- session and are accessible to you and the user.
-
-## Example
-const data = [10, 20, 15, 25];
-const sum = data.reduce((a, b) => a + b, 0);
-const avg = sum / data.length;
-console.log('Sum:', sum, 'Average:', avg);
-
-## Important Notes
-- Graphics: Use fixed dimensions (800x600), NOT window.innerWidth/Height
-- Chart.js: Set options: { responsive: false, animation: false }
-- Three.js: renderer.setSize(800, 600) with matching aspect ratio
-
-## Helper Functions (Automatically Available)
-
-These functions are injected into the execution environment and available globally:
-
-${runtimeProviderDescriptions.join("\n\n")}
-`;
-
-// ============================================================================
-// Artifacts Tool
-// ============================================================================
-
-export const ARTIFACTS_TOOL_DESCRIPTION = (
- runtimeProviderDescriptions: string[],
-) => `# Artifacts
-
-Create and manage persistent files that live alongside the conversation.
-
-## When to Use - Artifacts Tool vs REPL
-
-**Use artifacts tool when YOU are the author:**
-- Writing research summaries, analysis, ideas, documentation
-- Creating markdown notes for user to read
-- Building HTML applications/visualizations that present data
-- Creating HTML artifacts that render charts from programmatically generated data
-
-**Use repl + artifact storage functions when CODE processes data:**
-- Scraping workflows that extract and store data
-- Processing CSV/Excel files programmatically
-- Data transformation pipelines
-- Binary file generation requiring libraries (PDF, DOCX)
-
-**Pattern: REPL generates data → Artifacts tool creates HTML that visualizes it**
-Example: repl scrapes products → stores products.json → you author dashboard.html that reads products.json and renders Chart.js visualizations
-
-## Input
-- { action: "create", filename: "notes.md", content: "..." } - Create new file
-- { action: "update", filename: "notes.md", old_str: "...", new_str: "..." } - Update part of file (PREFERRED)
-- { action: "rewrite", filename: "notes.md", content: "..." } - Replace entire file (LAST RESORT)
-- { action: "get", filename: "data.json" } - Retrieve file content
-- { action: "delete", filename: "old.csv" } - Delete file
-- { action: "htmlArtifactLogs", filename: "app.html" } - Get console logs from HTML artifact
-
-## Returns
-Depends on action:
-- create/update/rewrite/delete: Success status or error
-- get: File content
-- htmlArtifactLogs: Console logs and errors
-
-## Supported File Types
-✅ Text-based files you author: .md, .txt, .html, .js, .css, .json, .csv, .svg
-❌ Binary files requiring libraries (use repl): .pdf, .docx
-
-## Critical - Prefer Update Over Rewrite
-❌ NEVER: get entire file + rewrite to change small sections
-✅ ALWAYS: update for targeted edits (token efficient)
-✅ Ask: Can I describe the change as old_str → new_str? Use update.
-
----
-
-## HTML Artifacts
-
-Interactive HTML applications that can visualize data from other artifacts.
-
-### Data Access
-- Can read artifacts created by repl and user attachments
-- Use to build dashboards, visualizations, interactive tools
-- See Helper Functions section below for available functions
-
-### Requirements
-- Self-contained single file
-- Import ES modules from esm.sh:
-- Use Tailwind CDN:
-- Can embed images from any domain:
-- MUST set background color explicitly (avoid transparent)
-- Inline CSS or Tailwind utility classes
-- No localStorage/sessionStorage
-
-### Styling
-- Use Tailwind utility classes for clean, functional designs
-- Ensure responsive layout (iframe may be resized)
-- Avoid purple gradients, AI aesthetic clichés, and emojis
-
-### Helper Functions (Automatically Available)
-
-These functions are injected into HTML artifact sandbox:
-
-${runtimeProviderDescriptions.join("\n\n")}
-`;
-
-// ============================================================================
-// Artifacts Runtime Provider
-// ============================================================================
-
-export const ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RW = `
-### Artifacts Storage
-
-Create, read, update, and delete files in artifacts storage.
-
-#### When to Use
-- Store intermediate results between tool calls
-- Save generated files (images, CSVs, processed data) for user to view and download
-
-#### Do NOT Use For
-- Content you author directly, like summaries of content you read (use artifacts tool instead)
-
-#### Functions
-- listArtifacts() - List all artifact filenames, returns Promise
-- getArtifact(filename) - Read artifact content, returns Promise. JSON files auto-parse to objects, binary files return base64 string
-- createOrUpdateArtifact(filename, content, mimeType?) - Create or update artifact, returns Promise. JSON files auto-stringify objects, binary requires base64 string with mimeType
-- deleteArtifact(filename) - Delete artifact, returns Promise
-
-#### Example
-JSON workflow:
-\`\`\`javascript
-// Fetch and save
-const response = await fetch('https://api.example.com/products');
-const products = await response.json();
-await createOrUpdateArtifact('products.json', products);
-
-// Later: read and filter
-const all = await getArtifact('products.json');
-const cheap = all.filter(p => p.price < 100);
-await createOrUpdateArtifact('cheap.json', cheap);
-\`\`\`
-
-Binary file (image):
-\`\`\`javascript
-const canvas = document.createElement('canvas');
-canvas.width = 800; canvas.height = 600;
-const ctx = canvas.getContext('2d');
-ctx.fillStyle = 'blue';
-ctx.fillRect(0, 0, 800, 600);
-// Remove data:image/png;base64, prefix
-const base64 = canvas.toDataURL().split(',')[1];
-await createOrUpdateArtifact('chart.png', base64, 'image/png');
-\`\`\`
-`;
-
-export const ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RO = `
-### Artifacts Storage
-
-Read files from artifacts storage.
-
-#### When to Use
-- Read artifacts created by REPL or artifacts tool
-- Access data from other HTML artifacts
-- Load configuration or data files
-
-#### Do NOT Use For
-- Creating new artifacts (not available in HTML artifacts)
-- Modifying artifacts (read-only access)
-
-#### Functions
-- listArtifacts() - List all artifact filenames, returns Promise
-- getArtifact(filename) - Read artifact content, returns Promise. JSON files auto-parse to objects, binary files return base64 string
-
-#### Example
-JSON data:
-\`\`\`javascript
-const products = await getArtifact('products.json');
-const html = products.map(p => \`\${p.name}: $\${p.price}
\`).join('');
-document.body.innerHTML = html;
-\`\`\`
-
-Binary image:
-\`\`\`javascript
-const base64 = await getArtifact('chart.png');
-const img = document.createElement('img');
-img.src = 'data:image/png;base64,' + base64;
-document.body.appendChild(img);
-\`\`\`
-`;
-
-// ============================================================================
-// Attachments Runtime Provider
-// ============================================================================
-
-export const ATTACHMENTS_RUNTIME_DESCRIPTION = `
-### User Attachments
-
-Read files the user uploaded to the conversation.
-
-#### When to Use
-- Process user-uploaded files (CSV, JSON, Excel, images, PDFs)
-
-#### Functions
-- listAttachments() - List all attachments, returns array of {id, fileName, mimeType, size}
-- readTextAttachment(id) - Read attachment as text, returns string
-- readBinaryAttachment(id) - Read attachment as binary data, returns Uint8Array
-
-#### Example
-CSV file:
-\`\`\`javascript
-const files = listAttachments();
-const csvFile = files.find(f => f.fileName.endsWith('.csv'));
-const csvData = readTextAttachment(csvFile.id);
-const rows = csvData.split('\\n').map(row => row.split(','));
-\`\`\`
-
-Excel file:
-\`\`\`javascript
-const XLSX = await import('https://esm.run/xlsx');
-const files = listAttachments();
-const xlsxFile = files.find(f => f.fileName.endsWith('.xlsx'));
-const bytes = readBinaryAttachment(xlsxFile.id);
-const workbook = XLSX.read(bytes);
-const data = XLSX.utils.sheet_to_json(workbook.Sheets[workbook.SheetNames[0]]);
-\`\`\`
-`;
-
-// ============================================================================
-// Extract Document Tool
-// ============================================================================
-
-export const EXTRACT_DOCUMENT_DESCRIPTION = `# Extract Document
-
-Extract plain text from documents on the web (PDF, DOCX, XLSX, PPTX).
-
-## When to Use
-User wants you to read a document at a URL.
-
-## Input
-- { url: "https://example.com/document.pdf" } - URL to PDF, DOCX, XLSX, or PPTX
-
-## Returns
-Structured plain text with page/sheet/slide delimiters.`;
diff --git a/packages/web-ui/src/storage/app-storage.ts b/packages/web-ui/src/storage/app-storage.ts
deleted file mode 100644
index fd5b056..0000000
--- a/packages/web-ui/src/storage/app-storage.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-import type { CustomProvidersStore } from "./stores/custom-providers-store.js";
-import type { ProviderKeysStore } from "./stores/provider-keys-store.js";
-import type { SessionsStore } from "./stores/sessions-store.js";
-import type { SettingsStore } from "./stores/settings-store.js";
-import type { StorageBackend } from "./types.js";
-
-/**
- * High-level storage API providing access to all storage operations.
- * Subclasses can extend this to add domain-specific stores.
- */
-export class AppStorage {
- readonly backend: StorageBackend;
- readonly settings: SettingsStore;
- readonly providerKeys: ProviderKeysStore;
- readonly sessions: SessionsStore;
- readonly customProviders: CustomProvidersStore;
-
- constructor(
- settings: SettingsStore,
- providerKeys: ProviderKeysStore,
- sessions: SessionsStore,
- customProviders: CustomProvidersStore,
- backend: StorageBackend,
- ) {
- this.settings = settings;
- this.providerKeys = providerKeys;
- this.sessions = sessions;
- this.customProviders = customProviders;
- this.backend = backend;
- }
-
- async getQuotaInfo(): Promise<{
- usage: number;
- quota: number;
- percent: number;
- }> {
- return this.backend.getQuotaInfo();
- }
-
- async requestPersistence(): Promise {
- return this.backend.requestPersistence();
- }
-}
-
-// Global instance management
-let globalAppStorage: AppStorage | null = null;
-
-/**
- * Get the global AppStorage instance.
- * Throws if not initialized.
- */
-export function getAppStorage(): AppStorage {
- if (!globalAppStorage) {
- throw new Error("AppStorage not initialized. Call setAppStorage() first.");
- }
- return globalAppStorage;
-}
-
-/**
- * Set the global AppStorage instance.
- */
-export function setAppStorage(storage: AppStorage): void {
- globalAppStorage = storage;
-}
diff --git a/packages/web-ui/src/storage/backends/indexeddb-storage-backend.ts b/packages/web-ui/src/storage/backends/indexeddb-storage-backend.ts
deleted file mode 100644
index 6a3d598..0000000
--- a/packages/web-ui/src/storage/backends/indexeddb-storage-backend.ts
+++ /dev/null
@@ -1,210 +0,0 @@
-import type {
- IndexedDBConfig,
- StorageBackend,
- StorageTransaction,
-} from "../types.js";
-
-/**
- * IndexedDB implementation of StorageBackend.
- * Provides multi-store key-value storage with transactions and quota management.
- */
-export class IndexedDBStorageBackend implements StorageBackend {
- private dbPromise: Promise | null = null;
-
- constructor(private config: IndexedDBConfig) {}
-
- private async getDB(): Promise {
- if (!this.dbPromise) {
- this.dbPromise = new Promise((resolve, reject) => {
- const request = indexedDB.open(this.config.dbName, this.config.version);
-
- request.onerror = () => reject(request.error);
- request.onsuccess = () => resolve(request.result);
-
- request.onupgradeneeded = (_event) => {
- const db = request.result;
-
- // Create object stores from config
- for (const storeConfig of this.config.stores) {
- if (!db.objectStoreNames.contains(storeConfig.name)) {
- const store = db.createObjectStore(storeConfig.name, {
- keyPath: storeConfig.keyPath,
- autoIncrement: storeConfig.autoIncrement,
- });
-
- // Create indices
- if (storeConfig.indices) {
- for (const indexConfig of storeConfig.indices) {
- store.createIndex(indexConfig.name, indexConfig.keyPath, {
- unique: indexConfig.unique,
- });
- }
- }
- }
- }
- };
- });
- }
-
- return this.dbPromise;
- }
-
- private promisifyRequest(request: IDBRequest): Promise {
- return new Promise((resolve, reject) => {
- request.onsuccess = () => resolve(request.result);
- request.onerror = () => reject(request.error);
- });
- }
-
- async get(storeName: string, key: string): Promise {
- const db = await this.getDB();
- const tx = db.transaction(storeName, "readonly");
- const store = tx.objectStore(storeName);
- const result = await this.promisifyRequest(store.get(key));
- return result ?? null;
- }
-
- async set(
- storeName: string,
- key: string,
- value: T,
- ): Promise {
- const db = await this.getDB();
- const tx = db.transaction(storeName, "readwrite");
- const store = tx.objectStore(storeName);
- // If store has keyPath, only pass value (in-line key)
- // Otherwise pass both value and key (out-of-line key)
- if (store.keyPath) {
- await this.promisifyRequest(store.put(value));
- } else {
- await this.promisifyRequest(store.put(value, key));
- }
- }
-
- async delete(storeName: string, key: string): Promise {
- const db = await this.getDB();
- const tx = db.transaction(storeName, "readwrite");
- const store = tx.objectStore(storeName);
- await this.promisifyRequest(store.delete(key));
- }
-
- async keys(storeName: string, prefix?: string): Promise {
- const db = await this.getDB();
- const tx = db.transaction(storeName, "readonly");
- const store = tx.objectStore(storeName);
-
- if (prefix) {
- // Use IDBKeyRange for efficient prefix filtering
- const range = IDBKeyRange.bound(prefix, `${prefix}\uffff`, false, false);
- const keys = await this.promisifyRequest(store.getAllKeys(range));
- return keys.map((k) => String(k));
- } else {
- const keys = await this.promisifyRequest(store.getAllKeys());
- return keys.map((k) => String(k));
- }
- }
-
- async getAllFromIndex(
- storeName: string,
- indexName: string,
- direction: "asc" | "desc" = "asc",
- ): Promise {
- const db = await this.getDB();
- const tx = db.transaction(storeName, "readonly");
- const store = tx.objectStore(storeName);
- const index = store.index(indexName);
-
- return new Promise((resolve, reject) => {
- const results: T[] = [];
- const request = index.openCursor(
- null,
- direction === "desc" ? "prev" : "next",
- );
-
- request.onsuccess = () => {
- const cursor = request.result;
- if (cursor) {
- results.push(cursor.value as T);
- cursor.continue();
- } else {
- resolve(results);
- }
- };
-
- request.onerror = () => reject(request.error);
- });
- }
-
- async clear(storeName: string): Promise {
- const db = await this.getDB();
- const tx = db.transaction(storeName, "readwrite");
- const store = tx.objectStore(storeName);
- await this.promisifyRequest(store.clear());
- }
-
- async has(storeName: string, key: string): Promise {
- const db = await this.getDB();
- const tx = db.transaction(storeName, "readonly");
- const store = tx.objectStore(storeName);
- const result = await this.promisifyRequest(store.getKey(key));
- return result !== undefined;
- }
-
- async transaction(
- storeNames: string[],
- mode: "readonly" | "readwrite",
- operation: (tx: StorageTransaction) => Promise,
- ): Promise {
- const db = await this.getDB();
- const idbTx = db.transaction(storeNames, mode);
-
- const storageTx: StorageTransaction = {
- get: async (storeName: string, key: string) => {
- const store = idbTx.objectStore(storeName);
- const result = await this.promisifyRequest(store.get(key));
- return (result ?? null) as T | null;
- },
- set: async (storeName: string, key: string, value: T) => {
- const store = idbTx.objectStore(storeName);
- // If store has keyPath, only pass value (in-line key)
- // Otherwise pass both value and key (out-of-line key)
- if (store.keyPath) {
- await this.promisifyRequest(store.put(value));
- } else {
- await this.promisifyRequest(store.put(value, key));
- }
- },
- delete: async (storeName: string, key: string) => {
- const store = idbTx.objectStore(storeName);
- await this.promisifyRequest(store.delete(key));
- },
- };
-
- return operation(storageTx);
- }
-
- async getQuotaInfo(): Promise<{
- usage: number;
- quota: number;
- percent: number;
- }> {
- if (navigator.storage?.estimate) {
- const estimate = await navigator.storage.estimate();
- return {
- usage: estimate.usage || 0,
- quota: estimate.quota || 0,
- percent: estimate.quota
- ? ((estimate.usage || 0) / estimate.quota) * 100
- : 0,
- };
- }
- return { usage: 0, quota: 0, percent: 0 };
- }
-
- async requestPersistence(): Promise {
- if (navigator.storage?.persist) {
- return await navigator.storage.persist();
- }
- return false;
- }
-}
diff --git a/packages/web-ui/src/storage/store.ts b/packages/web-ui/src/storage/store.ts
deleted file mode 100644
index c3b2fec..0000000
--- a/packages/web-ui/src/storage/store.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import type { StorageBackend, StoreConfig } from "./types.js";
-
-/**
- * Base class for all storage stores.
- * Each store defines its IndexedDB schema and provides domain-specific methods.
- */
-export abstract class Store {
- private backend: StorageBackend | null = null;
-
- /**
- * Returns the IndexedDB configuration for this store.
- * Defines store name, key path, and indices.
- */
- abstract getConfig(): StoreConfig;
-
- /**
- * Sets the storage backend. Called by AppStorage after backend creation.
- */
- setBackend(backend: StorageBackend): void {
- this.backend = backend;
- }
-
- /**
- * Gets the storage backend. Throws if backend not set.
- * Concrete stores must use this to access the backend.
- */
- protected getBackend(): StorageBackend {
- if (!this.backend) {
- throw new Error(`Backend not set on ${this.constructor.name}`);
- }
- return this.backend;
- }
-}
diff --git a/packages/web-ui/src/storage/stores/custom-providers-store.ts b/packages/web-ui/src/storage/stores/custom-providers-store.ts
deleted file mode 100644
index 9473fa4..0000000
--- a/packages/web-ui/src/storage/stores/custom-providers-store.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-import type { Model } from "@mariozechner/pi-ai";
-import { Store } from "../store.js";
-import type { StoreConfig } from "../types.js";
-
-export type AutoDiscoveryProviderType =
- | "ollama"
- | "llama.cpp"
- | "vllm"
- | "lmstudio";
-
-export type CustomProviderType =
- | AutoDiscoveryProviderType // Auto-discovery - models fetched on-demand
- | "openai-completions" // Manual models - stored in provider.models
- | "openai-responses" // Manual models - stored in provider.models
- | "anthropic-messages"; // Manual models - stored in provider.models
-
-export interface CustomProvider {
- id: string; // UUID
- name: string; // Display name, also used as Model.provider
- type: CustomProviderType;
- baseUrl: string;
- apiKey?: string; // Optional, applies to all models
-
- // For manual types ONLY - models stored directly on provider
- // Auto-discovery types: models fetched on-demand, never stored
- models?: Model[];
-}
-
-/**
- * Store for custom LLM providers (auto-discovery servers + manual providers).
- */
-export class CustomProvidersStore extends Store {
- getConfig(): StoreConfig {
- return {
- name: "custom-providers",
- };
- }
-
- async get(id: string): Promise {
- return this.getBackend().get("custom-providers", id);
- }
-
- async set(provider: CustomProvider): Promise {
- await this.getBackend().set("custom-providers", provider.id, provider);
- }
-
- async delete(id: string): Promise {
- await this.getBackend().delete("custom-providers", id);
- }
-
- async getAll(): Promise {
- const keys = await this.getBackend().keys("custom-providers");
- const providers: CustomProvider[] = [];
- for (const key of keys) {
- const provider = await this.get(key);
- if (provider) {
- providers.push(provider);
- }
- }
- return providers;
- }
-
- async has(id: string): Promise {
- return this.getBackend().has("custom-providers", id);
- }
-}
diff --git a/packages/web-ui/src/storage/stores/provider-keys-store.ts b/packages/web-ui/src/storage/stores/provider-keys-store.ts
deleted file mode 100644
index 5cff04e..0000000
--- a/packages/web-ui/src/storage/stores/provider-keys-store.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import { Store } from "../store.js";
-import type { StoreConfig } from "../types.js";
-
-/**
- * Store for LLM provider API keys (Anthropic, OpenAI, etc.).
- */
-export class ProviderKeysStore extends Store {
- getConfig(): StoreConfig {
- return {
- name: "provider-keys",
- };
- }
-
- async get(provider: string): Promise {
- return this.getBackend().get("provider-keys", provider);
- }
-
- async set(provider: string, key: string): Promise {
- await this.getBackend().set("provider-keys", provider, key);
- }
-
- async delete(provider: string): Promise {
- await this.getBackend().delete("provider-keys", provider);
- }
-
- async list(): Promise {
- return this.getBackend().keys("provider-keys");
- }
-
- async has(provider: string): Promise {
- return this.getBackend().has("provider-keys", provider);
- }
-}
diff --git a/packages/web-ui/src/storage/stores/sessions-store.ts b/packages/web-ui/src/storage/stores/sessions-store.ts
deleted file mode 100644
index 1264e79..0000000
--- a/packages/web-ui/src/storage/stores/sessions-store.ts
+++ /dev/null
@@ -1,152 +0,0 @@
-import type { AgentState } from "@mariozechner/pi-agent-core";
-import { Store } from "../store.js";
-import type { SessionData, SessionMetadata, StoreConfig } from "../types.js";
-
-/**
- * Store for chat sessions (data and metadata).
- * Uses two object stores: sessions (full data) and sessions-metadata (lightweight).
- */
-export class SessionsStore extends Store {
- getConfig(): StoreConfig {
- return {
- name: "sessions",
- keyPath: "id",
- indices: [{ name: "lastModified", keyPath: "lastModified" }],
- };
- }
-
- /**
- * Additional config for sessions-metadata store.
- * Must be included when creating the backend.
- */
- static getMetadataConfig(): StoreConfig {
- return {
- name: "sessions-metadata",
- keyPath: "id",
- indices: [{ name: "lastModified", keyPath: "lastModified" }],
- };
- }
-
- async save(data: SessionData, metadata: SessionMetadata): Promise {
- await this.getBackend().transaction(
- ["sessions", "sessions-metadata"],
- "readwrite",
- async (tx) => {
- await tx.set("sessions", data.id, data);
- await tx.set("sessions-metadata", metadata.id, metadata);
- },
- );
- }
-
- async get(id: string): Promise {
- return this.getBackend().get("sessions", id);
- }
-
- async getMetadata(id: string): Promise {
- return this.getBackend().get("sessions-metadata", id);
- }
-
- async getAllMetadata(): Promise {
- // Use the lastModified index to get sessions sorted by most recent first
- return this.getBackend().getAllFromIndex(
- "sessions-metadata",
- "lastModified",
- "desc",
- );
- }
-
- async delete(id: string): Promise {
- await this.getBackend().transaction(
- ["sessions", "sessions-metadata"],
- "readwrite",
- async (tx) => {
- await tx.delete("sessions", id);
- await tx.delete("sessions-metadata", id);
- },
- );
- }
-
- // Alias for backward compatibility
- async deleteSession(id: string): Promise {
- return this.delete(id);
- }
-
- async updateTitle(id: string, title: string): Promise {
- const metadata = await this.getMetadata(id);
- if (metadata) {
- metadata.title = title;
- await this.getBackend().set("sessions-metadata", id, metadata);
- }
-
- // Also update in full session data
- const data = await this.get(id);
- if (data) {
- data.title = title;
- await this.getBackend().set("sessions", id, data);
- }
- }
-
- async getQuotaInfo(): Promise<{
- usage: number;
- quota: number;
- percent: number;
- }> {
- return this.getBackend().getQuotaInfo();
- }
-
- async requestPersistence(): Promise {
- return this.getBackend().requestPersistence();
- }
-
- // Alias methods for backward compatibility
- async saveSession(
- id: string,
- state: AgentState,
- metadata: SessionMetadata | undefined,
- title?: string,
- ): Promise {
- // If metadata is provided, use it; otherwise create it from state
- const meta: SessionMetadata = metadata || {
- id,
- title: title || "",
- createdAt: new Date().toISOString(),
- lastModified: new Date().toISOString(),
- messageCount: state.messages?.length || 0,
- usage: {
- input: 0,
- output: 0,
- cacheRead: 0,
- cacheWrite: 0,
- totalTokens: 0,
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
- },
- thinkingLevel: state.thinkingLevel || "off",
- preview: "",
- };
-
- const data: SessionData = {
- id,
- title: title || meta.title,
- model: state.model,
- thinkingLevel: state.thinkingLevel,
- messages: state.messages || [],
- createdAt: meta.createdAt,
- lastModified: new Date().toISOString(),
- };
-
- await this.save(data, meta);
- }
-
- async loadSession(id: string): Promise {
- return this.get(id);
- }
-
- async getLatestSessionId(): Promise {
- const allMetadata = await this.getAllMetadata();
- if (allMetadata.length === 0) return null;
-
- // Sort by lastModified descending
- allMetadata.sort((a, b) => b.lastModified.localeCompare(a.lastModified));
- return allMetadata[0].id;
- }
-}
diff --git a/packages/web-ui/src/storage/stores/settings-store.ts b/packages/web-ui/src/storage/stores/settings-store.ts
deleted file mode 100644
index 6ae789a..0000000
--- a/packages/web-ui/src/storage/stores/settings-store.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import { Store } from "../store.js";
-import type { StoreConfig } from "../types.js";
-
-/**
- * Store for application settings (theme, proxy config, etc.).
- */
-export class SettingsStore extends Store {
- getConfig(): StoreConfig {
- return {
- name: "settings",
- // No keyPath - uses out-of-line keys
- };
- }
-
- async get(key: string): Promise {
- return this.getBackend().get("settings", key);
- }
-
- async set(key: string, value: T): Promise {
- await this.getBackend().set("settings", key, value);
- }
-
- async delete(key: string): Promise {
- await this.getBackend().delete("settings", key);
- }
-
- async list(): Promise {
- return this.getBackend().keys("settings");
- }
-
- async clear(): Promise {
- await this.getBackend().clear("settings");
- }
-}
diff --git a/packages/web-ui/src/storage/types.ts b/packages/web-ui/src/storage/types.ts
deleted file mode 100644
index b377920..0000000
--- a/packages/web-ui/src/storage/types.ts
+++ /dev/null
@@ -1,210 +0,0 @@
-import type { AgentMessage, ThinkingLevel } from "@mariozechner/pi-agent-core";
-import type { Model } from "@mariozechner/pi-ai";
-
-/**
- * Transaction interface for atomic operations across stores.
- */
-export interface StorageTransaction {
- /**
- * Get a value by key from a specific store.
- */
- get(storeName: string, key: string): Promise;
-
- /**
- * Set a value for a key in a specific store.
- */
- set(storeName: string, key: string, value: T): Promise;
-
- /**
- * Delete a key from a specific store.
- */
- delete(storeName: string, key: string): Promise;
-}
-
-/**
- * Base interface for all storage backends.
- * Multi-store key-value storage abstraction that can be implemented
- * by IndexedDB, remote APIs, or any other multi-collection storage system.
- */
-export interface StorageBackend {
- /**
- * Get a value by key from a specific store. Returns null if key doesn't exist.
- */
- get(storeName: string, key: string): Promise;
-
- /**
- * Set a value for a key in a specific store.
- */
- set(storeName: string, key: string, value: T): Promise;
-
- /**
- * Delete a key from a specific store.
- */
- delete(storeName: string, key: string): Promise;
-
- /**
- * Get all keys from a specific store, optionally filtered by prefix.
- */
- keys(storeName: string, prefix?: string): Promise;
-
- /**
- * Get all values from a specific store, ordered by an index.
- * @param storeName - The store to query
- * @param indexName - The index to use for ordering
- * @param direction - Sort direction ("asc" or "desc")
- */
- getAllFromIndex(
- storeName: string,
- indexName: string,
- direction?: "asc" | "desc",
- ): Promise;
-
- /**
- * Clear all data from a specific store.
- */
- clear(storeName: string): Promise;
-
- /**
- * Check if a key exists in a specific store.
- */
- has(storeName: string, key: string): Promise;
-
- /**
- * Execute atomic operations across multiple stores.
- */
- transaction(
- storeNames: string[],
- mode: "readonly" | "readwrite",
- operation: (tx: StorageTransaction) => Promise,
- ): Promise;
-
- /**
- * Get storage quota information.
- * Used for warning users when approaching limits.
- */
- getQuotaInfo(): Promise<{ usage: number; quota: number; percent: number }>;
-
- /**
- * Request persistent storage (prevents eviction).
- * Returns true if granted, false otherwise.
- */
- requestPersistence(): Promise;
-}
-
-/**
- * Lightweight session metadata for listing and searching.
- * Stored separately from full session data for performance.
- */
-export interface SessionMetadata {
- /** Unique session identifier (UUID v4) */
- id: string;
-
- /** User-defined title or auto-generated from first message */
- title: string;
-
- /** ISO 8601 UTC timestamp of creation */
- createdAt: string;
-
- /** ISO 8601 UTC timestamp of last modification */
- lastModified: string;
-
- /** Total number of messages (user + assistant + tool results) */
- messageCount: number;
-
- /** Cumulative usage statistics */
- usage: {
- /** Total input tokens */
- input: number;
- /** Total output tokens */
- output: number;
- /** Total cache read tokens */
- cacheRead: number;
- /** Total cache write tokens */
- cacheWrite: number;
- /** Total tokens processed */
- totalTokens: number;
- /** Total cost breakdown */
- cost: {
- input: number;
- output: number;
- cacheRead: number;
- cacheWrite: number;
- total: number;
- };
- };
-
- /** Last used thinking level */
- thinkingLevel: ThinkingLevel;
-
- /**
- * Preview text for search and display.
- * First 2KB of conversation text (user + assistant messages in sequence).
- * Tool calls and tool results are excluded.
- */
- preview: string;
-}
-
-/**
- * Full session data including all messages.
- * Only loaded when user opens a specific session.
- */
-export interface SessionData {
- /** Unique session identifier (UUID v4) */
- id: string;
-
- /** User-defined title or auto-generated from first message */
- title: string;
-
- /** Last selected model */
- model: Model;
-
- /** Last selected thinking level */
- thinkingLevel: ThinkingLevel;
-
- /** Full conversation history (with attachments inline) */
- messages: AgentMessage[];
-
- /** ISO 8601 UTC timestamp of creation */
- createdAt: string;
-
- /** ISO 8601 UTC timestamp of last modification */
- lastModified: string;
-}
-
-/**
- * Configuration for IndexedDB backend.
- */
-export interface IndexedDBConfig {
- /** Database name */
- dbName: string;
- /** Database version */
- version: number;
- /** Object stores to create */
- stores: StoreConfig[];
-}
-
-/**
- * Configuration for an IndexedDB object store.
- */
-export interface StoreConfig {
- /** Store name */
- name: string;
- /** Key path (optional, for auto-extracting keys from objects) */
- keyPath?: string;
- /** Auto-increment keys (optional) */
- autoIncrement?: boolean;
- /** Indices to create on this store */
- indices?: IndexConfig[];
-}
-
-/**
- * Configuration for an IndexedDB index.
- */
-export interface IndexConfig {
- /** Index name */
- name: string;
- /** Key path to index on */
- keyPath: string;
- /** Unique constraint (optional) */
- unique?: boolean;
-}
diff --git a/packages/web-ui/src/tools/artifacts/ArtifactElement.ts b/packages/web-ui/src/tools/artifacts/ArtifactElement.ts
deleted file mode 100644
index ca1e2ae..0000000
--- a/packages/web-ui/src/tools/artifacts/ArtifactElement.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { LitElement, type TemplateResult } from "lit";
-
-export abstract class ArtifactElement extends LitElement {
- public filename = "";
-
- 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/ArtifactPill.ts b/packages/web-ui/src/tools/artifacts/ArtifactPill.ts
deleted file mode 100644
index 2344fa7..0000000
--- a/packages/web-ui/src/tools/artifacts/ArtifactPill.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import { icon } from "@mariozechner/mini-lit";
-import { html, type TemplateResult } from "lit";
-import { FileCode2 } from "lucide";
-import type { ArtifactsPanel } from "./artifacts.js";
-
-export function ArtifactPill(
- filename: string,
- artifactsPanel?: ArtifactsPanel,
-): TemplateResult {
- const handleClick = (e: Event) => {
- if (!artifactsPanel) return;
- e.preventDefault();
- e.stopPropagation();
- // openArtifact will show the artifact and call onOpen() to open the panel if needed
- artifactsPanel.openArtifact(filename);
- };
-
- return html`
-
- ${icon(FileCode2, "sm")}
- ${filename}
-
- `;
-}
diff --git a/packages/web-ui/src/tools/artifacts/Console.ts b/packages/web-ui/src/tools/artifacts/Console.ts
deleted file mode 100644
index 2915cdb..0000000
--- a/packages/web-ui/src/tools/artifacts/Console.ts
+++ /dev/null
@@ -1,106 +0,0 @@
-import { icon } from "@mariozechner/mini-lit";
-import "@mariozechner/mini-lit/dist/CopyButton.js";
-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 { repeat } from "lit/directives/repeat.js";
-import { ChevronDown, ChevronRight, ChevronsDown, Lock } from "lucide";
-import { i18n } from "../../utils/i18n.js";
-
-interface LogEntry {
- type: "log" | "error";
- text: string;
-}
-
-@customElement("artifact-console")
-export class Console extends LitElement {
- @property({ attribute: false }) logs: LogEntry[] = [];
- @state() private expanded = false;
- @state() private autoscroll = true;
- private logsContainerRef: Ref = createRef();
-
- protected createRenderRoot() {
- return this; // light DOM
- }
-
- override updated() {
- // Autoscroll to bottom when new logs arrive
- if (this.autoscroll && this.expanded && this.logsContainerRef.value) {
- this.logsContainerRef.value.scrollTop =
- this.logsContainerRef.value.scrollHeight;
- }
- }
-
- private getLogsText(): string {
- return this.logs.map((l) => `[${l.type}] ${l.text}`).join("\n");
- }
-
- override render(): TemplateResult {
- const errorCount = this.logs.filter((l) => l.type === "error").length;
- const summary =
- errorCount > 0
- ? `${i18n("console")} (${errorCount} ${errorCount === 1 ? "error" : "errors"})`
- : `${i18n("console")} (${this.logs.length})`;
-
- return html`
-
-
- {
- this.expanded = !this.expanded;
- }}
- class="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors flex-1 text-left"
- >
- ${icon(this.expanded ? ChevronDown : ChevronRight, "sm")}
- ${summary}
-
- ${this.expanded
- ? html`
- {
- this.autoscroll = !this.autoscroll;
- }}
- class="p-1 rounded transition-colors ${this.autoscroll
- ? "bg-accent text-accent-foreground"
- : "hover:bg-muted"}"
- title=${this.autoscroll
- ? i18n("Autoscroll enabled")
- : i18n("Autoscroll disabled")}
- >
- ${icon(this.autoscroll ? ChevronsDown : Lock, "sm")}
-
-
- `
- : ""}
-
- ${this.expanded
- ? html`
-
- ${repeat(
- this.logs,
- (_log, index) => index,
- (log) => html`
-
- [${log.type}] ${log.text}
-
- `,
- )}
-
- `
- : ""}
-
- `;
- }
-}
diff --git a/packages/web-ui/src/tools/artifacts/DocxArtifact.ts b/packages/web-ui/src/tools/artifacts/DocxArtifact.ts
deleted file mode 100644
index e9869ca..0000000
--- a/packages/web-ui/src/tools/artifacts/DocxArtifact.ts
+++ /dev/null
@@ -1,218 +0,0 @@
-import { DownloadButton } from "@mariozechner/mini-lit/dist/DownloadButton.js";
-import { renderAsync } from "docx-preview";
-import { html, type TemplateResult } from "lit";
-import { customElement, property, state } from "lit/decorators.js";
-import { i18n } from "../../utils/i18n.js";
-import { ArtifactElement } from "./ArtifactElement.js";
-
-@customElement("docx-artifact")
-export class DocxArtifact extends ArtifactElement {
- @property({ type: String }) private _content = "";
- @state() private error: string | null = null;
-
- get content(): string {
- return this._content;
- }
-
- set content(value: string) {
- this._content = value;
- this.error = null;
- this.requestUpdate();
- }
-
- protected override createRenderRoot(): HTMLElement | DocumentFragment {
- return this;
- }
-
- override connectedCallback(): void {
- super.connectedCallback();
- this.style.display = "block";
- this.style.height = "100%";
- }
-
- private base64ToArrayBuffer(base64: string): ArrayBuffer {
- // Remove data URL prefix if present
- let base64Data = base64;
- if (base64.startsWith("data:")) {
- const base64Match = base64.match(/base64,(.+)/);
- if (base64Match) {
- base64Data = base64Match[1];
- }
- }
-
- const binaryString = atob(base64Data);
- const bytes = new Uint8Array(binaryString.length);
- for (let i = 0; i < binaryString.length; i++) {
- bytes[i] = binaryString.charCodeAt(i);
- }
- return bytes.buffer;
- }
-
- private decodeBase64(): Uint8Array {
- let base64Data = this._content;
- if (this._content.startsWith("data:")) {
- const base64Match = this._content.match(/base64,(.+)/);
- if (base64Match) {
- base64Data = base64Match[1];
- }
- }
-
- const binaryString = atob(base64Data);
- const bytes = new Uint8Array(binaryString.length);
- for (let i = 0; i < binaryString.length; i++) {
- bytes[i] = binaryString.charCodeAt(i);
- }
- return bytes;
- }
-
- public getHeaderButtons() {
- return html`
-
- ${DownloadButton({
- content: this.decodeBase64(),
- filename: this.filename,
- mimeType:
- "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
- title: i18n("Download"),
- })}
-
- `;
- }
-
- override async updated(changedProperties: Map) {
- super.updated(changedProperties);
-
- if (changedProperties.has("_content") && this._content && !this.error) {
- await this.renderDocx();
- }
- }
-
- private async renderDocx() {
- const container = this.querySelector("#docx-container");
- if (!container || !this._content) return;
-
- try {
- const arrayBuffer = this.base64ToArrayBuffer(this._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,
- 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");
- }
- }
-
- override render(): TemplateResult {
- if (this.error) {
- return html`
-
-
-
- ${i18n("Error loading document")}
-
-
${this.error}
-
-
- `;
- }
-
- return html`
-
- `;
- }
-}
-
-declare global {
- interface HTMLElementTagNameMap {
- "docx-artifact": DocxArtifact;
- }
-}
diff --git a/packages/web-ui/src/tools/artifacts/ExcelArtifact.ts b/packages/web-ui/src/tools/artifacts/ExcelArtifact.ts
deleted file mode 100644
index 7def113..0000000
--- a/packages/web-ui/src/tools/artifacts/ExcelArtifact.ts
+++ /dev/null
@@ -1,243 +0,0 @@
-import { DownloadButton } from "@mariozechner/mini-lit/dist/DownloadButton.js";
-import { html, type TemplateResult } from "lit";
-import { customElement, property, state } from "lit/decorators.js";
-import * as XLSX from "xlsx";
-import { i18n } from "../../utils/i18n.js";
-import { ArtifactElement } from "./ArtifactElement.js";
-
-@customElement("excel-artifact")
-export class ExcelArtifact extends ArtifactElement {
- @property({ type: String }) private _content = "";
- @state() private error: string | null = null;
-
- get content(): string {
- return this._content;
- }
-
- set content(value: string) {
- this._content = value;
- this.error = null;
- this.requestUpdate();
- }
-
- protected override createRenderRoot(): HTMLElement | DocumentFragment {
- return this;
- }
-
- override connectedCallback(): void {
- super.connectedCallback();
- this.style.display = "block";
- this.style.height = "100%";
- }
-
- private base64ToArrayBuffer(base64: string): ArrayBuffer {
- // Remove data URL prefix if present
- let base64Data = base64;
- if (base64.startsWith("data:")) {
- const base64Match = base64.match(/base64,(.+)/);
- if (base64Match) {
- base64Data = base64Match[1];
- }
- }
-
- const binaryString = atob(base64Data);
- const bytes = new Uint8Array(binaryString.length);
- for (let i = 0; i < binaryString.length; i++) {
- bytes[i] = binaryString.charCodeAt(i);
- }
- return bytes.buffer;
- }
-
- private decodeBase64(): Uint8Array {
- let base64Data = this._content;
- if (this._content.startsWith("data:")) {
- const base64Match = this._content.match(/base64,(.+)/);
- if (base64Match) {
- base64Data = base64Match[1];
- }
- }
-
- const binaryString = atob(base64Data);
- const bytes = new Uint8Array(binaryString.length);
- for (let i = 0; i < binaryString.length; i++) {
- bytes[i] = binaryString.charCodeAt(i);
- }
- return bytes;
- }
-
- private getMimeType(): string {
- const ext = this.filename.split(".").pop()?.toLowerCase();
- if (ext === "xls") return "application/vnd.ms-excel";
- return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
- }
-
- public getHeaderButtons() {
- return html`
-
- ${DownloadButton({
- content: this.decodeBase64(),
- filename: this.filename,
- mimeType: this.getMimeType(),
- title: i18n("Download"),
- })}
-
- `;
- }
-
- override async updated(changedProperties: Map) {
- super.updated(changedProperties);
-
- if (changedProperties.has("_content") && this._content && !this.error) {
- await this.renderExcel();
- }
- }
-
- private async renderExcel() {
- const container = this.querySelector("#excel-container");
- if (!container || !this._content) return;
-
- try {
- const arrayBuffer = this.base64ToArrayBuffer(this._content);
- const workbook = XLSX.read(arrayBuffer, { type: "array" });
-
- 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-background 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;
- }
-
- override render(): TemplateResult {
- if (this.error) {
- return html`
-
-
-
- ${i18n("Error loading spreadsheet")}
-
-
${this.error}
-
-
- `;
- }
-
- return html`
-
- `;
- }
-}
-
-declare global {
- interface HTMLElementTagNameMap {
- "excel-artifact": ExcelArtifact;
- }
-}
diff --git a/packages/web-ui/src/tools/artifacts/GenericArtifact.ts b/packages/web-ui/src/tools/artifacts/GenericArtifact.ts
deleted file mode 100644
index 39df5d3..0000000
--- a/packages/web-ui/src/tools/artifacts/GenericArtifact.ts
+++ /dev/null
@@ -1,120 +0,0 @@
-import { DownloadButton } from "@mariozechner/mini-lit/dist/DownloadButton.js";
-import { html, type TemplateResult } from "lit";
-import { customElement, property } from "lit/decorators.js";
-import { i18n } from "../../utils/i18n.js";
-import { ArtifactElement } from "./ArtifactElement.js";
-
-@customElement("generic-artifact")
-export class GenericArtifact extends ArtifactElement {
- @property({ type: String }) private _content = "";
-
- get content(): string {
- return this._content;
- }
-
- set content(value: string) {
- this._content = value;
- this.requestUpdate();
- }
-
- protected override createRenderRoot(): HTMLElement | DocumentFragment {
- return this;
- }
-
- override connectedCallback(): void {
- super.connectedCallback();
- this.style.display = "block";
- this.style.height = "100%";
- }
-
- private decodeBase64(): Uint8Array {
- let base64Data = this._content;
- if (this._content.startsWith("data:")) {
- const base64Match = this._content.match(/base64,(.+)/);
- if (base64Match) {
- base64Data = base64Match[1];
- }
- }
-
- const binaryString = atob(base64Data);
- const bytes = new Uint8Array(binaryString.length);
- for (let i = 0; i < binaryString.length; i++) {
- bytes[i] = binaryString.charCodeAt(i);
- }
- return bytes;
- }
-
- private getMimeType(): string {
- const ext = this.filename.split(".").pop()?.toLowerCase();
- // Add common MIME types
- const mimeTypes: Record = {
- pdf: "application/pdf",
- zip: "application/zip",
- tar: "application/x-tar",
- gz: "application/gzip",
- rar: "application/vnd.rar",
- "7z": "application/x-7z-compressed",
- mp3: "audio/mpeg",
- mp4: "video/mp4",
- avi: "video/x-msvideo",
- mov: "video/quicktime",
- wav: "audio/wav",
- ogg: "audio/ogg",
- json: "application/json",
- xml: "application/xml",
- bin: "application/octet-stream",
- };
- return mimeTypes[ext || ""] || "application/octet-stream";
- }
-
- public getHeaderButtons() {
- return html`
-
- ${DownloadButton({
- content: this.decodeBase64(),
- filename: this.filename,
- mimeType: this.getMimeType(),
- title: i18n("Download"),
- })}
-
- `;
- }
-
- override render(): TemplateResult {
- return html`
-
-
-
-
-
-
-
${this.filename}
-
- ${i18n("Preview not available for this file type.")}
- ${i18n(
- "Click the download button above to view it on your computer.",
- )}
-
-
-
-
- `;
- }
-}
-
-declare global {
- interface HTMLElementTagNameMap {
- "generic-artifact": GenericArtifact;
- }
-}
diff --git a/packages/web-ui/src/tools/artifacts/HtmlArtifact.ts b/packages/web-ui/src/tools/artifacts/HtmlArtifact.ts
deleted file mode 100644
index 1c419b2..0000000
--- a/packages/web-ui/src/tools/artifacts/HtmlArtifact.ts
+++ /dev/null
@@ -1,232 +0,0 @@
-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 { RefreshCw } from "lucide";
-import type { SandboxIframe } from "../../components/SandboxedIframe.js";
-import {
- type MessageConsumer,
- RUNTIME_MESSAGE_ROUTER,
-} from "../../components/sandbox/RuntimeMessageRouter.js";
-import type { SandboxRuntimeProvider } from "../../components/sandbox/SandboxRuntimeProvider.js";
-import { i18n } from "../../utils/i18n.js";
-import "../../components/SandboxedIframe.js";
-import { ArtifactElement } from "./ArtifactElement.js";
-import type { Console } from "./Console.js";
-import "./Console.js";
-import { icon } from "@mariozechner/mini-lit";
-import { Button } from "@mariozechner/mini-lit/dist/Button.js";
-import { CopyButton } from "@mariozechner/mini-lit/dist/CopyButton.js";
-import { DownloadButton } from "@mariozechner/mini-lit/dist/DownloadButton.js";
-import { PreviewCodeToggle } from "@mariozechner/mini-lit/dist/PreviewCodeToggle.js";
-
-@customElement("html-artifact")
-export class HtmlArtifact extends ArtifactElement {
- @property() override filename = "";
- @property({ attribute: false }) runtimeProviders: SandboxRuntimeProvider[] =
- [];
- @property({ attribute: false }) sandboxUrlProvider?: () => string;
-
- private _content = "";
- private logs: Array<{ type: "log" | "error"; text: string }> = [];
-
- // Refs for DOM elements
- public sandboxIframeRef: Ref = createRef();
- private consoleRef: Ref = createRef();
-
- @state() private viewMode: "preview" | "code" = "preview";
-
- 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;
-
- // Generate standalone HTML with all runtime code injected for download
- const sandbox = this.sandboxIframeRef.value;
- const sandboxId = `artifact-${this.filename}`;
- const downloadContent =
- sandbox?.prepareHtmlDocument(
- sandboxId,
- this._content,
- this.runtimeProviders || [],
- {
- isHtmlArtifact: true,
- isStandalone: true, // Skip runtime bridge and navigation interceptor for standalone downloads
- },
- ) || this._content;
-
- return html`
-
- ${toggle}
- ${Button({
- variant: "ghost",
- size: "sm",
- onClick: () => {
- this.logs = [];
- this.executeContent(this._content);
- },
- title: i18n("Reload HTML"),
- children: icon(RefreshCw, "sm"),
- })}
- ${copyButton}
- ${DownloadButton({
- content: downloadContent,
- 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 = [];
- this.requestUpdate();
- // Execute content in sandbox if it exists
- if (this.sandboxIframeRef.value && value) {
- this.executeContent(value);
- }
- }
- }
-
- public 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;
- }
-
- const sandboxId = `artifact-${this.filename}`;
-
- // Create consumer for console messages
- const consumer: MessageConsumer = {
- handleMessage: async (message: any): Promise => {
- if (message.type === "console") {
- // Create new array reference for Lit reactivity
- this.logs = [
- ...this.logs,
- {
- type: message.method === "error" ? "error" : "log",
- text: message.text,
- },
- ];
- this.requestUpdate(); // Re-render to show console
- }
- },
- };
-
- // Inject window.complete() call at the end of the HTML to signal when page is loaded
- // HTML artifacts don't time out - they call complete() when ready
- let modifiedHtml = html;
- if (modifiedHtml.includes("