mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-17 07:03:25 +00:00
web-ui package
This commit is contained in:
parent
7159c9734e
commit
f2eecb78d2
55 changed files with 10932 additions and 13 deletions
1
package-lock.json
generated
1
package-lock.json
generated
|
|
@ -5293,6 +5293,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mariozechner/mini-lit": "^0.1.7",
|
"@mariozechner/mini-lit": "^0.1.7",
|
||||||
"@mariozechner/pi-ai": "^0.5.43",
|
"@mariozechner/pi-ai": "^0.5.43",
|
||||||
|
"@mariozechner/pi-web-ui": "^0.5.43",
|
||||||
"docx-preview": "^0.3.7",
|
"docx-preview": "^0.3.7",
|
||||||
"js-interpreter": "^6.0.1",
|
"js-interpreter": "^6.0.1",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
|
|
|
||||||
252
packages/web-ui/README.md
Normal file
252
packages/web-ui/README.md
Normal 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
3
packages/web-ui/example/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.DS_Store
|
||||||
61
packages/web-ui/example/README.md
Normal file
61
packages/web-ui/example/README.md
Normal 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)
|
||||||
13
packages/web-ui/example/index.html
Normal file
13
packages/web-ui/example/index.html
Normal 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
1965
packages/web-ui/example/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
22
packages/web-ui/example/package.json
Normal file
22
packages/web-ui/example/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
packages/web-ui/example/src/app.css
Normal file
1
packages/web-ui/example/src/app.css
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
@import "../../dist/app.css";
|
||||||
51
packages/web-ui/example/src/main.ts
Normal file
51
packages/web-ui/example/src/main.ts
Normal 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);
|
||||||
104
packages/web-ui/example/src/test-sessions.ts
Normal file
104
packages/web-ui/example/src/test-sessions.ts
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
15
packages/web-ui/example/tsconfig.json
Normal file
15
packages/web-ui/example/tsconfig.json
Normal 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/**/*"]
|
||||||
|
}
|
||||||
6
packages/web-ui/example/vite.config.ts
Normal file
6
packages/web-ui/example/vite.config.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [tailwindcss()],
|
||||||
|
});
|
||||||
45
packages/web-ui/package.json
Normal file
45
packages/web-ui/package.json
Normal 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"
|
||||||
|
}
|
||||||
38
packages/web-ui/src/app.css
Normal file
38
packages/web-ui/src/app.css
Normal 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);
|
||||||
|
}
|
||||||
312
packages/web-ui/src/components/AgentInterface.ts
Normal file
312
packages/web-ui/src/components/AgentInterface.ts
Normal 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);
|
||||||
|
}
|
||||||
112
packages/web-ui/src/components/AttachmentTile.ts
Normal file
112
packages/web-ui/src/components/AttachmentTile.ts
Normal 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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
67
packages/web-ui/src/components/ConsoleBlock.ts
Normal file
67
packages/web-ui/src/components/ConsoleBlock.ts
Normal 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);
|
||||||
|
}
|
||||||
112
packages/web-ui/src/components/Input.ts
Normal file
112
packages/web-ui/src/components/Input.ts
Normal 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>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
);
|
||||||
272
packages/web-ui/src/components/MessageEditor.ts
Normal file
272
packages/web-ui/src/components/MessageEditor.ts
Normal 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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
82
packages/web-ui/src/components/MessageList.ts
Normal file
82
packages/web-ui/src/components/MessageList.ts
Normal 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);
|
||||||
|
}
|
||||||
310
packages/web-ui/src/components/Messages.ts
Normal file
310
packages/web-ui/src/components/Messages.ts
Normal 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>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { LitElement } from "lit";
|
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";
|
import type { Attachment } from "../utils/attachment-utils.js";
|
||||||
|
|
||||||
export interface SandboxFile {
|
export interface SandboxFile {
|
||||||
|
|
@ -15,10 +15,23 @@ export interface SandboxResult {
|
||||||
error?: { message: string; stack: string };
|
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")
|
@customElement("sandbox-iframe")
|
||||||
export class SandboxIframe extends LitElement {
|
export class SandboxIframe extends LitElement {
|
||||||
private iframe?: HTMLIFrameElement;
|
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() {
|
createRenderRoot() {
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
@ -41,6 +54,48 @@ export class SandboxIframe extends LitElement {
|
||||||
public loadContent(sandboxId: string, htmlContent: string, attachments: Attachment[]): void {
|
public loadContent(sandboxId: string, htmlContent: string, attachments: Attachment[]): void {
|
||||||
const completeHtml = this.prepareHtmlDocument(sandboxId, htmlContent, attachments);
|
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
|
// Always recreate iframe to ensure fresh sandbox
|
||||||
this.iframe?.remove();
|
this.iframe?.remove();
|
||||||
this.iframe = document.createElement("iframe");
|
this.iframe = document.createElement("iframe");
|
||||||
|
|
@ -50,7 +105,7 @@ export class SandboxIframe extends LitElement {
|
||||||
this.iframe.style.height = "100%";
|
this.iframe.style.height = "100%";
|
||||||
this.iframe.style.border = "none";
|
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.iframe.srcdoc = completeHtml;
|
||||||
|
|
||||||
this.appendChild(this.iframe);
|
this.appendChild(this.iframe);
|
||||||
|
|
@ -125,9 +180,14 @@ export class SandboxIframe extends LitElement {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let readyHandler: ((e: MessageEvent) => void) | undefined;
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
window.removeEventListener("message", messageHandler);
|
window.removeEventListener("message", messageHandler);
|
||||||
signal?.removeEventListener("abort", abortHandler);
|
signal?.removeEventListener("abort", abortHandler);
|
||||||
|
if (readyHandler) {
|
||||||
|
window.removeEventListener("message", readyHandler);
|
||||||
|
}
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -148,19 +208,52 @@ export class SandboxIframe extends LitElement {
|
||||||
}
|
}
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
// NOW create and append iframe AFTER all listeners are set up
|
if (this.sandboxUrlProvider) {
|
||||||
this.iframe?.remove();
|
// Browser extension mode: wait for sandbox-ready and send content
|
||||||
this.iframe = document.createElement("iframe");
|
readyHandler = (e: MessageEvent) => {
|
||||||
this.iframe.sandbox.add("allow-scripts");
|
if (e.data.type === "sandbox-ready" && e.source === this.iframe?.contentWindow) {
|
||||||
this.iframe.sandbox.add("allow-modals");
|
window.removeEventListener("message", readyHandler!);
|
||||||
this.iframe.style.width = "100%";
|
// Send the complete HTML
|
||||||
this.iframe.style.height = "100%";
|
this.iframe?.contentWindow?.postMessage(
|
||||||
this.iframe.style.border = "none";
|
{
|
||||||
|
type: "sandbox-load",
|
||||||
|
sandboxId,
|
||||||
|
code: completeHtml,
|
||||||
|
attachments,
|
||||||
|
},
|
||||||
|
"*",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("message", readyHandler);
|
||||||
|
|
||||||
// Set content via srcdoc BEFORE appending to DOM (no CSP restrictions in web-ui)
|
// Create iframe AFTER all listeners are set up
|
||||||
this.iframe.srcdoc = completeHtml;
|
this.iframe?.remove();
|
||||||
|
this.iframe = document.createElement("iframe");
|
||||||
|
this.iframe.sandbox.add("allow-scripts");
|
||||||
|
this.iframe.sandbox.add("allow-modals");
|
||||||
|
this.iframe.style.width = "100%";
|
||||||
|
this.iframe.style.height = "100%";
|
||||||
|
this.iframe.style.border = "none";
|
||||||
|
|
||||||
this.appendChild(this.iframe);
|
this.iframe.src = this.sandboxUrlProvider();
|
||||||
|
|
||||||
|
this.appendChild(this.iframe);
|
||||||
|
} else {
|
||||||
|
// Web mode: use srcdoc
|
||||||
|
this.iframe?.remove();
|
||||||
|
this.iframe = document.createElement("iframe");
|
||||||
|
this.iframe.sandbox.add("allow-scripts");
|
||||||
|
this.iframe.sandbox.add("allow-modals");
|
||||||
|
this.iframe.style.width = "100%";
|
||||||
|
this.iframe.style.height = "100%";
|
||||||
|
this.iframe.style.border = "none";
|
||||||
|
|
||||||
|
// Set content via srcdoc BEFORE appending to DOM
|
||||||
|
this.iframe.srcdoc = completeHtml;
|
||||||
|
|
||||||
|
this.appendChild(this.iframe);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
101
packages/web-ui/src/components/StreamingMessageContainer.ts
Normal file
101
packages/web-ui/src/components/StreamingMessageContainer.ts
Normal 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);
|
||||||
|
}
|
||||||
273
packages/web-ui/src/dialogs/ApiKeysDialog.ts
Normal file
273
packages/web-ui/src/dialogs/ApiKeysDialog.ts
Normal 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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
635
packages/web-ui/src/dialogs/AttachmentOverlay.ts
Normal file
635
packages/web-ui/src/dialogs/AttachmentOverlay.ts
Normal 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);
|
||||||
|
}
|
||||||
324
packages/web-ui/src/dialogs/ModelSelector.ts
Normal file
324
packages/web-ui/src/dialogs/ModelSelector.ts
Normal 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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
55
packages/web-ui/src/index.ts
Normal file
55
packages/web-ui/src/index.ts
Normal 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";
|
||||||
96
packages/web-ui/src/state/KeyStore.ts
Normal file
96
packages/web-ui/src/state/KeyStore.ts
Normal 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 };
|
||||||
311
packages/web-ui/src/state/agent-session.ts
Normal file
311
packages/web-ui/src/state/agent-session.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
32
packages/web-ui/src/state/transports/DirectTransport.ts
Normal file
32
packages/web-ui/src/state/transports/DirectTransport.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
359
packages/web-ui/src/state/transports/ProxyTransport.ts
Normal file
359
packages/web-ui/src/state/transports/ProxyTransport.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
3
packages/web-ui/src/state/transports/index.ts
Normal file
3
packages/web-ui/src/state/transports/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export * from "./DirectTransport.js";
|
||||||
|
export * from "./ProxyTransport.js";
|
||||||
|
export * from "./types.js";
|
||||||
15
packages/web-ui/src/state/transports/proxy-types.ts
Normal file
15
packages/web-ui/src/state/transports/proxy-types.ts
Normal 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 };
|
||||||
16
packages/web-ui/src/state/transports/types.ts
Normal file
16
packages/web-ui/src/state/transports/types.ts
Normal 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
|
||||||
|
}
|
||||||
11
packages/web-ui/src/state/types.ts
Normal file
11
packages/web-ui/src/state/types.ts
Normal 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;
|
||||||
|
}
|
||||||
15
packages/web-ui/src/tools/artifacts/ArtifactElement.ts
Normal file
15
packages/web-ui/src/tools/artifacts/ArtifactElement.ts
Normal 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;
|
||||||
|
}
|
||||||
221
packages/web-ui/src/tools/artifacts/HtmlArtifact.ts
Normal file
221
packages/web-ui/src/tools/artifacts/HtmlArtifact.ts
Normal 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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
81
packages/web-ui/src/tools/artifacts/MarkdownArtifact.ts
Normal file
81
packages/web-ui/src/tools/artifacts/MarkdownArtifact.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
77
packages/web-ui/src/tools/artifacts/SvgArtifact.ts
Normal file
77
packages/web-ui/src/tools/artifacts/SvgArtifact.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
148
packages/web-ui/src/tools/artifacts/TextArtifact.ts
Normal file
148
packages/web-ui/src/tools/artifacts/TextArtifact.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
888
packages/web-ui/src/tools/artifacts/artifacts.ts
Normal file
888
packages/web-ui/src/tools/artifacts/artifacts.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
6
packages/web-ui/src/tools/artifacts/index.ts
Normal file
6
packages/web-ui/src/tools/artifacts/index.ts
Normal 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";
|
||||||
18
packages/web-ui/src/tools/renderer-registry.ts
Normal file
18
packages/web-ui/src/tools/renderer-registry.ts
Normal 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);
|
||||||
|
}
|
||||||
45
packages/web-ui/src/tools/renderers/BashRenderer.ts
Normal file
45
packages/web-ui/src/tools/renderers/BashRenderer.ts
Normal 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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
49
packages/web-ui/src/tools/renderers/CalculateRenderer.ts
Normal file
49
packages/web-ui/src/tools/renderers/CalculateRenderer.ts
Normal 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>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
36
packages/web-ui/src/tools/renderers/DefaultRenderer.ts
Normal file
36
packages/web-ui/src/tools/renderers/DefaultRenderer.ts
Normal 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>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
7
packages/web-ui/src/tools/types.ts
Normal file
7
packages/web-ui/src/tools/types.ts
Normal 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;
|
||||||
|
}
|
||||||
472
packages/web-ui/src/utils/attachment-utils.ts
Normal file
472
packages/web-ui/src/utils/attachment-utils.ts
Normal 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)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
packages/web-ui/src/utils/auth-token.ts
Normal file
22
packages/web-ui/src/utils/auth-token.ts
Normal 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`);
|
||||||
|
}
|
||||||
42
packages/web-ui/src/utils/format.ts
Normal file
42
packages/web-ui/src/utils/format.ts
Normal 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";
|
||||||
|
}
|
||||||
311
packages/web-ui/src/utils/i18n.ts
Normal file
311
packages/web-ui/src/utils/i18n.ts
Normal 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";
|
||||||
2247
packages/web-ui/src/utils/test-sessions.ts
Normal file
2247
packages/web-ui/src/utils/test-sessions.ts
Normal file
File diff suppressed because one or more lines are too long
20
packages/web-ui/tsconfig.build.json
Normal file
20
packages/web-ui/tsconfig.build.json
Normal 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/**/*"]
|
||||||
|
}
|
||||||
7
packages/web-ui/tsconfig.json
Normal file
7
packages/web-ui/tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue