web-ui package

This commit is contained in:
Mario Zechner 2025-10-05 13:30:08 +02:00
parent 7159c9734e
commit f2eecb78d2
55 changed files with 10932 additions and 13 deletions

1
package-lock.json generated
View file

@ -5293,6 +5293,7 @@
"dependencies": {
"@mariozechner/mini-lit": "^0.1.7",
"@mariozechner/pi-ai": "^0.5.43",
"@mariozechner/pi-web-ui": "^0.5.43",
"docx-preview": "^0.3.7",
"js-interpreter": "^6.0.1",
"jszip": "^3.10.1",

252
packages/web-ui/README.md Normal file
View file

@ -0,0 +1,252 @@
# @mariozechner/pi-web-ui
Reusable web UI components for building AI chat interfaces powered by [@mariozechner/pi-ai](../ai).
Built with [mini-lit](https://github.com/mariozechner/mini-lit) web components and Tailwind CSS v4.
## Features
- 🎨 **Modern Chat Interface** - Complete chat UI with message history, streaming responses, and tool execution
- 🔧 **Tool Support** - Built-in renderers for calculator, bash, time, and custom tools
- 📎 **Attachments** - PDF, Office documents, images with preview and text extraction
- 🎭 **Artifacts** - HTML, SVG, Markdown, and text artifact rendering with sandboxed execution
- 🔌 **Pluggable Transports** - Direct API calls or proxy server support
- 🌐 **Platform Agnostic** - Works in browser extensions, web apps, VS Code extensions, Electron apps
- 🎯 **TypeScript** - Full type safety with TypeScript
## Installation
```bash
npm install @mariozechner/pi-web-ui
```
## Quick Start
See the [example](./example) directory for a complete working application.
```typescript
import { ChatPanel } from '@mariozechner/pi-web-ui';
import { calculateTool, getCurrentTimeTool } from '@mariozechner/pi-ai';
import '@mariozechner/pi-web-ui/app.css';
// Create a chat panel
const chatPanel = new ChatPanel();
chatPanel.systemPrompt = 'You are a helpful assistant.';
chatPanel.additionalTools = [calculateTool, getCurrentTimeTool];
document.body.appendChild(chatPanel);
```
**Run the example:**
```bash
cd example
npm install
npm run dev
```
## Core Components
### ChatPanel
The main chat interface component. Manages agent sessions, model selection, and conversation flow.
```typescript
import { ChatPanel } from '@mariozechner/pi-web-ui';
const panel = new ChatPanel({
initialModel: 'anthropic/claude-sonnet-4-20250514',
systemPrompt: 'You are a helpful assistant.',
transportMode: 'direct', // or 'proxy'
});
```
### AgentInterface
Lower-level chat interface for custom implementations.
```typescript
import { AgentInterface } from '@mariozechner/pi-web-ui';
const chat = new AgentInterface();
chat.session = myAgentSession;
```
## State Management
### AgentSession
Manages conversation state, tool execution, and streaming.
```typescript
import { AgentSession, DirectTransport } from '@mariozechner/pi-web-ui';
import { getModel, calculateTool, getCurrentTimeTool } from '@mariozechner/pi-ai';
const session = new AgentSession({
initialState: {
model: getModel('anthropic', 'claude-3-5-haiku-20241022'),
systemPrompt: 'You are a helpful assistant.',
tools: [calculateTool, getCurrentTimeTool],
messages: [],
},
transportMode: 'direct',
});
// Subscribe to state changes
session.subscribe((state) => {
console.log('Messages:', state.messages);
console.log('Streaming:', state.streaming);
});
// Send a message
await session.send('What is 25 * 18?');
```
### Transports
Transport layers handle communication with AI providers.
#### DirectTransport
Calls AI provider APIs directly from the browser using API keys stored locally.
```typescript
import { DirectTransport, KeyStore } from '@mariozechner/pi-web-ui';
// Set API keys
const keyStore = new KeyStore();
await keyStore.setKey('anthropic', 'sk-ant-...');
await keyStore.setKey('openai', 'sk-...');
// Use direct transport (default)
const session = new AgentSession({
transportMode: 'direct',
// ...
});
```
#### ProxyTransport
Routes requests through a proxy server using auth tokens.
```typescript
import { ProxyTransport, setAuthToken } from '@mariozechner/pi-web-ui';
// Set auth token
setAuthToken('your-auth-token');
// Use proxy transport
const session = new AgentSession({
transportMode: 'proxy',
// ...
});
```
## Tool Renderers
Customize how tool calls and results are displayed.
```typescript
import { registerToolRenderer, type ToolRenderer } from '@mariozechner/pi-web-ui';
import { html } from '@mariozechner/mini-lit';
const myRenderer: ToolRenderer = {
renderParams(params, isStreaming) {
return html`<div>Calling tool with: ${JSON.stringify(params)}</div>`;
},
renderResult(params, result) {
return html`<div>Result: ${result.output}</div>`;
}
};
registerToolRenderer('my_tool', myRenderer);
```
## Artifacts
Render rich content with sandboxed execution.
```typescript
import { artifactTools } from '@mariozechner/pi-web-ui';
import { getModel } from '@mariozechner/pi-ai';
const session = new AgentSession({
initialState: {
tools: [...artifactTools],
// ...
}
});
// AI can now create HTML artifacts, SVG diagrams, etc.
```
## Styling
The package includes pre-built Tailwind CSS with the Claude theme:
```typescript
import '@mariozechner/pi-web-ui/app.css';
```
Or customize with your own Tailwind config:
```css
@import '@mariozechner/mini-lit/themes/claude.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
```
## Platform Integration
### Browser Extension
```typescript
import { ChatPanel, KeyStore } from '@mariozechner/pi-web-ui';
// Use chrome.storage for persistence
const keyStore = new KeyStore({
get: async (key) => {
const result = await chrome.storage.local.get(key);
return result[key];
},
set: async (key, value) => {
await chrome.storage.local.set({ [key]: value });
}
});
```
### Web Application
```typescript
import { ChatPanel } from '@mariozechner/pi-web-ui';
// Uses localStorage by default
const panel = new ChatPanel();
document.querySelector('#app').appendChild(panel);
```
### VS Code Extension
```typescript
import { AgentSession, DirectTransport } from '@mariozechner/pi-web-ui';
// Custom storage using VS Code's globalState
const storage = {
get: async (key) => context.globalState.get(key),
set: async (key, value) => context.globalState.update(key, value)
};
```
## Examples
See the [browser-extension](../browser-extension) package for a complete implementation example.
## API Reference
See [src/index.ts](src/index.ts) for the full public API.
## License
MIT

3
packages/web-ui/example/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
node_modules
dist
.DS_Store

View file

@ -0,0 +1,61 @@
# Pi Web UI - Example
This is a minimal example showing how to use `@mariozechner/pi-web-ui` in a web application.
## Setup
```bash
npm install
```
## Development
```bash
npm run dev
```
Open [http://localhost:5173](http://localhost:5173) in your browser.
## What's Included
This example demonstrates:
- **ChatPanel** - The main chat interface component
- **System Prompt** - Custom configuration for the AI assistant
- **Tools** - JavaScript REPL and artifacts tool
## Configuration
### API Keys
The example uses **Direct Mode** by default, which means it calls AI provider APIs directly from the browser.
To use the chat:
1. Click the settings icon (⚙️) in the chat interface
2. Click "Manage API Keys"
3. Add your API key for your preferred provider:
- **Anthropic**: Get a key from [console.anthropic.com](https://console.anthropic.com/)
- **OpenAI**: Get a key from [platform.openai.com](https://platform.openai.com/)
- **Google**: Get a key from [makersuite.google.com](https://makersuite.google.com/)
API keys are stored in your browser's localStorage and never sent to any server except the AI provider's API.
## Project Structure
```
example/
├── src/
│ ├── main.ts # Main application entry point
│ └── app.css # Tailwind CSS configuration
├── index.html # HTML entry point
├── package.json # Dependencies
├── vite.config.ts # Vite configuration
└── tsconfig.json # TypeScript configuration
```
## Learn More
- [Pi Web UI Documentation](../README.md)
- [Pi AI Documentation](../../ai/README.md)
- [Mini Lit Documentation](https://github.com/mariozechner/mini-lit)

View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Pi Web UI - Example</title>
<meta name="description" content="Example usage of @mariozechner/pi-web-ui - Reusable AI chat interface" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1965
packages/web-ui/example/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,22 @@
{
"name": "pi-web-ui-example",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@mariozechner/mini-lit": "^0.1.7",
"@mariozechner/pi-ai": "file:../../ai",
"@mariozechner/pi-web-ui": "file:../",
"@tailwindcss/vite": "^4.1.13",
"lit": "^3.3.1",
"lucide": "^0.544.0"
},
"devDependencies": {
"typescript": "^5.7.3",
"vite": "^7.1.6"
}
}

View file

@ -0,0 +1 @@
@import "../../dist/app.css";

View file

@ -0,0 +1,51 @@
import { Button, icon } from "@mariozechner/mini-lit";
import "@mariozechner/mini-lit/dist/ThemeToggle.js";
import { ChatPanel, ApiKeysDialog } from "@mariozechner/pi-web-ui";
import { html, render } from "lit";
import { Settings } from "lucide";
import "./app.css";
const systemPrompt = `You are a helpful AI assistant with access to various tools.
Available tools:
- JavaScript REPL: Execute JavaScript code in a sandboxed browser environment (can do calculations, get time, process data, create visualizations, etc.)
- Artifacts: Create interactive HTML, SVG, Markdown, and text artifacts
Feel free to use these tools when needed to provide accurate and helpful responses.`;
// Create and configure the chat panel
const chatPanel = new ChatPanel();
chatPanel.systemPrompt = systemPrompt;
chatPanel.additionalTools = [];
// Render the app structure
const appHtml = html`
<div class="w-full h-screen flex flex-col bg-background text-foreground overflow-hidden">
<!-- Header -->
<div class="flex items-center justify-between border-b border-border shrink-0">
<div class="px-4 py-3">
<span class="text-base font-semibold text-foreground">Pi Web UI Example</span>
</div>
<div class="flex items-center gap-1 px-2">
<theme-toggle></theme-toggle>
${Button({
variant: "ghost",
size: "sm",
children: icon(Settings, "sm"),
onClick: () => ApiKeysDialog.open(),
title: "API Keys Settings",
})}
</div>
</div>
<!-- Chat Panel -->
${chatPanel}
</div>
`;
const app = document.getElementById("app");
if (!app) {
throw new Error("App container not found");
}
render(appHtml, app);

View file

@ -0,0 +1,104 @@
const simpleHtml = {
"systemPrompt": "You are a helpful AI assistant with access to various tools.\n\nAvailable tools:\n- Calculator: Perform mathematical calculations\n- Current Time: Get the current date and time in any timezone\n- Artifacts: Create interactive HTML, SVG, Markdown, and text artifacts\n\nFeel free to use these tools when needed to provide accurate and helpful responses.",
"model": {
"id": "claude-sonnet-4-5-20250929",
"name": "Claude Sonnet 4.5",
"api": "anthropic-messages",
"provider": "anthropic",
"baseUrl": "https://api.anthropic.com",
"reasoning": true,
"input": [
"text",
"image"
],
"cost": {
"input": 3,
"output": 15,
"cacheRead": 0.3,
"cacheWrite": 3.75
},
"contextWindow": 200000,
"maxTokens": 64000
},
"messages": [
{
"role": "user",
"content": [
{
"type": "text",
"text": "simple html"
}
]
},
{
"role": "assistant",
"content": [
{
"type": "text",
"text": "I'll create a simple HTML page for you!"
},
{
"type": "toolCall",
"id": "toolu_01JXaq9rMAUt1goJ5tMQXgLh",
"name": "artifacts",
"arguments": {
"command": "create",
"filename": "index.html",
"title": "Simple HTML Page",
"content": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Simple HTML Page</title>\n <style>\n body {\n font-family: Arial, sans-serif;\n max-width: 800px;\n margin: 0 auto;\n padding: 20px;\n background-color: #f5f5f5;\n }\n \n header {\n background-color: #4CAF50;\n color: white;\n padding: 20px;\n text-align: center;\n border-radius: 8px;\n }\n \n main {\n background-color: white;\n padding: 20px;\n margin-top: 20px;\n border-radius: 8px;\n box-shadow: 0 2px 4px rgba(0,0,0,0.1);\n }\n \n button {\n background-color: #4CAF50;\n color: white;\n padding: 10px 20px;\n border: none;\n border-radius: 4px;\n cursor: pointer;\n font-size: 16px;\n }\n \n button:hover {\n background-color: #45a049;\n }\n </style>\n</head>\n<body>\n <header>\n <h1>Welcome to My Simple Page</h1>\n </header>\n \n <main>\n <h2>Hello!</h2>\n <p>This is a simple HTML page with some basic styling.</p>\n \n <h3>Features:</h3>\n <ul>\n <li>Clean design</li>\n <li>Responsive layout</li>\n <li>Interactive button</li>\n </ul>\n \n <button onclick=\"showMessage()\">Click Me!</button>\n <p id=\"message\"></p>\n </main>\n \n <script>\n function showMessage() {\n document.getElementById('message').textContent = '✨ Thanks for clicking!';\n }\n </script>\n</body>\n</html>"
}
}
],
"api": "anthropic-messages",
"provider": "anthropic",
"model": "claude-sonnet-4-5-20250929",
"usage": {
"input": 2108,
"output": 666,
"cacheRead": 0,
"cacheWrite": 0,
"cost": {
"input": 0.006324,
"output": 0.00999,
"cacheRead": 0,
"cacheWrite": 0,
"total": 0.016314000000000002
}
},
"stopReason": "toolUse"
},
{
"role": "toolResult",
"toolCallId": "toolu_01JXaq9rMAUt1goJ5tMQXgLh",
"toolName": "artifacts",
"output": "Created file index.html\n\nExecution timed out. Partial logs:\nKeine Logs für index.html",
"isError": false
},
{
"role": "assistant",
"content": [
{
"type": "text",
"text": "I've created a simple HTML page for you! It includes:\n\n- **Clean header** with a green background\n- **Main content area** with a white card-style design\n- **A list** showing some features\n- **An interactive button** that displays a message when clicked\n- **Responsive styling** that looks good on different screen sizes\n\nThe page has a light gray background and uses simple, clean styling. Try clicking the button to see it in action! \n\nWould you like me to modify anything or add more features?"
}
],
"api": "anthropic-messages",
"provider": "anthropic",
"model": "claude-sonnet-4-5-20250929",
"usage": {
"input": 2811,
"output": 115,
"cacheRead": 0,
"cacheWrite": 0,
"cost": {
"input": 0.008433,
"output": 0.001725,
"cacheRead": 0,
"cacheWrite": 0,
"total": 0.010158
}
},
"stopReason": "stop"
}
]
};

View file

@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"experimentalDecorators": true,
"useDefineForClassFields": false
},
"include": ["src/**/*"]
}

View file

@ -0,0 +1,6 @@
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [tailwindcss()],
});

View file

@ -0,0 +1,45 @@
{
"name": "@mariozechner/pi-web-ui",
"version": "0.5.43",
"description": "Reusable web UI components for AI chat interfaces powered by @mariozechner/pi-ai",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": "./dist/index.js",
"./app.css": "./dist/app.css"
},
"scripts": {
"clean": "rm -rf dist",
"build": "tsc -p tsconfig.build.json && tailwindcss -i ./src/app.css -o ./dist/app.css --minify",
"dev": "concurrently --names \"build,example\" --prefix-colors \"cyan,green\" \"tsc -p tsconfig.build.json --watch\" \"tailwindcss -i ./src/app.css -o ./dist/app.css --watch\" \"npm run dev --prefix example\"",
"typecheck": "tsc --noEmit",
"check": "npm run typecheck"
},
"dependencies": {
"@mariozechner/mini-lit": "^0.1.7",
"@mariozechner/pi-ai": "^0.5.43",
"docx-preview": "^0.3.7",
"jszip": "^3.10.1",
"lit": "^3.3.1",
"lucide": "^0.544.0",
"pdfjs-dist": "^5.4.149",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@tailwindcss/cli": "^4.0.0-beta.14",
"concurrently": "^9.2.1",
"typescript": "^5.7.3"
},
"keywords": [
"ai",
"chat",
"ui",
"components",
"llm",
"web-components",
"mini-lit"
],
"author": "Mario Zechner",
"license": "MIT"
}

View file

@ -0,0 +1,38 @@
/* Import Claude theme from mini-lit */
@import "@mariozechner/mini-lit/styles/themes/default.css";
/* Tell Tailwind to scan mini-lit components */
/* biome-ignore lint/suspicious/noUnknownAtRules: Tailwind 4 source directive */
@source "../../../node_modules/@mariozechner/mini-lit/dist";
/* Import Tailwind */
/* biome-ignore lint/correctness/noInvalidPositionAtImportRule: fuck you */
@import "tailwindcss";
body {
font-size: 16px;
-webkit-font-smoothing: antialiased;
}
* {
scrollbar-width: thin;
scrollbar-color: var(--color-border) rgba(0, 0, 0, 0);
}
*::-webkit-scrollbar {
width: 8px;
height: 8px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background-color: var(--color-border);
border-radius: 4px;
}
*::-webkit-scrollbar-thumb:hover {
background-color: rgba(0, 0, 0, 0);
}

View file

@ -0,0 +1,312 @@
import { html } from "@mariozechner/mini-lit";
import type { ToolResultMessage, Usage } from "@mariozechner/pi-ai";
import { LitElement } from "lit";
import { customElement, property, query } from "lit/decorators.js";
import { ApiKeysDialog } from "../dialogs/ApiKeysDialog.js";
import { ModelSelector } from "../dialogs/ModelSelector.js";
import type { MessageEditor } from "./MessageEditor.js";
import "./MessageEditor.js";
import "./MessageList.js";
import "./Messages.js"; // Import for side effects to register the custom elements
import type { AgentSession, AgentSessionEvent } from "../state/agent-session.js";
import { keyStore } from "../state/KeyStore.js";
import "./StreamingMessageContainer.js";
import type { Attachment } from "../utils/attachment-utils.js";
import { formatUsage } from "../utils/format.js";
import { i18n } from "../utils/i18n.js";
import type { StreamingMessageContainer } from "./StreamingMessageContainer.js";
@customElement("agent-interface")
export class AgentInterface extends LitElement {
// Optional external session: when provided, this component becomes a view over the session
@property({ attribute: false }) session?: AgentSession;
@property() enableAttachments = true;
@property() enableModelSelector = true;
@property() enableThinking = true;
@property() showThemeToggle = false;
@property() showDebugToggle = false;
// References
@query("message-editor") private _messageEditor!: MessageEditor;
@query("streaming-message-container") private _streamingContainer!: StreamingMessageContainer;
private _autoScroll = true;
private _lastScrollTop = 0;
private _lastClientHeight = 0;
private _scrollContainer?: HTMLElement;
private _resizeObserver?: ResizeObserver;
private _unsubscribeSession?: () => void;
public setInput(text: string, attachments?: Attachment[]) {
const update = () => {
if (!this._messageEditor) requestAnimationFrame(update);
else {
this._messageEditor.value = text;
this._messageEditor.attachments = attachments || [];
}
};
update();
}
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
override async connectedCallback() {
super.connectedCallback();
this.style.display = "flex";
this.style.flexDirection = "column";
this.style.height = "100%";
this.style.minHeight = "0";
// Wait for first render to get scroll container
await this.updateComplete;
this._scrollContainer = this.querySelector(".overflow-y-auto") as HTMLElement;
if (this._scrollContainer) {
// Set up ResizeObserver to detect content changes
this._resizeObserver = new ResizeObserver(() => {
if (this._autoScroll && this._scrollContainer) {
this._scrollContainer.scrollTop = this._scrollContainer.scrollHeight;
}
});
// Observe the content container inside the scroll container
const contentContainer = this._scrollContainer.querySelector(".max-w-3xl");
if (contentContainer) {
this._resizeObserver.observe(contentContainer);
}
// Set up scroll listener with better detection
this._scrollContainer.addEventListener("scroll", this._handleScroll);
}
// Subscribe to external session if provided
this.setupSessionSubscription();
// Attach debug listener if session provided
if (this.session) {
this.session = this.session; // explicitly set to trigger subscription
}
}
override disconnectedCallback() {
super.disconnectedCallback();
// Clean up observers and listeners
if (this._resizeObserver) {
this._resizeObserver.disconnect();
this._resizeObserver = undefined;
}
if (this._scrollContainer) {
this._scrollContainer.removeEventListener("scroll", this._handleScroll);
}
if (this._unsubscribeSession) {
this._unsubscribeSession();
this._unsubscribeSession = undefined;
}
}
private setupSessionSubscription() {
if (this._unsubscribeSession) {
this._unsubscribeSession();
this._unsubscribeSession = undefined;
}
if (!this.session) return;
this._unsubscribeSession = this.session.subscribe(async (ev: AgentSessionEvent) => {
if (ev.type === "state-update") {
if (this._streamingContainer) {
this._streamingContainer.isStreaming = ev.state.isStreaming;
this._streamingContainer.setMessage(ev.state.streamMessage, !ev.state.isStreaming);
}
this.requestUpdate();
} else if (ev.type === "error-no-model") {
// TODO show some UI feedback
} else if (ev.type === "error-no-api-key") {
// Open API keys dialog to configure the missing key
ApiKeysDialog.open();
}
});
}
private _handleScroll = (_ev: any) => {
if (!this._scrollContainer) return;
const currentScrollTop = this._scrollContainer.scrollTop;
const scrollHeight = this._scrollContainer.scrollHeight;
const clientHeight = this._scrollContainer.clientHeight;
const distanceFromBottom = scrollHeight - currentScrollTop - clientHeight;
// Ignore relayout due to message editor getting pushed up by stats
if (clientHeight < this._lastClientHeight) {
this._lastClientHeight = clientHeight;
return;
}
// Only disable auto-scroll if user scrolled UP or is far from bottom
if (currentScrollTop !== 0 && currentScrollTop < this._lastScrollTop && distanceFromBottom > 50) {
this._autoScroll = false;
} else if (distanceFromBottom < 10) {
// Re-enable if very close to bottom
this._autoScroll = true;
}
this._lastScrollTop = currentScrollTop;
this._lastClientHeight = clientHeight;
};
public async sendMessage(input: string, attachments?: Attachment[]) {
if ((!input.trim() && attachments?.length === 0) || this.session?.state.isStreaming) return;
const session = this.session;
if (!session) throw new Error("No session set on AgentInterface");
if (!session.state.model) throw new Error("No model set on AgentInterface");
// Check if API key exists for the provider (only needed in direct mode)
const provider = session.state.model.provider;
let apiKey = await keyStore.getKey(provider);
// If no API key, open the API keys dialog
if (!apiKey) {
await ApiKeysDialog.open();
// Check again after dialog closes
apiKey = await keyStore.getKey(provider);
// If still no API key, abort the send
if (!apiKey) {
return;
}
}
// Only clear editor after we know we can send
this._messageEditor.value = "";
this._messageEditor.attachments = [];
this._autoScroll = true; // Enable auto-scroll when sending a message
await this.session?.prompt(input, attachments);
}
private renderMessages() {
if (!this.session)
return html`<div class="p-4 text-center text-muted-foreground">${i18n("No session available")}</div>`;
const state = this.session.state;
// Build a map of tool results to allow inline rendering in assistant messages
const toolResultsById = new Map<string, ToolResultMessage<any>>();
for (const message of state.messages) {
if (message.role === "toolResult") {
toolResultsById.set(message.toolCallId, message);
}
}
return html`
<div class="flex flex-col gap-3">
<!-- Stable messages list - won't re-render during streaming -->
<message-list
.messages=${this.session.state.messages}
.tools=${state.tools}
.pendingToolCalls=${this.session ? this.session.state.pendingToolCalls : new Set<string>()}
.isStreaming=${state.isStreaming}
></message-list>
<!-- Streaming message container - manages its own updates -->
<streaming-message-container
class="${state.isStreaming ? "" : "hidden"}"
.tools=${state.tools}
.isStreaming=${state.isStreaming}
.pendingToolCalls=${state.pendingToolCalls}
.toolResultsById=${toolResultsById}
></streaming-message-container>
</div>
`;
}
private renderStats() {
if (!this.session) return html`<div class="text-xs h-5"></div>`;
const state = this.session.state;
const totals = state.messages
.filter((m) => m.role === "assistant")
.reduce(
(acc, msg: any) => {
const usage = msg.usage;
if (usage) {
acc.input += usage.input;
acc.output += usage.output;
acc.cacheRead += usage.cacheRead;
acc.cacheWrite += usage.cacheWrite;
acc.cost.total += usage.cost.total;
}
return acc;
},
{
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
} satisfies Usage,
);
const hasTotals = totals.input || totals.output || totals.cacheRead || totals.cacheWrite;
const totalsText = hasTotals ? formatUsage(totals) : "";
return html`
<div class="text-xs text-muted-foreground flex justify-between items-center h-5">
<div class="flex items-center gap-1">
${this.showThemeToggle ? html`<theme-toggle></theme-toggle>` : html``}
</div>
<div class="flex ml-auto items-center gap-3">${totalsText ? html`<span>${totalsText}</span>` : ""}</div>
</div>
`;
}
override render() {
if (!this.session)
return html`<div class="p-4 text-center text-muted-foreground">${i18n("No session set")}</div>`;
const session = this.session;
const state = this.session.state;
return html`
<div class="flex flex-col h-full bg-background text-foreground">
<!-- Messages Area -->
<div class="flex-1 overflow-y-auto">
<div class="max-w-3xl mx-auto p-4 pb-0">${this.renderMessages()}</div>
</div>
<!-- Input Area -->
<div class="shrink-0">
<div class="max-w-3xl mx-auto px-2">
<message-editor
.isStreaming=${state.isStreaming}
.currentModel=${state.model}
.thinkingLevel=${state.thinkingLevel}
.showAttachmentButton=${this.enableAttachments}
.showModelSelector=${this.enableModelSelector}
.showThinking=${this.enableThinking}
.onSend=${(input: string, attachments: Attachment[]) => {
this.sendMessage(input, attachments);
}}
.onAbort=${() => session.abort()}
.onModelSelect=${() => {
ModelSelector.open(state.model, (model) => session.setModel(model));
}}
.onThinkingChange=${
this.enableThinking
? (level: "off" | "minimal" | "low" | "medium" | "high") => {
session.setThinkingLevel(level);
}
: undefined
}
></message-editor>
${this.renderStats()}
</div>
</div>
</div>
`;
}
}
// Register custom element with guard
if (!customElements.get("agent-interface")) {
customElements.define("agent-interface", AgentInterface);
}

View file

@ -0,0 +1,112 @@
import { html, icon } from "@mariozechner/mini-lit";
import { LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { FileSpreadsheet, FileText, X } from "lucide";
import { AttachmentOverlay } from "../dialogs/AttachmentOverlay.js";
import type { Attachment } from "../utils/attachment-utils.js";
import { i18n } from "../utils/i18n.js";
@customElement("attachment-tile")
export class AttachmentTile extends LitElement {
@property({ type: Object }) attachment!: Attachment;
@property({ type: Boolean }) showDelete = false;
@property() onDelete?: () => void;
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
override connectedCallback(): void {
super.connectedCallback();
this.style.display = "block";
this.classList.add("max-h-16");
}
private handleClick = () => {
AttachmentOverlay.open(this.attachment);
};
override render() {
const hasPreview = !!this.attachment.preview;
const isImage = this.attachment.type === "image";
const isPdf = this.attachment.mimeType === "application/pdf";
const isDocx =
this.attachment.mimeType?.includes("wordprocessingml") ||
this.attachment.fileName.toLowerCase().endsWith(".docx");
const isPptx =
this.attachment.mimeType?.includes("presentationml") ||
this.attachment.fileName.toLowerCase().endsWith(".pptx");
const isExcel =
this.attachment.mimeType?.includes("spreadsheetml") ||
this.attachment.fileName.toLowerCase().endsWith(".xlsx") ||
this.attachment.fileName.toLowerCase().endsWith(".xls");
// Choose the appropriate icon
const getDocumentIcon = () => {
if (isExcel) return icon(FileSpreadsheet, "md");
return icon(FileText, "md");
};
return html`
<div class="relative group inline-block">
${
hasPreview
? html`
<div class="relative">
<img
src="data:${isImage ? this.attachment.mimeType : "image/png"};base64,${this.attachment.preview}"
class="w-16 h-16 object-cover rounded-lg border border-input cursor-pointer hover:opacity-80 transition-opacity"
alt="${this.attachment.fileName}"
title="${this.attachment.fileName}"
@click=${this.handleClick}
/>
${
isPdf
? html`
<!-- PDF badge overlay -->
<div class="absolute bottom-0 left-0 right-0 bg-background/90 px-1 py-0.5 rounded-b-lg">
<div class="text-[10px] text-muted-foreground text-center font-medium">${i18n("PDF")}</div>
</div>
`
: ""
}
</div>
`
: html`
<!-- Fallback: document icon + filename -->
<div
class="w-16 h-16 rounded-lg border border-input cursor-pointer hover:opacity-80 transition-opacity bg-muted text-muted-foreground flex flex-col items-center justify-center p-2"
@click=${this.handleClick}
title="${this.attachment.fileName}"
>
${getDocumentIcon()}
<div class="text-[10px] text-center truncate w-full">
${
this.attachment.fileName.length > 10
? this.attachment.fileName.substring(0, 8) + "..."
: this.attachment.fileName
}
</div>
</div>
`
}
${
this.showDelete
? html`
<button
@click=${(e: Event) => {
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")}
</button>
`
: ""
}
</div>
`;
}
}

View file

@ -0,0 +1,67 @@
import { html, icon } from "@mariozechner/mini-lit";
import { LitElement } from "lit";
import { property, state } from "lit/decorators.js";
import { Check, Copy } from "lucide";
import { i18n } from "../utils/i18n.js";
export class ConsoleBlock extends LitElement {
@property() content: string = "";
@state() private copied = false;
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
override connectedCallback(): void {
super.connectedCallback();
this.style.display = "block";
}
private async copy() {
try {
await navigator.clipboard.writeText(this.content || "");
this.copied = true;
setTimeout(() => {
this.copied = false;
}, 1500);
} catch (e) {
console.error("Copy failed", e);
}
}
override updated() {
// Auto-scroll to bottom on content changes
const container = this.querySelector(".console-scroll") as HTMLElement | null;
if (container) {
container.scrollTop = container.scrollHeight;
}
}
override render() {
return html`
<div class="border border-border rounded-lg overflow-hidden">
<div class="flex items-center justify-between px-3 py-1.5 bg-muted border-b border-border">
<span class="text-xs text-muted-foreground font-mono">${i18n("console")}</span>
<button
@click=${() => 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`<span>${i18n("Copied!")}</span>` : ""}
</button>
</div>
<div class="console-scroll overflow-auto max-h-64">
<pre class="!bg-background !border-0 !rounded-none m-0 p-3 text-xs text-foreground font-mono whitespace-pre-wrap">
${this.content || ""}</pre
>
</div>
</div>
`;
}
}
// Register custom element
if (!customElements.get("console-block")) {
customElements.define("console-block", ConsoleBlock);
}

View file

@ -0,0 +1,112 @@
import { type BaseComponentProps, fc, html } from "@mariozechner/mini-lit";
import { type Ref, ref } from "lit/directives/ref.js";
import { i18n } from "../utils/i18n.js";
export type InputType = "text" | "email" | "password" | "number" | "url" | "tel" | "search";
export type InputSize = "sm" | "md" | "lg";
export interface InputProps extends BaseComponentProps {
type?: InputType;
size?: InputSize;
value?: string;
placeholder?: string;
label?: string;
error?: string;
disabled?: boolean;
required?: boolean;
name?: string;
autocomplete?: string;
min?: number;
max?: number;
step?: number;
inputRef?: Ref<HTMLInputElement>;
onInput?: (e: Event) => void;
onChange?: (e: Event) => void;
onKeyDown?: (e: KeyboardEvent) => void;
onKeyUp?: (e: KeyboardEvent) => void;
}
export const Input = fc<InputProps>(
({
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`
<div class="flex flex-col gap-1.5 ${className}">
${
label
? html`
<label class="text-sm font-medium text-foreground">
${label} ${required ? html`<span class="text-destructive">${i18n("*")}</span>` : ""}
</label>
`
: ""
}
<input
type="${type}"
class="${baseClasses} ${
sizeClasses[size]
} ${interactionClasses} ${focusClasses} ${darkClasses} ${stateClasses} ${disabledClasses}"
.value=${value}
placeholder="${placeholder}"
?disabled=${disabled}
?required=${required}
?aria-invalid=${!!error}
name="${name}"
autocomplete="${autocomplete}"
min="${min ?? ""}"
max="${max ?? ""}"
step="${step ?? ""}"
@input=${handleInput}
@change=${handleChange}
@keydown=${onKeyDown}
@keyup=${onKeyUp}
${inputRef ? ref(inputRef) : ""}
/>
${error ? html`<span class="text-sm text-destructive">${error}</span>` : ""}
</div>
`;
},
);

View file

@ -0,0 +1,272 @@
import { Button, html, icon } from "@mariozechner/mini-lit";
import type { Model } from "@mariozechner/pi-ai";
import { LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { createRef, ref } from "lit/directives/ref.js";
import { Loader2, Paperclip, Send, Sparkles, Square } from "lucide";
import "./AttachmentTile.js";
import { type Attachment, loadAttachment } from "../utils/attachment-utils.js";
import { i18n } from "../utils/i18n.js";
@customElement("message-editor")
export class MessageEditor extends LitElement {
private _value = "";
private textareaRef = createRef<HTMLTextAreaElement>();
@property()
get value() {
return this._value;
}
set value(val: string) {
const oldValue = this._value;
this._value = val;
this.requestUpdate("value", oldValue);
this.updateComplete.then(() => {
const textarea = this.textareaRef.value;
if (textarea) {
textarea.style.height = "auto";
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
}
});
}
@property() isStreaming = false;
@property() currentModel?: Model<any>;
@property() showAttachmentButton = true;
@property() showModelSelector = true;
@property() showThinking = false; // Disabled for now
@property() onInput?: (value: string) => void;
@property() onSend?: (input: string, attachments: Attachment[]) => void;
@property() onAbort?: () => void;
@property() onModelSelect?: () => void;
@property() onFilesChange?: (files: Attachment[]) => void;
@property() attachments: Attachment[] = [];
@property() maxFiles = 10;
@property() maxFileSize = 20 * 1024 * 1024; // 20MB
@property() acceptedTypes =
"image/*,application/pdf,.docx,.pptx,.xlsx,.xls,.txt,.md,.json,.xml,.html,.css,.js,.ts,.jsx,.tsx,.yml,.yaml";
@state() processingFiles = false;
private fileInputRef = createRef<HTMLInputElement>();
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
private handleTextareaInput = (e: Event) => {
const textarea = e.target as HTMLTextAreaElement;
this.value = textarea.value;
textarea.style.height = "auto";
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
this.onInput?.(this.value);
};
private handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (!this.isStreaming && !this.processingFiles && (this.value.trim() || this.attachments.length > 0)) {
this.handleSend();
}
} else if (e.key === "Escape" && this.isStreaming) {
e.preventDefault();
this.onAbort?.();
}
};
private handleSend = () => {
this.onSend?.(this.value, this.attachments);
};
private handleAttachmentClick = () => {
this.fileInputRef.value?.click();
};
private async handleFilesSelected(e: Event) {
const input = e.target as HTMLInputElement;
const files = Array.from(input.files || []);
if (files.length === 0) return;
if (files.length + this.attachments.length > this.maxFiles) {
alert(`Maximum ${this.maxFiles} files allowed`);
input.value = "";
return;
}
this.processingFiles = true;
const newAttachments: Attachment[] = [];
for (const file of files) {
try {
if (file.size > this.maxFileSize) {
alert(`${file.name} exceeds maximum size of ${Math.round(this.maxFileSize / 1024 / 1024)}MB`);
continue;
}
const attachment = await loadAttachment(file);
newAttachments.push(attachment);
} catch (error) {
console.error(`Error processing ${file.name}:`, error);
alert(`Failed to process ${file.name}: ${String(error)}`);
}
}
this.attachments = [...this.attachments, ...newAttachments];
this.onFilesChange?.(this.attachments);
this.processingFiles = false;
input.value = ""; // Reset input
}
private removeFile(fileId: string) {
this.attachments = this.attachments.filter((f) => f.id !== fileId);
this.onFilesChange?.(this.attachments);
}
private adjustTextareaHeight() {
const textarea = this.textareaRef.value;
if (textarea) {
// Reset height to auto to get accurate scrollHeight
textarea.style.height = "auto";
// Only adjust if there's content, otherwise keep minimal height
if (this.value.trim()) {
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
}
}
}
override firstUpdated() {
const textarea = this.textareaRef.value;
if (textarea) {
// Don't adjust height on first render - let it be minimal
textarea.focus();
}
}
override updated() {
// Only adjust height when component updates if there's content
if (this.value) {
this.adjustTextareaHeight();
}
}
override render() {
return html`
<div class="bg-card rounded-xl border border-border shadow-sm">
<!-- Attachments -->
${
this.attachments.length > 0
? html`
<div class="px-4 pt-3 pb-2 flex flex-wrap gap-2">
${this.attachments.map(
(attachment) => html`
<attachment-tile
.attachment=${attachment}
.showDelete=${true}
.onDelete=${() => this.removeFile(attachment.id)}
></attachment-tile>
`,
)}
</div>
`
: ""
}
<textarea
class="w-full bg-transparent p-4 text-foreground placeholder-muted-foreground outline-none resize-none overflow-y-auto"
placeholder=${i18n("Type a message...")}
rows="1"
style="max-height: 200px;"
.value=${this.value}
@input=${this.handleTextareaInput}
@keydown=${this.handleKeyDown}
${ref(this.textareaRef)}
></textarea>
<!-- Hidden file input -->
<input
type="file"
${ref(this.fileInputRef)}
@change=${this.handleFilesSelected}
accept=${this.acceptedTypes}
multiple
style="display: none;"
/>
<!-- Button Row -->
<div class="px-2 pb-2 flex items-center justify-between">
<!-- Left side - attachment and quick action buttons -->
<div class="flex gap-2 items-center">
${
this.showAttachmentButton
? this.processingFiles
? html`
<div class="h-8 w-8 flex items-center justify-center">
${icon(Loader2, "sm", "animate-spin text-muted-foreground")}
</div>
`
: html`
${Button({
variant: "ghost",
size: "icon",
className: "h-8 w-8",
onClick: this.handleAttachmentClick,
children: icon(Paperclip, "sm"),
})}
`
: ""
}
</div>
<!-- Model selector and send on the right -->
<div class="flex gap-2 items-center">
${
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")}
<span class="ml-1">${this.currentModel.id}</span>
`,
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`<div style="transform: rotate(-45deg)">${icon(Send, "sm")}</div>`,
className: "h-8 w-8",
})}
`
}
</div>
</div>
</div>
`;
}
}

View file

@ -0,0 +1,82 @@
import { html } from "@mariozechner/mini-lit";
import type {
AgentTool,
AssistantMessage as AssistantMessageType,
Message,
ToolResultMessage as ToolResultMessageType,
} from "@mariozechner/pi-ai";
import { LitElement, type TemplateResult } from "lit";
import { property } from "lit/decorators.js";
import { repeat } from "lit/directives/repeat.js";
export class MessageList extends LitElement {
@property({ type: Array }) messages: Message[] = [];
@property({ type: Array }) tools: AgentTool[] = [];
@property({ type: Object }) pendingToolCalls?: Set<string>;
@property({ type: Boolean }) isStreaming: boolean = false;
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
override connectedCallback(): void {
super.connectedCallback();
this.style.display = "block";
}
private buildRenderItems() {
// Map tool results by call id for quick lookup
const resultByCallId = new Map<string, ToolResultMessageType>();
for (const message of this.messages) {
if (message.role === "toolResult") {
resultByCallId.set(message.toolCallId, message);
}
}
const items: Array<{ key: string; template: TemplateResult }> = [];
let index = 0;
for (const msg of this.messages) {
if (msg.role === "user") {
items.push({
key: `msg:${index}`,
template: html`<user-message .message=${msg}></user-message>`,
});
index++;
} else if (msg.role === "assistant") {
const amsg = msg as AssistantMessageType;
items.push({
key: `msg:${index}`,
template: html`<assistant-message
.message=${amsg}
.tools=${this.tools}
.isStreaming=${this.isStreaming}
.pendingToolCalls=${this.pendingToolCalls}
.toolResultsById=${resultByCallId}
.hideToolCalls=${false}
></assistant-message>`,
});
index++;
} else {
// Skip standalone toolResult messages; they are rendered via paired tool-message above
// For completeness, other roles are not expected
}
}
return items;
}
override render() {
const items = this.buildRenderItems();
return html`<div class="flex flex-col gap-3">
${repeat(
items,
(it) => it.key,
(it) => it.template,
)}
</div>`;
}
}
// Register custom element
if (!customElements.get("message-list")) {
customElements.define("message-list", MessageList);
}

View file

@ -0,0 +1,310 @@
import { Button, html, icon } from "@mariozechner/mini-lit";
import type {
AgentTool,
AssistantMessage as AssistantMessageType,
ToolCall,
ToolResultMessage as ToolResultMessageType,
UserMessage as UserMessageType,
} from "@mariozechner/pi-ai";
import type { AgentToolResult } from "@mariozechner/pi-ai/dist/agent/types.js";
import { LitElement, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { Bug, Loader, Wrench } from "lucide";
import { renderToolParams, renderToolResult } from "../tools/index.js";
import type { Attachment } from "../utils/attachment-utils.js";
import { formatUsage } from "../utils/format.js";
import { i18n } from "../utils/i18n.js";
export type UserMessageWithAttachments = UserMessageType & { attachments?: Attachment[] };
export type AppMessage = AssistantMessageType | UserMessageWithAttachments | ToolResultMessageType;
@customElement("user-message")
export class UserMessage extends LitElement {
@property({ type: Object }) message!: UserMessageWithAttachments;
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
override connectedCallback(): void {
super.connectedCallback();
this.style.display = "block";
}
override render() {
const content =
typeof this.message.content === "string"
? this.message.content
: this.message.content.find((c) => c.type === "text")?.text || "";
return html`
<div class="py-2 px-4 border-l-4 border-accent-foreground/60 text-primary-foreground">
<markdown-block .content=${content}></markdown-block>
${
this.message.attachments && this.message.attachments.length > 0
? html`
<div class="mt-3 flex flex-wrap gap-2">
${this.message.attachments.map(
(attachment) => html` <attachment-tile .attachment=${attachment}></attachment-tile> `,
)}
</div>
`
: ""
}
</div>
`;
}
}
@customElement("assistant-message")
export class AssistantMessage extends LitElement {
@property({ type: Object }) message!: AssistantMessageType;
@property({ type: Array }) tools?: AgentTool<any>[];
@property({ type: Object }) pendingToolCalls?: Set<string>;
@property({ type: Boolean }) hideToolCalls = false;
@property({ type: Object }) toolResultsById?: Map<string, ToolResultMessageType>;
@property({ type: Boolean }) isStreaming: boolean = false;
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
override connectedCallback(): void {
super.connectedCallback();
this.style.display = "block";
}
override render() {
// Render content in the order it appears
const orderedParts: TemplateResult[] = [];
for (const chunk of this.message.content) {
if (chunk.type === "text" && chunk.text.trim() !== "") {
orderedParts.push(html`<markdown-block .content=${chunk.text}></markdown-block>`);
} else if (chunk.type === "thinking" && chunk.thinking.trim() !== "") {
orderedParts.push(html` <markdown-block .content=${chunk.thinking} .isThinking=${true}></markdown-block> `);
} else if (chunk.type === "toolCall") {
if (!this.hideToolCalls) {
const tool = this.tools?.find((t) => t.name === chunk.name);
const pending = this.pendingToolCalls?.has(chunk.id) ?? false;
const result = this.toolResultsById?.get(chunk.id);
const aborted = !pending && !result && !this.isStreaming;
orderedParts.push(
html`<tool-message
.tool=${tool}
.toolCall=${chunk}
.result=${result}
.pending=${pending}
.aborted=${aborted}
.isStreaming=${this.isStreaming}
></tool-message>`,
);
}
}
}
return html`
<div>
${orderedParts.length ? html` <div class="px-4 flex flex-col gap-3">${orderedParts}</div> ` : ""}
${
this.message.usage
? html` <div class="px-4 mt-2 text-xs text-muted-foreground">${formatUsage(this.message.usage)}</div> `
: ""
}
${
this.message.stopReason === "error" && this.message.errorMessage
? html`
<div class="mx-4 mt-3 p-3 bg-destructive/10 text-destructive rounded-lg text-sm overflow-hidden">
<strong>${i18n("Error:")}</strong> ${this.message.errorMessage}
</div>
`
: ""
}
${
this.message.stopReason === "aborted"
? html`<span class="text-sm text-destructive italic">${i18n("Request aborted")}</span>`
: ""
}
</div>
`;
}
}
@customElement("tool-message-debug")
export class ToolMessageDebugView extends LitElement {
@property({ type: Object }) callArgs: any;
@property({ type: String }) result?: AgentToolResult<any>;
@property({ type: Boolean }) hasResult: boolean = false;
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this; // light DOM for shared styles
}
override connectedCallback(): void {
super.connectedCallback();
this.style.display = "block";
}
private pretty(value: unknown): { content: string; isJson: boolean } {
try {
if (typeof value === "string") {
const maybeJson = JSON.parse(value);
return { content: JSON.stringify(maybeJson, null, 2), isJson: true };
}
return { content: JSON.stringify(value, null, 2), isJson: true };
} catch {
return { content: typeof value === "string" ? value : String(value), isJson: false };
}
}
override render() {
const output = this.pretty(this.result?.output);
const details = this.pretty(this.result?.details);
return html`
<div class="mt-3 flex flex-col gap-2">
<div>
<div class="text-xs font-medium mb-1 text-muted-foreground">${i18n("Call")}</div>
<code-block .code=${this.pretty(this.callArgs).content} language="json"></code-block>
</div>
<div>
<div class="text-xs font-medium mb-1 text-muted-foreground">${i18n("Result")}</div>
${
this.hasResult
? html`<code-block .code=${output.content} language="${output.isJson ? "json" : "text"}"></code-block>
<code-block .code=${details.content} language="${details.isJson ? "json" : "text"}"></code-block>`
: html`<div class="text-xs text-muted-foreground">${i18n("(no result)")}</div>`
}
</div>
</div>
`;
}
}
@customElement("tool-message")
export class ToolMessage extends LitElement {
@property({ type: Object }) toolCall!: ToolCall;
@property({ type: Object }) tool?: AgentTool<any>;
@property({ type: Object }) result?: ToolResultMessageType;
@property({ type: Boolean }) pending: boolean = false;
@property({ type: Boolean }) aborted: boolean = false;
@property({ type: Boolean }) isStreaming: boolean = false;
@state() private _showDebug = false;
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
override connectedCallback(): void {
super.connectedCallback();
this.style.display = "block";
}
private toggleDebug = () => {
this._showDebug = !this._showDebug;
};
override render() {
const toolLabel = this.tool?.label || this.toolCall.name;
const toolName = this.tool?.name || this.toolCall.name;
const isError = this.result?.isError === true;
const hasResult = !!this.result;
let statusIcon: TemplateResult;
if (this.pending || (this.isStreaming && !hasResult)) {
statusIcon = html`<span class="inline-block text-muted-foreground animate-spin">${icon(Loader, "sm")}</span>`;
} else if (this.aborted && !hasResult) {
statusIcon = html`<span class="inline-block text-destructive">${icon(Wrench, "sm")}</span>`;
} else if (hasResult && isError) {
statusIcon = html`<span class="inline-block text-destructive">${icon(Wrench, "sm")}</span>`;
} else if (hasResult) {
statusIcon = html`<span class="inline-block text-muted-foreground">${icon(Wrench, "sm")}</span>`;
} else {
statusIcon = html`<span class="inline-block text-muted-foreground">${icon(Wrench, "sm")}</span>`;
}
// Normalize error text
let errorMessage = this.result?.output || "";
if (isError) {
try {
const parsed = JSON.parse(errorMessage);
if ((parsed as any).error) errorMessage = (parsed as any).error;
else if ((parsed as any).message) errorMessage = (parsed as any).message;
} catch {}
errorMessage = errorMessage.replace(/^(Tool )?Error:\s*/i, "");
errorMessage = errorMessage.replace(/^Error:\s*/i, "");
}
const paramsTpl = renderToolParams(
toolName,
this.toolCall.arguments,
this.isStreaming || (this.pending && !hasResult),
);
const resultTpl =
hasResult && !isError ? renderToolResult(toolName, this.toolCall.arguments, this.result!) : undefined;
return html`
<div class="p-2.5 border border-border rounded-md bg-card text-card-foreground">
<div class="flex items-center justify-between text-xs text-muted-foreground">
<div class="flex items-center gap-2">
${statusIcon}
<span class="font-medium">${toolLabel}</span>
</div>
${Button({
variant: this._showDebug ? "default" : "ghost",
size: "sm",
onClick: this.toggleDebug,
children: icon(Bug, "sm"),
className: "text-muted-foreground",
})}
</div>
${
this._showDebug
? html`<tool-message-debug
.callArgs=${this.toolCall.arguments}
.result=${this.result}
.hasResult=${!!this.result}
></tool-message-debug>`
: html`
<div class="mt-2 text-sm text-muted-foreground">${paramsTpl}</div>
${
this.pending && !hasResult
? html`<div class="mt-2 text-sm text-muted-foreground">${i18n("Waiting for tool result…")}</div>`
: ""
}
${
this.aborted && !hasResult
? html`<div class="mt-2 text-sm text-muted-foreground">${i18n("Call was aborted; no result.")}</div>`
: ""
}
${
hasResult && isError
? html`<div class="mt-2 p-2 border border-destructive rounded bg-destructive/10 text-sm text-destructive">
${errorMessage}
</div>`
: ""
}
${resultTpl ? html`<div class="mt-2">${resultTpl}</div>` : ""}
`
}
</div>
`;
}
}
@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`<span class="text-sm text-destructive italic">${i18n("Request aborted")}</span>`;
}
}

View file

@ -1,5 +1,5 @@
import { LitElement } from "lit";
import { customElement } from "lit/decorators.js";
import { customElement, property } from "lit/decorators.js";
import type { Attachment } from "../utils/attachment-utils.js";
export interface SandboxFile {
@ -15,10 +15,23 @@ export interface SandboxResult {
error?: { message: string; stack: string };
}
/**
* Function that returns the URL to the sandbox HTML file.
* Used in browser extensions to load sandbox.html via chrome.runtime.getURL().
*/
export type SandboxUrlProvider = () => string;
@customElement("sandbox-iframe")
export class SandboxIframe extends LitElement {
private iframe?: HTMLIFrameElement;
/**
* Optional: Provide a function that returns the sandbox HTML URL.
* If provided, the iframe will use this URL instead of srcdoc.
* This is required for browser extensions with strict CSP.
*/
@property({ attribute: false }) sandboxUrlProvider?: SandboxUrlProvider;
createRenderRoot() {
return this;
}
@ -41,6 +54,48 @@ export class SandboxIframe extends LitElement {
public loadContent(sandboxId: string, htmlContent: string, attachments: Attachment[]): void {
const completeHtml = this.prepareHtmlDocument(sandboxId, htmlContent, attachments);
if (this.sandboxUrlProvider) {
// Browser extension mode: use sandbox.html with postMessage
this.loadViaSandboxUrl(sandboxId, completeHtml, attachments);
} else {
// Web mode: use srcdoc
this.loadViaSrcdoc(completeHtml);
}
}
private loadViaSandboxUrl(sandboxId: string, completeHtml: string, attachments: Attachment[]): void {
// Wait for sandbox-ready and send content
const readyHandler = (e: MessageEvent) => {
if (e.data.type === "sandbox-ready" && e.source === this.iframe?.contentWindow) {
window.removeEventListener("message", readyHandler);
this.iframe?.contentWindow?.postMessage(
{
type: "sandbox-load",
sandboxId,
code: completeHtml,
attachments,
},
"*",
);
}
};
window.addEventListener("message", readyHandler);
// Always recreate iframe to ensure fresh sandbox and sandbox-ready message
this.iframe?.remove();
this.iframe = document.createElement("iframe");
this.iframe.sandbox.add("allow-scripts");
this.iframe.sandbox.add("allow-modals");
this.iframe.style.width = "100%";
this.iframe.style.height = "100%";
this.iframe.style.border = "none";
this.iframe.src = this.sandboxUrlProvider!();
this.appendChild(this.iframe);
}
private loadViaSrcdoc(completeHtml: string): void {
// Always recreate iframe to ensure fresh sandbox
this.iframe?.remove();
this.iframe = document.createElement("iframe");
@ -50,7 +105,7 @@ export class SandboxIframe extends LitElement {
this.iframe.style.height = "100%";
this.iframe.style.border = "none";
// Set content directly via srcdoc (no CSP restrictions in web-ui)
// Set content directly via srcdoc (no CSP restrictions in web apps)
this.iframe.srcdoc = completeHtml;
this.appendChild(this.iframe);
@ -125,9 +180,14 @@ export class SandboxIframe extends LitElement {
}
};
let readyHandler: ((e: MessageEvent) => void) | undefined;
const cleanup = () => {
window.removeEventListener("message", messageHandler);
signal?.removeEventListener("abort", abortHandler);
if (readyHandler) {
window.removeEventListener("message", readyHandler);
}
clearTimeout(timeoutId);
};
@ -148,7 +208,26 @@ export class SandboxIframe extends LitElement {
}
}, 30000);
// NOW create and append iframe AFTER all listeners are set up
if (this.sandboxUrlProvider) {
// Browser extension mode: wait for sandbox-ready and send content
readyHandler = (e: MessageEvent) => {
if (e.data.type === "sandbox-ready" && e.source === this.iframe?.contentWindow) {
window.removeEventListener("message", readyHandler!);
// Send the complete HTML
this.iframe?.contentWindow?.postMessage(
{
type: "sandbox-load",
sandboxId,
code: completeHtml,
attachments,
},
"*",
);
}
};
window.addEventListener("message", readyHandler);
// Create iframe AFTER all listeners are set up
this.iframe?.remove();
this.iframe = document.createElement("iframe");
this.iframe.sandbox.add("allow-scripts");
@ -157,10 +236,24 @@ export class SandboxIframe extends LitElement {
this.iframe.style.height = "100%";
this.iframe.style.border = "none";
// Set content via srcdoc BEFORE appending to DOM (no CSP restrictions in web-ui)
this.iframe.src = this.sandboxUrlProvider();
this.appendChild(this.iframe);
} else {
// Web mode: use srcdoc
this.iframe?.remove();
this.iframe = document.createElement("iframe");
this.iframe.sandbox.add("allow-scripts");
this.iframe.sandbox.add("allow-modals");
this.iframe.style.width = "100%";
this.iframe.style.height = "100%";
this.iframe.style.border = "none";
// Set content via srcdoc BEFORE appending to DOM
this.iframe.srcdoc = completeHtml;
this.appendChild(this.iframe);
}
});
}

View file

@ -0,0 +1,101 @@
import { html } from "@mariozechner/mini-lit";
import type { AgentTool, Message, ToolResultMessage } from "@mariozechner/pi-ai";
import { LitElement } from "lit";
import { property, state } from "lit/decorators.js";
export class StreamingMessageContainer extends LitElement {
@property({ type: Array }) tools: AgentTool[] = [];
@property({ type: Boolean }) isStreaming = false;
@property({ type: Object }) pendingToolCalls?: Set<string>;
@property({ type: Object }) toolResultsById?: Map<string, ToolResultMessage>;
@state() private _message: Message | null = null;
private _pendingMessage: Message | null = null;
private _updateScheduled = false;
private _immediateUpdate = false;
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
override connectedCallback(): void {
super.connectedCallback();
this.style.display = "block";
}
// Public method to update the message with batching for performance
public setMessage(message: Message | null, immediate = false) {
// Store the latest message
this._pendingMessage = message;
// If this is an immediate update (like clearing), apply it right away
if (immediate || message === null) {
this._immediateUpdate = true;
this._message = message;
this.requestUpdate();
// Cancel any pending updates since we're clearing
this._pendingMessage = null;
this._updateScheduled = false;
return;
}
// Otherwise batch updates for performance during streaming
if (!this._updateScheduled) {
this._updateScheduled = true;
requestAnimationFrame(async () => {
// Only apply the update if we haven't been cleared
if (!this._immediateUpdate && this._pendingMessage !== null) {
// Deep clone the message to ensure Lit detects changes in nested properties
// (like toolCall.arguments being mutated during streaming)
this._message = JSON.parse(JSON.stringify(this._pendingMessage));
this.requestUpdate();
}
// Reset for next batch
this._pendingMessage = null;
this._updateScheduled = false;
this._immediateUpdate = false;
});
}
}
override render() {
// Show loading indicator if loading but no message yet
if (!this._message) {
if (this.isStreaming)
return html`<div class="flex flex-col gap-3 mb-3">
<span class="mx-4 inline-block w-2 h-4 bg-muted-foreground animate-pulse"></span>
</div>`;
return html``; // Empty until a message is set
}
const msg = this._message;
if (msg.role === "toolResult") {
// Skip standalone tool result in streaming; the stable list will render paired tool-message
return html``;
} else if (msg.role === "user") {
// Skip standalone tool result in streaming; the stable list will render it immediiately
return html``;
} else if (msg.role === "assistant") {
// Assistant message - render inline tool messages during streaming
return html`
<div class="flex flex-col gap-3 mb-3">
<assistant-message
.message=${msg}
.tools=${this.tools}
.isStreaming=${this.isStreaming}
.pendingToolCalls=${this.pendingToolCalls}
.toolResultsById=${this.toolResultsById}
.hideToolCalls=${false}
></assistant-message>
${this.isStreaming ? html`<span class="mx-4 inline-block w-2 h-4 bg-muted-foreground animate-pulse"></span>` : ""}
</div>
`;
}
}
}
// Register custom element
if (!customElements.get("streaming-message-container")) {
customElements.define("streaming-message-container", StreamingMessageContainer);
}

View file

@ -0,0 +1,273 @@
import { Alert, Badge, Button, DialogBase, DialogHeader, html, type TemplateResult } from "@mariozechner/mini-lit";
import { type Context, complete, getModel, getProviders } from "@mariozechner/pi-ai";
import type { PropertyValues } from "lit";
import { customElement, state } from "lit/decorators.js";
import { Input } from "../components/Input.js";
import { keyStore } from "../state/KeyStore.js";
import { i18n } from "../utils/i18n.js";
// Test models for each provider - known to be reliable and cheap
const TEST_MODELS: Record<string, string> = {
anthropic: "claude-3-5-haiku-20241022",
openai: "gpt-4o-mini",
google: "gemini-2.0-flash-exp",
groq: "llama-3.3-70b-versatile",
openrouter: "openai/gpt-4o-mini",
cerebras: "llama3.1-8b",
xai: "grok-2-1212",
zai: "glm-4-plus",
};
@customElement("api-keys-dialog")
export class ApiKeysDialog extends DialogBase {
@state() apiKeys: Record<string, boolean> = {}; // provider -> configured
@state() apiKeyInputs: Record<string, string> = {};
@state() testResults: Record<string, "success" | "error" | "testing"> = {};
@state() savingProvider = "";
@state() testingProvider = "";
@state() error = "";
protected override modalWidth = "min(600px, 90vw)";
protected override modalHeight = "min(600px, 80vh)";
static async open() {
const dialog = new ApiKeysDialog();
dialog.open();
await dialog.loadKeys();
}
override async firstUpdated(changedProperties: PropertyValues): Promise<void> {
super.firstUpdated(changedProperties);
await this.loadKeys();
}
private async loadKeys() {
this.apiKeys = await keyStore.getAllKeys();
}
private async testApiKey(provider: string, apiKey: string): Promise<boolean> {
try {
// Get the test model for this provider
const modelId = TEST_MODELS[provider];
if (!modelId) {
this.error = `No test model configured for ${provider}`;
return false;
}
const model = getModel(provider as any, modelId);
if (!model) {
this.error = `Test model ${modelId} not found for ${provider}`;
return false;
}
// Simple test prompt
const context: Context = {
messages: [{ role: "user", content: "Reply with exactly: test successful" }],
};
const response = await complete(model, context, {
apiKey,
maxTokens: 10, // Keep it minimal for testing
} as any);
// Check if response contains expected text
const text = response.content
.filter((b) => b.type === "text")
.map((b) => b.text)
.join("");
return text.toLowerCase().includes("test successful");
} catch (error) {
console.error(`API key test failed for ${provider}:`, error);
return false;
}
}
private async saveKey(provider: string) {
const key = this.apiKeyInputs[provider];
if (!key) return;
this.savingProvider = provider;
this.testResults[provider] = "testing";
this.error = "";
try {
// Test the key first
const isValid = await this.testApiKey(provider, key);
if (isValid) {
await keyStore.setKey(provider, key);
this.apiKeyInputs[provider] = ""; // Clear input
await this.loadKeys();
this.testResults[provider] = "success";
} else {
this.testResults[provider] = "error";
this.error = `Invalid API key for ${provider}`;
}
} catch (err: any) {
this.testResults[provider] = "error";
this.error = `Failed to save key for ${provider}: ${err.message}`;
} finally {
this.savingProvider = "";
// Clear test result after 3 seconds
setTimeout(() => {
delete this.testResults[provider];
this.requestUpdate();
}, 3000);
}
}
private async testExistingKey(provider: string) {
this.testingProvider = provider;
this.testResults[provider] = "testing";
this.error = "";
try {
const apiKey = await keyStore.getKey(provider);
if (!apiKey) {
this.testResults[provider] = "error";
this.error = `No API key found for ${provider}`;
return;
}
const isValid = await this.testApiKey(provider, apiKey);
if (isValid) {
this.testResults[provider] = "success";
} else {
this.testResults[provider] = "error";
this.error = `API key for ${provider} is no longer valid`;
}
} catch (err: any) {
this.testResults[provider] = "error";
this.error = `Test failed for ${provider}: ${err.message}`;
} finally {
this.testingProvider = "";
// Clear test result after 3 seconds
setTimeout(() => {
delete this.testResults[provider];
this.requestUpdate();
}, 3000);
}
}
private async removeKey(provider: string) {
if (!confirm(`Remove API key for ${provider}?`)) return;
await keyStore.removeKey(provider);
this.apiKeyInputs[provider] = "";
await this.loadKeys();
}
protected override renderContent(): TemplateResult {
const providers = getProviders();
return html`
<div class="flex flex-col h-full">
<!-- Header -->
<div class="p-6 pb-4 border-b border-border flex-shrink-0">
${DialogHeader({ title: i18n("API Keys Configuration") })}
<p class="text-sm text-muted-foreground mt-2">
${i18n("Configure API keys for LLM providers. Keys are stored locally in your browser.")}
</p>
</div>
<!-- Error message -->
${
this.error
? html`
<div class="px-6 pt-4">${Alert(this.error, "destructive")}</div>
`
: ""
}
<!-- test-->
<!-- Scrollable content -->
<div class="flex-1 overflow-y-auto p-6">
<div class="space-y-6">
${providers.map(
(provider) => html`
<div class="space-y-3">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-muted-foreground capitalize">${provider}</span>
${
this.apiKeys[provider]
? Badge({ children: i18n("Configured"), variant: "default" })
: Badge({ children: i18n("Not configured"), variant: "secondary" })
}
${
this.testResults[provider] === "success"
? Badge({ children: i18n("✓ Valid"), variant: "default" })
: this.testResults[provider] === "error"
? Badge({ children: i18n("✗ Invalid"), variant: "destructive" })
: this.testResults[provider] === "testing"
? Badge({ children: i18n("Testing..."), variant: "secondary" })
: ""
}
</div>
<div class="flex gap-2">
${Input({
type: "password",
placeholder: this.apiKeys[provider] ? i18n("Update API key") : i18n("Enter API key"),
value: this.apiKeyInputs[provider] || "",
onInput: (e: Event) => {
this.apiKeyInputs[provider] = (e.target as HTMLInputElement).value;
this.requestUpdate();
},
className: "flex-1",
})}
${Button({
onClick: () => this.saveKey(provider),
variant: "default",
size: "sm",
disabled: !this.apiKeyInputs[provider] || this.savingProvider === provider,
loading: this.savingProvider === provider,
children:
this.savingProvider === provider
? i18n("Testing...")
: this.apiKeys[provider]
? i18n("Update")
: i18n("Save"),
})}
${
this.apiKeys[provider]
? html`
${Button({
onClick: () => this.testExistingKey(provider),
variant: "outline",
size: "sm",
loading: this.testingProvider === provider,
disabled: this.testingProvider !== "" && this.testingProvider !== provider,
children:
this.testingProvider === provider ? i18n("Testing...") : i18n("Test"),
})}
${Button({
onClick: () => this.removeKey(provider),
variant: "ghost",
size: "sm",
children: i18n("Remove"),
})}
`
: ""
}
</div>
</div>
`,
)}
</div>
</div>
<!-- Footer with help text -->
<div class="p-6 pt-4 border-t border-border flex-shrink-0">
<p class="text-xs text-muted-foreground">
${i18n("API keys are required to use AI models. Get your keys from the provider's website.")}
</p>
</div>
</div>
`;
}
}

View file

@ -0,0 +1,635 @@
import { Button, html, icon } from "@mariozechner/mini-lit";
import "@mariozechner/mini-lit/dist/ModeToggle.js";
import { renderAsync } from "docx-preview";
import { LitElement } from "lit";
import { state } from "lit/decorators.js";
import { Download, X } from "lucide";
import * as pdfjsLib from "pdfjs-dist";
import * as XLSX from "xlsx";
import type { Attachment } from "../utils/attachment-utils.js";
import { i18n } from "../utils/i18n.js";
type FileType = "image" | "pdf" | "docx" | "pptx" | "excel" | "text";
export class AttachmentOverlay extends LitElement {
@state() private attachment?: Attachment;
@state() private showExtractedText = false;
@state() private error: string | null = null;
// Track current loading task to cancel if needed
private currentLoadingTask: any = null;
private onCloseCallback?: () => void;
private boundHandleKeyDown?: (e: KeyboardEvent) => void;
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
static open(attachment: Attachment, onClose?: () => void) {
const overlay = new AttachmentOverlay();
overlay.attachment = attachment;
overlay.onCloseCallback = onClose;
document.body.appendChild(overlay);
overlay.setupEventListeners();
}
private setupEventListeners() {
this.boundHandleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
this.close();
}
};
window.addEventListener("keydown", this.boundHandleKeyDown);
}
private close() {
this.cleanup();
if (this.boundHandleKeyDown) {
window.removeEventListener("keydown", this.boundHandleKeyDown);
}
this.onCloseCallback?.();
this.remove();
}
private getFileType(): FileType {
if (!this.attachment) return "text";
if (this.attachment.type === "image") return "image";
if (this.attachment.mimeType === "application/pdf") return "pdf";
if (this.attachment.mimeType?.includes("wordprocessingml")) return "docx";
if (
this.attachment.mimeType?.includes("presentationml") ||
this.attachment.fileName.toLowerCase().endsWith(".pptx")
)
return "pptx";
if (
this.attachment.mimeType?.includes("spreadsheetml") ||
this.attachment.mimeType?.includes("ms-excel") ||
this.attachment.fileName.toLowerCase().endsWith(".xlsx") ||
this.attachment.fileName.toLowerCase().endsWith(".xls")
)
return "excel";
return "text";
}
private getFileTypeLabel(): string {
const type = this.getFileType();
switch (type) {
case "pdf":
return i18n("PDF");
case "docx":
return i18n("Document");
case "pptx":
return i18n("Presentation");
case "excel":
return i18n("Spreadsheet");
default:
return "";
}
}
private handleBackdropClick = () => {
this.close();
};
private handleDownload = () => {
if (!this.attachment) return;
// Create a blob from the base64 content
const byteCharacters = atob(this.attachment.content);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], { type: this.attachment.mimeType });
// Create download link
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = this.attachment.fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
private cleanup() {
this.showExtractedText = false;
this.error = null;
// Cancel any loading PDF task when closing
if (this.currentLoadingTask) {
this.currentLoadingTask.destroy();
this.currentLoadingTask = null;
}
}
override render() {
if (!this.attachment) return html``;
return html`
<!-- Full screen overlay -->
<div class="fixed inset-0 bg-black/90 z-50 flex flex-col" @click=${this.handleBackdropClick}>
<!-- Compact header bar -->
<div class="bg-background/95 backdrop-blur border-b border-border" @click=${(e: Event) => e.stopPropagation()}>
<div class="px-4 py-2 flex items-center justify-between">
<div class="flex items-center gap-3 min-w-0">
<span class="text-sm font-medium text-foreground truncate">${this.attachment.fileName}</span>
</div>
<div class="flex items-center gap-2">
${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",
})}
</div>
</div>
</div>
<!-- Content container -->
<div class="flex-1 flex items-center justify-center overflow-auto" @click=${(e: Event) => e.stopPropagation()}>
${this.renderContent()}
</div>
</div>
`;
}
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`
<mode-toggle
.modes=${[fileTypeLabel, i18n("Text")]}
.selectedIndex=${this.showExtractedText ? 1 : 0}
@mode-change=${(e: CustomEvent<{ index: number; mode: string }>) => {
e.stopPropagation();
this.showExtractedText = e.detail.index === 1;
this.error = null;
}}
></mode-toggle>
`;
}
private renderContent() {
if (!this.attachment) return html``;
// Error state
if (this.error) {
return html`
<div class="bg-destructive/10 border border-destructive text-destructive p-4 rounded-lg max-w-2xl">
<div class="font-medium mb-1">${i18n("Error loading file")}</div>
<div class="text-sm opacity-90">${this.error}</div>
</div>
`;
}
// 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`
<div class="bg-card border border-border text-foreground p-6 w-full h-full max-w-4xl overflow-auto">
<pre class="whitespace-pre-wrap font-mono text-xs leading-relaxed">${
this.attachment.extractedText || i18n("No text content available")
}</pre>
</div>
`;
}
// Render based on file type
switch (fileType) {
case "image": {
const imageUrl = `data:${this.attachment.mimeType};base64,${this.attachment.content}`;
return html`
<img src="${imageUrl}" class="max-w-full max-h-full object-contain rounded-lg shadow-lg" alt="${this.attachment.fileName}" />
`;
}
case "pdf":
return html`
<div
id="pdf-container"
class="bg-card text-foreground overflow-auto shadow-lg border border-border w-full h-full max-w-[1000px]"
></div>
`;
case "docx":
return html`
<div
id="docx-container"
class="bg-card text-foreground overflow-auto shadow-lg border border-border w-full h-full max-w-[1000px]"
></div>
`;
case "excel":
return html` <div id="excel-container" class="bg-card text-foreground overflow-auto w-full h-full"></div> `;
case "pptx":
return html`
<div
id="pptx-container"
class="bg-card text-foreground overflow-auto shadow-lg border border-border w-full h-full max-w-[1000px]"
></div>
`;
default:
return html`
<div class="bg-card border border-border text-foreground p-6 w-full h-full max-w-4xl overflow-auto">
<pre class="whitespace-pre-wrap font-mono text-sm">${
this.attachment.extractedText || i18n("No content available")
}</pre>
</div>
`;
}
}
override async updated(changedProperties: Map<string, any>) {
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);
}

View file

@ -0,0 +1,324 @@
import { Badge, Button, DialogBase, DialogHeader, html, icon, type TemplateResult } from "@mariozechner/mini-lit";
import type { Model } from "@mariozechner/pi-ai";
import { MODELS } from "@mariozechner/pi-ai/dist/models.generated.js";
import type { PropertyValues } from "lit";
import { customElement, state } from "lit/decorators.js";
import { createRef, ref } from "lit/directives/ref.js";
import { Brain, Image as ImageIcon } from "lucide";
import { Ollama } from "ollama/dist/browser.mjs";
import { Input } from "../components/Input.js";
import { formatModelCost } from "../utils/format.js";
import { i18n } from "../utils/i18n.js";
@customElement("agent-model-selector")
export class ModelSelector extends DialogBase {
@state() currentModel: Model<any> | null = null;
@state() searchQuery = "";
@state() filterThinking = false;
@state() filterVision = false;
@state() ollamaModels: Model<any>[] = [];
@state() ollamaError: string | null = null;
@state() selectedIndex = 0;
@state() private navigationMode: "mouse" | "keyboard" = "mouse";
private onSelectCallback?: (model: Model<any>) => void;
private scrollContainerRef = createRef<HTMLDivElement>();
private searchInputRef = createRef<HTMLInputElement>();
private lastMousePosition = { x: 0, y: 0 };
protected override modalWidth = "min(400px, 90vw)";
static async open(currentModel: Model<any> | null, onSelect: (model: Model<any>) => void) {
const selector = new ModelSelector();
selector.currentModel = currentModel;
selector.onSelectCallback = onSelect;
selector.open();
selector.fetchOllamaModels();
}
override async firstUpdated(changedProperties: PropertyValues): Promise<void> {
super.firstUpdated(changedProperties);
// Wait for dialog to be fully rendered
await this.updateComplete;
// Focus the search input when dialog opens
this.searchInputRef.value?.focus();
// Track actual mouse movement
this.addEventListener("mousemove", (e: MouseEvent) => {
// Check if mouse actually moved
if (e.clientX !== this.lastMousePosition.x || e.clientY !== this.lastMousePosition.y) {
this.lastMousePosition = { x: e.clientX, y: e.clientY };
// Only switch to mouse mode on actual mouse movement
if (this.navigationMode === "keyboard") {
this.navigationMode = "mouse";
// Update selection to the item under the mouse
const target = e.target as HTMLElement;
const modelItem = target.closest("[data-model-item]");
if (modelItem) {
const allItems = this.scrollContainerRef.value?.querySelectorAll("[data-model-item]");
if (allItems) {
const index = Array.from(allItems).indexOf(modelItem);
if (index !== -1) {
this.selectedIndex = index;
}
}
}
}
}
});
// Add global keyboard handler for the dialog
this.addEventListener("keydown", (e: KeyboardEvent) => {
// Get filtered models to know the bounds
const filteredModels = this.getFilteredModels();
if (e.key === "ArrowDown") {
e.preventDefault();
this.navigationMode = "keyboard";
this.selectedIndex = Math.min(this.selectedIndex + 1, filteredModels.length - 1);
this.scrollToSelected();
} else if (e.key === "ArrowUp") {
e.preventDefault();
this.navigationMode = "keyboard";
this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
this.scrollToSelected();
} else if (e.key === "Enter") {
e.preventDefault();
if (filteredModels[this.selectedIndex]) {
this.handleSelect(filteredModels[this.selectedIndex].model);
}
}
});
}
private async fetchOllamaModels() {
try {
// Create Ollama client
const ollama = new Ollama({ host: "http://localhost:11434" });
// Get list of available models
const { models } = await ollama.list();
// Fetch details for each model and convert to Model format
const ollamaModelPromises: Promise<Model<any> | null>[] = models
.map(async (model) => {
try {
// Get model details
const details = await ollama.show({
model: model.name,
});
// Some Ollama servers don't report capabilities; don't filter on them
// Extract model info
const modelInfo: any = details.model_info || {};
// Get context window size - look for architecture-specific keys
const architecture = modelInfo["general.architecture"] || "";
const contextKey = `${architecture}.context_length`;
const contextWindow = parseInt(modelInfo[contextKey] || "8192", 10);
const maxTokens = 4096; // Default max output tokens
// Create Model object manually since ollama models aren't in MODELS constant
const ollamaModel: Model<any> = {
id: model.name,
name: model.name,
api: "openai-completions" as any,
provider: "ollama",
baseUrl: "http://localhost:11434/v1",
reasoning: false,
input: ["text"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: contextWindow,
maxTokens: maxTokens,
};
return ollamaModel;
} catch (err) {
console.error(`Failed to fetch details for model ${model.name}:`, err);
return null;
}
})
.filter((m) => m !== null);
const results = await Promise.all(ollamaModelPromises);
this.ollamaModels = results.filter((m): m is Model<any> => m !== null);
} catch (err) {
// Ollama not available or other error - silently ignore
console.debug("Ollama not available:", err);
this.ollamaError = err instanceof Error ? err.message : String(err);
}
}
private formatTokens(tokens: number): string {
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(0)}M`;
if (tokens >= 1000) return `${(tokens / 1000).toFixed(0)}`;
return String(tokens);
}
private handleSelect(model: Model<any>) {
if (model) {
this.onSelectCallback?.(model);
this.close();
}
}
private getFilteredModels(): Array<{ provider: string; id: string; model: any }> {
// Collect all models from all providers
const allModels: Array<{ provider: string; id: string; model: any }> = [];
for (const [provider, providerData] of Object.entries(MODELS)) {
for (const [modelId, model] of Object.entries(providerData)) {
allModels.push({ provider, id: modelId, model });
}
}
// Add Ollama models
for (const ollamaModel of this.ollamaModels) {
allModels.push({
id: ollamaModel.id,
provider: "ollama",
model: ollamaModel,
});
}
// Filter models based on search and capability filters
let filteredModels = allModels;
// Apply search filter
if (this.searchQuery) {
filteredModels = filteredModels.filter(({ provider, id, model }) => {
const searchTokens = this.searchQuery.split(/\s+/).filter((t) => t);
const searchText = `${provider} ${id} ${model.name}`.toLowerCase();
return searchTokens.every((token) => searchText.includes(token));
});
}
// Apply capability filters
if (this.filterThinking) {
filteredModels = filteredModels.filter(({ model }) => model.reasoning);
}
if (this.filterVision) {
filteredModels = filteredModels.filter(({ model }) => model.input.includes("image"));
}
// Sort: current model first, then by provider
filteredModels.sort((a, b) => {
const aIsCurrent = this.currentModel?.id === a.model.id;
const bIsCurrent = this.currentModel?.id === b.model.id;
if (aIsCurrent && !bIsCurrent) return -1;
if (!aIsCurrent && bIsCurrent) return 1;
return a.provider.localeCompare(b.provider);
});
return filteredModels;
}
private scrollToSelected() {
requestAnimationFrame(() => {
const scrollContainer = this.scrollContainerRef.value;
const selectedElement = scrollContainer?.querySelectorAll("[data-model-item]")[
this.selectedIndex
] as HTMLElement;
if (selectedElement) {
selectedElement.scrollIntoView({ block: "nearest", behavior: "smooth" });
}
});
}
protected override renderContent(): TemplateResult {
const filteredModels = this.getFilteredModels();
return html`
<!-- Header and Search -->
<div class="p-6 pb-4 flex flex-col gap-4 border-b border-border flex-shrink-0">
${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;
}
},
})}
<div class="flex gap-2">
${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`<span class="inline-flex items-center gap-1">${icon(Brain, "sm")} ${i18n("Thinking")}</span>`,
})}
${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`<span class="inline-flex items-center gap-1">${icon(ImageIcon, "sm")} ${i18n("Vision")}</span>`,
})}
</div>
</div>
<!-- Scrollable model list -->
<div class="flex-1 overflow-y-auto" ${ref(this.scrollContainerRef)}>
${filteredModels.map(({ provider, id, model }, index) => {
// Check if this is the current model by comparing IDs
const isCurrent = this.currentModel?.id === model.id;
const isSelected = index === this.selectedIndex;
return html`
<div
data-model-item
class="px-4 py-3 ${
this.navigationMode === "mouse" ? "hover:bg-muted" : ""
} cursor-pointer border-b border-border ${isSelected ? "bg-accent" : ""}"
@click=${() => this.handleSelect(model)}
@mouseenter=${() => {
// Only update selection in mouse mode
if (this.navigationMode === "mouse") {
this.selectedIndex = index;
}
}}
>
<div class="flex items-center justify-between gap-2 mb-1">
<div class="flex items-center gap-2 flex-1 min-w-0">
<span class="text-sm font-medium text-foreground truncate">${id}</span>
${isCurrent ? html`<span class="text-green-500">✓</span>` : ""}
</div>
${Badge(provider, "outline")}
</div>
<div class="flex items-center justify-between text-xs text-muted-foreground">
<div class="flex items-center gap-2">
<span class="${model.reasoning ? "" : "opacity-30"}">${icon(Brain, "sm")}</span>
<span class="${model.input.includes("image") ? "" : "opacity-30"}">${icon(ImageIcon, "sm")}</span>
<span>${this.formatTokens(model.contextWindow)}K/${this.formatTokens(model.maxTokens)}K</span>
</div>
<span>${formatModelCost(model.cost)}</span>
</div>
</div>
`;
})}
</div>
`;
}
}

View file

@ -0,0 +1,55 @@
// Main chat interface
export { ChatPanel } from "./ChatPanel.js";
// Components
export { AgentInterface } from "./components/AgentInterface.js";
export { AttachmentTile } from "./components/AttachmentTile.js";
export { ConsoleBlock } from "./components/ConsoleBlock.js";
export { Input } from "./components/Input.js";
export { MessageEditor } from "./components/MessageEditor.js";
export { MessageList } from "./components/MessageList.js";
// Message components
export { AssistantMessage, ToolMessage, UserMessage } from "./components/Messages.js";
export {
type SandboxFile,
SandboxIframe,
type SandboxResult,
type SandboxUrlProvider,
} from "./components/SandboxedIframe.js";
export { StreamingMessageContainer } from "./components/StreamingMessageContainer.js";
export { ApiKeysDialog } from "./dialogs/ApiKeysDialog.js";
export { AttachmentOverlay } from "./dialogs/AttachmentOverlay.js";
// Dialogs
export { ModelSelector } from "./dialogs/ModelSelector.js";
// State management
export { AgentSession } from "./state/agent-session.js";
export type { KeyStore, StorageAdapter } from "./state/KeyStore.js";
export { KeyStoreImpl, keyStore } from "./state/KeyStore.js";
// Transports
export { DirectTransport } from "./state/transports/DirectTransport.js";
export { ProxyTransport } from "./state/transports/ProxyTransport.js";
export type { ProxyAssistantMessageEvent } from "./state/transports/proxy-types.js";
export type { AgentRunConfig, AgentTransport } from "./state/transports/types.js";
// Artifacts
export { ArtifactElement } from "./tools/artifacts/ArtifactElement.js";
export { type Artifact, ArtifactsPanel, type ArtifactsParams } from "./tools/artifacts/artifacts.js";
export { HtmlArtifact } from "./tools/artifacts/HtmlArtifact.js";
export { MarkdownArtifact } from "./tools/artifacts/MarkdownArtifact.js";
export { SvgArtifact } from "./tools/artifacts/SvgArtifact.js";
export { TextArtifact } from "./tools/artifacts/TextArtifact.js";
// Tools
export { getToolRenderer, registerToolRenderer, renderToolParams, renderToolResult } from "./tools/index.js";
export { BashRenderer } from "./tools/renderers/BashRenderer.js";
export { CalculateRenderer } from "./tools/renderers/CalculateRenderer.js";
// Tool renderers
export { DefaultRenderer } from "./tools/renderers/DefaultRenderer.js";
export { GetCurrentTimeRenderer } from "./tools/renderers/GetCurrentTimeRenderer.js";
export type { ToolRenderer } from "./tools/types.js";
export type { Attachment } from "./utils/attachment-utils.js";
// Utils
export { loadAttachment } from "./utils/attachment-utils.js";
export { clearAuthToken, getAuthToken } from "./utils/auth-token.js";
export { formatCost, formatModelCost, formatTokenCount, formatUsage } from "./utils/format.js";
export { i18n, setLanguage } from "./utils/i18n.js";

View file

@ -0,0 +1,96 @@
import { getProviders } from "@mariozechner/pi-ai";
/**
* Generic storage adapter interface
*/
export interface StorageAdapter {
get(key: string): Promise<string | null>;
set(key: string, value: string): Promise<void>;
remove(key: string): Promise<void>;
getAll(): Promise<Record<string, string>>;
}
/**
* Interface for API key storage
*/
export interface KeyStore {
getKey(provider: string): Promise<string | null>;
setKey(provider: string, key: string): Promise<void>;
removeKey(provider: string): Promise<void>;
getAllKeys(): Promise<Record<string, boolean>>; // provider -> isConfigured
}
/**
* Default localStorage implementation for web
*/
class LocalStorageAdapter implements StorageAdapter {
async get(key: string): Promise<string | null> {
return localStorage.getItem(key);
}
async set(key: string, value: string): Promise<void> {
localStorage.setItem(key, value);
}
async remove(key: string): Promise<void> {
localStorage.removeItem(key);
}
async getAll(): Promise<Record<string, string>> {
const result: Record<string, string> = {};
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key) {
const value = localStorage.getItem(key);
if (value) result[key] = value;
}
}
return result;
}
}
/**
* Generic KeyStore implementation
*/
class GenericKeyStore implements KeyStore {
private readonly prefix = "apiKey_";
private readonly storage: StorageAdapter;
constructor(storage?: StorageAdapter) {
this.storage = storage || new LocalStorageAdapter();
}
async getKey(provider: string): Promise<string | null> {
const key = `${this.prefix}${provider}`;
return await this.storage.get(key);
}
async setKey(provider: string, key: string): Promise<void> {
const storageKey = `${this.prefix}${provider}`;
await this.storage.set(storageKey, key);
}
async removeKey(provider: string): Promise<void> {
const key = `${this.prefix}${provider}`;
await this.storage.remove(key);
}
async getAllKeys(): Promise<Record<string, boolean>> {
const providers = getProviders();
const allStorage = await this.storage.getAll();
const result: Record<string, boolean> = {};
for (const provider of providers) {
const key = `${this.prefix}${provider}`;
result[provider] = !!allStorage[key];
}
return result;
}
}
// Export singleton instance (uses localStorage by default)
export const keyStore = new GenericKeyStore();
// Export class for custom storage implementations
export { GenericKeyStore as KeyStoreImpl };

View file

@ -0,0 +1,311 @@
import type { Context } from "@mariozechner/pi-ai";
import {
type AgentTool,
type AssistantMessage as AssistantMessageType,
getModel,
type ImageContent,
type Message,
type Model,
type TextContent,
} from "@mariozechner/pi-ai";
import type { AppMessage } from "../components/Messages.js";
import type { Attachment } from "../utils/attachment-utils.js";
import { DirectTransport } from "./transports/DirectTransport.js";
import { ProxyTransport } from "./transports/ProxyTransport.js";
import type { AgentRunConfig, AgentTransport } from "./transports/types.js";
import type { DebugLogEntry } from "./types.js";
export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high";
export interface AgentSessionState {
id: string;
systemPrompt: string;
model: Model<any> | null;
thinkingLevel: ThinkingLevel;
tools: AgentTool<any>[];
messages: AppMessage[];
isStreaming: boolean;
streamMessage: Message | null;
pendingToolCalls: Set<string>;
error?: string;
}
export type AgentSessionEvent =
| { type: "state-update"; state: AgentSessionState }
| { type: "error-no-model" }
| { type: "error-no-api-key"; provider: string };
export type TransportMode = "direct" | "proxy";
export interface AgentSessionOptions {
initialState?: Partial<AgentSessionState>;
messagePreprocessor?: (messages: AppMessage[]) => Promise<Message[]>;
debugListener?: (entry: DebugLogEntry) => void;
transportMode?: TransportMode;
authTokenProvider?: () => Promise<string | undefined>;
}
export class AgentSession {
private _state: AgentSessionState = {
id: "default",
systemPrompt: "",
model: getModel("google", "gemini-2.5-flash-lite-preview-06-17"),
thinkingLevel: "off",
tools: [],
messages: [],
isStreaming: false,
streamMessage: null,
pendingToolCalls: new Set<string>(),
error: undefined,
};
private listeners = new Set<(e: AgentSessionEvent) => void>();
private abortController?: AbortController;
private transport: AgentTransport;
private messagePreprocessor?: (messages: AppMessage[]) => Promise<Message[]>;
private debugListener?: (entry: DebugLogEntry) => void;
constructor(opts: AgentSessionOptions = {}) {
this._state = { ...this._state, ...opts.initialState };
this.messagePreprocessor = opts.messagePreprocessor;
this.debugListener = opts.debugListener;
const mode = opts.transportMode || "direct";
if (mode === "proxy") {
this.transport = new ProxyTransport(async () => this.preprocessMessages());
} else {
this.transport = new DirectTransport(async () => this.preprocessMessages());
}
}
private async preprocessMessages(): Promise<Message[]> {
const filtered = this._state.messages.map((m) => {
if (m.role === "user") {
const { attachments, ...rest } = m as AppMessage & { attachments?: Attachment[] };
return rest;
}
return m;
});
return this.messagePreprocessor ? this.messagePreprocessor(filtered as AppMessage[]) : (filtered as Message[]);
}
get state(): AgentSessionState {
return this._state;
}
subscribe(fn: (e: AgentSessionEvent) => void): () => void {
this.listeners.add(fn);
fn({ type: "state-update", state: this._state });
return () => this.listeners.delete(fn);
}
// Mutators
setSystemPrompt(v: string) {
this.patch({ systemPrompt: v });
}
setModel(m: Model<any> | null) {
this.patch({ model: m });
}
setThinkingLevel(l: ThinkingLevel) {
this.patch({ thinkingLevel: l });
}
setTools(t: AgentTool<any>[]) {
this.patch({ tools: t });
}
replaceMessages(ms: AppMessage[]) {
this.patch({ messages: ms.slice() });
}
appendMessage(m: AppMessage) {
this.patch({ messages: [...this._state.messages, m] });
}
clearMessages() {
this.patch({ messages: [] });
}
abort() {
this.abortController?.abort();
}
async prompt(input: string, attachments?: Attachment[]) {
const model = this._state.model;
if (!model) {
this.emit({ type: "error-no-model" });
return;
}
// Build user message with attachments
const content: Array<TextContent | ImageContent> = [{ type: "text", text: input }];
if (attachments?.length) {
for (const a of attachments) {
if (a.type === "image") {
content.push({ type: "image", data: a.content, mimeType: a.mimeType });
} else if (a.type === "document" && a.extractedText) {
content.push({
type: "text",
text: `\n\n[Document: ${a.fileName}]\n${a.extractedText}`,
isDocument: true,
} as TextContent);
}
}
}
const userMessage: AppMessage = {
role: "user",
content,
attachments: attachments?.length ? attachments : undefined,
};
this.abortController = new AbortController();
this.patch({ isStreaming: true, streamMessage: null, error: undefined });
const reasoning =
this._state.thinkingLevel === "off"
? undefined
: this._state.thinkingLevel === "minimal"
? "low"
: this._state.thinkingLevel;
const cfg: AgentRunConfig = {
systemPrompt: this._state.systemPrompt,
tools: this._state.tools,
model,
reasoning,
};
try {
let partial: Message | null = null;
let turnDebug: DebugLogEntry | null = null;
let turnStart = 0;
for await (const ev of this.transport.run(userMessage as Message, cfg, this.abortController.signal)) {
switch (ev.type) {
case "turn_start": {
turnStart = performance.now();
// Build request context snapshot
const existing = this._state.messages as Message[];
const ctx: Context = {
systemPrompt: this._state.systemPrompt,
messages: [...existing],
tools: this._state.tools,
};
turnDebug = {
timestamp: new Date().toISOString(),
request: {
provider: cfg.model.provider,
model: cfg.model.id,
context: { ...ctx },
},
sseEvents: [],
};
break;
}
case "message_start":
case "message_update": {
partial = ev.message;
// Collect SSE-like events for debug (drop heavy partial)
if (ev.type === "message_update" && ev.assistantMessageEvent && turnDebug) {
const copy: any = { ...ev.assistantMessageEvent };
if (copy && "partial" in copy) delete copy.partial;
turnDebug.sseEvents.push(JSON.stringify(copy));
if (!turnDebug.ttft) turnDebug.ttft = performance.now() - turnStart;
}
this.patch({ streamMessage: ev.message });
break;
}
case "message_end": {
partial = null;
this.appendMessage(ev.message as AppMessage);
this.patch({ streamMessage: null });
if (turnDebug) {
if (ev.message.role !== "assistant" && ev.message.role !== "toolResult") {
turnDebug.request.context.messages.push(ev.message);
}
if (ev.message.role === "assistant") turnDebug.response = ev.message as any;
}
break;
}
case "tool_execution_start": {
const s = new Set(this._state.pendingToolCalls);
s.add(ev.toolCallId);
this.patch({ pendingToolCalls: s });
break;
}
case "tool_execution_end": {
const s = new Set(this._state.pendingToolCalls);
s.delete(ev.toolCallId);
this.patch({ pendingToolCalls: s });
break;
}
case "turn_end": {
// finalize current turn
if (turnDebug) {
turnDebug.totalTime = performance.now() - turnStart;
this.debugListener?.(turnDebug);
turnDebug = null;
}
break;
}
case "agent_end": {
this.patch({ streamMessage: null });
break;
}
}
}
if (partial && partial.role === "assistant" && partial.content.length > 0) {
const onlyEmpty = !partial.content.some(
(c) =>
(c.type === "thinking" && c.thinking.trim().length > 0) ||
(c.type === "text" && c.text.trim().length > 0) ||
(c.type === "toolCall" && c.name.trim().length > 0),
);
if (!onlyEmpty) {
this.appendMessage(partial as AppMessage);
} else {
if (this.abortController?.signal.aborted) {
throw new Error("Request was aborted");
}
}
}
} catch (err: any) {
if (String(err?.message || err) === "no-api-key") {
this.emit({ type: "error-no-api-key", provider: model.provider });
} else {
const msg: AssistantMessageType = {
role: "assistant",
content: [{ type: "text", text: "" }],
api: model.api,
provider: model.provider,
model: model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: this.abortController?.signal.aborted ? "aborted" : "error",
errorMessage: err?.message || String(err),
};
this.appendMessage(msg as AppMessage);
this.patch({ error: err?.message || String(err) });
}
} finally {
this.patch({ isStreaming: false, streamMessage: null, pendingToolCalls: new Set<string>() });
this.abortController = undefined;
}
{
const { systemPrompt, model, messages } = this._state;
console.log("final state:", { systemPrompt, model, messages });
}
}
private patch(p: Partial<AgentSessionState>): void {
this._state = { ...this._state, ...p };
this.emit({ type: "state-update", state: this._state });
}
private emit(e: AgentSessionEvent) {
for (const listener of this.listeners) {
listener(e);
}
}
}

View file

@ -0,0 +1,32 @@
import { type AgentContext, agentLoop, type Message, type PromptConfig, type UserMessage } from "@mariozechner/pi-ai";
import { keyStore } from "../KeyStore.js";
import type { AgentRunConfig, AgentTransport } from "./types.js";
export class DirectTransport implements AgentTransport {
constructor(private readonly getMessages: () => Promise<Message[]>) {}
async *run(userMessage: Message, cfg: AgentRunConfig, signal?: AbortSignal) {
// Get API key from KeyStore
const apiKey = await keyStore.getKey(cfg.model.provider);
if (!apiKey) {
throw new Error("no-api-key");
}
const context: AgentContext = {
systemPrompt: cfg.systemPrompt,
messages: await this.getMessages(),
tools: cfg.tools,
};
const pc: PromptConfig = {
model: cfg.model,
reasoning: cfg.reasoning,
apiKey,
};
// Yield events from agentLoop
for await (const ev of agentLoop(userMessage as unknown as UserMessage, context, pc, signal)) {
yield ev;
}
}
}

View file

@ -0,0 +1,359 @@
import type {
AgentContext,
Api,
AssistantMessage,
AssistantMessageEvent,
Context,
Message,
Model,
PromptConfig,
SimpleStreamOptions,
ToolCall,
UserMessage,
} from "@mariozechner/pi-ai";
import { agentLoop } from "@mariozechner/pi-ai";
import { AssistantMessageEventStream } from "@mariozechner/pi-ai/dist/utils/event-stream.js";
import { parseStreamingJson } from "@mariozechner/pi-ai/dist/utils/json-parse.js";
import { clearAuthToken, getAuthToken } from "../../utils/auth-token.js";
import { i18n } from "../../utils/i18n.js";
import type { ProxyAssistantMessageEvent } from "./proxy-types.js";
import type { AgentRunConfig, AgentTransport } from "./types.js";
/**
* Stream function that proxies through a server instead of calling providers directly.
* The server strips the partial field from delta events to reduce bandwidth.
* We reconstruct the partial message client-side.
*/
function streamSimpleProxy(
model: Model<any>,
context: Context,
options: SimpleStreamOptions & { authToken: string },
proxyUrl: string,
): AssistantMessageEventStream {
const stream = new AssistantMessageEventStream();
(async () => {
// Initialize the partial message that we'll build up from events
const partial: AssistantMessage = {
role: "assistant",
stopReason: "stop",
content: [],
api: model.api,
provider: model.provider,
model: model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
};
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
// Set up abort handler to cancel the reader
const abortHandler = () => {
if (reader) {
reader.cancel("Request aborted by user").catch(() => {});
}
};
if (options.signal) {
options.signal.addEventListener("abort", abortHandler);
}
try {
const response = await fetch(`${proxyUrl}/api/stream`, {
method: "POST",
headers: {
Authorization: `Bearer ${options.authToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model,
context,
options: {
temperature: options.temperature,
maxTokens: options.maxTokens,
reasoning: options.reasoning,
// Don't send apiKey or signal - those are added server-side
},
}),
signal: options.signal,
});
if (!response.ok) {
let errorMessage = `Proxy error: ${response.status} ${response.statusText}`;
try {
const errorData = await response.json();
if (errorData.error) {
errorMessage = `Proxy error: ${errorData.error}`;
}
} catch {
// Couldn't parse error response, use default message
}
throw new Error(errorMessage);
}
// Parse SSE stream
reader = response.body!.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
// Check if aborted after reading
if (options.signal?.aborted) {
throw new Error("Request aborted by user");
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = line.slice(6).trim();
if (data) {
const proxyEvent = JSON.parse(data) as ProxyAssistantMessageEvent;
let event: AssistantMessageEvent | undefined;
// Handle different event types
// Server sends events with partial for non-delta events,
// and without partial for delta events
switch (proxyEvent.type) {
case "start":
event = { type: "start", partial };
break;
case "text_start":
partial.content[proxyEvent.contentIndex] = {
type: "text",
text: "",
};
event = { type: "text_start", contentIndex: proxyEvent.contentIndex, partial };
break;
case "text_delta": {
const content = partial.content[proxyEvent.contentIndex];
if (content?.type === "text") {
content.text += proxyEvent.delta;
event = {
type: "text_delta",
contentIndex: proxyEvent.contentIndex,
delta: proxyEvent.delta,
partial,
};
} else {
throw new Error("Received text_delta for non-text content");
}
break;
}
case "text_end": {
const content = partial.content[proxyEvent.contentIndex];
if (content?.type === "text") {
content.textSignature = proxyEvent.contentSignature;
event = {
type: "text_end",
contentIndex: proxyEvent.contentIndex,
content: content.text,
partial,
};
} else {
throw new Error("Received text_end for non-text content");
}
break;
}
case "thinking_start":
partial.content[proxyEvent.contentIndex] = {
type: "thinking",
thinking: "",
};
event = { type: "thinking_start", contentIndex: proxyEvent.contentIndex, partial };
break;
case "thinking_delta": {
const content = partial.content[proxyEvent.contentIndex];
if (content?.type === "thinking") {
content.thinking += proxyEvent.delta;
event = {
type: "thinking_delta",
contentIndex: proxyEvent.contentIndex,
delta: proxyEvent.delta,
partial,
};
} else {
throw new Error("Received thinking_delta for non-thinking content");
}
break;
}
case "thinking_end": {
const content = partial.content[proxyEvent.contentIndex];
if (content?.type === "thinking") {
content.thinkingSignature = proxyEvent.contentSignature;
event = {
type: "thinking_end",
contentIndex: proxyEvent.contentIndex,
content: content.thinking,
partial,
};
} else {
throw new Error("Received thinking_end for non-thinking content");
}
break;
}
case "toolcall_start":
partial.content[proxyEvent.contentIndex] = {
type: "toolCall",
id: proxyEvent.id,
name: proxyEvent.toolName,
arguments: {},
partialJson: "",
} satisfies ToolCall & { partialJson: string } as ToolCall;
event = { type: "toolcall_start", contentIndex: proxyEvent.contentIndex, partial };
break;
case "toolcall_delta": {
const content = partial.content[proxyEvent.contentIndex];
if (content?.type === "toolCall") {
(content as any).partialJson += proxyEvent.delta;
content.arguments = parseStreamingJson((content as any).partialJson) || {};
event = {
type: "toolcall_delta",
contentIndex: proxyEvent.contentIndex,
delta: proxyEvent.delta,
partial,
};
partial.content[proxyEvent.contentIndex] = { ...content }; // Trigger reactivity
} else {
throw new Error("Received toolcall_delta for non-toolCall content");
}
break;
}
case "toolcall_end": {
const content = partial.content[proxyEvent.contentIndex];
if (content?.type === "toolCall") {
delete (content as any).partialJson;
event = {
type: "toolcall_end",
contentIndex: proxyEvent.contentIndex,
toolCall: content,
partial,
};
}
break;
}
case "done":
partial.stopReason = proxyEvent.reason;
partial.usage = proxyEvent.usage;
event = { type: "done", reason: proxyEvent.reason, message: partial };
break;
case "error":
partial.stopReason = proxyEvent.reason;
partial.errorMessage = proxyEvent.errorMessage;
partial.usage = proxyEvent.usage;
event = { type: "error", reason: proxyEvent.reason, error: partial };
break;
default: {
// Exhaustive check
const _exhaustiveCheck: never = proxyEvent;
console.warn(`Unhandled event type: ${(proxyEvent as any).type}`);
break;
}
}
// Push the event to stream
if (event) {
stream.push(event);
} else {
throw new Error("Failed to create event from proxy event");
}
}
}
}
}
// Check if aborted after reading
if (options.signal?.aborted) {
throw new Error("Request aborted by user");
}
stream.end();
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage.toLowerCase().includes("proxy") && errorMessage.includes("Unauthorized")) {
clearAuthToken();
}
partial.stopReason = options.signal?.aborted ? "aborted" : "error";
partial.errorMessage = errorMessage;
stream.push({
type: "error",
reason: partial.stopReason,
error: partial,
} satisfies AssistantMessageEvent);
stream.end();
} finally {
// Clean up abort handler
if (options.signal) {
options.signal.removeEventListener("abort", abortHandler);
}
}
})();
return stream;
}
// Proxy transport executes the turn using a remote proxy server
export class ProxyTransport implements AgentTransport {
// Hardcoded proxy URL for now - will be made configurable later
private readonly proxyUrl = "https://genai.mariozechner.at";
constructor(private readonly getMessages: () => Promise<Message[]>) {}
async *run(userMessage: Message, cfg: AgentRunConfig, signal?: AbortSignal) {
const authToken = await getAuthToken();
if (!authToken) {
throw new Error(i18n("Auth token is required for proxy transport"));
}
// Use proxy - no local API key needed
const streamFn = <TApi extends Api>(model: Model<TApi>, context: Context, options?: SimpleStreamOptions) => {
return streamSimpleProxy(
model,
context,
{
...options,
authToken,
},
this.proxyUrl,
);
};
const context: AgentContext = {
systemPrompt: cfg.systemPrompt,
messages: await this.getMessages(),
tools: cfg.tools,
};
const pc: PromptConfig = {
model: cfg.model,
reasoning: cfg.reasoning,
};
// Yield events from the upstream agentLoop iterator
// Pass streamFn as the 5th parameter to use proxy
for await (const ev of agentLoop(userMessage as unknown as UserMessage, context, pc, signal, streamFn as any)) {
yield ev;
}
}
}

View file

@ -0,0 +1,3 @@
export * from "./DirectTransport.js";
export * from "./ProxyTransport.js";
export * from "./types.js";

View file

@ -0,0 +1,15 @@
import type { StopReason, Usage } from "@mariozechner/pi-ai";
export type ProxyAssistantMessageEvent =
| { type: "start" }
| { type: "text_start"; contentIndex: number }
| { type: "text_delta"; contentIndex: number; delta: string }
| { type: "text_end"; contentIndex: number; contentSignature?: string }
| { type: "thinking_start"; contentIndex: number }
| { type: "thinking_delta"; contentIndex: number; delta: string }
| { type: "thinking_end"; contentIndex: number; contentSignature?: string }
| { type: "toolcall_start"; contentIndex: number; id: string; toolName: string }
| { type: "toolcall_delta"; contentIndex: number; delta: string }
| { type: "toolcall_end"; contentIndex: number }
| { type: "done"; reason: Extract<StopReason, "stop" | "length" | "toolUse">; usage: Usage }
| { type: "error"; reason: Extract<StopReason, "aborted" | "error">; errorMessage: string; usage: Usage };

View file

@ -0,0 +1,16 @@
import type { AgentEvent, AgentTool, Message, Model } from "@mariozechner/pi-ai";
// The minimal configuration needed to run a turn.
export interface AgentRunConfig {
systemPrompt: string;
tools: AgentTool<any>[];
model: Model<any>;
reasoning?: "low" | "medium" | "high";
}
// Events yielded by transports must match the @mariozechner/pi-ai prompt() events.
// We re-export the Message type above; consumers should use the upstream AgentEvent type.
export interface AgentTransport {
run(userMessage: Message, config: AgentRunConfig, signal?: AbortSignal): AsyncIterable<AgentEvent>; // passthrough of AgentEvent from upstream
}

View file

@ -0,0 +1,11 @@
import type { AssistantMessage, Context } from "@mariozechner/pi-ai";
export interface DebugLogEntry {
timestamp: string;
request: { provider: string; model: string; context: Context };
response?: AssistantMessage;
error?: unknown;
sseEvents: string[];
ttft?: number;
totalTime?: number;
}

View file

@ -0,0 +1,15 @@
import { LitElement, type TemplateResult } from "lit";
export abstract class ArtifactElement extends LitElement {
public filename = "";
public displayTitle = "";
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this; // light DOM for shared styles
}
public abstract get content(): string;
public abstract set content(value: string);
abstract getHeaderButtons(): TemplateResult | HTMLElement;
}

View file

@ -0,0 +1,221 @@
import { CopyButton, DownloadButton, PreviewCodeToggle } from "@mariozechner/mini-lit";
import hljs from "highlight.js";
import { html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { createRef, type Ref, ref } from "lit/directives/ref.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import type { SandboxIframe } from "../../components/SandboxedIframe.js";
import type { Attachment } from "../../utils/attachment-utils.js";
import { i18n } from "../../utils/i18n.js";
import "../../components/SandboxedIframe.js";
import { ArtifactElement } from "./ArtifactElement.js";
@customElement("html-artifact")
export class HtmlArtifact extends ArtifactElement {
@property() override filename = "";
@property({ attribute: false }) override displayTitle = "";
@property({ attribute: false }) attachments: Attachment[] = [];
@property({ attribute: false }) sandboxUrlProvider?: () => string;
private _content = "";
private logs: Array<{ type: "log" | "error"; text: string }> = [];
// Refs for DOM elements
private sandboxIframeRef: Ref<SandboxIframe> = createRef();
private consoleLogsRef: Ref<HTMLDivElement> = createRef();
private consoleButtonRef: Ref<HTMLButtonElement> = createRef();
// Store message handler so we can remove it
private messageHandler?: (e: MessageEvent) => void;
@state() private viewMode: "preview" | "code" = "preview";
@state() private consoleOpen = false;
private setViewMode(mode: "preview" | "code") {
this.viewMode = mode;
}
public getHeaderButtons() {
const toggle = new PreviewCodeToggle();
toggle.mode = this.viewMode;
toggle.addEventListener("mode-change", (e: Event) => {
this.setViewMode((e as CustomEvent).detail);
});
const copyButton = new CopyButton();
copyButton.text = this._content;
copyButton.title = i18n("Copy HTML");
copyButton.showText = false;
return html`
<div class="flex items-center gap-2">
${toggle}
${copyButton}
${DownloadButton({ content: this._content, filename: this.filename, mimeType: "text/html", title: i18n("Download HTML") })}
</div>
`;
}
override set content(value: string) {
const oldValue = this._content;
this._content = value;
if (oldValue !== value) {
// Reset logs when content changes
this.logs = [];
if (this.consoleLogsRef.value) {
this.consoleLogsRef.value.innerHTML = "";
}
this.requestUpdate();
// Execute content in sandbox if it exists
if (this.sandboxIframeRef.value && value) {
this.updateConsoleButton();
this.executeContent(value);
}
}
}
private executeContent(html: string) {
const sandbox = this.sandboxIframeRef.value;
if (!sandbox) return;
// Configure sandbox URL provider if provided (for browser extensions)
if (this.sandboxUrlProvider) {
sandbox.sandboxUrlProvider = this.sandboxUrlProvider;
}
// Remove previous message handler if it exists
if (this.messageHandler) {
window.removeEventListener("message", this.messageHandler);
}
const sandboxId = `artifact-${this.filename}`;
// Set up message listener to collect logs
this.messageHandler = (e: MessageEvent) => {
if (e.data.sandboxId !== sandboxId) return;
if (e.data.type === "console") {
this.logs.push({
type: e.data.method === "error" ? "error" : "log",
text: e.data.text,
});
this.updateConsoleButton();
}
};
window.addEventListener("message", this.messageHandler);
// Load content (iframe persists, doesn't get removed)
sandbox.loadContent(sandboxId, html, this.attachments);
}
override get content(): string {
return this._content;
}
override disconnectedCallback() {
super.disconnectedCallback();
// Clean up message handler when element is removed from DOM
if (this.messageHandler) {
window.removeEventListener("message", this.messageHandler);
this.messageHandler = undefined;
}
}
override firstUpdated() {
// Execute initial content
if (this._content && this.sandboxIframeRef.value) {
this.executeContent(this._content);
}
}
override updated(changedProperties: Map<string | number | symbol, unknown>) {
super.updated(changedProperties);
// If we have content but haven't executed yet (e.g., during reconstruction),
// execute when the iframe ref becomes available
if (this._content && this.sandboxIframeRef.value && this.logs.length === 0) {
this.executeContent(this._content);
}
}
private updateConsoleButton() {
const button = this.consoleButtonRef.value;
if (!button) return;
const errorCount = this.logs.filter((l) => l.type === "error").length;
const text =
errorCount > 0
? `${i18n("console")} <span class="text-destructive">${errorCount} errors</span>`
: `${i18n("console")} (${this.logs.length})`;
button.innerHTML = `<span>${text}</span><span>${this.consoleOpen ? "▼" : "▶"}</span>`;
}
private toggleConsole() {
this.consoleOpen = !this.consoleOpen;
this.requestUpdate();
// Populate console logs if opening
if (this.consoleOpen) {
requestAnimationFrame(() => {
if (this.consoleLogsRef.value) {
// Populate with existing logs
this.consoleLogsRef.value.innerHTML = "";
this.logs.forEach((log) => {
const logEl = document.createElement("div");
logEl.className = `text-xs font-mono ${log.type === "error" ? "text-destructive" : "text-muted-foreground"}`;
logEl.textContent = `[${log.type}] ${log.text}`;
this.consoleLogsRef.value!.appendChild(logEl);
});
}
});
}
}
public getLogs(): string {
if (this.logs.length === 0) return i18n("No logs for {filename}").replace("{filename}", this.filename);
return this.logs.map((l) => `[${l.type}] ${l.text}`).join("\n");
}
override render() {
return html`
<div class="h-full flex flex-col">
<div class="flex-1 overflow-hidden relative">
<!-- Preview container - always in DOM, just hidden when not active -->
<div class="absolute inset-0 flex flex-col" style="display: ${this.viewMode === "preview" ? "flex" : "none"}">
<sandbox-iframe class="flex-1" ${ref(this.sandboxIframeRef)}></sandbox-iframe>
${
this.logs.length > 0
? html`
<div class="border-t border-border">
<button
@click=${() => this.toggleConsole()}
class="w-full px-3 py-1 text-xs text-left hover:bg-muted flex items-center justify-between"
${ref(this.consoleButtonRef)}
>
<span
>${i18n("console")}
${
this.logs.filter((l) => l.type === "error").length > 0
? html`<span class="text-destructive">${this.logs.filter((l) => l.type === "error").length} errors</span>`
: `(${this.logs.length})`
}</span
>
<span>${this.consoleOpen ? "▼" : "▶"}</span>
</button>
${this.consoleOpen ? html` <div class="max-h-48 overflow-y-auto bg-muted/50 p-2" ${ref(this.consoleLogsRef)}></div> ` : ""}
</div>
`
: ""
}
</div>
<!-- Code view - always in DOM, just hidden when not active -->
<div class="absolute inset-0 overflow-auto bg-background" style="display: ${this.viewMode === "code" ? "block" : "none"}">
<pre class="m-0 p-4 text-xs"><code class="hljs language-html">${unsafeHTML(
hljs.highlight(this._content, { language: "html" }).value,
)}</code></pre>
</div>
</div>
</div>
`;
}
}

View file

@ -0,0 +1,81 @@
import { CopyButton, DownloadButton, PreviewCodeToggle } from "@mariozechner/mini-lit";
import hljs from "highlight.js";
import { html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { i18n } from "../../utils/i18n.js";
import "@mariozechner/mini-lit/dist/MarkdownBlock.js";
import { ArtifactElement } from "./ArtifactElement.js";
@customElement("markdown-artifact")
export class MarkdownArtifact extends ArtifactElement {
@property() override filename = "";
@property({ attribute: false }) override displayTitle = "";
private _content = "";
override get content(): string {
return this._content;
}
override set content(value: string) {
this._content = value;
this.requestUpdate();
}
@state() private viewMode: "preview" | "code" = "preview";
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this; // light DOM
}
private setViewMode(mode: "preview" | "code") {
this.viewMode = mode;
}
public getHeaderButtons() {
const toggle = new PreviewCodeToggle();
toggle.mode = this.viewMode;
toggle.addEventListener("mode-change", (e: Event) => {
this.setViewMode((e as CustomEvent).detail);
});
const copyButton = new CopyButton();
copyButton.text = this._content;
copyButton.title = i18n("Copy Markdown");
copyButton.showText = false;
return html`
<div class="flex items-center gap-2">
${toggle}
${copyButton}
${DownloadButton({
content: this._content,
filename: this.filename,
mimeType: "text/markdown",
title: i18n("Download Markdown"),
})}
</div>
`;
}
override render() {
return html`
<div class="h-full flex flex-col">
<div class="flex-1 overflow-auto">
${
this.viewMode === "preview"
? html`<div class="p-4"><markdown-block .content=${this.content}></markdown-block></div>`
: html`<pre class="m-0 p-4 text-xs whitespace-pre-wrap break-words"><code class="hljs language-markdown">${unsafeHTML(
hljs.highlight(this.content, { language: "markdown", ignoreIllegals: true }).value,
)}</code></pre>`
}
</div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"markdown-artifact": MarkdownArtifact;
}
}

View file

@ -0,0 +1,77 @@
import { CopyButton, DownloadButton, PreviewCodeToggle } from "@mariozechner/mini-lit";
import hljs from "highlight.js";
import { html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { i18n } from "../../utils/i18n.js";
import { ArtifactElement } from "./ArtifactElement.js";
@customElement("svg-artifact")
export class SvgArtifact extends ArtifactElement {
@property() override filename = "";
@property({ attribute: false }) override displayTitle = "";
private _content = "";
override get content(): string {
return this._content;
}
override set content(value: string) {
this._content = value;
this.requestUpdate();
}
@state() private viewMode: "preview" | "code" = "preview";
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this; // light DOM
}
private setViewMode(mode: "preview" | "code") {
this.viewMode = mode;
}
public getHeaderButtons() {
const toggle = new PreviewCodeToggle();
toggle.mode = this.viewMode;
toggle.addEventListener("mode-change", (e: Event) => {
this.setViewMode((e as CustomEvent).detail);
});
const copyButton = new CopyButton();
copyButton.text = this._content;
copyButton.title = i18n("Copy SVG");
copyButton.showText = false;
return html`
<div class="flex items-center gap-2">
${toggle}
${copyButton}
${DownloadButton({ content: this._content, filename: this.filename, mimeType: "image/svg+xml", title: i18n("Download SVG") })}
</div>
`;
}
override render() {
return html`
<div class="h-full flex flex-col">
<div class="flex-1 overflow-auto">
${
this.viewMode === "preview"
? html`<div class="h-full flex items-center justify-center">
${unsafeHTML(this.content.replace(/<svg(\s|>)/i, (_m, p1) => `<svg class="w-full h-full"${p1}`))}
</div>`
: html`<pre class="m-0 p-4 text-xs"><code class="hljs language-xml">${unsafeHTML(
hljs.highlight(this.content, { language: "xml", ignoreIllegals: true }).value,
)}</code></pre>`
}
</div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"svg-artifact": SvgArtifact;
}
}

View file

@ -0,0 +1,148 @@
import { CopyButton, DownloadButton } from "@mariozechner/mini-lit";
import hljs from "highlight.js";
import { html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { i18n } from "../../utils/i18n.js";
import { ArtifactElement } from "./ArtifactElement.js";
// Known code file extensions for highlighting
const CODE_EXTENSIONS = [
"js",
"javascript",
"ts",
"typescript",
"jsx",
"tsx",
"py",
"python",
"java",
"c",
"cpp",
"cs",
"php",
"rb",
"ruby",
"go",
"rust",
"swift",
"kotlin",
"scala",
"dart",
"html",
"css",
"scss",
"sass",
"less",
"json",
"xml",
"yaml",
"yml",
"toml",
"sql",
"sh",
"bash",
"ps1",
"bat",
"r",
"matlab",
"julia",
"lua",
"perl",
"vue",
"svelte",
];
@customElement("text-artifact")
export class TextArtifact extends ArtifactElement {
@property() override filename = "";
@property({ attribute: false }) override displayTitle = "";
private _content = "";
override get content(): string {
return this._content;
}
override set content(value: string) {
this._content = value;
this.requestUpdate();
}
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this; // light DOM
}
private isCode(): boolean {
const ext = this.filename.split(".").pop()?.toLowerCase() || "";
return CODE_EXTENSIONS.includes(ext);
}
private getLanguageFromExtension(ext: string): string {
const languageMap: Record<string, string> = {
js: "javascript",
ts: "typescript",
py: "python",
rb: "ruby",
yml: "yaml",
ps1: "powershell",
bat: "batch",
};
return languageMap[ext] || ext;
}
private getMimeType(): string {
const ext = this.filename.split(".").pop()?.toLowerCase() || "";
if (ext === "svg") return "image/svg+xml";
if (ext === "md" || ext === "markdown") return "text/markdown";
return "text/plain";
}
public getHeaderButtons() {
const copyButton = new CopyButton();
copyButton.text = this.content;
copyButton.title = i18n("Copy");
copyButton.showText = false;
return html`
<div class="flex items-center gap-1">
${copyButton}
${DownloadButton({
content: this.content,
filename: this.filename,
mimeType: this.getMimeType(),
title: i18n("Download"),
})}
</div>
`;
}
override render() {
const isCode = this.isCode();
const ext = this.filename.split(".").pop() || "";
return html`
<div class="h-full flex flex-col">
<div class="flex-1 overflow-auto">
${
isCode
? html`
<pre class="m-0 p-4 text-xs"><code class="hljs language-${this.getLanguageFromExtension(
ext.toLowerCase(),
)}">${unsafeHTML(
hljs.highlight(this.content, {
language: this.getLanguageFromExtension(ext.toLowerCase()),
ignoreIllegals: true,
}).value,
)}</code></pre>
`
: html` <pre class="m-0 p-4 text-xs font-mono">${this.content}</pre> `
}
</div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"text-artifact": TextArtifact;
}
}

View file

@ -0,0 +1,888 @@
import { Button, Diff, icon } from "@mariozechner/mini-lit";
import { type AgentTool, type Message, StringEnum, type ToolCall, type ToolResultMessage } from "@mariozechner/pi-ai";
import { type Static, Type } from "@sinclair/typebox";
import { html, LitElement, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { createRef, type Ref, ref } from "lit/directives/ref.js";
import { X } from "lucide";
import type { Attachment } from "../../utils/attachment-utils.js";
import { i18n } from "../../utils/i18n.js";
import type { ToolRenderer } from "../types.js";
import type { ArtifactElement } from "./ArtifactElement.js";
import { HtmlArtifact } from "./HtmlArtifact.js";
import { MarkdownArtifact } from "./MarkdownArtifact.js";
import { SvgArtifact } from "./SvgArtifact.js";
import { TextArtifact } from "./TextArtifact.js";
import "@mariozechner/mini-lit/dist/MarkdownBlock.js";
import "@mariozechner/mini-lit/dist/CodeBlock.js";
// Simple artifact model
export interface Artifact {
filename: string;
title: string;
content: string;
createdAt: Date;
updatedAt: Date;
}
// JSON-schema friendly parameters object (LLM-facing)
const artifactsParamsSchema = Type.Object({
command: StringEnum(["create", "update", "rewrite", "get", "delete", "logs"], {
description: "The operation to perform",
}),
filename: Type.String({ description: "Filename including extension (e.g., 'index.html', 'script.js')" }),
title: Type.Optional(Type.String({ description: "Display title for the tab (defaults to filename)" })),
content: Type.Optional(Type.String({ description: "File content" })),
old_str: Type.Optional(Type.String({ description: "String to replace (for update command)" })),
new_str: Type.Optional(Type.String({ description: "Replacement string (for update command)" })),
});
export type ArtifactsParams = Static<typeof artifactsParamsSchema>;
// Minimal helper to render plain text outputs consistently
function plainOutput(text: string): TemplateResult {
return html`<div class="text-xs text-muted-foreground whitespace-pre-wrap font-mono">${text}</div>`;
}
@customElement("artifacts-panel")
export class ArtifactsPanel extends LitElement implements ToolRenderer<ArtifactsParams, undefined> {
@state() private _artifacts = new Map<string, Artifact>();
@state() private _activeFilename: string | null = null;
// Programmatically managed artifact elements
private artifactElements = new Map<string, ArtifactElement>();
private contentRef: Ref<HTMLDivElement> = createRef();
// External provider for attachments (decouples panel from AgentInterface)
@property({ attribute: false }) attachmentsProvider?: () => Attachment[];
// Sandbox URL provider for browser extensions (optional)
@property({ attribute: false }) sandboxUrlProvider?: () => string;
// Callbacks
@property({ attribute: false }) onArtifactsChange?: () => void;
@property({ attribute: false }) onClose?: () => void;
@property({ attribute: false }) onOpen?: () => void;
// Collapsed mode: hides panel content but can show a floating reopen pill
@property({ type: Boolean }) collapsed = false;
// Overlay mode: when true, panel renders full-screen overlay (mobile)
@property({ type: Boolean }) overlay = false;
// Public getter for artifacts
get artifacts() {
return this._artifacts;
}
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this; // light DOM for shared styles
}
override connectedCallback(): void {
super.connectedCallback();
this.style.display = "block";
this.style.height = "100%";
// Reattach existing artifact elements when panel is re-inserted into the DOM
requestAnimationFrame(() => {
const container = this.contentRef.value;
if (!container) return;
// Ensure we have an active filename
if (!this._activeFilename && this._artifacts.size > 0) {
this._activeFilename = Array.from(this._artifacts.keys())[0];
}
this.artifactElements.forEach((element, name) => {
if (!element.parentElement) container.appendChild(element);
element.style.display = name === this._activeFilename ? "block" : "none";
});
});
}
override disconnectedCallback() {
super.disconnectedCallback();
// Do not tear down artifact elements; keep them to restore on next mount
}
// Helper to determine file type from extension
private getFileType(filename: string): "html" | "svg" | "markdown" | "text" {
const ext = filename.split(".").pop()?.toLowerCase();
if (ext === "html") return "html";
if (ext === "svg") return "svg";
if (ext === "md" || ext === "markdown") return "markdown";
return "text";
}
// Helper to determine language for syntax highlighting
private getLanguageFromFilename(filename?: string): string {
if (!filename) return "text";
const ext = filename.split(".").pop()?.toLowerCase();
const languageMap: Record<string, string> = {
js: "javascript",
jsx: "javascript",
ts: "typescript",
tsx: "typescript",
html: "html",
css: "css",
scss: "scss",
json: "json",
py: "python",
md: "markdown",
svg: "xml",
xml: "xml",
yaml: "yaml",
yml: "yaml",
sh: "bash",
bash: "bash",
sql: "sql",
java: "java",
c: "c",
cpp: "cpp",
cs: "csharp",
go: "go",
rs: "rust",
php: "php",
rb: "ruby",
swift: "swift",
kt: "kotlin",
r: "r",
};
return languageMap[ext || ""] || "text";
}
// Get or create artifact element
private getOrCreateArtifactElement(filename: string, content: string, title: string): ArtifactElement {
let element = this.artifactElements.get(filename);
if (!element) {
const type = this.getFileType(filename);
if (type === "html") {
element = new HtmlArtifact();
(element as HtmlArtifact).attachments = this.attachmentsProvider?.() || [];
if (this.sandboxUrlProvider) {
(element as HtmlArtifact).sandboxUrlProvider = this.sandboxUrlProvider;
}
} else if (type === "svg") {
element = new SvgArtifact();
} else if (type === "markdown") {
element = new MarkdownArtifact();
} else {
element = new TextArtifact();
}
element.filename = filename;
element.displayTitle = title;
element.content = content;
element.style.display = "none";
element.style.height = "100%";
// Store element
this.artifactElements.set(filename, element);
// Add to DOM - try immediately if container exists, otherwise schedule
const newElement = element;
if (this.contentRef.value) {
this.contentRef.value.appendChild(newElement);
} else {
requestAnimationFrame(() => {
if (this.contentRef.value && !newElement.parentElement) {
this.contentRef.value.appendChild(newElement);
}
});
}
} else {
// Just update content
element.content = content;
element.displayTitle = title;
if (element instanceof HtmlArtifact) {
element.attachments = this.attachmentsProvider?.() || [];
}
}
return element;
}
// Show/hide artifact elements
private showArtifact(filename: string) {
// Ensure the active element is in the DOM
requestAnimationFrame(() => {
this.artifactElements.forEach((element, name) => {
if (this.contentRef.value && !element.parentElement) {
this.contentRef.value.appendChild(element);
}
element.style.display = name === filename ? "block" : "none";
});
});
this._activeFilename = filename;
this.requestUpdate(); // Only for tab bar update
}
// Open panel and focus an artifact tab by filename
private openArtifact(filename: string) {
if (this._artifacts.has(filename)) {
this.showArtifact(filename);
// Ask host to open panel (AgentInterface demo listens to onOpen)
this.onOpen?.();
}
}
// Build the AgentTool (no details payload; return only output strings)
public get tool(): AgentTool<typeof artifactsParamsSchema, undefined> {
return {
label: "Artifacts",
name: "artifacts",
description: `Creates and manages file artifacts. Each artifact is a file with a filename and content.
IMPORTANT: Always prefer updating existing files over creating new ones. Check available files first.
Commands:
1. create: Create a new file
- filename: Name with extension (required, e.g., 'index.html', 'script.js', 'README.md')
- title: Display name for the tab (optional, defaults to filename)
- content: File content (required)
2. update: Update part of an existing file
- filename: File to update (required)
- old_str: Exact string to replace (required)
- new_str: Replacement string (required)
3. rewrite: Completely replace a file's content
- filename: File to rewrite (required)
- content: New content (required)
- title: Optionally update display title
4. get: Retrieve the full content of a file
- filename: File to retrieve (required)
- Returns the complete file content
5. delete: Delete a file
- filename: File to delete (required)
6. logs: Get console logs and errors (HTML files only)
- filename: HTML file to get logs for (required)
- Returns all console output and runtime errors
For text/html artifacts with attachments:
- HTML artifacts automatically have access to user attachments via JavaScript
- Available global functions in HTML artifacts:
* listFiles() - Returns array of {id, fileName, mimeType, size} for all attachments
* readTextFile(attachmentId) - Returns text content of attachment (for CSV, JSON, text files)
* readBinaryFile(attachmentId) - Returns Uint8Array of binary data (for images, Excel, etc.)
- Example HTML artifact that processes a CSV attachment:
<script>
// List available files
const files = listFiles();
console.log('Available files:', files);
// Find CSV file
const csvFile = files.find(f => f.mimeType === 'text/csv');
if (csvFile) {
const csvContent = readTextFile(csvFile.id);
// Process CSV data...
}
// Display image
const imageFile = files.find(f => f.mimeType.startsWith('image/'));
if (imageFile) {
const bytes = readBinaryFile(imageFile.id);
const blob = new Blob([bytes], {type: imageFile.mimeType});
const url = URL.createObjectURL(blob);
document.body.innerHTML = '<img src="' + url + '">';
}
</script>
For text/html artifacts:
- Must be a single self-contained file
- External scripts: Use CDNs like https://esm.sh, https://unpkg.com, or https://cdnjs.cloudflare.com
- Preferred: Use https://esm.sh for npm packages (e.g., https://esm.sh/three for Three.js)
- For ES modules, use: <script type="module">import * as THREE from 'https://esm.sh/three';</script>
- For Three.js specifically: import from 'https://esm.sh/three' or 'https://esm.sh/three@0.160.0'
- For addons: import from 'https://esm.sh/three/examples/jsm/controls/OrbitControls.js'
- No localStorage/sessionStorage - use in-memory variables only
- CSS should be included inline
- CRITICAL REMINDER FOR HTML ARTIFACTS:
- ALWAYS set a background color inline in <style> or directly on body element
- Failure to set a background color is a COMPLIANCE ERROR
- Background color MUST be explicitly defined to ensure visibility and proper rendering
- Can embed base64 images directly in img tags
- Ensure the layout is responsive as the iframe might be resized
- Note: Network errors (404s) for external scripts may not be captured in logs due to browser security
For application/vnd.ant.code artifacts:
- Include the language parameter for syntax highlighting
- Supports all major programming languages
For text/markdown:
- Standard markdown syntax
- Will be rendered with full formatting
- Can include base64 images using markdown syntax
For image/svg+xml:
- Complete SVG markup
- Will be rendered inline
- Can embed raster images as base64 in SVG
CRITICAL REMINDER FOR ALL ARTIFACTS:
- Prefer to update existing files rather than creating new ones
- Keep filenames consistent and descriptive
- Use appropriate file extensions
- Ensure HTML artifacts have a defined background color
`,
parameters: artifactsParamsSchema,
// Execute mutates our local store and returns a plain output
execute: async (_toolCallId: string, args: Static<typeof artifactsParamsSchema>, _signal?: AbortSignal) => {
const output = await this.executeCommand(args);
return { output, details: undefined };
},
};
}
// ToolRenderer implementation
renderParams(params: ArtifactsParams, isStreaming?: boolean): TemplateResult {
if (isStreaming && !params.command) {
return html`<div class="text-sm text-muted-foreground">${i18n("Processing artifact...")}</div>`;
}
let commandLabel = i18n("Processing");
if (params.command) {
switch (params.command) {
case "create":
commandLabel = i18n("Create");
break;
case "update":
commandLabel = i18n("Update");
break;
case "rewrite":
commandLabel = i18n("Rewrite");
break;
case "get":
commandLabel = i18n("Get");
break;
case "delete":
commandLabel = i18n("Delete");
break;
case "logs":
commandLabel = i18n("Get logs");
break;
default:
commandLabel = params.command.charAt(0).toUpperCase() + params.command.slice(1);
}
}
const filename = params.filename || "";
switch (params.command) {
case "create":
return html`
<div
class="text-sm cursor-pointer hover:bg-muted/50 rounded-sm px-2 py-1"
@click=${() => this.openArtifact(params.filename)}
>
<div>
<span class="font-medium">${i18n("Create")}</span>
<span class="text-muted-foreground ml-1">${filename}</span>
</div>
${
params.content
? html`<code-block
.code=${params.content}
language=${this.getLanguageFromFilename(params.filename)}
class="mt-2"
></code-block>`
: ""
}
</div>
`;
case "update":
return html`
<div
class="text-sm cursor-pointer hover:bg-muted/50 rounded-sm px-2 py-1"
@click=${() => this.openArtifact(params.filename)}
>
<div>
<span class="font-medium">${i18n("Update")}</span>
<span class="text-muted-foreground ml-1">${filename}</span>
</div>
${
params.old_str !== undefined && params.new_str !== undefined
? Diff({ oldText: params.old_str, newText: params.new_str, className: "mt-2" })
: ""
}
</div>
`;
case "rewrite":
return html`
<div
class="text-sm cursor-pointer hover:bg-muted/50 rounded-sm px-2 py-1"
@click=${() => this.openArtifact(params.filename)}
>
<div>
<span class="font-medium">${i18n("Rewrite")}</span>
<span class="text-muted-foreground ml-1">${filename}</span>
</div>
${
params.content
? html`<code-block
.code=${params.content}
language=${this.getLanguageFromFilename(params.filename)}
class="mt-2"
></code-block>`
: ""
}
</div>
`;
case "get":
return html`
<div
class="text-sm cursor-pointer hover:bg-muted/50 rounded-sm px-2 py-1"
@click=${() => this.openArtifact(params.filename)}
>
<span class="font-medium">${i18n("Get")}</span>
<span class="text-muted-foreground ml-1">${filename}</span>
</div>
`;
case "delete":
return html`
<div
class="text-sm cursor-pointer hover:bg-muted/50 rounded-sm px-2 py-1"
@click=${() => this.openArtifact(params.filename)}
>
<span class="font-medium">${i18n("Delete")}</span>
<span class="text-muted-foreground ml-1">${filename}</span>
</div>
`;
case "logs":
return html`
<div
class="text-sm cursor-pointer hover:bg-muted/50 rounded-sm px-2 py-1"
@click=${() => this.openArtifact(params.filename)}
>
<span class="font-medium">${i18n("Get logs")}</span>
<span class="text-muted-foreground ml-1">${filename}</span>
</div>
`;
default:
// Fallback for any command not yet handled during streaming
return html`
<div
class="text-sm cursor-pointer hover:bg-muted/50 rounded-sm px-2 py-1"
@click=${() => this.openArtifact(params.filename)}
>
<span class="font-medium">${commandLabel}</span>
<span class="text-muted-foreground ml-1">${filename}</span>
</div>
`;
}
}
renderResult(params: ArtifactsParams, result: ToolResultMessage<undefined>): TemplateResult {
// Make result clickable to focus the referenced file when applicable
const content = result.output || i18n("(no output)");
return html`
<div class="cursor-pointer hover:bg-muted/50 rounded-sm px-2 py-1" @click=${() => this.openArtifact(params.filename)}>
${plainOutput(content)}
</div>
`;
}
// Re-apply artifacts by scanning a message list (optional utility)
public async reconstructFromMessages(messages: Array<Message | { role: "aborted" }>): Promise<void> {
const toolCalls = new Map<string, ToolCall>();
const artifactToolName = "artifacts";
// 1) Collect tool calls from assistant messages
for (const message of messages) {
if (message.role === "assistant") {
for (const block of message.content) {
if (block.type === "toolCall" && block.name === artifactToolName) {
toolCalls.set(block.id, block);
}
}
}
}
// 2) Build an ordered list of successful artifact operations
const operations: Array<ArtifactsParams> = [];
for (const m of messages) {
if ((m as any).role === "toolResult" && (m as any).toolName === artifactToolName && !(m as any).isError) {
const toolCallId = (m as any).toolCallId as string;
const call = toolCalls.get(toolCallId);
if (!call) continue;
const params = call.arguments as ArtifactsParams;
if (params.command === "get" || params.command === "logs") continue; // no state change
operations.push(params);
}
}
// 3) Compute final state per filename by simulating operations in-memory
type FinalArtifact = { title: string; content: string };
const finalArtifacts = new Map<string, FinalArtifact>();
for (const op of operations) {
const filename = op.filename;
switch (op.command) {
case "create": {
if (op.content) {
finalArtifacts.set(filename, { title: op.title || filename, content: op.content });
}
break;
}
case "rewrite": {
if (op.content) {
// If file didn't exist earlier but rewrite succeeded, treat as fresh content
const existing = finalArtifacts.get(filename);
finalArtifacts.set(filename, { title: op.title || existing?.title || filename, content: op.content });
}
break;
}
case "update": {
const existing = finalArtifacts.get(filename);
if (!existing) break; // skip invalid update (shouldn't happen for successful results)
if (op.old_str !== undefined && op.new_str !== undefined) {
existing.content = existing.content.replace(op.old_str, op.new_str);
finalArtifacts.set(filename, existing);
}
break;
}
case "delete": {
finalArtifacts.delete(filename);
break;
}
case "get":
case "logs":
// Ignored above, just for completeness
break;
}
}
// 4) Reset current UI state before bulk create
this._artifacts.clear();
this.artifactElements.forEach((el) => {
el.remove();
});
this.artifactElements.clear();
this._activeFilename = null;
this._artifacts = new Map(this._artifacts);
// 5) Create artifacts in a single pass without waiting for iframe execution or tab switching
for (const [filename, { title, content }] of finalArtifacts.entries()) {
const createParams: ArtifactsParams = { command: "create", filename, title, content } as const;
try {
await this.createArtifact(createParams, { skipWait: true, silent: true });
} catch {
// Ignore failures during reconstruction
}
}
// 6) Show first artifact if any exist, and notify listeners once
if (!this._activeFilename && this._artifacts.size > 0) {
this.showArtifact(Array.from(this._artifacts.keys())[0]);
}
this.onArtifactsChange?.();
this.requestUpdate();
}
// Core command executor
private async executeCommand(
params: ArtifactsParams,
options: { skipWait?: boolean; silent?: boolean } = {},
): Promise<string> {
switch (params.command) {
case "create":
return await this.createArtifact(params, options);
case "update":
return await this.updateArtifact(params, options);
case "rewrite":
return await this.rewriteArtifact(params, options);
case "get":
return this.getArtifact(params);
case "delete":
return this.deleteArtifact(params);
case "logs":
return this.getLogs(params);
default:
// Should never happen with TypeBox validation
return `Error: Unknown command ${(params as any).command}`;
}
}
// Wait for HTML artifact execution and get logs
private async waitForHtmlExecution(filename: string): Promise<string> {
const element = this.artifactElements.get(filename);
if (!(element instanceof HtmlArtifact)) {
return "";
}
return new Promise((resolve) => {
let resolved = false;
// Listen for the execution-complete message
const messageHandler = (event: MessageEvent) => {
if (event.data?.type === "execution-complete" && event.data?.artifactId === filename) {
if (!resolved) {
resolved = true;
window.removeEventListener("message", messageHandler);
// Get the logs from the element
const logs = element.getLogs();
if (logs.includes("[error]")) {
resolve(`\n\nExecution completed with errors:\n${logs}`);
} else if (logs !== `No logs for ${filename}`) {
resolve(`\n\nExecution logs:\n${logs}`);
} else {
resolve("");
}
}
}
};
window.addEventListener("message", messageHandler);
// Fallback timeout in case the message never arrives
setTimeout(() => {
if (!resolved) {
resolved = true;
window.removeEventListener("message", messageHandler);
// Get whatever logs we have so far
const logs = element.getLogs();
if (logs.includes("[error]")) {
resolve(`\n\nExecution timed out with errors:\n${logs}`);
} else if (logs !== `No logs for ${filename}`) {
resolve(`\n\nExecution timed out. Partial logs:\n${logs}`);
} else {
resolve("");
}
}
}, 1500);
});
}
private async createArtifact(
params: ArtifactsParams,
options: { skipWait?: boolean; silent?: boolean } = {},
): Promise<string> {
if (!params.filename || !params.content) {
return "Error: create command requires filename and content";
}
if (this._artifacts.has(params.filename)) {
return `Error: File ${params.filename} already exists`;
}
const title = params.title || params.filename;
const artifact: Artifact = {
filename: params.filename,
title: title,
content: params.content,
createdAt: new Date(),
updatedAt: new Date(),
};
this._artifacts.set(params.filename, artifact);
this._artifacts = new Map(this._artifacts);
// Create or update element
this.getOrCreateArtifactElement(params.filename, params.content, title);
if (!options.silent) {
this.showArtifact(params.filename);
this.onArtifactsChange?.();
this.requestUpdate();
}
// For HTML files, wait for execution
let result = `Created file ${params.filename}`;
if (this.getFileType(params.filename) === "html" && !options.skipWait) {
const logs = await this.waitForHtmlExecution(params.filename);
result += logs;
}
return result;
}
private async updateArtifact(
params: ArtifactsParams,
options: { skipWait?: boolean; silent?: boolean } = {},
): Promise<string> {
const artifact = this._artifacts.get(params.filename);
if (!artifact) {
const files = Array.from(this._artifacts.keys());
if (files.length === 0) return `Error: File ${params.filename} not found. No files have been created yet.`;
return `Error: File ${params.filename} not found. Available files: ${files.join(", ")}`;
}
if (!params.old_str || params.new_str === undefined) {
return "Error: update command requires old_str and new_str";
}
if (!artifact.content.includes(params.old_str)) {
return `Error: String not found in file. Here is the full content:\n\n${artifact.content}`;
}
artifact.content = artifact.content.replace(params.old_str, params.new_str);
artifact.updatedAt = new Date();
this._artifacts.set(params.filename, artifact);
// Update element
this.getOrCreateArtifactElement(params.filename, artifact.content, artifact.title);
if (!options.silent) {
this.onArtifactsChange?.();
this.requestUpdate();
}
// Show the artifact
this.showArtifact(params.filename);
// For HTML files, wait for execution
let result = `Updated file ${params.filename}`;
if (this.getFileType(params.filename) === "html" && !options.skipWait) {
const logs = await this.waitForHtmlExecution(params.filename);
result += logs;
}
return result;
}
private async rewriteArtifact(
params: ArtifactsParams,
options: { skipWait?: boolean; silent?: boolean } = {},
): Promise<string> {
const artifact = this._artifacts.get(params.filename);
if (!artifact) {
const files = Array.from(this._artifacts.keys());
if (files.length === 0) return `Error: File ${params.filename} not found. No files have been created yet.`;
return `Error: File ${params.filename} not found. Available files: ${files.join(", ")}`;
}
if (!params.content) {
return "Error: rewrite command requires content";
}
artifact.content = params.content;
if (params.title) artifact.title = params.title;
artifact.updatedAt = new Date();
this._artifacts.set(params.filename, artifact);
// Update element
this.getOrCreateArtifactElement(params.filename, artifact.content, artifact.title);
if (!options.silent) {
this.onArtifactsChange?.();
}
// Show the artifact
this.showArtifact(params.filename);
// For HTML files, wait for execution
let result = `Rewrote file ${params.filename}`;
if (this.getFileType(params.filename) === "html" && !options.skipWait) {
const logs = await this.waitForHtmlExecution(params.filename);
result += logs;
}
return result;
}
private getArtifact(params: ArtifactsParams): string {
const artifact = this._artifacts.get(params.filename);
if (!artifact) {
const files = Array.from(this._artifacts.keys());
if (files.length === 0) return `Error: File ${params.filename} not found. No files have been created yet.`;
return `Error: File ${params.filename} not found. Available files: ${files.join(", ")}`;
}
return artifact.content;
}
private deleteArtifact(params: ArtifactsParams): string {
const artifact = this._artifacts.get(params.filename);
if (!artifact) {
const files = Array.from(this._artifacts.keys());
if (files.length === 0) return `Error: File ${params.filename} not found. No files have been created yet.`;
return `Error: File ${params.filename} not found. Available files: ${files.join(", ")}`;
}
this._artifacts.delete(params.filename);
this._artifacts = new Map(this._artifacts);
// Remove element
const element = this.artifactElements.get(params.filename);
if (element) {
element.remove();
this.artifactElements.delete(params.filename);
}
// Show another artifact if this was active
if (this._activeFilename === params.filename) {
const remaining = Array.from(this._artifacts.keys());
if (remaining.length > 0) {
this.showArtifact(remaining[0]);
} else {
this._activeFilename = null;
this.requestUpdate();
}
}
this.onArtifactsChange?.();
this.requestUpdate();
return `Deleted file ${params.filename}`;
}
private getLogs(params: ArtifactsParams): string {
const element = this.artifactElements.get(params.filename);
if (!element) {
const files = Array.from(this._artifacts.keys());
if (files.length === 0) return `Error: File ${params.filename} not found. No files have been created yet.`;
return `Error: File ${params.filename} not found. Available files: ${files.join(", ")}`;
}
if (!(element instanceof HtmlArtifact)) {
return `Error: File ${params.filename} is not an HTML file. Logs are only available for HTML files.`;
}
return element.getLogs();
}
override render(): TemplateResult {
const artifacts = Array.from(this._artifacts.values());
// Panel is hidden when collapsed OR when there are no artifacts
const showPanel = artifacts.length > 0 && !this.collapsed;
return html`
<div
class="${showPanel ? "" : "hidden"} ${
this.overlay ? "fixed inset-0 z-40 pointer-events-auto backdrop-blur-sm bg-background/95" : "relative"
} h-full flex flex-col bg-background text-card-foreground ${
!this.overlay ? "border-l border-border" : ""
} overflow-hidden shadow-xl"
>
<!-- Tab bar (always shown when there are artifacts) -->
<div class="flex items-center justify-between border-b border-border bg-background">
<div class="flex overflow-x-auto">
${artifacts.map((a) => {
const isActive = a.filename === this._activeFilename;
const activeClass = isActive
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground";
return html`
<button
class="px-3 py-2 whitespace-nowrap border-b-2 ${activeClass}"
@click=${() => this.showArtifact(a.filename)}
>
<span class="font-mono text-xs">${a.filename}</span>
</button>
`;
})}
</div>
<div class="flex items-center gap-1 px-2">
${(() => {
const active = this._activeFilename ? this.artifactElements.get(this._activeFilename) : undefined;
return active ? active.getHeaderButtons() : "";
})()}
${Button({
variant: "ghost",
size: "sm",
onClick: () => this.onClose?.(),
title: i18n("Close artifacts"),
children: icon(X, "sm"),
})}
</div>
</div>
<!-- Content area where artifact elements are added programmatically -->
<div class="flex-1 overflow-hidden" ${ref(this.contentRef)}></div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"artifacts-panel": ArtifactsPanel;
}
}

View file

@ -0,0 +1,6 @@
export { ArtifactElement } from "./ArtifactElement.js";
export { type Artifact, ArtifactsPanel, type ArtifactsParams } from "./artifacts.js";
export { HtmlArtifact } from "./HtmlArtifact.js";
export { MarkdownArtifact } from "./MarkdownArtifact.js";
export { SvgArtifact } from "./SvgArtifact.js";
export { TextArtifact } from "./TextArtifact.js";

View file

@ -0,0 +1,18 @@
import type { ToolRenderer } from "./types.js";
// Registry of tool renderers
export const toolRenderers = new Map<string, ToolRenderer>();
/**
* Register a custom tool renderer
*/
export function registerToolRenderer(toolName: string, renderer: ToolRenderer): void {
toolRenderers.set(toolName, renderer);
}
/**
* Get a tool renderer by name
*/
export function getToolRenderer(toolName: string): ToolRenderer | undefined {
return toolRenderers.get(toolName);
}

View file

@ -0,0 +1,45 @@
import { html, type TemplateResult } from "@mariozechner/mini-lit";
import type { ToolResultMessage } from "@mariozechner/pi-ai";
import { i18n } from "../../utils/i18n.js";
import type { ToolRenderer } from "../types.js";
interface BashParams {
command: string;
}
// Bash tool has undefined details (only uses output)
export class BashRenderer implements ToolRenderer<BashParams, undefined> {
renderParams(params: BashParams, isStreaming?: boolean): TemplateResult {
if (isStreaming && (!params.command || params.command.length === 0)) {
return html`<div class="text-sm text-muted-foreground">${i18n("Writing command...")}</div>`;
}
return html`
<div class="text-sm text-muted-foreground">
<span>${i18n("Running command:")}</span>
<code class="ml-1 px-1.5 py-0.5 bg-muted rounded text-xs font-mono">${params.command}</code>
</div>
`;
}
renderResult(_params: BashParams, result: ToolResultMessage<undefined>): TemplateResult {
const output = result.output || "";
const isError = result.isError === true;
if (isError) {
return html`
<div class="text-sm">
<div class="text-destructive font-medium mb-1">${i18n("Command failed:")}</div>
<pre class="text-xs font-mono text-destructive bg-destructive/10 p-2 rounded overflow-x-auto">${output}</pre>
</div>
`;
}
// Display the command output
return html`
<div class="text-sm">
<pre class="text-xs font-mono text-foreground bg-muted/50 p-2 rounded overflow-x-auto">${output}</pre>
</div>
`;
}
}

View file

@ -0,0 +1,49 @@
import { html, type TemplateResult } from "@mariozechner/mini-lit";
import type { ToolResultMessage } from "@mariozechner/pi-ai";
import { i18n } from "../../utils/i18n.js";
import type { ToolRenderer } from "../types.js";
interface CalculateParams {
expression: string;
}
// Calculate tool has undefined details (only uses output)
export class CalculateRenderer implements ToolRenderer<CalculateParams, undefined> {
renderParams(params: CalculateParams, isStreaming?: boolean): TemplateResult {
if (isStreaming && !params.expression) {
return html`<div class="text-sm text-muted-foreground">${i18n("Writing expression...")}</div>`;
}
return html`
<div class="text-sm text-muted-foreground">
<span>${i18n("Calculating")}</span>
<code class="mx-1 px-1.5 py-0.5 bg-muted rounded text-xs font-mono">${params.expression}</code>
</div>
`;
}
renderResult(_params: CalculateParams, result: ToolResultMessage<undefined>): TemplateResult {
// Parse the output to make it look nicer
const output = result.output || "";
const isError = result.isError === true;
if (isError) {
return html`<div class="text-sm text-destructive">${output}</div>`;
}
// Try to split on = to show expression and result separately
const parts = output.split(" = ");
if (parts.length === 2) {
return html`
<div class="text-sm font-mono">
<span class="text-muted-foreground">${parts[0]}</span>
<span class="text-muted-foreground mx-1">=</span>
<span class="text-foreground font-semibold">${parts[1]}</span>
</div>
`;
}
// Fallback to showing the whole output
return html`<div class="text-sm font-mono text-foreground">${output}</div>`;
}
}

View file

@ -0,0 +1,36 @@
import { html, type TemplateResult } from "@mariozechner/mini-lit";
import type { ToolResultMessage } from "@mariozechner/pi-ai";
import { i18n } from "../../utils/i18n.js";
import type { ToolRenderer } from "../types.js";
export class DefaultRenderer implements ToolRenderer {
renderParams(params: any, isStreaming?: boolean): TemplateResult {
let text: string;
let isJson = false;
try {
text = JSON.stringify(JSON.parse(params), null, 2);
isJson = true;
} catch {
try {
text = JSON.stringify(params, null, 2);
isJson = true;
} catch {
text = String(params);
}
}
if (isStreaming && (!text || text === "{}" || text === "null")) {
return html`<div class="text-sm text-muted-foreground">${i18n("Preparing tool parameters...")}</div>`;
}
return html`<console-block .content=${text}></console-block>`;
}
renderResult(_params: any, result: ToolResultMessage): TemplateResult {
// Just show the output field - that's what was sent to the LLM
const text = result.output || i18n("(no output)");
return html`<div class="text-sm text-muted-foreground whitespace-pre-wrap font-mono">${text}</div>`;
}
}

View file

@ -0,0 +1,39 @@
import { html, type TemplateResult } from "@mariozechner/mini-lit";
import type { ToolResultMessage } from "@mariozechner/pi-ai";
import { i18n } from "../../utils/i18n.js";
import type { ToolRenderer } from "../types.js";
interface GetCurrentTimeParams {
timezone?: string;
}
// GetCurrentTime tool has undefined details (only uses output)
export class GetCurrentTimeRenderer implements ToolRenderer<GetCurrentTimeParams, undefined> {
renderParams(params: GetCurrentTimeParams, isStreaming?: boolean): TemplateResult {
if (params.timezone) {
return html`
<div class="text-sm text-muted-foreground">
<span>${i18n("Getting current time in")}</span>
<code class="mx-1 px-1.5 py-0.5 bg-muted rounded text-xs font-mono">${params.timezone}</code>
</div>
`;
}
return html`
<div class="text-sm text-muted-foreground">
<span>${i18n("Getting current date and time")}${isStreaming ? "..." : ""}</span>
</div>
`;
}
renderResult(_params: GetCurrentTimeParams, result: ToolResultMessage<undefined>): TemplateResult {
const output = result.output || "";
const isError = result.isError === true;
if (isError) {
return html`<div class="text-sm text-destructive">${output}</div>`;
}
// Display the date/time result
return html`<div class="text-sm font-mono text-foreground">${output}</div>`;
}
}

View file

@ -0,0 +1,7 @@
import type { ToolResultMessage } from "@mariozechner/pi-ai";
import type { TemplateResult } from "lit";
export interface ToolRenderer<TParams = any, TDetails = any> {
renderParams(params: TParams, isStreaming?: boolean): TemplateResult;
renderResult(params: TParams, result: ToolResultMessage<TDetails>): TemplateResult;
}

View file

@ -0,0 +1,472 @@
import { parseAsync } from "docx-preview";
import JSZip from "jszip";
import type { PDFDocumentProxy } from "pdfjs-dist";
import * as pdfjsLib from "pdfjs-dist";
import * as XLSX from "xlsx";
import { i18n } from "./i18n.js";
// Configure PDF.js worker - we'll need to bundle this
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL("pdfjs-dist/build/pdf.worker.min.mjs", import.meta.url).toString();
export interface Attachment {
id: string;
type: "image" | "document";
fileName: string;
mimeType: string;
size: number;
content: string; // base64 encoded original data (without data URL prefix)
extractedText?: string; // For documents: <pdf filename="..."><page number="1">text</page></pdf>
preview?: string; // base64 image preview (first page for PDFs, or same as content for images)
}
/**
* Load an attachment from various sources
* @param source - URL string, File, Blob, or ArrayBuffer
* @param fileName - Optional filename override
* @returns Promise<Attachment>
* @throws Error if loading fails
*/
export async function loadAttachment(
source: string | File | Blob | ArrayBuffer,
fileName?: string,
): Promise<Attachment> {
let arrayBuffer: ArrayBuffer;
let detectedFileName = fileName || "unnamed";
let mimeType = "application/octet-stream";
let size = 0;
// Convert source to ArrayBuffer
if (typeof source === "string") {
// It's a URL - fetch it
const response = await fetch(source);
if (!response.ok) {
throw new Error(i18n("Failed to fetch file"));
}
arrayBuffer = await response.arrayBuffer();
size = arrayBuffer.byteLength;
mimeType = response.headers.get("content-type") || mimeType;
if (!fileName) {
// Try to extract filename from URL
const urlParts = source.split("/");
detectedFileName = urlParts[urlParts.length - 1] || "document";
}
} else if (source instanceof File) {
arrayBuffer = await source.arrayBuffer();
size = source.size;
mimeType = source.type || mimeType;
detectedFileName = fileName || source.name;
} else if (source instanceof Blob) {
arrayBuffer = await source.arrayBuffer();
size = source.size;
mimeType = source.type || mimeType;
} else if (source instanceof ArrayBuffer) {
arrayBuffer = source;
size = source.byteLength;
} else {
throw new Error(i18n("Invalid source type"));
}
// Convert ArrayBuffer to base64 - handle large files properly
const uint8Array = new Uint8Array(arrayBuffer);
let binary = "";
const chunkSize = 0x8000; // Process in 32KB chunks to avoid stack overflow
for (let i = 0; i < uint8Array.length; i += chunkSize) {
const chunk = uint8Array.slice(i, i + chunkSize);
binary += String.fromCharCode(...chunk);
}
const base64Content = btoa(binary);
// Detect type and process accordingly
const id = `${detectedFileName}_${Date.now()}_${Math.random()}`;
// Check if it's a PDF
if (mimeType === "application/pdf" || detectedFileName.toLowerCase().endsWith(".pdf")) {
const { extractedText, preview } = await processPdf(arrayBuffer, detectedFileName);
return {
id,
type: "document",
fileName: detectedFileName,
mimeType: "application/pdf",
size,
content: base64Content,
extractedText,
preview,
};
}
// Check if it's a DOCX file
if (
mimeType === "application/vnd.openxmlformats-officedocument.wordprocessingml.document" ||
detectedFileName.toLowerCase().endsWith(".docx")
) {
const { extractedText } = await processDocx(arrayBuffer, detectedFileName);
return {
id,
type: "document",
fileName: detectedFileName,
mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
size,
content: base64Content,
extractedText,
};
}
// Check if it's a PPTX file
if (
mimeType === "application/vnd.openxmlformats-officedocument.presentationml.presentation" ||
detectedFileName.toLowerCase().endsWith(".pptx")
) {
const { extractedText } = await processPptx(arrayBuffer, detectedFileName);
return {
id,
type: "document",
fileName: detectedFileName,
mimeType: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
size,
content: base64Content,
extractedText,
};
}
// Check if it's an Excel file (XLSX/XLS)
const excelMimeTypes = [
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.ms-excel",
];
if (
excelMimeTypes.includes(mimeType) ||
detectedFileName.toLowerCase().endsWith(".xlsx") ||
detectedFileName.toLowerCase().endsWith(".xls")
) {
const { extractedText } = await processExcel(arrayBuffer, detectedFileName);
return {
id,
type: "document",
fileName: detectedFileName,
mimeType: mimeType.startsWith("application/vnd")
? mimeType
: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
size,
content: base64Content,
extractedText,
};
}
// Check if it's an image
if (mimeType.startsWith("image/")) {
return {
id,
type: "image",
fileName: detectedFileName,
mimeType,
size,
content: base64Content,
preview: base64Content, // For images, preview is the same as content
};
}
// Check if it's a text document
const textExtensions = [
".txt",
".md",
".json",
".xml",
".html",
".css",
".js",
".ts",
".jsx",
".tsx",
".yml",
".yaml",
];
const isTextFile =
mimeType.startsWith("text/") || textExtensions.some((ext) => detectedFileName.toLowerCase().endsWith(ext));
if (isTextFile) {
const decoder = new TextDecoder();
const text = decoder.decode(arrayBuffer);
return {
id,
type: "document",
fileName: detectedFileName,
mimeType: mimeType.startsWith("text/") ? mimeType : "text/plain",
size,
content: base64Content,
extractedText: text,
};
}
throw new Error(`Unsupported file type: ${mimeType}`);
}
async function processPdf(
arrayBuffer: ArrayBuffer,
fileName: string,
): Promise<{ extractedText: string; preview?: string }> {
let pdf: PDFDocumentProxy | null = null;
try {
pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
// Extract text with page structure
let extractedText = `<pdf filename="${fileName}">`;
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const textContent = await page.getTextContent();
const pageText = textContent.items
.map((item: any) => item.str)
.filter((str: string) => str.trim())
.join(" ");
extractedText += `\n<page number="${i}">\n${pageText}\n</page>`;
}
extractedText += "\n</pdf>";
// Generate preview from first page
const preview = await generatePdfPreview(pdf);
return { extractedText, preview };
} catch (error) {
console.error("Error processing PDF:", error);
throw new Error(`Failed to process PDF: ${String(error)}`);
} finally {
// Clean up PDF resources
if (pdf) {
pdf.destroy();
}
}
}
async function generatePdfPreview(pdf: PDFDocumentProxy): Promise<string | undefined> {
try {
const page = await pdf.getPage(1);
const viewport = page.getViewport({ scale: 1.0 });
// Create canvas with reasonable size for thumbnail (160x160 max)
const scale = Math.min(160 / viewport.width, 160 / viewport.height);
const scaledViewport = page.getViewport({ scale });
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d");
if (!context) {
return undefined;
}
canvas.height = scaledViewport.height;
canvas.width = scaledViewport.width;
const renderContext = {
canvasContext: context,
viewport: scaledViewport,
canvas: canvas,
};
await page.render(renderContext).promise;
// Return base64 without data URL prefix
return canvas.toDataURL("image/png").split(",")[1];
} catch (error) {
console.error("Error generating PDF preview:", error);
return undefined;
}
}
async function processDocx(arrayBuffer: ArrayBuffer, fileName: string): Promise<{ extractedText: string }> {
try {
// Parse document structure
const wordDoc = await parseAsync(arrayBuffer);
// Extract structured text from document body
let extractedText = `<docx filename="${fileName}">\n<page number="1">\n`;
const body = wordDoc.documentPart?.body;
if (body?.children) {
// Walk through document elements and extract text
const texts: string[] = [];
for (const element of body.children) {
const text = extractTextFromElement(element);
if (text) {
texts.push(text);
}
}
extractedText += texts.join("\n");
}
extractedText += `\n</page>\n</docx>`;
return { extractedText };
} catch (error) {
console.error("Error processing DOCX:", error);
throw new Error(`Failed to process DOCX: ${String(error)}`);
}
}
function extractTextFromElement(element: any): string {
let text = "";
// Check type with lowercase
const elementType = element.type?.toLowerCase() || "";
// Handle paragraphs
if (elementType === "paragraph" && element.children) {
for (const child of element.children) {
const childType = child.type?.toLowerCase() || "";
if (childType === "run" && child.children) {
for (const textChild of child.children) {
const textType = textChild.type?.toLowerCase() || "";
if (textType === "text") {
text += textChild.text || "";
}
}
} else if (childType === "text") {
text += child.text || "";
}
}
}
// Handle tables
else if (elementType === "table") {
if (element.children) {
const tableTexts: string[] = [];
for (const row of element.children) {
const rowType = row.type?.toLowerCase() || "";
if (rowType === "tablerow" && row.children) {
const rowTexts: string[] = [];
for (const cell of row.children) {
const cellType = cell.type?.toLowerCase() || "";
if (cellType === "tablecell" && cell.children) {
const cellTexts: string[] = [];
for (const cellElement of cell.children) {
const cellText = extractTextFromElement(cellElement);
if (cellText) cellTexts.push(cellText);
}
if (cellTexts.length > 0) rowTexts.push(cellTexts.join(" "));
}
}
if (rowTexts.length > 0) tableTexts.push(rowTexts.join(" | "));
}
}
if (tableTexts.length > 0) {
text = "\n[Table]\n" + tableTexts.join("\n") + "\n[/Table]\n";
}
}
}
// Recursively handle other container elements
else if (element.children && Array.isArray(element.children)) {
const childTexts: string[] = [];
for (const child of element.children) {
const childText = extractTextFromElement(child);
if (childText) childTexts.push(childText);
}
text = childTexts.join(" ");
}
return text.trim();
}
async function processPptx(arrayBuffer: ArrayBuffer, fileName: string): Promise<{ extractedText: string }> {
try {
// Load the PPTX file as a ZIP
const zip = await JSZip.loadAsync(arrayBuffer);
// PPTX slides are stored in ppt/slides/slide[n].xml
let extractedText = `<pptx filename="${fileName}">`;
// Get all slide files and sort them numerically
const slideFiles = Object.keys(zip.files)
.filter((name) => name.match(/ppt\/slides\/slide\d+\.xml$/))
.sort((a, b) => {
const numA = Number.parseInt(a.match(/slide(\d+)\.xml$/)?.[1] || "0", 10);
const numB = Number.parseInt(b.match(/slide(\d+)\.xml$/)?.[1] || "0", 10);
return numA - numB;
});
// Extract text from each slide
for (let i = 0; i < slideFiles.length; i++) {
const slideFile = zip.file(slideFiles[i]);
if (slideFile) {
const slideXml = await slideFile.async("text");
// Extract text from XML (simple regex approach)
// Looking for <a:t> tags which contain text in PPTX
const textMatches = slideXml.match(/<a:t[^>]*>([^<]+)<\/a:t>/g);
if (textMatches) {
extractedText += `\n<slide number="${i + 1}">`;
const slideTexts = textMatches
.map((match) => {
const textMatch = match.match(/<a:t[^>]*>([^<]+)<\/a:t>/);
return textMatch ? textMatch[1] : "";
})
.filter((t) => t.trim());
if (slideTexts.length > 0) {
extractedText += "\n" + slideTexts.join("\n");
}
extractedText += "\n</slide>";
}
}
}
// Also try to extract text from notes
const notesFiles = Object.keys(zip.files)
.filter((name) => name.match(/ppt\/notesSlides\/notesSlide\d+\.xml$/))
.sort((a, b) => {
const numA = Number.parseInt(a.match(/notesSlide(\d+)\.xml$/)?.[1] || "0", 10);
const numB = Number.parseInt(b.match(/notesSlide(\d+)\.xml$/)?.[1] || "0", 10);
return numA - numB;
});
if (notesFiles.length > 0) {
extractedText += "\n<notes>";
for (const noteFile of notesFiles) {
const file = zip.file(noteFile);
if (file) {
const noteXml = await file.async("text");
const textMatches = noteXml.match(/<a:t[^>]*>([^<]+)<\/a:t>/g);
if (textMatches) {
const noteTexts = textMatches
.map((match) => {
const textMatch = match.match(/<a:t[^>]*>([^<]+)<\/a:t>/);
return textMatch ? textMatch[1] : "";
})
.filter((t) => t.trim());
if (noteTexts.length > 0) {
const slideNum = noteFile.match(/notesSlide(\d+)\.xml$/)?.[1];
extractedText += `\n[Slide ${slideNum} notes]: ${noteTexts.join(" ")}`;
}
}
}
}
extractedText += "\n</notes>";
}
extractedText += "\n</pptx>";
return { extractedText };
} catch (error) {
console.error("Error processing PPTX:", error);
throw new Error(`Failed to process PPTX: ${String(error)}`);
}
}
async function processExcel(arrayBuffer: ArrayBuffer, fileName: string): Promise<{ extractedText: string }> {
try {
// Read the workbook
const workbook = XLSX.read(arrayBuffer, { type: "array" });
let extractedText = `<excel filename="${fileName}">`;
// Process each sheet
for (const [index, sheetName] of workbook.SheetNames.entries()) {
const worksheet = workbook.Sheets[sheetName];
// Extract text as CSV for the extractedText field
const csvText = XLSX.utils.sheet_to_csv(worksheet);
extractedText += `\n<sheet name="${sheetName}" index="${index + 1}">\n${csvText}\n</sheet>`;
}
extractedText += "\n</excel>";
return { extractedText };
} catch (error) {
console.error("Error processing Excel:", error);
throw new Error(`Failed to process Excel: ${String(error)}`);
}
}

View file

@ -0,0 +1,22 @@
import { PromptDialog } from "@mariozechner/mini-lit";
import { i18n } from "./i18n.js";
export async function getAuthToken(): Promise<string | undefined> {
let authToken: string | undefined = localStorage.getItem(`auth-token`) || "";
if (authToken) return authToken;
while (true) {
authToken = (
await PromptDialog.ask(i18n("Enter Auth Token"), i18n("Please enter your auth token."), "", true)
)?.trim();
if (authToken) {
localStorage.setItem(`auth-token`, authToken);
break;
}
}
return authToken?.trim() || undefined;
}
export async function clearAuthToken() {
localStorage.removeItem(`auth-token`);
}

View file

@ -0,0 +1,42 @@
import { i18n } from "@mariozechner/mini-lit";
import type { Usage } from "@mariozechner/pi-ai";
export function formatCost(cost: number): string {
return `$${cost.toFixed(4)}`;
}
export function formatModelCost(cost: any): string {
if (!cost) return i18n("Free");
const input = cost.input || 0;
const output = cost.output || 0;
if (input === 0 && output === 0) return i18n("Free");
// Format numbers with appropriate precision
const formatNum = (num: number): string => {
if (num >= 100) return num.toFixed(0);
if (num >= 10) return num.toFixed(1).replace(/\.0$/, "");
if (num >= 1) return num.toFixed(2).replace(/\.?0+$/, "");
return num.toFixed(3).replace(/\.?0+$/, "");
};
return `$${formatNum(input)}/$${formatNum(output)}`;
}
export function formatUsage(usage: Usage) {
if (!usage) return "";
const parts = [];
if (usage.input) parts.push(`${formatTokenCount(usage.input)}`);
if (usage.output) parts.push(`${formatTokenCount(usage.output)}`);
if (usage.cacheRead) parts.push(`R${formatTokenCount(usage.cacheRead)}`);
if (usage.cacheWrite) parts.push(`W${formatTokenCount(usage.cacheWrite)}`);
if (usage.cost?.total) parts.push(formatCost(usage.cost.total));
return parts.join(" ");
}
export function formatTokenCount(count: number): string {
if (count < 1000) return count.toString();
if (count < 10000) return (count / 1000).toFixed(1) + "k";
return Math.round(count / 1000) + "k";
}

View file

@ -0,0 +1,311 @@
import { defaultEnglish, defaultGerman, type MiniLitRequiredMessages, setTranslations } from "@mariozechner/mini-lit";
declare module "@mariozechner/mini-lit" {
interface i18nMessages extends MiniLitRequiredMessages {
Free: string;
"Input Required": string;
Cancel: string;
Confirm: string;
"Select Model": string;
"Search models...": string;
Format: string;
Thinking: string;
Vision: string;
You: string;
Assistant: string;
"Thinking...": string;
"Type your message...": string;
"API Keys Configuration": string;
"Configure API keys for LLM providers. Keys are stored locally in your browser.": string;
Configured: string;
"Not configured": string;
"✓ Valid": string;
"✗ Invalid": string;
"Testing...": string;
Update: string;
Test: string;
Remove: string;
Save: string;
"Update API key": string;
"Enter API key": string;
"Type a message...": string;
"Failed to fetch file": string;
"Invalid source type": string;
PDF: string;
Document: string;
Presentation: string;
Spreadsheet: string;
Text: string;
"Error loading file": string;
"No text content available": string;
"Failed to load PDF": string;
"Failed to load document": string;
"Failed to load spreadsheet": string;
"No content available": string;
"Failed to display text content": string;
"API keys are required to use AI models. Get your keys from the provider's website.": string;
console: string;
"Copy output": string;
"Copied!": string;
"Error:": string;
"Request aborted": string;
Call: string;
Result: string;
"(no result)": string;
"Waiting for tool result…": string;
"Call was aborted; no result.": string;
"No session available": string;
"No session set": string;
"Preparing tool parameters...": string;
"(no output)": string;
"Writing expression...": string;
Calculating: string;
"Getting current time in": string;
"Getting current date and time": string;
"Writing command...": string;
"Running command:": string;
"Command failed:": string;
"Enter Auth Token": string;
"Please enter your auth token.": string;
"Auth token is required for proxy transport": string;
// JavaScript REPL strings
"Execution aborted": string;
"Code parameter is required": string;
"Unknown error": string;
"Code executed successfully (no output)": string;
"Execution failed": string;
"JavaScript REPL": string;
"JavaScript code to execute": string;
"Writing JavaScript code...": string;
"Executing JavaScript": string;
// Artifacts strings
"Processing artifact...": string;
Processing: string;
Create: string;
Rewrite: string;
Get: string;
Delete: string;
"Get logs": string;
"Show artifacts": string;
"Close artifacts": string;
Artifacts: string;
"Copy HTML": string;
"Download HTML": string;
"Copy SVG": string;
"Download SVG": string;
"Copy Markdown": string;
"Download Markdown": string;
Download: string;
"No logs for {filename}": string;
"API Keys Settings": string;
}
}
const translations = {
en: {
...defaultEnglish,
Free: "Free",
"Input Required": "Input Required",
Cancel: "Cancel",
Confirm: "Confirm",
"Select Model": "Select Model",
"Search models...": "Search models...",
Format: "Format",
Thinking: "Thinking",
Vision: "Vision",
You: "You",
Assistant: "Assistant",
"Thinking...": "Thinking...",
"Type your message...": "Type your message...",
"API Keys Configuration": "API Keys Configuration",
"Configure API keys for LLM providers. Keys are stored locally in your browser.":
"Configure API keys for LLM providers. Keys are stored locally in your browser.",
Configured: "Configured",
"Not configured": "Not configured",
"✓ Valid": "✓ Valid",
"✗ Invalid": "✗ Invalid",
"Testing...": "Testing...",
Update: "Update",
Test: "Test",
Remove: "Remove",
Save: "Save",
"Update API key": "Update API key",
"Enter API key": "Enter API key",
"Type a message...": "Type a message...",
"Failed to fetch file": "Failed to fetch file",
"Invalid source type": "Invalid source type",
PDF: "PDF",
Document: "Document",
Presentation: "Presentation",
Spreadsheet: "Spreadsheet",
Text: "Text",
"Error loading file": "Error loading file",
"No text content available": "No text content available",
"Failed to load PDF": "Failed to load PDF",
"Failed to load document": "Failed to load document",
"Failed to load spreadsheet": "Failed to load spreadsheet",
"No content available": "No content available",
"Failed to display text content": "Failed to display text content",
"API keys are required to use AI models. Get your keys from the provider's website.":
"API keys are required to use AI models. Get your keys from the provider's website.",
console: "console",
"Copy output": "Copy output",
"Copied!": "Copied!",
"Error:": "Error:",
"Request aborted": "Request aborted",
Call: "Call",
Result: "Result",
"(no result)": "(no result)",
"Waiting for tool result…": "Waiting for tool result…",
"Call was aborted; no result.": "Call was aborted; no result.",
"No session available": "No session available",
"No session set": "No session set",
"Preparing tool parameters...": "Preparing tool parameters...",
"(no output)": "(no output)",
"Writing expression...": "Writing expression...",
Calculating: "Calculating",
"Getting current time in": "Getting current time in",
"Getting current date and time": "Getting current date and time",
"Writing command...": "Writing command...",
"Running command:": "Running command:",
"Command failed:": "Command failed:",
"Enter Auth Token": "Enter Auth Token",
"Please enter your auth token.": "Please enter your auth token.",
"Auth token is required for proxy transport": "Auth token is required for proxy transport",
// JavaScript REPL strings
"Execution aborted": "Execution aborted",
"Code parameter is required": "Code parameter is required",
"Unknown error": "Unknown error",
"Code executed successfully (no output)": "Code executed successfully (no output)",
"Execution failed": "Execution failed",
"JavaScript REPL": "JavaScript REPL",
"JavaScript code to execute": "JavaScript code to execute",
"Writing JavaScript code...": "Writing JavaScript code...",
"Executing JavaScript": "Executing JavaScript",
// Artifacts strings
"Processing artifact...": "Processing artifact...",
Processing: "Processing",
Create: "Create",
Rewrite: "Rewrite",
Get: "Get",
Delete: "Delete",
"Get logs": "Get logs",
"Show artifacts": "Show artifacts",
"Close artifacts": "Close artifacts",
Artifacts: "Artifacts",
"Copy HTML": "Copy HTML",
"Download HTML": "Download HTML",
"Copy SVG": "Copy SVG",
"Download SVG": "Download SVG",
"Copy Markdown": "Copy Markdown",
"Download Markdown": "Download Markdown",
Download: "Download",
"No logs for {filename}": "No logs for {filename}",
"API Keys Settings": "API Keys Settings",
},
de: {
...defaultGerman,
Free: "Kostenlos",
"Input Required": "Eingabe erforderlich",
Cancel: "Abbrechen",
Confirm: "Bestätigen",
"Select Model": "Modell auswählen",
"Search models...": "Modelle suchen...",
Format: "Formatieren",
Thinking: "Thinking",
Vision: "Vision",
You: "Sie",
Assistant: "Assistent",
"Thinking...": "Denkt nach...",
"Type your message...": "Geben Sie Ihre Nachricht ein...",
"API Keys Configuration": "API-Schlüssel-Konfiguration",
"Configure API keys for LLM providers. Keys are stored locally in your browser.":
"Konfigurieren Sie API-Schlüssel für LLM-Anbieter. Schlüssel werden lokal in Ihrem Browser gespeichert.",
Configured: "Konfiguriert",
"Not configured": "Nicht konfiguriert",
"✓ Valid": "✓ Gültig",
"✗ Invalid": "✗ Ungültig",
"Testing...": "Teste...",
Update: "Aktualisieren",
Test: "Testen",
Remove: "Entfernen",
Save: "Speichern",
"Update API key": "API-Schlüssel aktualisieren",
"Enter API key": "API-Schlüssel eingeben",
"Type a message...": "Nachricht eingeben...",
"Failed to fetch file": "Datei konnte nicht abgerufen werden",
"Invalid source type": "Ungültiger Quellentyp",
PDF: "PDF",
Document: "Dokument",
Presentation: "Präsentation",
Spreadsheet: "Tabelle",
Text: "Text",
"Error loading file": "Fehler beim Laden der Datei",
"No text content available": "Kein Textinhalt verfügbar",
"Failed to load PDF": "PDF konnte nicht geladen werden",
"Failed to load document": "Dokument konnte nicht geladen werden",
"Failed to load spreadsheet": "Tabelle konnte nicht geladen werden",
"No content available": "Kein Inhalt verfügbar",
"Failed to display text content": "Textinhalt konnte nicht angezeigt werden",
"API keys are required to use AI models. Get your keys from the provider's website.":
"API-Schlüssel sind erforderlich, um KI-Modelle zu verwenden. Holen Sie sich Ihre Schlüssel von der Website des Anbieters.",
console: "Konsole",
"Copy output": "Ausgabe kopieren",
"Copied!": "Kopiert!",
"Error:": "Fehler:",
"Request aborted": "Anfrage abgebrochen",
Call: "Aufruf",
Result: "Ergebnis",
"(no result)": "(kein Ergebnis)",
"Waiting for tool result…": "Warte auf Tool-Ergebnis…",
"Call was aborted; no result.": "Aufruf wurde abgebrochen; kein Ergebnis.",
"No session available": "Keine Sitzung verfügbar",
"No session set": "Keine Sitzung gesetzt",
"Preparing tool parameters...": "Bereite Tool-Parameter vor...",
"(no output)": "(keine Ausgabe)",
"Writing expression...": "Schreibe Ausdruck...",
Calculating: "Berechne",
"Getting current time in": "Hole aktuelle Zeit in",
"Getting current date and time": "Hole aktuelles Datum und Uhrzeit",
"Writing command...": "Schreibe Befehl...",
"Running command:": "Führe Befehl aus:",
"Command failed:": "Befehl fehlgeschlagen:",
"Enter Auth Token": "Auth-Token eingeben",
"Please enter your auth token.": "Bitte geben Sie Ihr Auth-Token ein.",
"Auth token is required for proxy transport": "Auth-Token ist für Proxy-Transport erforderlich",
// JavaScript REPL strings
"Execution aborted": "Ausführung abgebrochen",
"Code parameter is required": "Code-Parameter ist erforderlich",
"Unknown error": "Unbekannter Fehler",
"Code executed successfully (no output)": "Code erfolgreich ausgeführt (keine Ausgabe)",
"Execution failed": "Ausführung fehlgeschlagen",
"JavaScript REPL": "JavaScript REPL",
"JavaScript code to execute": "Auszuführender JavaScript-Code",
"Writing JavaScript code...": "Schreibe JavaScript-Code...",
"Executing JavaScript": "Führe JavaScript aus",
// Artifacts strings
"Processing artifact...": "Verarbeite Artefakt...",
Processing: "Verarbeitung",
Create: "Erstellen",
Rewrite: "Überschreiben",
Get: "Abrufen",
Delete: "Löschen",
"Get logs": "Logs abrufen",
"Show artifacts": "Artefakte anzeigen",
"Close artifacts": "Artefakte schließen",
Artifacts: "Artefakte",
"Copy HTML": "HTML kopieren",
"Download HTML": "HTML herunterladen",
"Copy SVG": "SVG kopieren",
"Download SVG": "SVG herunterladen",
"Copy Markdown": "Markdown kopieren",
"Download Markdown": "Markdown herunterladen",
Download: "Herunterladen",
"No logs for {filename}": "Keine Logs für {filename}",
"API Keys Settings": "API-Schlüssel Einstellungen",
},
};
setTranslations(translations);
export * from "@mariozechner/mini-lit/dist/i18n.js";

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"moduleResolution": "bundler",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"experimentalDecorators": true,
"useDefineForClassFields": false,
"rootDir": "./src",
"outDir": "./dist"
},
"include": ["src/**/*"]
}

View file

@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true
},
"include": ["src/**/*"]
}