mirror of
https://github.com/harivansh-afk/clanker-agent.git
synced 2026-04-15 03:00:44 +00:00
chore: remove unused pi web-ui package
Delete the dormant companion-os web-ui workspace and clean up the workspace, build, lint, and lockfile references that still pointed at it. Fixes #253 Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
parent
c0bba5c38f
commit
357ec23c6b
92 changed files with 8 additions and 17866 deletions
|
|
@ -42,7 +42,7 @@ fi
|
|||
RUN_BROWSER_SMOKE=0
|
||||
for file in $STAGED_FILES; do
|
||||
case "$file" in
|
||||
packages/ai/*|packages/web-ui/*|package.json|package-lock.json)
|
||||
packages/ai/*|package.json|package-lock.json)
|
||||
RUN_BROWSER_SMOKE=1
|
||||
break
|
||||
;;
|
||||
|
|
|
|||
|
|
@ -27,12 +27,9 @@
|
|||
"includes": [
|
||||
"packages/*/src/**/*.ts",
|
||||
"packages/*/test/**/*.ts",
|
||||
"packages/web-ui/src/**/*.ts",
|
||||
"packages/web-ui/example/**/*.ts",
|
||||
"!**/node_modules/**/*",
|
||||
"!**/test-sessions.ts",
|
||||
"!**/models.generated.ts",
|
||||
"!packages/web-ui/src/app.css",
|
||||
"!!**/node_modules"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
53
package-lock.json
generated
53
package-lock.json
generated
|
|
@ -8,8 +8,7 @@
|
|||
"name": "pi",
|
||||
"version": "0.0.3",
|
||||
"workspaces": [
|
||||
"packages/*",
|
||||
"packages/web-ui/example"
|
||||
"packages/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"@mariozechner/jiti": "^2.6.5",
|
||||
|
|
@ -1761,10 +1760,6 @@
|
|||
"resolved": "packages/tui",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@mariozechner/pi-web-ui": {
|
||||
"resolved": "packages/web-ui",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@mistralai/mistralai": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-1.14.1.tgz",
|
||||
|
|
@ -7062,10 +7057,6 @@
|
|||
"resolved": "packages/pi-teams",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/pi-web-ui-example": {
|
||||
"resolved": "packages/web-ui/example",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
|
|
@ -9343,48 +9334,6 @@
|
|||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"packages/web-ui": {
|
||||
"name": "@mariozechner/pi-web-ui",
|
||||
"version": "0.56.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lmstudio/sdk": "^1.5.0",
|
||||
"@mariozechner/pi-ai": "^0.56.2",
|
||||
"@mariozechner/pi-tui": "^0.56.2",
|
||||
"docx-preview": "^0.3.7",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide": "^0.544.0",
|
||||
"ollama": "^0.6.0",
|
||||
"pdfjs-dist": "5.4.394",
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mariozechner/mini-lit": "^0.2.0",
|
||||
"@tailwindcss/cli": "^4.0.0-beta.14",
|
||||
"concurrently": "^9.2.1",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@mariozechner/mini-lit": "^0.2.0",
|
||||
"lit": "^3.3.1"
|
||||
}
|
||||
},
|
||||
"packages/web-ui/example": {
|
||||
"name": "pi-web-ui-example",
|
||||
"version": "1.44.2",
|
||||
"dependencies": {
|
||||
"@mariozechner/mini-lit": "^0.2.0",
|
||||
"@mariozechner/pi-ai": "file:../../ai",
|
||||
"@mariozechner/pi-web-ui": "file:../",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"lit": "^3.3.1",
|
||||
"lucide": "^0.544.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^7.1.6"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
11
package.json
11
package.json
|
|
@ -11,15 +11,14 @@
|
|||
"url": "git+https://github.com/getcompanion-ai/co-mono.git"
|
||||
},
|
||||
"workspaces": [
|
||||
"packages/*",
|
||||
"packages/web-ui/example"
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"clean": "npm run clean --workspaces",
|
||||
"build": "cd packages/tui && npm run build && cd ../ai && npm run build && cd ../agent && npm run build && cd ../coding-agent && npm run build && cd ../web-ui && npm run build",
|
||||
"dev": "concurrently --names \"ai,agent,coding-agent,web-ui,tui\" --prefix-colors \"cyan,yellow,red,green,magenta\" \"cd packages/ai && npm run dev\" \"cd packages/agent && npm run dev\" \"cd packages/coding-agent && npm run dev\" \"cd packages/web-ui && npm run dev\" \"cd packages/tui && npm run dev\"",
|
||||
"dev:tsc": "concurrently --names \"ai,web-ui\" --prefix-colors \"cyan,green\" \"cd packages/ai && npm run dev:tsc\" \"cd packages/web-ui && npm run dev:tsc\"",
|
||||
"check": "biome check --write --error-on-warnings . && tsgo --noEmit && npm run check:browser-smoke && cd packages/web-ui && npm run check",
|
||||
"build": "cd packages/tui && npm run build && cd ../ai && npm run build && cd ../agent && npm run build && cd ../coding-agent && npm run build",
|
||||
"dev": "concurrently --names \"ai,agent,coding-agent,tui\" --prefix-colors \"cyan,yellow,red,magenta\" \"cd packages/ai && npm run dev\" \"cd packages/agent && npm run dev\" \"cd packages/coding-agent && npm run dev\" \"cd packages/tui && npm run dev\"",
|
||||
"dev:tsc": "cd packages/ai && npm run dev:tsc",
|
||||
"check": "biome check --write --error-on-warnings . && tsgo --noEmit && npm run check:browser-smoke",
|
||||
"check:browser-smoke": "sh -c 'esbuild scripts/browser-smoke-entry.ts --bundle --platform=browser --format=esm --log-limit=0 --outfile=/tmp/pi-browser-smoke.js > /tmp/pi-browser-smoke-errors.log 2>&1 || { echo \"Browser smoke check failed. See /tmp/pi-browser-smoke-errors.log\"; exit 1; }'",
|
||||
"test": "npm run test --workspaces --if-present",
|
||||
"version:patch": "npm version patch -ws --no-git-tag-version && node scripts/sync-versions.js && shx rm -rf node_modules packages/*/node_modules package-lock.json && npm install",
|
||||
|
|
|
|||
|
|
@ -1,287 +0,0 @@
|
|||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.56.2] - 2026-03-05
|
||||
|
||||
## [0.56.1] - 2026-03-05
|
||||
|
||||
## [0.56.0] - 2026-03-04
|
||||
|
||||
## [0.55.4] - 2026-03-02
|
||||
|
||||
## [0.55.3] - 2026-02-27
|
||||
|
||||
## [0.55.2] - 2026-02-27
|
||||
|
||||
## [0.55.1] - 2026-02-26
|
||||
|
||||
## [0.55.0] - 2026-02-24
|
||||
|
||||
## [0.54.2] - 2026-02-23
|
||||
|
||||
## [0.54.1] - 2026-02-22
|
||||
|
||||
## [0.54.0] - 2026-02-19
|
||||
|
||||
## [0.53.1] - 2026-02-19
|
||||
|
||||
## [0.53.0] - 2026-02-17
|
||||
|
||||
## [0.52.12] - 2026-02-13
|
||||
|
||||
## [0.52.11] - 2026-02-13
|
||||
|
||||
## [0.52.10] - 2026-02-12
|
||||
|
||||
### Fixed
|
||||
|
||||
- Made model selector search case-insensitive by normalizing query tokens, fixing auto-capitalized mobile input filtering ([#1443](https://github.com/badlogic/pi-mono/issues/1443))
|
||||
|
||||
## [0.52.9] - 2026-02-08
|
||||
|
||||
## [0.52.8] - 2026-02-07
|
||||
|
||||
## [0.52.7] - 2026-02-06
|
||||
|
||||
## [0.52.6] - 2026-02-05
|
||||
|
||||
## [0.52.5] - 2026-02-05
|
||||
|
||||
## [0.52.4] - 2026-02-05
|
||||
|
||||
## [0.52.3] - 2026-02-05
|
||||
|
||||
## [0.52.2] - 2026-02-05
|
||||
|
||||
## [0.52.1] - 2026-02-05
|
||||
|
||||
## [0.52.0] - 2026-02-05
|
||||
|
||||
## [0.51.6] - 2026-02-04
|
||||
|
||||
## [0.51.5] - 2026-02-04
|
||||
|
||||
## [0.51.4] - 2026-02-03
|
||||
|
||||
## [0.51.3] - 2026-02-03
|
||||
|
||||
## [0.51.2] - 2026-02-03
|
||||
|
||||
## [0.51.1] - 2026-02-02
|
||||
|
||||
## [0.51.0] - 2026-02-01
|
||||
|
||||
## [0.50.9] - 2026-02-01
|
||||
|
||||
## [0.50.8] - 2026-02-01
|
||||
|
||||
## [0.50.7] - 2026-01-31
|
||||
|
||||
## [0.50.6] - 2026-01-30
|
||||
|
||||
## [0.50.5] - 2026-01-30
|
||||
|
||||
## [0.50.3] - 2026-01-29
|
||||
|
||||
## [0.50.2] - 2026-01-29
|
||||
|
||||
### Added
|
||||
|
||||
- Exported `CustomProviderCard`, `ProviderKeyInput`, `AbortedMessage`, and `ToolMessageDebugView` components for custom UIs ([#1015](https://github.com/badlogic/pi-mono/issues/1015))
|
||||
|
||||
## [0.50.1] - 2026-01-26
|
||||
|
||||
## [0.50.0] - 2026-01-26
|
||||
|
||||
## [0.49.3] - 2026-01-22
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated tsgo to 7.0.0-dev.20260120.1 for decorator support ([#873](https://github.com/badlogic/pi-mono/issues/873))
|
||||
|
||||
## [0.49.2] - 2026-01-19
|
||||
|
||||
## [0.49.1] - 2026-01-18
|
||||
|
||||
## [0.49.0] - 2026-01-17
|
||||
|
||||
## [0.48.0] - 2026-01-16
|
||||
|
||||
## [0.47.0] - 2026-01-16
|
||||
|
||||
## [0.46.0] - 2026-01-15
|
||||
|
||||
## [0.45.7] - 2026-01-13
|
||||
|
||||
## [0.45.6] - 2026-01-13
|
||||
|
||||
## [0.45.5] - 2026-01-13
|
||||
|
||||
## [0.45.4] - 2026-01-13
|
||||
|
||||
## [0.45.3] - 2026-01-13
|
||||
|
||||
## [0.45.2] - 2026-01-13
|
||||
|
||||
## [0.45.1] - 2026-01-13
|
||||
|
||||
## [0.45.0] - 2026-01-13
|
||||
|
||||
## [0.44.0] - 2026-01-12
|
||||
|
||||
## [0.43.0] - 2026-01-11
|
||||
|
||||
## [0.42.5] - 2026-01-11
|
||||
|
||||
## [0.42.4] - 2026-01-10
|
||||
|
||||
## [0.42.3] - 2026-01-10
|
||||
|
||||
## [0.42.2] - 2026-01-10
|
||||
|
||||
## [0.42.1] - 2026-01-09
|
||||
|
||||
## [0.42.0] - 2026-01-09
|
||||
|
||||
## [0.41.0] - 2026-01-09
|
||||
|
||||
## [0.40.1] - 2026-01-09
|
||||
|
||||
## [0.40.0] - 2026-01-08
|
||||
|
||||
## [0.39.1] - 2026-01-08
|
||||
|
||||
## [0.39.0] - 2026-01-08
|
||||
|
||||
## [0.38.0] - 2026-01-08
|
||||
|
||||
## [0.37.8] - 2026-01-07
|
||||
|
||||
## [0.37.7] - 2026-01-07
|
||||
|
||||
## [0.37.6] - 2026-01-06
|
||||
|
||||
## [0.37.5] - 2026-01-06
|
||||
|
||||
## [0.37.4] - 2026-01-06
|
||||
|
||||
## [0.37.3] - 2026-01-06
|
||||
|
||||
## [0.37.2] - 2026-01-05
|
||||
|
||||
## [0.37.1] - 2026-01-05
|
||||
|
||||
## [0.37.0] - 2026-01-05
|
||||
|
||||
## [0.36.0] - 2026-01-05
|
||||
|
||||
## [0.35.0] - 2026-01-05
|
||||
|
||||
## [0.34.2] - 2026-01-04
|
||||
|
||||
## [0.34.1] - 2026-01-04
|
||||
|
||||
## [0.34.0] - 2026-01-04
|
||||
|
||||
## [0.33.0] - 2026-01-04
|
||||
|
||||
## [0.32.3] - 2026-01-03
|
||||
|
||||
## [0.32.2] - 2026-01-03
|
||||
|
||||
## [0.32.1] - 2026-01-03
|
||||
|
||||
## [0.32.0] - 2026-01-03
|
||||
|
||||
## [0.31.1] - 2026-01-02
|
||||
|
||||
## [0.31.0] - 2026-01-02
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- **Agent class moved to `@mariozechner/pi-agent-core`**: The `Agent` class, `AgentState`, and related types are no longer exported from this package. Import them from `@mariozechner/pi-agent-core` instead.
|
||||
|
||||
- **Transport abstraction removed**: `ProviderTransport`, `AppTransport`, `AgentTransport` interface, and related types have been removed. The `Agent` class now uses `streamFn` for custom streaming.
|
||||
|
||||
- **`AppMessage` renamed to `AgentMessage`**: Now imported from `@mariozechner/pi-agent-core`. Custom message types use declaration merging on `CustomAgentMessages` interface.
|
||||
|
||||
- **`UserMessageWithAttachments` is now a custom message type**: Has `role: "user-with-attachments"` instead of `role: "user"`. Use `isUserMessageWithAttachments()` type guard.
|
||||
|
||||
- **`CustomMessages` interface removed**: Use declaration merging on `CustomAgentMessages` from `@mariozechner/pi-agent-core` instead.
|
||||
|
||||
- **`agent.appendMessage()` removed**: Use `agent.queueMessage()` instead.
|
||||
|
||||
- **Agent event types changed**: `AgentInterface` now handles new event types from `@mariozechner/pi-agent-core`: `message_start`, `message_end`, `message_update`, `turn_start`, `turn_end`, `agent_start`, `agent_end`.
|
||||
|
||||
### Added
|
||||
|
||||
- **`defaultConvertToLlm`**: Default message transformer that handles `UserMessageWithAttachments` and `ArtifactMessage`. Apps can extend this for custom message types.
|
||||
|
||||
- **`convertAttachments`**: Utility to convert `Attachment[]` to LLM content blocks (images and extracted document text).
|
||||
|
||||
- **`isUserMessageWithAttachments` / `isArtifactMessage`**: Type guard functions for custom message types.
|
||||
|
||||
- **`createStreamFn`**: Creates a stream function with CORS proxy support. Reads proxy settings on each call for dynamic configuration.
|
||||
|
||||
- **Default `streamFn` and `getApiKey`**: `AgentInterface` now sets sensible defaults if not provided:
|
||||
- `streamFn`: Uses `createStreamFn` with proxy settings from storage
|
||||
- `getApiKey`: Reads from `providerKeys` storage
|
||||
|
||||
- **Proxy utilities exported**: `applyProxyIfNeeded`, `shouldUseProxyForProvider`, `isCorsError`, `createStreamFn`
|
||||
|
||||
### Removed
|
||||
|
||||
- `Agent` class (moved to `@mariozechner/pi-agent-core`)
|
||||
- `ProviderTransport` class
|
||||
- `AppTransport` class
|
||||
- `AgentTransport` interface
|
||||
- `AgentRunConfig` type
|
||||
- `ProxyAssistantMessageEvent` type
|
||||
- `test-sessions.ts` example file
|
||||
|
||||
### Migration Guide
|
||||
|
||||
**Before (0.30.x):**
|
||||
|
||||
```typescript
|
||||
import { Agent, ProviderTransport, type AppMessage } from '@mariozechner/pi-web-ui';
|
||||
|
||||
const agent = new Agent({
|
||||
transport: new ProviderTransport(),
|
||||
messageTransformer: (messages: AppMessage[]) => messages.filter(...)
|
||||
});
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```typescript
|
||||
import { Agent, type AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import { defaultConvertToLlm } from "@mariozechner/pi-web-ui";
|
||||
|
||||
const agent = new Agent({
|
||||
convertToLlm: (messages: AgentMessage[]) => {
|
||||
// Extend defaultConvertToLlm for custom types
|
||||
return defaultConvertToLlm(messages);
|
||||
},
|
||||
});
|
||||
// AgentInterface will set streamFn and getApiKey defaults automatically
|
||||
```
|
||||
|
||||
**Custom message types:**
|
||||
|
||||
```typescript
|
||||
// Before: declaration merging on CustomMessages
|
||||
declare module "@mariozechner/pi-web-ui" {
|
||||
interface CustomMessages {
|
||||
"my-message": MyMessage;
|
||||
}
|
||||
}
|
||||
|
||||
// After: declaration merging on CustomAgentMessages
|
||||
declare module "@mariozechner/pi-agent-core" {
|
||||
interface CustomAgentMessages {
|
||||
"my-message": MyMessage;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
@ -1,650 +0,0 @@
|
|||
# @mariozechner/pi-web-ui
|
||||
|
||||
Reusable web UI components for building AI chat interfaces powered by [@mariozechner/pi-ai](../ai) and [@mariozechner/pi-agent-core](../agent).
|
||||
|
||||
Built with [mini-lit](https://github.com/badlogic/mini-lit) web components and Tailwind CSS v4.
|
||||
|
||||
## Features
|
||||
|
||||
- **Chat UI**: Complete interface with message history, streaming, and tool execution
|
||||
- **Tools**: JavaScript REPL, document extraction, and artifacts (HTML, SVG, Markdown, etc.)
|
||||
- **Attachments**: PDF, DOCX, XLSX, PPTX, images with preview and text extraction
|
||||
- **Artifacts**: Interactive HTML, SVG, Markdown with sandboxed execution
|
||||
- **Storage**: IndexedDB-backed storage for sessions, API keys, and settings
|
||||
- **CORS Proxy**: Automatic proxy handling for browser environments
|
||||
- **Custom Providers**: Support for Ollama, LM Studio, vLLM, and OpenAI-compatible APIs
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @mariozechner/pi-web-ui @mariozechner/pi-agent-core @mariozechner/pi-ai
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
See the [example](./example) directory for a complete working application.
|
||||
|
||||
```typescript
|
||||
import { Agent } from "@mariozechner/pi-agent-core";
|
||||
import { getModel } from "@mariozechner/pi-ai";
|
||||
import {
|
||||
ChatPanel,
|
||||
AppStorage,
|
||||
IndexedDBStorageBackend,
|
||||
ProviderKeysStore,
|
||||
SessionsStore,
|
||||
SettingsStore,
|
||||
setAppStorage,
|
||||
defaultConvertToLlm,
|
||||
ApiKeyPromptDialog,
|
||||
} from "@mariozechner/pi-web-ui";
|
||||
import "@mariozechner/pi-web-ui/app.css";
|
||||
|
||||
// Set up storage
|
||||
const settings = new SettingsStore();
|
||||
const providerKeys = new ProviderKeysStore();
|
||||
const sessions = new SessionsStore();
|
||||
|
||||
const backend = new IndexedDBStorageBackend({
|
||||
dbName: "my-app",
|
||||
version: 1,
|
||||
stores: [
|
||||
settings.getConfig(),
|
||||
providerKeys.getConfig(),
|
||||
sessions.getConfig(),
|
||||
SessionsStore.getMetadataConfig(),
|
||||
],
|
||||
});
|
||||
|
||||
settings.setBackend(backend);
|
||||
providerKeys.setBackend(backend);
|
||||
sessions.setBackend(backend);
|
||||
|
||||
const storage = new AppStorage(
|
||||
settings,
|
||||
providerKeys,
|
||||
sessions,
|
||||
undefined,
|
||||
backend,
|
||||
);
|
||||
setAppStorage(storage);
|
||||
|
||||
// Create agent
|
||||
const agent = new Agent({
|
||||
initialState: {
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
model: getModel("anthropic", "claude-sonnet-4-5-20250929"),
|
||||
thinkingLevel: "off",
|
||||
messages: [],
|
||||
tools: [],
|
||||
},
|
||||
convertToLlm: defaultConvertToLlm,
|
||||
});
|
||||
|
||||
// Create chat panel
|
||||
const chatPanel = new ChatPanel();
|
||||
await chatPanel.setAgent(agent, {
|
||||
onApiKeyRequired: (provider) => ApiKeyPromptDialog.prompt(provider),
|
||||
});
|
||||
|
||||
document.body.appendChild(chatPanel);
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ ChatPanel │
|
||||
│ ┌─────────────────────┐ ┌─────────────────────┐ │
|
||||
│ │ AgentInterface │ │ ArtifactsPanel │ │
|
||||
│ │ (messages, input) │ │ (HTML, SVG, MD) │ │
|
||||
│ └─────────────────────┘ └─────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Agent (from pi-agent-core) │
|
||||
│ - State management (messages, model, tools) │
|
||||
│ - Event emission (agent_start, message_update, ...) │
|
||||
│ - Tool execution │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ AppStorage │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ Settings │ │ Provider │ │ Sessions │ │
|
||||
│ │ Store │ │Keys Store│ │ Store │ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ │
|
||||
│ │ │
|
||||
│ IndexedDBStorageBackend │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
### ChatPanel
|
||||
|
||||
High-level chat interface with built-in artifacts panel.
|
||||
|
||||
```typescript
|
||||
const chatPanel = new ChatPanel();
|
||||
await chatPanel.setAgent(agent, {
|
||||
// Prompt for API key when needed
|
||||
onApiKeyRequired: async (provider) => ApiKeyPromptDialog.prompt(provider),
|
||||
|
||||
// Hook before sending messages
|
||||
onBeforeSend: async () => {
|
||||
/* save draft, etc. */
|
||||
},
|
||||
|
||||
// Handle cost display click
|
||||
onCostClick: () => {
|
||||
/* show cost breakdown */
|
||||
},
|
||||
|
||||
// Custom sandbox URL for browser extensions
|
||||
sandboxUrlProvider: () => chrome.runtime.getURL("sandbox.html"),
|
||||
|
||||
// Add custom tools
|
||||
toolsFactory: (
|
||||
agent,
|
||||
agentInterface,
|
||||
artifactsPanel,
|
||||
runtimeProvidersFactory,
|
||||
) => {
|
||||
const replTool = createJavaScriptReplTool();
|
||||
replTool.runtimeProvidersFactory = runtimeProvidersFactory;
|
||||
return [replTool];
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### AgentInterface
|
||||
|
||||
Lower-level chat interface for custom layouts.
|
||||
|
||||
```typescript
|
||||
const chat = document.createElement("agent-interface") as AgentInterface;
|
||||
chat.session = agent;
|
||||
chat.enableAttachments = true;
|
||||
chat.enableModelSelector = true;
|
||||
chat.enableThinkingSelector = true;
|
||||
chat.onApiKeyRequired = async (provider) => {
|
||||
/* ... */
|
||||
};
|
||||
chat.onBeforeSend = async () => {
|
||||
/* ... */
|
||||
};
|
||||
```
|
||||
|
||||
Properties:
|
||||
|
||||
- `session`: Agent instance
|
||||
- `enableAttachments`: Show attachment button (default: true)
|
||||
- `enableModelSelector`: Show model selector (default: true)
|
||||
- `enableThinkingSelector`: Show thinking level selector (default: true)
|
||||
- `showThemeToggle`: Show theme toggle (default: false)
|
||||
|
||||
### Agent (from pi-agent-core)
|
||||
|
||||
```typescript
|
||||
import { Agent } from '@mariozechner/pi-agent-core';
|
||||
|
||||
const agent = new Agent({
|
||||
initialState: {
|
||||
model: getModel('anthropic', 'claude-sonnet-4-5-20250929'),
|
||||
systemPrompt: 'You are helpful.',
|
||||
thinkingLevel: 'off',
|
||||
messages: [],
|
||||
tools: [],
|
||||
},
|
||||
convertToLlm: defaultConvertToLlm,
|
||||
});
|
||||
|
||||
// Events
|
||||
agent.subscribe((event) => {
|
||||
switch (event.type) {
|
||||
case 'agent_start': // Agent loop started
|
||||
case 'agent_end': // Agent loop finished
|
||||
case 'turn_start': // LLM call started
|
||||
case 'turn_end': // LLM call finished
|
||||
case 'message_start':
|
||||
case 'message_update': // Streaming update
|
||||
case 'message_end':
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Send message
|
||||
await agent.prompt('Hello!');
|
||||
await agent.prompt({ role: 'user-with-attachments', content: 'Check this', attachments, timestamp: Date.now() });
|
||||
|
||||
// Control
|
||||
agent.abort();
|
||||
agent.setModel(newModel);
|
||||
agent.setThinkingLevel('medium');
|
||||
agent.setTools([...]);
|
||||
agent.queueMessage(customMessage);
|
||||
```
|
||||
|
||||
## Message Types
|
||||
|
||||
### UserMessageWithAttachments
|
||||
|
||||
User message with file attachments:
|
||||
|
||||
```typescript
|
||||
const message: UserMessageWithAttachments = {
|
||||
role: "user-with-attachments",
|
||||
content: "Analyze this document",
|
||||
attachments: [pdfAttachment],
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// Type guard
|
||||
if (isUserMessageWithAttachments(msg)) {
|
||||
console.log(msg.attachments);
|
||||
}
|
||||
```
|
||||
|
||||
### ArtifactMessage
|
||||
|
||||
For session persistence of artifacts:
|
||||
|
||||
```typescript
|
||||
const artifact: ArtifactMessage = {
|
||||
role: "artifact",
|
||||
action: "create", // or 'update', 'delete'
|
||||
filename: "chart.html",
|
||||
content: "<div>...</div>",
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Type guard
|
||||
if (isArtifactMessage(msg)) {
|
||||
console.log(msg.filename);
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Message Types
|
||||
|
||||
Extend via declaration merging:
|
||||
|
||||
```typescript
|
||||
interface SystemNotification {
|
||||
role: "system-notification";
|
||||
message: string;
|
||||
level: "info" | "warning" | "error";
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
declare module "@mariozechner/pi-agent-core" {
|
||||
interface CustomAgentMessages {
|
||||
"system-notification": SystemNotification;
|
||||
}
|
||||
}
|
||||
|
||||
// Register renderer
|
||||
registerMessageRenderer("system-notification", {
|
||||
render: (msg) => html`<div class="alert">${msg.message}</div>`,
|
||||
});
|
||||
|
||||
// Extend convertToLlm
|
||||
function myConvertToLlm(messages: AgentMessage[]): Message[] {
|
||||
const processed = messages.map((m) => {
|
||||
if (m.role === "system-notification") {
|
||||
return {
|
||||
role: "user",
|
||||
content: `<system>${m.message}</system>`,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
return m;
|
||||
});
|
||||
return defaultConvertToLlm(processed);
|
||||
}
|
||||
```
|
||||
|
||||
## Message Transformer
|
||||
|
||||
`convertToLlm` transforms app messages to LLM-compatible format:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
defaultConvertToLlm,
|
||||
convertAttachments,
|
||||
} from "@mariozechner/pi-web-ui";
|
||||
|
||||
// defaultConvertToLlm handles:
|
||||
// - UserMessageWithAttachments → user message with image/text content blocks
|
||||
// - ArtifactMessage → filtered out (UI-only)
|
||||
// - Standard messages (user, assistant, toolResult) → passed through
|
||||
```
|
||||
|
||||
## Tools
|
||||
|
||||
### JavaScript REPL
|
||||
|
||||
Execute JavaScript in a sandboxed browser environment:
|
||||
|
||||
```typescript
|
||||
import { createJavaScriptReplTool } from "@mariozechner/pi-web-ui";
|
||||
|
||||
const replTool = createJavaScriptReplTool();
|
||||
|
||||
// Configure runtime providers for artifact/attachment access
|
||||
replTool.runtimeProvidersFactory = () => [
|
||||
new AttachmentsRuntimeProvider(attachments),
|
||||
new ArtifactsRuntimeProvider(artifactsPanel, agent, true), // read-write
|
||||
];
|
||||
|
||||
agent.setTools([replTool]);
|
||||
```
|
||||
|
||||
### Extract Document
|
||||
|
||||
Extract text from documents at URLs:
|
||||
|
||||
```typescript
|
||||
import { createExtractDocumentTool } from "@mariozechner/pi-web-ui";
|
||||
|
||||
const extractTool = createExtractDocumentTool();
|
||||
extractTool.corsProxyUrl = "https://corsproxy.io/?";
|
||||
|
||||
agent.setTools([extractTool]);
|
||||
```
|
||||
|
||||
### Artifacts Tool
|
||||
|
||||
Built into ArtifactsPanel, supports: HTML, SVG, Markdown, text, JSON, images, PDF, DOCX, XLSX.
|
||||
|
||||
```typescript
|
||||
const artifactsPanel = new ArtifactsPanel();
|
||||
artifactsPanel.agent = agent;
|
||||
|
||||
// The tool is available as artifactsPanel.tool
|
||||
agent.setTools([artifactsPanel.tool]);
|
||||
```
|
||||
|
||||
### Custom Tool Renderers
|
||||
|
||||
```typescript
|
||||
import {
|
||||
registerToolRenderer,
|
||||
type ToolRenderer,
|
||||
} from "@mariozechner/pi-web-ui";
|
||||
|
||||
const myRenderer: ToolRenderer = {
|
||||
render(params, result, isStreaming) {
|
||||
return {
|
||||
content: html`<div>...</div>`,
|
||||
isCustom: false, // true = no card wrapper
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
registerToolRenderer("my_tool", myRenderer);
|
||||
```
|
||||
|
||||
## Storage
|
||||
|
||||
### Setup
|
||||
|
||||
```typescript
|
||||
import {
|
||||
AppStorage,
|
||||
IndexedDBStorageBackend,
|
||||
SettingsStore,
|
||||
ProviderKeysStore,
|
||||
SessionsStore,
|
||||
CustomProvidersStore,
|
||||
setAppStorage,
|
||||
getAppStorage,
|
||||
} from "@mariozechner/pi-web-ui";
|
||||
|
||||
// Create stores
|
||||
const settings = new SettingsStore();
|
||||
const providerKeys = new ProviderKeysStore();
|
||||
const sessions = new SessionsStore();
|
||||
const customProviders = new CustomProvidersStore();
|
||||
|
||||
// Create backend with all store configs
|
||||
const backend = new IndexedDBStorageBackend({
|
||||
dbName: "my-app",
|
||||
version: 1,
|
||||
stores: [
|
||||
settings.getConfig(),
|
||||
providerKeys.getConfig(),
|
||||
sessions.getConfig(),
|
||||
SessionsStore.getMetadataConfig(),
|
||||
customProviders.getConfig(),
|
||||
],
|
||||
});
|
||||
|
||||
// Wire stores to backend
|
||||
settings.setBackend(backend);
|
||||
providerKeys.setBackend(backend);
|
||||
sessions.setBackend(backend);
|
||||
customProviders.setBackend(backend);
|
||||
|
||||
// Create and set global storage
|
||||
const storage = new AppStorage(
|
||||
settings,
|
||||
providerKeys,
|
||||
sessions,
|
||||
customProviders,
|
||||
backend,
|
||||
);
|
||||
setAppStorage(storage);
|
||||
```
|
||||
|
||||
### SettingsStore
|
||||
|
||||
Key-value settings:
|
||||
|
||||
```typescript
|
||||
await storage.settings.set("proxy.enabled", true);
|
||||
await storage.settings.set("proxy.url", "https://proxy.example.com");
|
||||
const enabled = await storage.settings.get<boolean>("proxy.enabled");
|
||||
```
|
||||
|
||||
### ProviderKeysStore
|
||||
|
||||
API keys by provider:
|
||||
|
||||
```typescript
|
||||
await storage.providerKeys.set("anthropic", "sk-ant-...");
|
||||
const key = await storage.providerKeys.get("anthropic");
|
||||
const providers = await storage.providerKeys.list();
|
||||
```
|
||||
|
||||
### SessionsStore
|
||||
|
||||
Chat sessions with metadata:
|
||||
|
||||
```typescript
|
||||
// Save session
|
||||
await storage.sessions.save(sessionData, metadata);
|
||||
|
||||
// Load session
|
||||
const data = await storage.sessions.get(sessionId);
|
||||
const metadata = await storage.sessions.getMetadata(sessionId);
|
||||
|
||||
// List sessions (sorted by lastModified)
|
||||
const allMetadata = await storage.sessions.getAllMetadata();
|
||||
|
||||
// Update title
|
||||
await storage.sessions.updateTitle(sessionId, "New Title");
|
||||
|
||||
// Delete
|
||||
await storage.sessions.delete(sessionId);
|
||||
```
|
||||
|
||||
### CustomProvidersStore
|
||||
|
||||
Custom LLM providers:
|
||||
|
||||
```typescript
|
||||
const provider: CustomProvider = {
|
||||
id: crypto.randomUUID(),
|
||||
name: "My Ollama",
|
||||
type: "ollama",
|
||||
baseUrl: "http://localhost:11434",
|
||||
};
|
||||
|
||||
await storage.customProviders.set(provider);
|
||||
const all = await storage.customProviders.getAll();
|
||||
```
|
||||
|
||||
## Attachments
|
||||
|
||||
Load and process files:
|
||||
|
||||
```typescript
|
||||
import { loadAttachment, type Attachment } from "@mariozechner/pi-web-ui";
|
||||
|
||||
// From File input
|
||||
const file = inputElement.files[0];
|
||||
const attachment = await loadAttachment(file);
|
||||
|
||||
// From URL
|
||||
const attachment = await loadAttachment("https://example.com/doc.pdf");
|
||||
|
||||
// From ArrayBuffer
|
||||
const attachment = await loadAttachment(arrayBuffer, "document.pdf");
|
||||
|
||||
// Attachment structure
|
||||
interface Attachment {
|
||||
id: string;
|
||||
type: "image" | "document";
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
content: string; // base64 encoded
|
||||
extractedText?: string; // For documents
|
||||
preview?: string; // base64 preview image
|
||||
}
|
||||
```
|
||||
|
||||
Supported formats: PDF, DOCX, XLSX, PPTX, images, text files.
|
||||
|
||||
## CORS Proxy
|
||||
|
||||
For browser environments with CORS restrictions:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
createStreamFn,
|
||||
shouldUseProxyForProvider,
|
||||
isCorsError,
|
||||
} from "@mariozechner/pi-web-ui";
|
||||
|
||||
// AgentInterface auto-configures proxy from settings
|
||||
// For manual setup:
|
||||
agent.streamFn = createStreamFn(async () => {
|
||||
const enabled = await storage.settings.get<boolean>("proxy.enabled");
|
||||
return enabled ? await storage.settings.get<string>("proxy.url") : undefined;
|
||||
});
|
||||
|
||||
// Providers requiring proxy:
|
||||
// - zai: always
|
||||
// - anthropic: only OAuth tokens (sk-ant-oat-*)
|
||||
```
|
||||
|
||||
## Dialogs
|
||||
|
||||
### SettingsDialog
|
||||
|
||||
```typescript
|
||||
import {
|
||||
SettingsDialog,
|
||||
ProvidersModelsTab,
|
||||
ProxyTab,
|
||||
ApiKeysTab,
|
||||
} from "@mariozechner/pi-web-ui";
|
||||
|
||||
SettingsDialog.open([
|
||||
new ProvidersModelsTab(), // Custom providers + model list
|
||||
new ProxyTab(), // CORS proxy settings
|
||||
new ApiKeysTab(), // API keys per provider
|
||||
]);
|
||||
```
|
||||
|
||||
### SessionListDialog
|
||||
|
||||
```typescript
|
||||
import { SessionListDialog } from "@mariozechner/pi-web-ui";
|
||||
|
||||
SessionListDialog.open(
|
||||
async (sessionId) => {
|
||||
/* load session */
|
||||
},
|
||||
(deletedId) => {
|
||||
/* handle deletion */
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
### ApiKeyPromptDialog
|
||||
|
||||
```typescript
|
||||
import { ApiKeyPromptDialog } from "@mariozechner/pi-web-ui";
|
||||
|
||||
const success = await ApiKeyPromptDialog.prompt("anthropic");
|
||||
```
|
||||
|
||||
### ModelSelector
|
||||
|
||||
```typescript
|
||||
import { ModelSelector } from "@mariozechner/pi-web-ui";
|
||||
|
||||
ModelSelector.open(currentModel, (selectedModel) => {
|
||||
agent.setModel(selectedModel);
|
||||
});
|
||||
```
|
||||
|
||||
## Styling
|
||||
|
||||
Import the pre-built CSS:
|
||||
|
||||
```typescript
|
||||
import "@mariozechner/pi-web-ui/app.css";
|
||||
```
|
||||
|
||||
Or use Tailwind with custom config:
|
||||
|
||||
```css
|
||||
@import "@mariozechner/mini-lit/themes/claude.css";
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
```
|
||||
|
||||
## Internationalization
|
||||
|
||||
```typescript
|
||||
import { i18n, setLanguage, translations } from "@mariozechner/pi-web-ui";
|
||||
|
||||
// Add translations
|
||||
translations.de = {
|
||||
"Loading...": "Laden...",
|
||||
"No sessions yet": "Noch keine Sitzungen",
|
||||
};
|
||||
|
||||
setLanguage("de");
|
||||
console.log(i18n("Loading...")); // "Laden..."
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
- [example/](./example) - Complete web app with sessions, artifacts, custom messages
|
||||
- [sitegeist](https://sitegeist.ai) - Browser extension using pi-web-ui
|
||||
|
||||
## Known Issues
|
||||
|
||||
- **PersistentStorageDialog**: Currently broken
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
3
packages/web-ui/example/.gitignore
vendored
3
packages/web-ui/example/.gitignore
vendored
|
|
@ -1,3 +0,0 @@
|
|||
node_modules
|
||||
dist
|
||||
.DS_Store
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
# Pi Web UI - Example
|
||||
|
||||
This is a minimal example showing how to use `@mariozechner/pi-web-ui` in a web application.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open [http://localhost:5173](http://localhost:5173) in your browser.
|
||||
|
||||
## What's Included
|
||||
|
||||
This example demonstrates:
|
||||
|
||||
- **ChatPanel** - The main chat interface component
|
||||
- **System Prompt** - Custom configuration for the AI assistant
|
||||
- **Tools** - JavaScript REPL and artifacts tool
|
||||
|
||||
## Configuration
|
||||
|
||||
### API Keys
|
||||
|
||||
The example uses **Direct Mode** by default, which means it calls AI provider APIs directly from the browser.
|
||||
|
||||
To use the chat:
|
||||
|
||||
1. Click the settings icon (⚙️) in the chat interface
|
||||
2. Click "Manage API Keys"
|
||||
3. Add your API key for your preferred provider:
|
||||
- **Anthropic**: Get a key from [console.anthropic.com](https://console.anthropic.com/)
|
||||
- **OpenAI**: Get a key from [platform.openai.com](https://platform.openai.com/)
|
||||
- **Google**: Get a key from [makersuite.google.com](https://makersuite.google.com/)
|
||||
|
||||
API keys are stored in your browser's localStorage and never sent to any server except the AI provider's API.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
example/
|
||||
├── src/
|
||||
│ ├── main.ts # Main application entry point
|
||||
│ └── app.css # Tailwind CSS configuration
|
||||
├── index.html # HTML entry point
|
||||
├── package.json # Dependencies
|
||||
├── vite.config.ts # Vite configuration
|
||||
└── tsconfig.json # TypeScript configuration
|
||||
```
|
||||
|
||||
## Learn More
|
||||
|
||||
- [Pi Web UI Documentation](../README.md)
|
||||
- [Pi AI Documentation](../../ai/README.md)
|
||||
- [Mini Lit Documentation](https://github.com/badlogic/mini-lit)
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
<!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 class="bg-background">
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
{
|
||||
"name": "pi-web-ui-example",
|
||||
"version": "1.44.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "tsgo --noEmit",
|
||||
"clean": "shx rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mariozechner/mini-lit": "^0.2.0",
|
||||
"@mariozechner/pi-ai": "file:../../ai",
|
||||
"@mariozechner/pi-web-ui": "file:../",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"lit": "^3.3.1",
|
||||
"lucide": "^0.544.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^7.1.6"
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
@import "../../dist/app.css";
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
import { Alert } from "@mariozechner/mini-lit/dist/Alert.js";
|
||||
import type { Message } from "@mariozechner/pi-ai";
|
||||
import type { AgentMessage, MessageRenderer } from "@mariozechner/pi-web-ui";
|
||||
import {
|
||||
defaultConvertToLlm,
|
||||
registerMessageRenderer,
|
||||
} from "@mariozechner/pi-web-ui";
|
||||
import { html } from "lit";
|
||||
|
||||
// ============================================================================
|
||||
// 1. EXTEND AppMessage TYPE VIA DECLARATION MERGING
|
||||
// ============================================================================
|
||||
|
||||
// Define custom message types
|
||||
export interface SystemNotificationMessage {
|
||||
role: "system-notification";
|
||||
message: string;
|
||||
variant: "default" | "destructive";
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// Extend CustomAgentMessages interface via declaration merging
|
||||
// This must target pi-agent-core where CustomAgentMessages is defined
|
||||
declare module "@mariozechner/pi-agent-core" {
|
||||
interface CustomAgentMessages {
|
||||
"system-notification": SystemNotificationMessage;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 2. CREATE CUSTOM RENDERER (TYPED TO SystemNotificationMessage)
|
||||
// ============================================================================
|
||||
|
||||
const systemNotificationRenderer: MessageRenderer<SystemNotificationMessage> = {
|
||||
render: (notification) => {
|
||||
// notification is fully typed as SystemNotificationMessage!
|
||||
return html`
|
||||
<div class="px-4">
|
||||
${Alert({
|
||||
variant: notification.variant,
|
||||
children: html`
|
||||
<div class="flex flex-col gap-1">
|
||||
<div>${notification.message}</div>
|
||||
<div class="text-xs opacity-70">
|
||||
${new Date(notification.timestamp).toLocaleTimeString()}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 3. REGISTER RENDERER
|
||||
// ============================================================================
|
||||
|
||||
export function registerCustomMessageRenderers() {
|
||||
registerMessageRenderer("system-notification", systemNotificationRenderer);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 4. HELPER TO CREATE CUSTOM MESSAGES
|
||||
// ============================================================================
|
||||
|
||||
export function createSystemNotification(
|
||||
message: string,
|
||||
variant: "default" | "destructive" = "default",
|
||||
): SystemNotificationMessage {
|
||||
return {
|
||||
role: "system-notification",
|
||||
message,
|
||||
variant,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 5. CUSTOM MESSAGE TRANSFORMER
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Custom message transformer that extends defaultConvertToLlm.
|
||||
* Handles system-notification messages by converting them to user messages.
|
||||
*/
|
||||
export function customConvertToLlm(messages: AgentMessage[]): Message[] {
|
||||
// First, handle our custom system-notification type
|
||||
const processed = messages.map((m): AgentMessage => {
|
||||
if (m.role === "system-notification") {
|
||||
const notification = m as SystemNotificationMessage;
|
||||
// Convert to user message with <system> tags
|
||||
return {
|
||||
role: "user",
|
||||
content: `<system>${notification.message}</system>`,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
return m;
|
||||
});
|
||||
|
||||
// Then use defaultConvertToLlm for standard handling
|
||||
return defaultConvertToLlm(processed);
|
||||
}
|
||||
|
|
@ -1,473 +0,0 @@
|
|||
import "@mariozechner/mini-lit/dist/ThemeToggle.js";
|
||||
import { Agent, type AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import { getModel } from "@mariozechner/pi-ai";
|
||||
import {
|
||||
type AgentState,
|
||||
ApiKeyPromptDialog,
|
||||
AppStorage,
|
||||
ChatPanel,
|
||||
CustomProvidersStore,
|
||||
createJavaScriptReplTool,
|
||||
IndexedDBStorageBackend,
|
||||
// PersistentStorageDialog, // TODO: Fix - currently broken
|
||||
ProviderKeysStore,
|
||||
ProvidersModelsTab,
|
||||
ProxyTab,
|
||||
SessionListDialog,
|
||||
SessionsStore,
|
||||
SettingsDialog,
|
||||
SettingsStore,
|
||||
setAppStorage,
|
||||
} from "@mariozechner/pi-web-ui";
|
||||
import { html, render } from "lit";
|
||||
import { Bell, History, Plus, Settings } from "lucide";
|
||||
import "./app.css";
|
||||
import { icon } from "@mariozechner/mini-lit";
|
||||
import { Button } from "@mariozechner/mini-lit/dist/Button.js";
|
||||
import { Input } from "@mariozechner/mini-lit/dist/Input.js";
|
||||
import {
|
||||
createSystemNotification,
|
||||
customConvertToLlm,
|
||||
registerCustomMessageRenderers,
|
||||
} from "./custom-messages.js";
|
||||
|
||||
// Register custom message renderers
|
||||
registerCustomMessageRenderers();
|
||||
|
||||
// Create stores
|
||||
const settings = new SettingsStore();
|
||||
const providerKeys = new ProviderKeysStore();
|
||||
const sessions = new SessionsStore();
|
||||
const customProviders = new CustomProvidersStore();
|
||||
|
||||
// Gather configs
|
||||
const configs = [
|
||||
settings.getConfig(),
|
||||
SessionsStore.getMetadataConfig(),
|
||||
providerKeys.getConfig(),
|
||||
customProviders.getConfig(),
|
||||
sessions.getConfig(),
|
||||
];
|
||||
|
||||
// Create backend
|
||||
const backend = new IndexedDBStorageBackend({
|
||||
dbName: "pi-web-ui-example",
|
||||
version: 2, // Incremented for custom-providers store
|
||||
stores: configs,
|
||||
});
|
||||
|
||||
// Wire backend to stores
|
||||
settings.setBackend(backend);
|
||||
providerKeys.setBackend(backend);
|
||||
customProviders.setBackend(backend);
|
||||
sessions.setBackend(backend);
|
||||
|
||||
// Create and set app storage
|
||||
const storage = new AppStorage(
|
||||
settings,
|
||||
providerKeys,
|
||||
sessions,
|
||||
customProviders,
|
||||
backend,
|
||||
);
|
||||
setAppStorage(storage);
|
||||
|
||||
let currentSessionId: string | undefined;
|
||||
let currentTitle = "";
|
||||
let isEditingTitle = false;
|
||||
let agent: Agent;
|
||||
let chatPanel: ChatPanel;
|
||||
let agentUnsubscribe: (() => void) | undefined;
|
||||
|
||||
const generateTitle = (messages: AgentMessage[]): string => {
|
||||
const firstUserMsg = messages.find(
|
||||
(m) => m.role === "user" || m.role === "user-with-attachments",
|
||||
);
|
||||
if (
|
||||
!firstUserMsg ||
|
||||
(firstUserMsg.role !== "user" &&
|
||||
firstUserMsg.role !== "user-with-attachments")
|
||||
)
|
||||
return "";
|
||||
|
||||
let text = "";
|
||||
const content = firstUserMsg.content;
|
||||
|
||||
if (typeof content === "string") {
|
||||
text = content;
|
||||
} else {
|
||||
const textBlocks = content.filter((c: any) => c.type === "text");
|
||||
text = textBlocks.map((c: any) => c.text || "").join(" ");
|
||||
}
|
||||
|
||||
text = text.trim();
|
||||
if (!text) return "";
|
||||
|
||||
const sentenceEnd = text.search(/[.!?]/);
|
||||
if (sentenceEnd > 0 && sentenceEnd <= 50) {
|
||||
return text.substring(0, sentenceEnd + 1);
|
||||
}
|
||||
return text.length <= 50 ? text : `${text.substring(0, 47)}...`;
|
||||
};
|
||||
|
||||
const shouldSaveSession = (messages: AgentMessage[]): boolean => {
|
||||
const hasUserMsg = messages.some(
|
||||
(m: any) => m.role === "user" || m.role === "user-with-attachments",
|
||||
);
|
||||
const hasAssistantMsg = messages.some((m: any) => m.role === "assistant");
|
||||
return hasUserMsg && hasAssistantMsg;
|
||||
};
|
||||
|
||||
const saveSession = async () => {
|
||||
if (!storage.sessions || !currentSessionId || !agent || !currentTitle) return;
|
||||
|
||||
const state = agent.state;
|
||||
if (!shouldSaveSession(state.messages)) return;
|
||||
|
||||
try {
|
||||
// Create session data
|
||||
const sessionData = {
|
||||
id: currentSessionId,
|
||||
title: currentTitle,
|
||||
model: state.model!,
|
||||
thinkingLevel: state.thinkingLevel,
|
||||
messages: state.messages,
|
||||
createdAt: new Date().toISOString(),
|
||||
lastModified: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Create session metadata
|
||||
const metadata = {
|
||||
id: currentSessionId,
|
||||
title: currentTitle,
|
||||
createdAt: sessionData.createdAt,
|
||||
lastModified: sessionData.lastModified,
|
||||
messageCount: state.messages.length,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
total: 0,
|
||||
},
|
||||
},
|
||||
modelId: state.model?.id || null,
|
||||
thinkingLevel: state.thinkingLevel,
|
||||
preview: generateTitle(state.messages),
|
||||
};
|
||||
|
||||
await storage.sessions.save(sessionData, metadata);
|
||||
} catch (err) {
|
||||
console.error("Failed to save session:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const updateUrl = (sessionId: string) => {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("session", sessionId);
|
||||
window.history.replaceState({}, "", url);
|
||||
};
|
||||
|
||||
const createAgent = async (initialState?: Partial<AgentState>) => {
|
||||
if (agentUnsubscribe) {
|
||||
agentUnsubscribe();
|
||||
}
|
||||
|
||||
agent = new Agent({
|
||||
initialState: initialState || {
|
||||
systemPrompt: `You are a helpful AI assistant with access to various tools.
|
||||
|
||||
Available tools:
|
||||
- JavaScript REPL: Execute JavaScript code in a sandboxed browser environment (can do calculations, get time, process data, create visualizations, etc.)
|
||||
- Artifacts: Create interactive HTML, SVG, Markdown, and text artifacts
|
||||
|
||||
Feel free to use these tools when needed to provide accurate and helpful responses.`,
|
||||
model: getModel("anthropic", "claude-sonnet-4-5-20250929"),
|
||||
thinkingLevel: "off",
|
||||
messages: [],
|
||||
tools: [],
|
||||
},
|
||||
// Custom transformer: convert custom messages to LLM-compatible format
|
||||
convertToLlm: customConvertToLlm,
|
||||
});
|
||||
|
||||
agentUnsubscribe = agent.subscribe((event: any) => {
|
||||
if (event.type === "state-update") {
|
||||
const messages = event.state.messages;
|
||||
|
||||
// Generate title after first successful response
|
||||
if (!currentTitle && shouldSaveSession(messages)) {
|
||||
currentTitle = generateTitle(messages);
|
||||
}
|
||||
|
||||
// Create session ID on first successful save
|
||||
if (!currentSessionId && shouldSaveSession(messages)) {
|
||||
currentSessionId = crypto.randomUUID();
|
||||
updateUrl(currentSessionId);
|
||||
}
|
||||
|
||||
// Auto-save
|
||||
if (currentSessionId) {
|
||||
saveSession();
|
||||
}
|
||||
|
||||
renderApp();
|
||||
}
|
||||
});
|
||||
|
||||
await chatPanel.setAgent(agent, {
|
||||
onApiKeyRequired: async (provider: string) => {
|
||||
return await ApiKeyPromptDialog.prompt(provider);
|
||||
},
|
||||
toolsFactory: (
|
||||
_agent,
|
||||
_agentInterface,
|
||||
_artifactsPanel,
|
||||
runtimeProvidersFactory,
|
||||
) => {
|
||||
// Create javascript_repl tool with access to attachments + artifacts
|
||||
const replTool = createJavaScriptReplTool();
|
||||
replTool.runtimeProvidersFactory = runtimeProvidersFactory;
|
||||
return [replTool];
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const loadSession = async (sessionId: string): Promise<boolean> => {
|
||||
if (!storage.sessions) return false;
|
||||
|
||||
const sessionData = await storage.sessions.get(sessionId);
|
||||
if (!sessionData) {
|
||||
console.error("Session not found:", sessionId);
|
||||
return false;
|
||||
}
|
||||
|
||||
currentSessionId = sessionId;
|
||||
const metadata = await storage.sessions.getMetadata(sessionId);
|
||||
currentTitle = metadata?.title || "";
|
||||
|
||||
await createAgent({
|
||||
model: sessionData.model,
|
||||
thinkingLevel: sessionData.thinkingLevel,
|
||||
messages: sessionData.messages,
|
||||
tools: [],
|
||||
});
|
||||
|
||||
updateUrl(sessionId);
|
||||
renderApp();
|
||||
return true;
|
||||
};
|
||||
|
||||
const newSession = () => {
|
||||
const url = new URL(window.location.href);
|
||||
url.search = "";
|
||||
window.location.href = url.toString();
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// RENDER
|
||||
// ============================================================================
|
||||
const renderApp = () => {
|
||||
const app = document.getElementById("app");
|
||||
if (!app) return;
|
||||
|
||||
const appHtml = html`
|
||||
<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="flex items-center gap-2 px-4 py-">
|
||||
${Button({
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
children: icon(History, "sm"),
|
||||
onClick: () => {
|
||||
SessionListDialog.open(
|
||||
async (sessionId) => {
|
||||
await loadSession(sessionId);
|
||||
},
|
||||
(deletedSessionId) => {
|
||||
// Only reload if the current session was deleted
|
||||
if (deletedSessionId === currentSessionId) {
|
||||
newSession();
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
title: "Sessions",
|
||||
})}
|
||||
${Button({
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
children: icon(Plus, "sm"),
|
||||
onClick: newSession,
|
||||
title: "New Session",
|
||||
})}
|
||||
${currentTitle
|
||||
? isEditingTitle
|
||||
? html`<div class="flex items-center gap-2">
|
||||
${Input({
|
||||
type: "text",
|
||||
value: currentTitle,
|
||||
className: "text-sm w-64",
|
||||
onChange: async (e: Event) => {
|
||||
const newTitle = (
|
||||
e.target as HTMLInputElement
|
||||
).value.trim();
|
||||
if (
|
||||
newTitle &&
|
||||
newTitle !== currentTitle &&
|
||||
storage.sessions &&
|
||||
currentSessionId
|
||||
) {
|
||||
await storage.sessions.updateTitle(
|
||||
currentSessionId,
|
||||
newTitle,
|
||||
);
|
||||
currentTitle = newTitle;
|
||||
}
|
||||
isEditingTitle = false;
|
||||
renderApp();
|
||||
},
|
||||
onKeyDown: async (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
const newTitle = (
|
||||
e.target as HTMLInputElement
|
||||
).value.trim();
|
||||
if (
|
||||
newTitle &&
|
||||
newTitle !== currentTitle &&
|
||||
storage.sessions &&
|
||||
currentSessionId
|
||||
) {
|
||||
await storage.sessions.updateTitle(
|
||||
currentSessionId,
|
||||
newTitle,
|
||||
);
|
||||
currentTitle = newTitle;
|
||||
}
|
||||
isEditingTitle = false;
|
||||
renderApp();
|
||||
} else if (e.key === "Escape") {
|
||||
isEditingTitle = false;
|
||||
renderApp();
|
||||
}
|
||||
},
|
||||
})}
|
||||
</div>`
|
||||
: html`<button
|
||||
class="px-2 py-1 text-sm text-foreground hover:bg-secondary rounded transition-colors"
|
||||
@click=${() => {
|
||||
isEditingTitle = true;
|
||||
renderApp();
|
||||
requestAnimationFrame(() => {
|
||||
const input = app?.querySelector(
|
||||
'input[type="text"]',
|
||||
) as HTMLInputElement;
|
||||
if (input) {
|
||||
input.focus();
|
||||
input.select();
|
||||
}
|
||||
});
|
||||
}}
|
||||
title="Click to edit title"
|
||||
>
|
||||
${currentTitle}
|
||||
</button>`
|
||||
: html`<span class="text-base font-semibold text-foreground"
|
||||
>Pi Web UI Example</span
|
||||
>`}
|
||||
</div>
|
||||
<div class="flex items-center gap-1 px-2">
|
||||
${Button({
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
children: icon(Bell, "sm"),
|
||||
onClick: () => {
|
||||
// Demo: Inject custom message (will appear on next agent run)
|
||||
if (agent) {
|
||||
agent.steer(
|
||||
createSystemNotification(
|
||||
"This is a custom message! It appears in the UI but is never sent to the LLM.",
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
title: "Demo: Add Custom Notification",
|
||||
})}
|
||||
<theme-toggle></theme-toggle>
|
||||
${Button({
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
children: icon(Settings, "sm"),
|
||||
onClick: () =>
|
||||
SettingsDialog.open([new ProvidersModelsTab(), new ProxyTab()]),
|
||||
title: "Settings",
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat Panel -->
|
||||
${chatPanel}
|
||||
</div>
|
||||
`;
|
||||
|
||||
render(appHtml, app);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// INIT
|
||||
// ============================================================================
|
||||
async function initApp() {
|
||||
const app = document.getElementById("app");
|
||||
if (!app) throw new Error("App container not found");
|
||||
|
||||
// Show loading
|
||||
render(
|
||||
html`
|
||||
<div
|
||||
class="w-full h-screen flex items-center justify-center bg-background text-foreground"
|
||||
>
|
||||
<div class="text-muted-foreground">Loading...</div>
|
||||
</div>
|
||||
`,
|
||||
app,
|
||||
);
|
||||
|
||||
// TODO: Fix PersistentStorageDialog - currently broken
|
||||
// Request persistent storage
|
||||
// if (storage.sessions) {
|
||||
// await PersistentStorageDialog.request();
|
||||
// }
|
||||
|
||||
// Create ChatPanel
|
||||
chatPanel = new ChatPanel();
|
||||
|
||||
// Check for session in URL
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const sessionIdFromUrl = urlParams.get("session");
|
||||
|
||||
if (sessionIdFromUrl) {
|
||||
const loaded = await loadSession(sessionIdFromUrl);
|
||||
if (!loaded) {
|
||||
// Session doesn't exist, redirect to new session
|
||||
newSession();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
await createAgent();
|
||||
}
|
||||
|
||||
renderApp();
|
||||
}
|
||||
|
||||
initApp();
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"moduleResolution": "bundler",
|
||||
"paths": {
|
||||
"*": ["./*"],
|
||||
"@mariozechner/pi-agent-core": ["../../agent/dist/index.d.ts"],
|
||||
"@mariozechner/pi-ai": ["../../ai/dist/index.d.ts"],
|
||||
"@mariozechner/pi-tui": ["../../tui/dist/index.d.ts"],
|
||||
"@mariozechner/pi-web-ui": ["../dist/index.d.ts"]
|
||||
},
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["../src"]
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss()],
|
||||
});
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
{
|
||||
"name": "@mariozechner/pi-web-ui",
|
||||
"version": "0.56.2",
|
||||
"description": "Reusable web UI components for AI chat interfaces powered by @mariozechner/pi-ai",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": "./dist/index.js",
|
||||
"./app.css": "./dist/app.css"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "shx rm -rf dist",
|
||||
"build": "tsgo -p tsconfig.build.json && tailwindcss -i ./src/app.css -o ./dist/app.css --minify",
|
||||
"dev": "concurrently --names \"build,example\" --prefix-colors \"cyan,green\" \"tsc -p tsconfig.build.json --watch --preserveWatchOutput\" \"tailwindcss -i ./src/app.css -o ./dist/app.css --watch\" \"npm run dev --prefix example\"",
|
||||
"dev:tsc": "concurrently --names \"build\" --prefix-colors \"cyan\" \"tsc -p tsconfig.build.json --watch --preserveWatchOutput\" \"tailwindcss -i ./src/app.css -o ./dist/app.css --watch\"",
|
||||
"check": "biome check --write --error-on-warnings . && tsc --noEmit && cd example && biome check --write --error-on-warnings . && tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lmstudio/sdk": "^1.5.0",
|
||||
"@mariozechner/pi-ai": "^0.56.2",
|
||||
"@mariozechner/pi-tui": "^0.56.2",
|
||||
"docx-preview": "^0.3.7",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide": "^0.544.0",
|
||||
"ollama": "^0.6.0",
|
||||
"pdfjs-dist": "5.4.394",
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@mariozechner/mini-lit": "^0.2.0",
|
||||
"lit": "^3.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mariozechner/mini-lit": "^0.2.0",
|
||||
"@tailwindcss/cli": "^4.0.0-beta.14",
|
||||
"concurrently": "^9.2.1",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"keywords": [
|
||||
"ai",
|
||||
"chat",
|
||||
"ui",
|
||||
"components",
|
||||
"llm",
|
||||
"web-components",
|
||||
"mini-lit"
|
||||
],
|
||||
"author": "Mario Zechner",
|
||||
"license": "MIT"
|
||||
}
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* Count tokens in system prompts using Anthropic's token counter API
|
||||
*/
|
||||
|
||||
import * as prompts from "../src/prompts/prompts.js";
|
||||
|
||||
const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
|
||||
|
||||
if (!ANTHROPIC_API_KEY) {
|
||||
console.error("Error: ANTHROPIC_API_KEY environment variable not set");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
interface TokenCountResponse {
|
||||
input_tokens: number;
|
||||
}
|
||||
|
||||
async function countTokens(text: string): Promise<number> {
|
||||
const response = await fetch(
|
||||
"https://api.anthropic.com/v1/messages/count_tokens",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": ANTHROPIC_API_KEY,
|
||||
"anthropic-version": "2023-06-01",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "claude-3-5-sonnet-20241022",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: text,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`API error: ${response.status} ${error}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as TokenCountResponse;
|
||||
return data.input_tokens;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("Counting tokens in prompts...\n");
|
||||
|
||||
const promptsToCount: Array<{ name: string; content: string }> = [
|
||||
{
|
||||
name: "ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RW",
|
||||
content: prompts.ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RW,
|
||||
},
|
||||
{
|
||||
name: "ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RO",
|
||||
content: prompts.ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RO,
|
||||
},
|
||||
{
|
||||
name: "ATTACHMENTS_RUNTIME_DESCRIPTION",
|
||||
content: prompts.ATTACHMENTS_RUNTIME_DESCRIPTION,
|
||||
},
|
||||
{
|
||||
name: "JAVASCRIPT_REPL_TOOL_DESCRIPTION (without runtime providers)",
|
||||
content: prompts.JAVASCRIPT_REPL_TOOL_DESCRIPTION([]),
|
||||
},
|
||||
{
|
||||
name: "ARTIFACTS_TOOL_DESCRIPTION (without runtime providers)",
|
||||
content: prompts.ARTIFACTS_TOOL_DESCRIPTION([]),
|
||||
},
|
||||
];
|
||||
|
||||
let total = 0;
|
||||
|
||||
for (const prompt of promptsToCount) {
|
||||
try {
|
||||
const tokens = await countTokens(prompt.content);
|
||||
total += tokens;
|
||||
console.log(`${prompt.name}: ${tokens.toLocaleString()} tokens`);
|
||||
} catch (error) {
|
||||
console.error(`Error counting tokens for ${prompt.name}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nTotal: ${total.toLocaleString()} tokens`);
|
||||
}
|
||||
|
||||
main();
|
||||
|
|
@ -1,239 +0,0 @@
|
|||
import { Badge } from "@mariozechner/mini-lit/dist/Badge.js";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import "./components/AgentInterface.js";
|
||||
import type { Agent, AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import type { AgentInterface } from "./components/AgentInterface.js";
|
||||
import { ArtifactsRuntimeProvider } from "./components/sandbox/ArtifactsRuntimeProvider.js";
|
||||
import { AttachmentsRuntimeProvider } from "./components/sandbox/AttachmentsRuntimeProvider.js";
|
||||
import type { SandboxRuntimeProvider } from "./components/sandbox/SandboxRuntimeProvider.js";
|
||||
import {
|
||||
ArtifactsPanel,
|
||||
ArtifactsToolRenderer,
|
||||
} from "./tools/artifacts/index.js";
|
||||
import { registerToolRenderer } from "./tools/renderer-registry.js";
|
||||
import type { Attachment } from "./utils/attachment-utils.js";
|
||||
import { i18n } from "./utils/i18n.js";
|
||||
|
||||
const BREAKPOINT = 800; // px - switch between overlay and side-by-side
|
||||
|
||||
@customElement("pi-chat-panel")
|
||||
export class ChatPanel extends LitElement {
|
||||
@state() public agent?: Agent;
|
||||
@state() public agentInterface?: AgentInterface;
|
||||
@state() public artifactsPanel?: ArtifactsPanel;
|
||||
@state() private hasArtifacts = false;
|
||||
@state() private artifactCount = 0;
|
||||
@state() private showArtifactsPanel = false;
|
||||
@state() private windowWidth = 0;
|
||||
|
||||
private resizeHandler = () => {
|
||||
this.windowWidth = window.innerWidth;
|
||||
this.requestUpdate();
|
||||
};
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.windowWidth = window.innerWidth; // Set initial width after connection
|
||||
window.addEventListener("resize", this.resizeHandler);
|
||||
this.style.display = "flex";
|
||||
this.style.flexDirection = "column";
|
||||
this.style.height = "100%";
|
||||
this.style.minHeight = "0";
|
||||
// Update width after initial render
|
||||
requestAnimationFrame(() => {
|
||||
this.windowWidth = window.innerWidth;
|
||||
this.requestUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener("resize", this.resizeHandler);
|
||||
}
|
||||
|
||||
async setAgent(
|
||||
agent: Agent,
|
||||
config?: {
|
||||
onApiKeyRequired?: (provider: string) => Promise<boolean>;
|
||||
onBeforeSend?: () => void | Promise<void>;
|
||||
onCostClick?: () => void;
|
||||
sandboxUrlProvider?: () => string;
|
||||
toolsFactory?: (
|
||||
agent: Agent,
|
||||
agentInterface: AgentInterface,
|
||||
artifactsPanel: ArtifactsPanel,
|
||||
runtimeProvidersFactory: () => SandboxRuntimeProvider[],
|
||||
) => AgentTool<any>[];
|
||||
},
|
||||
) {
|
||||
this.agent = agent;
|
||||
|
||||
// Create AgentInterface
|
||||
this.agentInterface = document.createElement(
|
||||
"agent-interface",
|
||||
) as AgentInterface;
|
||||
this.agentInterface.session = agent;
|
||||
this.agentInterface.enableAttachments = true;
|
||||
this.agentInterface.enableModelSelector = true;
|
||||
this.agentInterface.enableThinkingSelector = true;
|
||||
this.agentInterface.showThemeToggle = false;
|
||||
this.agentInterface.onApiKeyRequired = config?.onApiKeyRequired;
|
||||
this.agentInterface.onBeforeSend = config?.onBeforeSend;
|
||||
this.agentInterface.onCostClick = config?.onCostClick;
|
||||
|
||||
// Set up artifacts panel
|
||||
this.artifactsPanel = new ArtifactsPanel();
|
||||
this.artifactsPanel.agent = agent; // Pass agent for HTML artifact runtime providers
|
||||
if (config?.sandboxUrlProvider) {
|
||||
this.artifactsPanel.sandboxUrlProvider = config.sandboxUrlProvider;
|
||||
}
|
||||
// Register the standalone tool renderer (not the panel itself)
|
||||
registerToolRenderer(
|
||||
"artifacts",
|
||||
new ArtifactsToolRenderer(this.artifactsPanel),
|
||||
);
|
||||
|
||||
// Runtime providers factory for REPL tools (read-write access)
|
||||
const runtimeProvidersFactory = () => {
|
||||
const attachments: Attachment[] = [];
|
||||
for (const message of this.agent!.state.messages) {
|
||||
if (message.role === "user-with-attachments") {
|
||||
message.attachments?.forEach((a) => {
|
||||
attachments.push(a);
|
||||
});
|
||||
}
|
||||
}
|
||||
const providers: SandboxRuntimeProvider[] = [];
|
||||
|
||||
// Add attachments provider if there are attachments
|
||||
if (attachments.length > 0) {
|
||||
providers.push(new AttachmentsRuntimeProvider(attachments));
|
||||
}
|
||||
|
||||
// Add artifacts provider with read-write access (for REPL)
|
||||
providers.push(
|
||||
new ArtifactsRuntimeProvider(this.artifactsPanel!, this.agent!, true),
|
||||
);
|
||||
|
||||
return providers;
|
||||
};
|
||||
|
||||
this.artifactsPanel.onArtifactsChange = () => {
|
||||
const count = this.artifactsPanel?.artifacts?.size ?? 0;
|
||||
const created = count > this.artifactCount;
|
||||
this.hasArtifacts = count > 0;
|
||||
this.artifactCount = count;
|
||||
if (this.hasArtifacts && created) {
|
||||
this.showArtifactsPanel = true;
|
||||
}
|
||||
this.requestUpdate();
|
||||
};
|
||||
|
||||
this.artifactsPanel.onClose = () => {
|
||||
this.showArtifactsPanel = false;
|
||||
this.requestUpdate();
|
||||
};
|
||||
|
||||
this.artifactsPanel.onOpen = () => {
|
||||
this.showArtifactsPanel = true;
|
||||
this.requestUpdate();
|
||||
};
|
||||
|
||||
// Set tools on the agent
|
||||
// Pass runtimeProvidersFactory so consumers can configure their own REPL tools
|
||||
const additionalTools =
|
||||
config?.toolsFactory?.(
|
||||
agent,
|
||||
this.agentInterface,
|
||||
this.artifactsPanel,
|
||||
runtimeProvidersFactory,
|
||||
) || [];
|
||||
const tools = [this.artifactsPanel.tool, ...additionalTools];
|
||||
this.agent.setTools(tools);
|
||||
|
||||
// Reconstruct artifacts from existing messages
|
||||
// Temporarily disable the onArtifactsChange callback to prevent auto-opening on load
|
||||
const originalCallback = this.artifactsPanel.onArtifactsChange;
|
||||
this.artifactsPanel.onArtifactsChange = undefined;
|
||||
await this.artifactsPanel.reconstructFromMessages(
|
||||
this.agent.state.messages,
|
||||
);
|
||||
this.artifactsPanel.onArtifactsChange = originalCallback;
|
||||
|
||||
this.hasArtifacts = this.artifactsPanel.artifacts.size > 0;
|
||||
this.artifactCount = this.artifactsPanel.artifacts.size;
|
||||
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.agent || !this.agentInterface) {
|
||||
return html`<div class="flex items-center justify-center h-full">
|
||||
<div class="text-muted-foreground">No agent set</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const isMobile = this.windowWidth < BREAKPOINT;
|
||||
|
||||
// Set panel props
|
||||
if (this.artifactsPanel) {
|
||||
this.artifactsPanel.collapsed = !this.showArtifactsPanel;
|
||||
this.artifactsPanel.overlay = isMobile;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="relative w-full h-full overflow-hidden flex">
|
||||
<div
|
||||
class="h-full"
|
||||
style="${!isMobile && this.showArtifactsPanel && this.hasArtifacts
|
||||
? "width: 50%;"
|
||||
: "width: 100%;"}"
|
||||
>
|
||||
${this.agentInterface}
|
||||
</div>
|
||||
|
||||
<!-- Floating pill when artifacts exist and panel is collapsed -->
|
||||
${this.hasArtifacts && !this.showArtifactsPanel
|
||||
? html`
|
||||
<button
|
||||
class="absolute z-30 top-4 left-1/2 -translate-x-1/2 pointer-events-auto"
|
||||
@click=${() => {
|
||||
this.showArtifactsPanel = true;
|
||||
this.requestUpdate();
|
||||
}}
|
||||
title=${i18n("Show artifacts")}
|
||||
>
|
||||
${Badge(html`
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span>${i18n("Artifacts")}</span>
|
||||
<span
|
||||
class="text-[10px] leading-none bg-primary-foreground/20 text-primary-foreground rounded px-1 font-mono tabular-nums"
|
||||
>${this.artifactCount}</span
|
||||
>
|
||||
</span>
|
||||
`)}
|
||||
</button>
|
||||
`
|
||||
: ""}
|
||||
|
||||
<div
|
||||
class="h-full ${isMobile
|
||||
? "absolute inset-0 pointer-events-none"
|
||||
: ""}"
|
||||
style="${!isMobile
|
||||
? !this.hasArtifacts || !this.showArtifactsPanel
|
||||
? "display: none;"
|
||||
: "width: 50%;"
|
||||
: ""}"
|
||||
>
|
||||
${this.artifactsPanel}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
/* Import Claude theme from mini-lit */
|
||||
@import "@mariozechner/mini-lit/styles/themes/default.css";
|
||||
|
||||
/* Tell Tailwind to scan mini-lit components */
|
||||
/* biome-ignore lint/suspicious/noUnknownAtRules: Tailwind 4 source directive */
|
||||
@source "../../../node_modules/@mariozechner/mini-lit/dist";
|
||||
|
||||
/* Import Tailwind */
|
||||
/* biome-ignore lint/correctness/noInvalidPositionAtImportRule: fuck you */
|
||||
@import "tailwindcss";
|
||||
|
||||
body {
|
||||
font-size: 16px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--color-border) rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background-color: var(--color-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
/* Fix cursor for dialog close buttons */
|
||||
.fixed.inset-0 button[aria-label*="Close"],
|
||||
.fixed.inset-0 button[type="button"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Shimmer animation for thinking text */
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-shimmer {
|
||||
animation: shimmer 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* User message with fancy pill styling */
|
||||
.user-message-container {
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
background: linear-gradient(135deg, rgba(217, 79, 0, 0.12), rgba(255, 107, 0, 0.12), rgba(212, 165, 0, 0.12));
|
||||
border: 1px solid rgba(255, 107, 0, 0.25);
|
||||
backdrop-filter: blur(10px);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
|
@ -1,428 +0,0 @@
|
|||
import {
|
||||
streamSimple,
|
||||
type ToolResultMessage,
|
||||
type Usage,
|
||||
} from "@mariozechner/pi-ai";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators.js";
|
||||
import { ModelSelector } from "../dialogs/ModelSelector.js";
|
||||
import type { MessageEditor } from "./MessageEditor.js";
|
||||
import "./MessageEditor.js";
|
||||
import "./MessageList.js";
|
||||
import "./Messages.js"; // Import for side effects to register the custom elements
|
||||
import { getAppStorage } from "../storage/app-storage.js";
|
||||
import "./StreamingMessageContainer.js";
|
||||
import type { Agent, AgentEvent } from "@mariozechner/pi-agent-core";
|
||||
import type { Attachment } from "../utils/attachment-utils.js";
|
||||
import { formatUsage } from "../utils/format.js";
|
||||
import { i18n } from "../utils/i18n.js";
|
||||
import { createStreamFn } from "../utils/proxy-utils.js";
|
||||
import type { UserMessageWithAttachments } from "./Messages.js";
|
||||
import type { StreamingMessageContainer } from "./StreamingMessageContainer.js";
|
||||
|
||||
@customElement("agent-interface")
|
||||
export class AgentInterface extends LitElement {
|
||||
// Optional external session: when provided, this component becomes a view over the session
|
||||
@property({ attribute: false }) session?: Agent;
|
||||
@property({ type: Boolean }) enableAttachments = true;
|
||||
@property({ type: Boolean }) enableModelSelector = true;
|
||||
@property({ type: Boolean }) enableThinkingSelector = true;
|
||||
@property({ type: Boolean }) showThemeToggle = false;
|
||||
// Optional custom API key prompt handler - if not provided, uses default dialog
|
||||
@property({ attribute: false }) onApiKeyRequired?: (
|
||||
provider: string,
|
||||
) => Promise<boolean>;
|
||||
// Optional callback called before sending a message
|
||||
@property({ attribute: false }) onBeforeSend?: () => void | Promise<void>;
|
||||
// Optional callback called before executing a tool call - return false to prevent execution
|
||||
@property({ attribute: false }) onBeforeToolCall?: (
|
||||
toolName: string,
|
||||
args: any,
|
||||
) => boolean | Promise<boolean>;
|
||||
// Optional callback called when cost display is clicked
|
||||
@property({ attribute: false }) onCostClick?: () => void;
|
||||
|
||||
// References
|
||||
@query("message-editor") private _messageEditor!: MessageEditor;
|
||||
@query("streaming-message-container")
|
||||
private _streamingContainer!: StreamingMessageContainer;
|
||||
|
||||
private _autoScroll = true;
|
||||
private _lastScrollTop = 0;
|
||||
private _lastClientHeight = 0;
|
||||
private _scrollContainer?: HTMLElement;
|
||||
private _resizeObserver?: ResizeObserver;
|
||||
private _unsubscribeSession?: () => void;
|
||||
|
||||
public setInput(text: string, attachments?: Attachment[]) {
|
||||
const update = () => {
|
||||
if (!this._messageEditor) requestAnimationFrame(update);
|
||||
else {
|
||||
this._messageEditor.value = text;
|
||||
this._messageEditor.attachments = attachments || [];
|
||||
}
|
||||
};
|
||||
update();
|
||||
}
|
||||
|
||||
public setAutoScroll(enabled: boolean) {
|
||||
this._autoScroll = enabled;
|
||||
}
|
||||
|
||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
||||
return this;
|
||||
}
|
||||
|
||||
override willUpdate(changedProperties: Map<string, any>) {
|
||||
super.willUpdate(changedProperties);
|
||||
|
||||
// Re-subscribe when session property changes
|
||||
if (changedProperties.has("session")) {
|
||||
this.setupSessionSubscription();
|
||||
}
|
||||
}
|
||||
|
||||
override async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
this.style.display = "flex";
|
||||
this.style.flexDirection = "column";
|
||||
this.style.height = "100%";
|
||||
this.style.minHeight = "0";
|
||||
|
||||
// Wait for first render to get scroll container
|
||||
await this.updateComplete;
|
||||
this._scrollContainer = this.querySelector(
|
||||
".overflow-y-auto",
|
||||
) as HTMLElement;
|
||||
|
||||
if (this._scrollContainer) {
|
||||
// Set up ResizeObserver to detect content changes
|
||||
this._resizeObserver = new ResizeObserver(() => {
|
||||
if (this._autoScroll && this._scrollContainer) {
|
||||
this._scrollContainer.scrollTop = this._scrollContainer.scrollHeight;
|
||||
}
|
||||
});
|
||||
|
||||
// Observe the content container inside the scroll container
|
||||
const contentContainer =
|
||||
this._scrollContainer.querySelector(".max-w-3xl");
|
||||
if (contentContainer) {
|
||||
this._resizeObserver.observe(contentContainer);
|
||||
}
|
||||
|
||||
// Set up scroll listener with better detection
|
||||
this._scrollContainer.addEventListener("scroll", this._handleScroll);
|
||||
}
|
||||
|
||||
// Subscribe to external session if provided
|
||||
this.setupSessionSubscription();
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
|
||||
// Clean up observers and listeners
|
||||
if (this._resizeObserver) {
|
||||
this._resizeObserver.disconnect();
|
||||
this._resizeObserver = undefined;
|
||||
}
|
||||
|
||||
if (this._scrollContainer) {
|
||||
this._scrollContainer.removeEventListener("scroll", this._handleScroll);
|
||||
}
|
||||
|
||||
if (this._unsubscribeSession) {
|
||||
this._unsubscribeSession();
|
||||
this._unsubscribeSession = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private setupSessionSubscription() {
|
||||
if (this._unsubscribeSession) {
|
||||
this._unsubscribeSession();
|
||||
this._unsubscribeSession = undefined;
|
||||
}
|
||||
if (!this.session) return;
|
||||
|
||||
// Set default streamFn with proxy support if not already set
|
||||
if (this.session.streamFn === streamSimple) {
|
||||
this.session.streamFn = createStreamFn(async () => {
|
||||
const enabled =
|
||||
await getAppStorage().settings.get<boolean>("proxy.enabled");
|
||||
return enabled
|
||||
? (await getAppStorage().settings.get<string>("proxy.url")) ||
|
||||
undefined
|
||||
: undefined;
|
||||
});
|
||||
}
|
||||
|
||||
// Set default getApiKey if not already set
|
||||
if (!this.session.getApiKey) {
|
||||
this.session.getApiKey = async (provider: string) => {
|
||||
const key = await getAppStorage().providerKeys.get(provider);
|
||||
return key ?? undefined;
|
||||
};
|
||||
}
|
||||
|
||||
this._unsubscribeSession = this.session.subscribe(
|
||||
async (ev: AgentEvent) => {
|
||||
switch (ev.type) {
|
||||
case "message_start":
|
||||
case "message_end":
|
||||
case "turn_start":
|
||||
case "turn_end":
|
||||
case "agent_start":
|
||||
this.requestUpdate();
|
||||
break;
|
||||
case "agent_end":
|
||||
// Clear streaming container when agent finishes
|
||||
if (this._streamingContainer) {
|
||||
this._streamingContainer.isStreaming = false;
|
||||
this._streamingContainer.setMessage(null, true);
|
||||
}
|
||||
this.requestUpdate();
|
||||
break;
|
||||
case "message_update":
|
||||
if (this._streamingContainer) {
|
||||
const isStreaming = this.session?.state.isStreaming || false;
|
||||
this._streamingContainer.isStreaming = isStreaming;
|
||||
this._streamingContainer.setMessage(ev.message, !isStreaming);
|
||||
}
|
||||
this.requestUpdate();
|
||||
break;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private _handleScroll = (_ev: any) => {
|
||||
if (!this._scrollContainer) return;
|
||||
|
||||
const currentScrollTop = this._scrollContainer.scrollTop;
|
||||
const scrollHeight = this._scrollContainer.scrollHeight;
|
||||
const clientHeight = this._scrollContainer.clientHeight;
|
||||
const distanceFromBottom = scrollHeight - currentScrollTop - clientHeight;
|
||||
|
||||
// Ignore relayout due to message editor getting pushed up by stats
|
||||
if (clientHeight < this._lastClientHeight) {
|
||||
this._lastClientHeight = clientHeight;
|
||||
return;
|
||||
}
|
||||
|
||||
// Only disable auto-scroll if user scrolled UP or is far from bottom
|
||||
if (
|
||||
currentScrollTop !== 0 &&
|
||||
currentScrollTop < this._lastScrollTop &&
|
||||
distanceFromBottom > 50
|
||||
) {
|
||||
this._autoScroll = false;
|
||||
} else if (distanceFromBottom < 10) {
|
||||
// Re-enable if very close to bottom
|
||||
this._autoScroll = true;
|
||||
}
|
||||
|
||||
this._lastScrollTop = currentScrollTop;
|
||||
this._lastClientHeight = clientHeight;
|
||||
};
|
||||
|
||||
public async sendMessage(input: string, attachments?: Attachment[]) {
|
||||
if (
|
||||
(!input.trim() && attachments?.length === 0) ||
|
||||
this.session?.state.isStreaming
|
||||
)
|
||||
return;
|
||||
const session = this.session;
|
||||
if (!session) throw new Error("No session set on AgentInterface");
|
||||
if (!session.state.model) throw new Error("No model set on AgentInterface");
|
||||
|
||||
// Check if API key exists for the provider (only needed in direct mode)
|
||||
const provider = session.state.model.provider;
|
||||
const apiKey = await getAppStorage().providerKeys.get(provider);
|
||||
|
||||
// If no API key, prompt for it
|
||||
if (!apiKey) {
|
||||
if (!this.onApiKeyRequired) {
|
||||
console.error(
|
||||
"No API key configured and no onApiKeyRequired handler set",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await this.onApiKeyRequired(provider);
|
||||
|
||||
// If still no API key, abort the send
|
||||
if (!success) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Call onBeforeSend hook before sending
|
||||
if (this.onBeforeSend) {
|
||||
await this.onBeforeSend();
|
||||
}
|
||||
|
||||
// Only clear editor after we know we can send
|
||||
this._messageEditor.value = "";
|
||||
this._messageEditor.attachments = [];
|
||||
this._autoScroll = true; // Enable auto-scroll when sending a message
|
||||
|
||||
// Compose message with attachments if any
|
||||
if (attachments && attachments.length > 0) {
|
||||
const message: UserMessageWithAttachments = {
|
||||
role: "user-with-attachments",
|
||||
content: input,
|
||||
attachments,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
await this.session?.prompt(message);
|
||||
} else {
|
||||
await this.session?.prompt(input);
|
||||
}
|
||||
}
|
||||
|
||||
private renderMessages() {
|
||||
if (!this.session)
|
||||
return html`<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}
|
||||
.onCostClick=${this.onCostClick}
|
||||
></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}
|
||||
.onCostClick=${this.onCostClick}
|
||||
></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,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
} satisfies Usage,
|
||||
);
|
||||
|
||||
const hasTotals =
|
||||
totals.input || totals.output || totals.cacheRead || totals.cacheWrite;
|
||||
const totalsText = hasTotals ? formatUsage(totals) : "";
|
||||
|
||||
return html`
|
||||
<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
|
||||
? this.onCostClick
|
||||
? html`<span
|
||||
class="cursor-pointer hover:text-foreground transition-colors"
|
||||
@click=${this.onCostClick}
|
||||
>${totalsText}</span
|
||||
>`
|
||||
: 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}
|
||||
.showThinkingSelector=${this.enableThinkingSelector}
|
||||
.onSend=${(input: string, attachments: Attachment[]) => {
|
||||
this.sendMessage(input, attachments);
|
||||
}}
|
||||
.onAbort=${() => session.abort()}
|
||||
.onModelSelect=${() => {
|
||||
ModelSelector.open(state.model, (model) =>
|
||||
session.setModel(model),
|
||||
);
|
||||
}}
|
||||
.onThinkingChange=${this.enableThinkingSelector
|
||||
? (level: "off" | "minimal" | "low" | "medium" | "high") => {
|
||||
session.setThinkingLevel(level);
|
||||
}
|
||||
: undefined}
|
||||
></message-editor>
|
||||
${this.renderStats()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Register custom element with guard
|
||||
if (!customElements.get("agent-interface")) {
|
||||
customElements.define("agent-interface", AgentInterface);
|
||||
}
|
||||
|
|
@ -1,107 +0,0 @@
|
|||
import { icon } from "@mariozechner/mini-lit/dist/icons.js";
|
||||
import { LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { html } from "lit/html.js";
|
||||
import { FileSpreadsheet, FileText, X } from "lucide";
|
||||
import { AttachmentOverlay } from "../dialogs/AttachmentOverlay.js";
|
||||
import type { Attachment } from "../utils/attachment-utils.js";
|
||||
import { i18n } from "../utils/i18n.js";
|
||||
|
||||
@customElement("attachment-tile")
|
||||
export class AttachmentTile extends LitElement {
|
||||
@property({ type: Object }) attachment!: Attachment;
|
||||
@property({ type: Boolean }) showDelete = false;
|
||||
@property() onDelete?: () => void;
|
||||
|
||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
||||
return this;
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.style.display = "block";
|
||||
this.classList.add("max-h-16");
|
||||
}
|
||||
|
||||
private handleClick = () => {
|
||||
AttachmentOverlay.open(this.attachment);
|
||||
};
|
||||
|
||||
override render() {
|
||||
const hasPreview = !!this.attachment.preview;
|
||||
const isImage = this.attachment.type === "image";
|
||||
const isPdf = this.attachment.mimeType === "application/pdf";
|
||||
const isExcel =
|
||||
this.attachment.mimeType?.includes("spreadsheetml") ||
|
||||
this.attachment.fileName.toLowerCase().endsWith(".xlsx") ||
|
||||
this.attachment.fileName.toLowerCase().endsWith(".xls");
|
||||
|
||||
// Choose the appropriate icon
|
||||
const getDocumentIcon = () => {
|
||||
if (isExcel) return icon(FileSpreadsheet, "md");
|
||||
return icon(FileText, "md");
|
||||
};
|
||||
|
||||
return html`
|
||||
<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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
import { icon } from "@mariozechner/mini-lit";
|
||||
import { LitElement } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { html } from "lit/html.js";
|
||||
import { Check, Copy } from "lucide";
|
||||
import { i18n } from "../utils/i18n.js";
|
||||
|
||||
export class ConsoleBlock extends LitElement {
|
||||
@property() content: string = "";
|
||||
@property() variant: "default" | "error" = "default";
|
||||
@state() private copied = false;
|
||||
|
||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
||||
return this;
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.style.display = "block";
|
||||
}
|
||||
|
||||
private async copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(this.content || "");
|
||||
this.copied = true;
|
||||
setTimeout(() => {
|
||||
this.copied = false;
|
||||
}, 1500);
|
||||
} catch (e) {
|
||||
console.error("Copy failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
override updated() {
|
||||
// Auto-scroll to bottom on content changes
|
||||
const container = this.querySelector(
|
||||
".console-scroll",
|
||||
) as HTMLElement | null;
|
||||
if (container) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
override render() {
|
||||
const isError = this.variant === "error";
|
||||
const textClass = isError ? "text-destructive" : "text-foreground";
|
||||
|
||||
return html`
|
||||
<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 ${textClass} font-mono whitespace-pre-wrap"
|
||||
>
|
||||
${this.content || ""}</pre
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Register custom element
|
||||
if (!customElements.get("console-block")) {
|
||||
customElements.define("console-block", ConsoleBlock);
|
||||
}
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
import { i18n } from "@mariozechner/mini-lit";
|
||||
import { Button } from "@mariozechner/mini-lit/dist/Button.js";
|
||||
import { html, LitElement, type TemplateResult } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import type { CustomProvider } from "../storage/stores/custom-providers-store.js";
|
||||
|
||||
@customElement("custom-provider-card")
|
||||
export class CustomProviderCard extends LitElement {
|
||||
@property({ type: Object }) provider!: CustomProvider;
|
||||
@property({ type: Boolean }) isAutoDiscovery = false;
|
||||
@property({ type: Object }) status?: {
|
||||
modelCount: number;
|
||||
status: "connected" | "disconnected" | "checking";
|
||||
};
|
||||
@property() onRefresh?: (provider: CustomProvider) => void;
|
||||
@property() onEdit?: (provider: CustomProvider) => void;
|
||||
@property() onDelete?: (provider: CustomProvider) => void;
|
||||
|
||||
protected createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
private renderStatus(): TemplateResult {
|
||||
if (!this.isAutoDiscovery) {
|
||||
return html`
|
||||
<div class="text-xs text-muted-foreground mt-1">
|
||||
${i18n("Models")}: ${this.provider.models?.length || 0}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (!this.status) return html``;
|
||||
|
||||
const statusIcon =
|
||||
this.status.status === "connected"
|
||||
? html`<span class="text-green-500">●</span>`
|
||||
: this.status.status === "checking"
|
||||
? html`<span class="text-yellow-500">●</span>`
|
||||
: html`<span class="text-red-500">●</span>`;
|
||||
|
||||
const statusText =
|
||||
this.status.status === "connected"
|
||||
? `${this.status.modelCount} ${i18n("models")}`
|
||||
: this.status.status === "checking"
|
||||
? i18n("Checking...")
|
||||
: i18n("Disconnected");
|
||||
|
||||
return html`
|
||||
<div class="text-xs text-muted-foreground mt-1 flex items-center gap-1">
|
||||
${statusIcon} ${statusText}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
return html`
|
||||
<div class="border border-border rounded-lg p-4 space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-sm text-foreground">
|
||||
${this.provider.name}
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground mt-1">
|
||||
<span class="capitalize">${this.provider.type}</span>
|
||||
${this.provider.baseUrl ? html` • ${this.provider.baseUrl}` : ""}
|
||||
</div>
|
||||
${this.renderStatus()}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
${this.isAutoDiscovery && this.onRefresh
|
||||
? Button({
|
||||
onClick: () => this.onRefresh?.(this.provider),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
children: i18n("Refresh"),
|
||||
})
|
||||
: ""}
|
||||
${this.onEdit
|
||||
? Button({
|
||||
onClick: () => this.onEdit?.(this.provider),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
children: i18n("Edit"),
|
||||
})
|
||||
: ""}
|
||||
${this.onDelete
|
||||
? Button({
|
||||
onClick: () => this.onDelete?.(this.provider),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
children: i18n("Delete"),
|
||||
})
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
import { icon } from "@mariozechner/mini-lit";
|
||||
import { html, LitElement, type TemplateResult } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { ChevronDown, ChevronRight } from "lucide";
|
||||
|
||||
/**
|
||||
* Reusable expandable section component for tool renderers.
|
||||
* Captures children in connectedCallback and re-renders them in the details area.
|
||||
*/
|
||||
@customElement("expandable-section")
|
||||
export class ExpandableSection extends LitElement {
|
||||
@property() summary!: string;
|
||||
@property({ type: Boolean }) defaultExpanded = false;
|
||||
@state() private expanded = false;
|
||||
private capturedChildren: Node[] = [];
|
||||
|
||||
protected createRenderRoot() {
|
||||
return this; // light DOM
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
// Capture children before first render
|
||||
this.capturedChildren = Array.from(this.childNodes);
|
||||
// Clear children (we'll re-insert them in render)
|
||||
this.innerHTML = "";
|
||||
this.expanded = this.defaultExpanded;
|
||||
}
|
||||
|
||||
override render(): TemplateResult {
|
||||
return html`
|
||||
<div>
|
||||
<button
|
||||
@click=${() => {
|
||||
this.expanded = !this.expanded;
|
||||
}}
|
||||
class="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors w-full text-left"
|
||||
>
|
||||
${icon(this.expanded ? ChevronDown : ChevronRight, "sm")}
|
||||
<span>${this.summary}</span>
|
||||
</button>
|
||||
${this.expanded
|
||||
? html`<div class="mt-2">${this.capturedChildren}</div>`
|
||||
: ""}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
import {
|
||||
type BaseComponentProps,
|
||||
fc,
|
||||
} from "@mariozechner/mini-lit/dist/mini.js";
|
||||
import { html } from "lit";
|
||||
import { type Ref, ref } from "lit/directives/ref.js";
|
||||
import { i18n } from "../utils/i18n.js";
|
||||
|
||||
export type InputType =
|
||||
| "text"
|
||||
| "email"
|
||||
| "password"
|
||||
| "number"
|
||||
| "url"
|
||||
| "tel"
|
||||
| "search";
|
||||
export type InputSize = "sm" | "md" | "lg";
|
||||
|
||||
export interface InputProps extends BaseComponentProps {
|
||||
type?: InputType;
|
||||
size?: InputSize;
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
label?: string;
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
name?: string;
|
||||
autocomplete?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
inputRef?: Ref<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>
|
||||
`;
|
||||
},
|
||||
);
|
||||
|
|
@ -1,444 +0,0 @@
|
|||
import { icon } from "@mariozechner/mini-lit";
|
||||
import { Button } from "@mariozechner/mini-lit/dist/Button.js";
|
||||
import {
|
||||
Select,
|
||||
type SelectOption,
|
||||
} from "@mariozechner/mini-lit/dist/Select.js";
|
||||
import type { Model } from "@mariozechner/pi-ai";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { createRef, ref } from "lit/directives/ref.js";
|
||||
import { Brain, Loader2, Paperclip, Send, Sparkles, Square } from "lucide";
|
||||
import { type Attachment, loadAttachment } from "../utils/attachment-utils.js";
|
||||
import { i18n } from "../utils/i18n.js";
|
||||
import "./AttachmentTile.js";
|
||||
import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
|
||||
|
||||
@customElement("message-editor")
|
||||
export class MessageEditor extends LitElement {
|
||||
private _value = "";
|
||||
private textareaRef = createRef<HTMLTextAreaElement>();
|
||||
|
||||
@property()
|
||||
get value() {
|
||||
return this._value;
|
||||
}
|
||||
|
||||
set value(val: string) {
|
||||
const oldValue = this._value;
|
||||
this._value = val;
|
||||
this.requestUpdate("value", oldValue);
|
||||
}
|
||||
|
||||
@property() isStreaming = false;
|
||||
@property() currentModel?: Model<any>;
|
||||
@property() thinkingLevel: ThinkingLevel = "off";
|
||||
@property() showAttachmentButton = true;
|
||||
@property() showModelSelector = true;
|
||||
@property() showThinkingSelector = true;
|
||||
@property() onInput?: (value: string) => void;
|
||||
@property() onSend?: (input: string, attachments: Attachment[]) => void;
|
||||
@property() onAbort?: () => void;
|
||||
@property() onModelSelect?: () => void;
|
||||
@property() onThinkingChange?: (
|
||||
level: "off" | "minimal" | "low" | "medium" | "high",
|
||||
) => void;
|
||||
@property() onFilesChange?: (files: Attachment[]) => void;
|
||||
@property() attachments: Attachment[] = [];
|
||||
@property() maxFiles = 10;
|
||||
@property() maxFileSize = 20 * 1024 * 1024; // 20MB
|
||||
@property() acceptedTypes =
|
||||
"image/*,application/pdf,.docx,.pptx,.xlsx,.xls,.txt,.md,.json,.xml,.html,.css,.js,.ts,.jsx,.tsx,.yml,.yaml";
|
||||
|
||||
@state() processingFiles = false;
|
||||
@state() isDragging = false;
|
||||
private fileInputRef = createRef<HTMLInputElement>();
|
||||
|
||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
||||
return this;
|
||||
}
|
||||
|
||||
private handleTextareaInput = (e: Event) => {
|
||||
const textarea = e.target as HTMLTextAreaElement;
|
||||
this.value = textarea.value;
|
||||
this.onInput?.(this.value);
|
||||
};
|
||||
|
||||
private handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (
|
||||
!this.isStreaming &&
|
||||
!this.processingFiles &&
|
||||
(this.value.trim() || this.attachments.length > 0)
|
||||
) {
|
||||
this.handleSend();
|
||||
}
|
||||
} else if (e.key === "Escape" && this.isStreaming) {
|
||||
e.preventDefault();
|
||||
this.onAbort?.();
|
||||
}
|
||||
};
|
||||
|
||||
private handlePaste = async (e: ClipboardEvent) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
|
||||
const imageFiles: File[] = [];
|
||||
|
||||
// Check for image items in clipboard
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item.type.startsWith("image/")) {
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
imageFiles.push(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we found images, process them
|
||||
if (imageFiles.length > 0) {
|
||||
e.preventDefault(); // Prevent default paste behavior
|
||||
|
||||
if (imageFiles.length + this.attachments.length > this.maxFiles) {
|
||||
alert(`Maximum ${this.maxFiles} files allowed`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.processingFiles = true;
|
||||
const newAttachments: Attachment[] = [];
|
||||
|
||||
for (const file of imageFiles) {
|
||||
try {
|
||||
if (file.size > this.maxFileSize) {
|
||||
alert(
|
||||
`Image exceeds maximum size of ${Math.round(this.maxFileSize / 1024 / 1024)}MB`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const attachment = await loadAttachment(file);
|
||||
newAttachments.push(attachment);
|
||||
} catch (error) {
|
||||
console.error("Error processing pasted image:", error);
|
||||
alert(`Failed to process pasted image: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.attachments = [...this.attachments, ...newAttachments];
|
||||
this.onFilesChange?.(this.attachments);
|
||||
this.processingFiles = false;
|
||||
}
|
||||
};
|
||||
|
||||
private handleSend = () => {
|
||||
this.onSend?.(this.value, this.attachments);
|
||||
};
|
||||
|
||||
private handleAttachmentClick = () => {
|
||||
this.fileInputRef.value?.click();
|
||||
};
|
||||
|
||||
private async handleFilesSelected(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const files = Array.from(input.files || []);
|
||||
if (files.length === 0) return;
|
||||
|
||||
if (files.length + this.attachments.length > this.maxFiles) {
|
||||
alert(`Maximum ${this.maxFiles} files allowed`);
|
||||
input.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
this.processingFiles = true;
|
||||
const newAttachments: Attachment[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
if (file.size > this.maxFileSize) {
|
||||
alert(
|
||||
`${file.name} exceeds maximum size of ${Math.round(this.maxFileSize / 1024 / 1024)}MB`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const attachment = await loadAttachment(file);
|
||||
newAttachments.push(attachment);
|
||||
} catch (error) {
|
||||
console.error(`Error processing ${file.name}:`, error);
|
||||
alert(`Failed to process ${file.name}: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.attachments = [...this.attachments, ...newAttachments];
|
||||
this.onFilesChange?.(this.attachments);
|
||||
this.processingFiles = false;
|
||||
input.value = ""; // Reset input
|
||||
}
|
||||
|
||||
private removeFile(fileId: string) {
|
||||
this.attachments = this.attachments.filter((f) => f.id !== fileId);
|
||||
this.onFilesChange?.(this.attachments);
|
||||
}
|
||||
|
||||
private handleDragOver = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!this.isDragging) {
|
||||
this.isDragging = true;
|
||||
}
|
||||
};
|
||||
|
||||
private handleDragLeave = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// Only set isDragging to false if we're leaving the entire component
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const x = e.clientX;
|
||||
const y = e.clientY;
|
||||
if (
|
||||
x <= rect.left ||
|
||||
x >= rect.right ||
|
||||
y <= rect.top ||
|
||||
y >= rect.bottom
|
||||
) {
|
||||
this.isDragging = false;
|
||||
}
|
||||
};
|
||||
|
||||
private handleDrop = async (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.isDragging = false;
|
||||
|
||||
const files = Array.from(e.dataTransfer?.files || []);
|
||||
if (files.length === 0) return;
|
||||
|
||||
if (files.length + this.attachments.length > this.maxFiles) {
|
||||
alert(`Maximum ${this.maxFiles} files allowed`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.processingFiles = true;
|
||||
const newAttachments: Attachment[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
if (file.size > this.maxFileSize) {
|
||||
alert(
|
||||
`${file.name} exceeds maximum size of ${Math.round(this.maxFileSize / 1024 / 1024)}MB`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const attachment = await loadAttachment(file);
|
||||
newAttachments.push(attachment);
|
||||
} catch (error) {
|
||||
console.error(`Error processing ${file.name}:`, error);
|
||||
alert(`Failed to process ${file.name}: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.attachments = [...this.attachments, ...newAttachments];
|
||||
this.onFilesChange?.(this.attachments);
|
||||
this.processingFiles = false;
|
||||
};
|
||||
|
||||
override firstUpdated() {
|
||||
const textarea = this.textareaRef.value;
|
||||
if (textarea) {
|
||||
textarea.focus();
|
||||
}
|
||||
}
|
||||
|
||||
override render() {
|
||||
// Check if current model supports thinking/reasoning
|
||||
const model = this.currentModel;
|
||||
const supportsThinking = model?.reasoning === true; // Models with reasoning:true support thinking
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="bg-card rounded-xl border shadow-sm relative ${this.isDragging
|
||||
? "border-primary border-2 bg-primary/5"
|
||||
: "border-border"}"
|
||||
@dragover=${this.handleDragOver}
|
||||
@dragleave=${this.handleDragLeave}
|
||||
@drop=${this.handleDrop}
|
||||
>
|
||||
<!-- Drag overlay -->
|
||||
${this.isDragging
|
||||
? html`
|
||||
<div
|
||||
class="absolute inset-0 bg-primary/10 rounded-xl pointer-events-none z-10 flex items-center justify-center"
|
||||
>
|
||||
<div class="text-primary font-medium">
|
||||
${i18n("Drop files here")}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
|
||||
<!-- 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; field-sizing: content; min-height: 1lh; height: auto;"
|
||||
.value=${this.value}
|
||||
@input=${this.handleTextareaInput}
|
||||
@keydown=${this.handleKeyDown}
|
||||
@paste=${this.handlePaste}
|
||||
${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 thinking selector -->
|
||||
<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"),
|
||||
})}
|
||||
`
|
||||
: ""}
|
||||
${supportsThinking && this.showThinkingSelector
|
||||
? html`
|
||||
${Select({
|
||||
value: this.thinkingLevel,
|
||||
placeholder: i18n("Off"),
|
||||
options: [
|
||||
{
|
||||
value: "off",
|
||||
label: i18n("Off"),
|
||||
icon: icon(Brain, "sm"),
|
||||
},
|
||||
{
|
||||
value: "minimal",
|
||||
label: i18n("Minimal"),
|
||||
icon: icon(Brain, "sm"),
|
||||
},
|
||||
{
|
||||
value: "low",
|
||||
label: i18n("Low"),
|
||||
icon: icon(Brain, "sm"),
|
||||
},
|
||||
{
|
||||
value: "medium",
|
||||
label: i18n("Medium"),
|
||||
icon: icon(Brain, "sm"),
|
||||
},
|
||||
{
|
||||
value: "high",
|
||||
label: i18n("High"),
|
||||
icon: icon(Brain, "sm"),
|
||||
},
|
||||
] as SelectOption[],
|
||||
onChange: (value: string) => {
|
||||
this.onThinkingChange?.(
|
||||
value as "off" | "minimal" | "low" | "medium" | "high",
|
||||
);
|
||||
},
|
||||
width: "80px",
|
||||
size: "sm",
|
||||
variant: "ghost",
|
||||
fitContent: true,
|
||||
})}
|
||||
`
|
||||
: ""}
|
||||
</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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
import type { AgentMessage, AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import type {
|
||||
AssistantMessage as AssistantMessageType,
|
||||
ToolResultMessage as ToolResultMessageType,
|
||||
} from "@mariozechner/pi-ai";
|
||||
import { html, LitElement, type TemplateResult } from "lit";
|
||||
import { property } from "lit/decorators.js";
|
||||
import { repeat } from "lit/directives/repeat.js";
|
||||
import { renderMessage } from "./message-renderer-registry.js";
|
||||
|
||||
export class MessageList extends LitElement {
|
||||
@property({ type: Array }) messages: AgentMessage[] = [];
|
||||
@property({ type: Array }) tools: AgentTool[] = [];
|
||||
@property({ type: Object }) pendingToolCalls?: Set<string>;
|
||||
@property({ type: Boolean }) isStreaming: boolean = false;
|
||||
@property({ attribute: false }) onCostClick?: () => void;
|
||||
|
||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
||||
return this;
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.style.display = "block";
|
||||
}
|
||||
|
||||
private buildRenderItems() {
|
||||
// Map tool results by call id for quick lookup
|
||||
const resultByCallId = new Map<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) {
|
||||
// Skip artifact messages - they're for session persistence only, not UI display
|
||||
if (msg.role === "artifact") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try custom renderer first
|
||||
const customTemplate = renderMessage(msg);
|
||||
if (customTemplate) {
|
||||
items.push({ key: `msg:${index}`, template: customTemplate });
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fall back to built-in renderers
|
||||
if (msg.role === "user" || msg.role === "user-with-attachments") {
|
||||
items.push({
|
||||
key: `msg:${index}`,
|
||||
template: html`<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=${false}
|
||||
.pendingToolCalls=${this.pendingToolCalls}
|
||||
.toolResultsById=${resultByCallId}
|
||||
.hideToolCalls=${false}
|
||||
.hidePendingToolCalls=${this.isStreaming}
|
||||
.onCostClick=${this.onCostClick}
|
||||
></assistant-message>`,
|
||||
});
|
||||
index++;
|
||||
} else {
|
||||
// Skip standalone toolResult messages; they are rendered via paired tool-message above
|
||||
// Skip unknown roles
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
override render() {
|
||||
const items = this.buildRenderItems();
|
||||
return html`<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);
|
||||
}
|
||||
|
|
@ -1,436 +0,0 @@
|
|||
import type {
|
||||
AssistantMessage as AssistantMessageType,
|
||||
ImageContent,
|
||||
TextContent,
|
||||
ToolCall,
|
||||
ToolResultMessage as ToolResultMessageType,
|
||||
UserMessage as UserMessageType,
|
||||
} from "@mariozechner/pi-ai";
|
||||
import { html, LitElement, type TemplateResult } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { renderTool } from "../tools/index.js";
|
||||
import type { Attachment } from "../utils/attachment-utils.js";
|
||||
import { formatUsage } from "../utils/format.js";
|
||||
import { i18n } from "../utils/i18n.js";
|
||||
import "./ThinkingBlock.js";
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
|
||||
export type UserMessageWithAttachments = {
|
||||
role: "user-with-attachments";
|
||||
content: string | (TextContent | ImageContent)[];
|
||||
timestamp: number;
|
||||
attachments?: Attachment[];
|
||||
};
|
||||
|
||||
// Artifact message type for session persistence
|
||||
export interface ArtifactMessage {
|
||||
role: "artifact";
|
||||
action: "create" | "update" | "delete";
|
||||
filename: string;
|
||||
content?: string;
|
||||
title?: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
declare module "@mariozechner/pi-agent-core" {
|
||||
interface CustomAgentMessages {
|
||||
"user-with-attachments": UserMessageWithAttachments;
|
||||
artifact: ArtifactMessage;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement("user-message")
|
||||
export class UserMessage extends LitElement {
|
||||
@property({ type: Object }) message!:
|
||||
| UserMessageWithAttachments
|
||||
| UserMessageType;
|
||||
|
||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
||||
return this;
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.style.display = "block";
|
||||
}
|
||||
|
||||
override render() {
|
||||
const content =
|
||||
typeof this.message.content === "string"
|
||||
? this.message.content
|
||||
: this.message.content.find((c) => c.type === "text")?.text || "";
|
||||
|
||||
return html`
|
||||
<div class="flex justify-start mx-4">
|
||||
<div class="user-message-container py-2 px-4 rounded-xl">
|
||||
<markdown-block .content=${content}></markdown-block>
|
||||
${this.message.role === "user-with-attachments" &&
|
||||
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>
|
||||
</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;
|
||||
@property({ type: Boolean }) hidePendingToolCalls = false;
|
||||
@property({ attribute: false }) onCostClick?: () => void;
|
||||
|
||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
||||
return this;
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.style.display = "block";
|
||||
}
|
||||
|
||||
override render() {
|
||||
// Render content in the order it appears
|
||||
const orderedParts: TemplateResult[] = [];
|
||||
|
||||
for (const chunk of this.message.content) {
|
||||
if (chunk.type === "text" && chunk.text.trim() !== "") {
|
||||
orderedParts.push(
|
||||
html`<markdown-block .content=${chunk.text}></markdown-block>`,
|
||||
);
|
||||
} else if (chunk.type === "thinking" && chunk.thinking.trim() !== "") {
|
||||
orderedParts.push(
|
||||
html`<thinking-block
|
||||
.content=${chunk.thinking}
|
||||
.isStreaming=${this.isStreaming}
|
||||
></thinking-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);
|
||||
// Skip rendering pending tool calls when hidePendingToolCalls is true
|
||||
// (used to prevent duplication when StreamingMessageContainer is showing them)
|
||||
if (this.hidePendingToolCalls && pending && !result) {
|
||||
continue;
|
||||
}
|
||||
// A tool call is aborted if the message was aborted and there's no result for this tool call
|
||||
const aborted = this.message.stopReason === "aborted" && !result;
|
||||
orderedParts.push(
|
||||
html`<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 && !this.isStreaming
|
||||
? this.onCostClick
|
||||
? html`
|
||||
<div
|
||||
class="px-4 mt-2 text-xs text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
|
||||
@click=${this.onCostClick}
|
||||
>
|
||||
${formatUsage(this.message.usage)}
|
||||
</div>
|
||||
`
|
||||
: 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: Object }) result?: ToolResultMessageType;
|
||||
@property({ type: Boolean }) hasResult: boolean = false;
|
||||
|
||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
||||
return this; // light DOM for shared styles
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.style.display = "block";
|
||||
}
|
||||
|
||||
private pretty(value: unknown): { content: string; isJson: boolean } {
|
||||
try {
|
||||
if (typeof value === "string") {
|
||||
const maybeJson = JSON.parse(value);
|
||||
return { content: JSON.stringify(maybeJson, null, 2), isJson: true };
|
||||
}
|
||||
return { content: JSON.stringify(value, null, 2), isJson: true };
|
||||
} catch {
|
||||
return {
|
||||
content: typeof value === "string" ? value : String(value),
|
||||
isJson: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
override render() {
|
||||
const textOutput =
|
||||
this.result?.content
|
||||
?.filter((c) => c.type === "text")
|
||||
.map((c: any) => c.text)
|
||||
.join("\n") || "";
|
||||
const output = this.pretty(textOutput);
|
||||
const details = this.pretty(this.result?.details);
|
||||
|
||||
return html`
|
||||
<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;
|
||||
|
||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
||||
return this;
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.style.display = "block";
|
||||
}
|
||||
|
||||
override render() {
|
||||
const toolName = this.tool?.name || this.toolCall.name;
|
||||
|
||||
// Render tool content (renderer handles errors and styling)
|
||||
const result: ToolResultMessageType<any> | undefined = this.aborted
|
||||
? {
|
||||
role: "toolResult",
|
||||
isError: true,
|
||||
content: [],
|
||||
toolCallId: this.toolCall.id,
|
||||
toolName: this.toolCall.name,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
: this.result;
|
||||
const renderResult = renderTool(
|
||||
toolName,
|
||||
this.toolCall.arguments,
|
||||
result,
|
||||
!this.aborted && (this.isStreaming || this.pending),
|
||||
);
|
||||
|
||||
// Handle custom rendering (no card wrapper)
|
||||
if (renderResult.isCustom) {
|
||||
return renderResult.content;
|
||||
}
|
||||
|
||||
// Default: wrap in card
|
||||
return html`
|
||||
<div
|
||||
class="p-2.5 border border-border rounded-md bg-card text-card-foreground shadow-xs"
|
||||
>
|
||||
${renderResult.content}
|
||||
</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
|
||||
>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Default Message Transformer
|
||||
// ============================================================================
|
||||
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import type { Message } from "@mariozechner/pi-ai";
|
||||
|
||||
/**
|
||||
* Convert attachments to content blocks for LLM.
|
||||
* - Images become ImageContent blocks
|
||||
* - Documents with extractedText become TextContent blocks with filename header
|
||||
*/
|
||||
export function convertAttachments(
|
||||
attachments: Attachment[],
|
||||
): (TextContent | ImageContent)[] {
|
||||
const content: (TextContent | ImageContent)[] = [];
|
||||
for (const attachment of attachments) {
|
||||
if (attachment.type === "image") {
|
||||
content.push({
|
||||
type: "image",
|
||||
data: attachment.content,
|
||||
mimeType: attachment.mimeType,
|
||||
} as ImageContent);
|
||||
} else if (attachment.type === "document" && attachment.extractedText) {
|
||||
content.push({
|
||||
type: "text",
|
||||
text: `\n\n[Document: ${attachment.fileName}]\n${attachment.extractedText}`,
|
||||
} as TextContent);
|
||||
}
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a message is a UserMessageWithAttachments.
|
||||
*/
|
||||
export function isUserMessageWithAttachments(
|
||||
msg: AgentMessage,
|
||||
): msg is UserMessageWithAttachments {
|
||||
return (msg as UserMessageWithAttachments).role === "user-with-attachments";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a message is an ArtifactMessage.
|
||||
*/
|
||||
export function isArtifactMessage(msg: AgentMessage): msg is ArtifactMessage {
|
||||
return (msg as ArtifactMessage).role === "artifact";
|
||||
}
|
||||
|
||||
/**
|
||||
* Default convertToLlm for web-ui apps.
|
||||
*
|
||||
* Handles:
|
||||
* - UserMessageWithAttachments: converts to user message with content blocks
|
||||
* - ArtifactMessage: filtered out (UI-only, for session reconstruction)
|
||||
* - Standard LLM messages (user, assistant, toolResult): passed through
|
||||
*/
|
||||
export function defaultConvertToLlm(messages: AgentMessage[]): Message[] {
|
||||
return messages
|
||||
.filter((m) => {
|
||||
// Filter out artifact messages - they're for session reconstruction only
|
||||
if (isArtifactMessage(m)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map((m): Message | null => {
|
||||
// Convert user-with-attachments to user message with content blocks
|
||||
if (isUserMessageWithAttachments(m)) {
|
||||
const textContent: (TextContent | ImageContent)[] =
|
||||
typeof m.content === "string"
|
||||
? [{ type: "text", text: m.content }]
|
||||
: [...m.content];
|
||||
|
||||
if (m.attachments) {
|
||||
textContent.push(...convertAttachments(m.attachments));
|
||||
}
|
||||
|
||||
return {
|
||||
role: "user",
|
||||
content: textContent,
|
||||
timestamp: m.timestamp,
|
||||
} as Message;
|
||||
}
|
||||
|
||||
// Pass through standard LLM roles
|
||||
if (
|
||||
m.role === "user" ||
|
||||
m.role === "assistant" ||
|
||||
m.role === "toolResult"
|
||||
) {
|
||||
return m as Message;
|
||||
}
|
||||
|
||||
// Filter out unknown message types
|
||||
return null;
|
||||
})
|
||||
.filter((m): m is Message => m !== null);
|
||||
}
|
||||
|
|
@ -1,165 +0,0 @@
|
|||
import { i18n } from "@mariozechner/mini-lit";
|
||||
import { Badge } from "@mariozechner/mini-lit/dist/Badge.js";
|
||||
import { Button } from "@mariozechner/mini-lit/dist/Button.js";
|
||||
import { type Context, complete, getModel } from "@mariozechner/pi-ai";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { getAppStorage } from "../storage/app-storage.js";
|
||||
import { applyProxyIfNeeded } from "../utils/proxy-utils.js";
|
||||
import { Input } from "./Input.js";
|
||||
|
||||
// Test models for each provider
|
||||
const TEST_MODELS: Record<string, string> = {
|
||||
anthropic: "claude-3-5-haiku-20241022",
|
||||
openai: "gpt-4o-mini",
|
||||
google: "gemini-2.5-flash",
|
||||
groq: "openai/gpt-oss-20b",
|
||||
openrouter: "z-ai/glm-4.6",
|
||||
"vercel-ai-gateway": "anthropic/claude-opus-4.5",
|
||||
cerebras: "gpt-oss-120b",
|
||||
xai: "grok-4-fast-non-reasoning",
|
||||
zai: "glm-4.5-air",
|
||||
};
|
||||
|
||||
@customElement("provider-key-input")
|
||||
export class ProviderKeyInput extends LitElement {
|
||||
@property() provider = "";
|
||||
@state() private keyInput = "";
|
||||
@state() private testing = false;
|
||||
@state() private failed = false;
|
||||
@state() private hasKey = false;
|
||||
@state() private inputChanged = false;
|
||||
|
||||
protected createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
override async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
await this.checkKeyStatus();
|
||||
}
|
||||
|
||||
private async checkKeyStatus() {
|
||||
try {
|
||||
const key = await getAppStorage().providerKeys.get(this.provider);
|
||||
this.hasKey = !!key;
|
||||
} catch (error) {
|
||||
console.error("Failed to check key status:", error);
|
||||
}
|
||||
}
|
||||
|
||||
private async testApiKey(provider: string, apiKey: string): Promise<boolean> {
|
||||
try {
|
||||
const modelId = TEST_MODELS[provider];
|
||||
// Returning true here for Ollama and friends. Can' know which model to use for testing
|
||||
if (!modelId) return true;
|
||||
|
||||
let model = getModel(provider as any, modelId);
|
||||
if (!model) return false;
|
||||
|
||||
// Get proxy URL from settings (if available)
|
||||
const proxyEnabled =
|
||||
await getAppStorage().settings.get<boolean>("proxy.enabled");
|
||||
const proxyUrl = await getAppStorage().settings.get<string>("proxy.url");
|
||||
|
||||
// Apply proxy only if this provider/key combination requires it
|
||||
model = applyProxyIfNeeded(
|
||||
model,
|
||||
apiKey,
|
||||
proxyEnabled ? proxyUrl || undefined : undefined,
|
||||
);
|
||||
|
||||
const context: Context = {
|
||||
messages: [
|
||||
{ role: "user", content: "Reply with: ok", timestamp: Date.now() },
|
||||
],
|
||||
};
|
||||
|
||||
const result = await complete(model, context, {
|
||||
apiKey,
|
||||
maxTokens: 200,
|
||||
} as any);
|
||||
|
||||
return result.stopReason === "stop";
|
||||
} catch (error) {
|
||||
console.error(`API key test failed for ${provider}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async saveKey() {
|
||||
if (!this.keyInput) return;
|
||||
|
||||
this.testing = true;
|
||||
this.failed = false;
|
||||
|
||||
const success = await this.testApiKey(this.provider, this.keyInput);
|
||||
|
||||
this.testing = false;
|
||||
|
||||
if (success) {
|
||||
try {
|
||||
await getAppStorage().providerKeys.set(this.provider, this.keyInput);
|
||||
this.hasKey = true;
|
||||
this.inputChanged = false;
|
||||
this.requestUpdate();
|
||||
} catch (error) {
|
||||
console.error("Failed to save API key:", error);
|
||||
this.failed = true;
|
||||
setTimeout(() => {
|
||||
this.failed = false;
|
||||
this.requestUpdate();
|
||||
}, 5000);
|
||||
}
|
||||
} else {
|
||||
this.failed = true;
|
||||
setTimeout(() => {
|
||||
this.failed = false;
|
||||
this.requestUpdate();
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium capitalize text-foreground"
|
||||
>${this.provider}</span
|
||||
>
|
||||
${this.testing
|
||||
? Badge({ children: i18n("Testing..."), variant: "secondary" })
|
||||
: this.hasKey
|
||||
? html`<span class="text-green-600 dark:text-green-400">✓</span>`
|
||||
: ""}
|
||||
${this.failed
|
||||
? Badge({ children: i18n("✗ Invalid"), variant: "destructive" })
|
||||
: ""}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
${Input({
|
||||
type: "password",
|
||||
placeholder: this.hasKey ? "••••••••••••" : i18n("Enter API key"),
|
||||
value: this.keyInput,
|
||||
onInput: (e: Event) => {
|
||||
this.keyInput = (e.target as HTMLInputElement).value;
|
||||
this.inputChanged = true;
|
||||
this.requestUpdate();
|
||||
},
|
||||
className: "flex-1",
|
||||
})}
|
||||
${Button({
|
||||
onClick: () => this.saveKey(),
|
||||
variant: "default",
|
||||
size: "sm",
|
||||
disabled:
|
||||
!this.keyInput ||
|
||||
this.testing ||
|
||||
(this.hasKey && !this.inputChanged),
|
||||
children: i18n("Save"),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,672 +0,0 @@
|
|||
import { LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { ConsoleRuntimeProvider } from "./sandbox/ConsoleRuntimeProvider.js";
|
||||
import { RuntimeMessageBridge } from "./sandbox/RuntimeMessageBridge.js";
|
||||
import {
|
||||
type MessageConsumer,
|
||||
RUNTIME_MESSAGE_ROUTER,
|
||||
} from "./sandbox/RuntimeMessageRouter.js";
|
||||
import type { SandboxRuntimeProvider } from "./sandbox/SandboxRuntimeProvider.js";
|
||||
|
||||
export interface SandboxFile {
|
||||
fileName: string;
|
||||
content: string | Uint8Array;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
export interface SandboxResult {
|
||||
success: boolean;
|
||||
console: Array<{ type: string; text: string }>;
|
||||
files?: SandboxFile[];
|
||||
error?: { message: string; stack: string };
|
||||
returnValue?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Function that returns the URL to the sandbox HTML file.
|
||||
* Used in browser extensions to load sandbox.html via chrome.runtime.getURL().
|
||||
*/
|
||||
export type SandboxUrlProvider = () => string;
|
||||
|
||||
/**
|
||||
* Configuration for prepareHtmlDocument
|
||||
*/
|
||||
export interface PrepareHtmlOptions {
|
||||
/** True if this is an HTML artifact (inject into existing HTML), false if REPL (wrap in HTML) */
|
||||
isHtmlArtifact: boolean;
|
||||
/** True if this is a standalone download (no runtime bridge, no navigation interceptor) */
|
||||
isStandalone?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML special sequences in code to prevent premature tag closure
|
||||
* @param code Code that will be injected into <script> tags
|
||||
* @returns Escaped code safe for injection
|
||||
*/
|
||||
function escapeScriptContent(code: string): string {
|
||||
return code.replace(/<\/script/gi, "<\\/script");
|
||||
}
|
||||
|
||||
@customElement("sandbox-iframe")
|
||||
export class SandboxIframe extends LitElement {
|
||||
private iframe?: HTMLIFrameElement;
|
||||
|
||||
/**
|
||||
* Optional: Provide a function that returns the sandbox HTML URL.
|
||||
* If provided, the iframe will use this URL instead of srcdoc.
|
||||
* This is required for browser extensions with strict CSP.
|
||||
*/
|
||||
@property({ attribute: false }) sandboxUrlProvider?: SandboxUrlProvider;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
// Note: We don't unregister the sandbox here for loadContent() mode
|
||||
// because the caller (HtmlArtifact) owns the sandbox lifecycle.
|
||||
// For execute() mode, the sandbox is unregistered in the cleanup function.
|
||||
this.iframe?.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load HTML content into sandbox and keep it displayed (for HTML artifacts)
|
||||
* @param sandboxId Unique ID
|
||||
* @param htmlContent Full HTML content
|
||||
* @param providers Runtime providers to inject
|
||||
* @param consumers Message consumers to register (optional)
|
||||
*/
|
||||
public loadContent(
|
||||
sandboxId: string,
|
||||
htmlContent: string,
|
||||
providers: SandboxRuntimeProvider[] = [],
|
||||
consumers: MessageConsumer[] = [],
|
||||
): void {
|
||||
// Unregister previous sandbox if exists
|
||||
try {
|
||||
RUNTIME_MESSAGE_ROUTER.unregisterSandbox(sandboxId);
|
||||
} catch {
|
||||
// Sandbox might not exist, that's ok
|
||||
}
|
||||
|
||||
providers = [new ConsoleRuntimeProvider(), ...providers];
|
||||
|
||||
RUNTIME_MESSAGE_ROUTER.registerSandbox(sandboxId, providers, consumers);
|
||||
|
||||
// loadContent is always used for HTML artifacts (not standalone)
|
||||
const completeHtml = this.prepareHtmlDocument(
|
||||
sandboxId,
|
||||
htmlContent,
|
||||
providers,
|
||||
{
|
||||
isHtmlArtifact: true,
|
||||
isStandalone: false,
|
||||
},
|
||||
);
|
||||
|
||||
// Validate HTML before loading
|
||||
const validationError = this.validateHtml(completeHtml);
|
||||
if (validationError) {
|
||||
console.error("HTML validation failed:", validationError);
|
||||
// Show error in iframe instead of crashing
|
||||
this.iframe?.remove();
|
||||
this.iframe = document.createElement("iframe");
|
||||
this.iframe.style.cssText = "width: 100%; height: 100%; border: none;";
|
||||
this.iframe.srcdoc = `
|
||||
<html>
|
||||
<body style="font-family: monospace; padding: 20px; background: #fff; color: #000;">
|
||||
<h3 style="color: #c00;">HTML Validation Error</h3>
|
||||
<pre style="background: #f5f5f5; padding: 10px; border-radius: 4px; overflow-x: auto; white-space: pre-wrap;">${validationError}</pre>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
this.appendChild(this.iframe);
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove previous iframe if exists
|
||||
this.iframe?.remove();
|
||||
|
||||
if (this.sandboxUrlProvider) {
|
||||
// Browser extension mode: use sandbox.html with postMessage
|
||||
this.loadViaSandboxUrl(sandboxId, completeHtml);
|
||||
} else {
|
||||
// Web mode: use srcdoc
|
||||
this.loadViaSrcdoc(sandboxId, completeHtml);
|
||||
}
|
||||
}
|
||||
|
||||
private loadViaSandboxUrl(sandboxId: string, completeHtml: string): void {
|
||||
// Create iframe pointing to sandbox URL
|
||||
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!();
|
||||
|
||||
// Update router with iframe reference BEFORE appending to DOM
|
||||
RUNTIME_MESSAGE_ROUTER.setSandboxIframe(sandboxId, this.iframe);
|
||||
|
||||
// Listen for open-external-url messages from iframe
|
||||
const externalUrlHandler = (e: MessageEvent) => {
|
||||
if (
|
||||
e.data.type === "open-external-url" &&
|
||||
e.source === this.iframe?.contentWindow
|
||||
) {
|
||||
// Use chrome.tabs API to open in new tab
|
||||
const chromeAPI = (globalThis as any).chrome;
|
||||
if (chromeAPI?.tabs) {
|
||||
chromeAPI.tabs.create({ url: e.data.url });
|
||||
} else {
|
||||
// Fallback for non-extension context
|
||||
window.open(e.data.url, "_blank");
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener("message", externalUrlHandler);
|
||||
|
||||
// Listen for sandbox-ready and sandbox-error messages directly
|
||||
const readyHandler = (e: MessageEvent) => {
|
||||
if (
|
||||
e.data.type === "sandbox-ready" &&
|
||||
e.source === this.iframe?.contentWindow
|
||||
) {
|
||||
window.removeEventListener("message", readyHandler);
|
||||
window.removeEventListener("message", errorHandler);
|
||||
|
||||
// Send content to sandbox
|
||||
this.iframe?.contentWindow?.postMessage(
|
||||
{
|
||||
type: "sandbox-load",
|
||||
sandboxId,
|
||||
code: completeHtml,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const errorHandler = (e: MessageEvent) => {
|
||||
if (
|
||||
e.data.type === "sandbox-error" &&
|
||||
e.source === this.iframe?.contentWindow
|
||||
) {
|
||||
window.removeEventListener("message", readyHandler);
|
||||
window.removeEventListener("message", errorHandler);
|
||||
|
||||
// The sandbox.js already sent us the error via postMessage.
|
||||
// We need to convert it to an execution-error message that the execute() consumer will handle.
|
||||
// Simulate receiving an execution-error from the sandbox
|
||||
window.postMessage(
|
||||
{
|
||||
sandboxId: sandboxId,
|
||||
type: "execution-error",
|
||||
error: { message: e.data.error, stack: e.data.stack },
|
||||
},
|
||||
"*",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("message", readyHandler);
|
||||
window.addEventListener("message", errorHandler);
|
||||
|
||||
this.appendChild(this.iframe);
|
||||
}
|
||||
|
||||
private loadViaSrcdoc(sandboxId: string, completeHtml: string): void {
|
||||
// Create iframe with srcdoc
|
||||
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.srcdoc = completeHtml;
|
||||
|
||||
// Update router with iframe reference BEFORE appending to DOM
|
||||
RUNTIME_MESSAGE_ROUTER.setSandboxIframe(sandboxId, this.iframe);
|
||||
|
||||
// Listen for open-external-url messages from iframe
|
||||
const externalUrlHandler = (e: MessageEvent) => {
|
||||
if (
|
||||
e.data.type === "open-external-url" &&
|
||||
e.source === this.iframe?.contentWindow
|
||||
) {
|
||||
// Fallback for non-extension context
|
||||
window.open(e.data.url, "_blank");
|
||||
}
|
||||
};
|
||||
window.addEventListener("message", externalUrlHandler);
|
||||
|
||||
this.appendChild(this.iframe);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute code in sandbox
|
||||
* @param sandboxId Unique ID for this execution
|
||||
* @param code User code (plain JS for REPL, or full HTML for artifacts)
|
||||
* @param providers Runtime providers to inject
|
||||
* @param consumers Additional message consumers (optional, execute has its own internal consumer)
|
||||
* @param signal Abort signal
|
||||
* @returns Promise resolving to execution result
|
||||
*/
|
||||
public async execute(
|
||||
sandboxId: string,
|
||||
code: string,
|
||||
providers: SandboxRuntimeProvider[] = [],
|
||||
consumers: MessageConsumer[] = [],
|
||||
signal?: AbortSignal,
|
||||
isHtmlArtifact: boolean = false,
|
||||
): Promise<SandboxResult> {
|
||||
if (signal?.aborted) {
|
||||
throw new Error("Execution aborted");
|
||||
}
|
||||
|
||||
const consoleProvider = new ConsoleRuntimeProvider();
|
||||
providers = [consoleProvider, ...providers];
|
||||
RUNTIME_MESSAGE_ROUTER.registerSandbox(sandboxId, providers, consumers);
|
||||
|
||||
// Notify providers that execution is starting
|
||||
for (const provider of providers) {
|
||||
provider.onExecutionStart?.(sandboxId, signal);
|
||||
}
|
||||
|
||||
const files: SandboxFile[] = [];
|
||||
let completed = false;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// 4. Create execution consumer for lifecycle messages
|
||||
const executionConsumer: MessageConsumer = {
|
||||
async handleMessage(message: any): Promise<void> {
|
||||
if (message.type === "file-returned") {
|
||||
files.push({
|
||||
fileName: message.fileName,
|
||||
content: message.content,
|
||||
mimeType: message.mimeType,
|
||||
});
|
||||
} else if (message.type === "execution-complete") {
|
||||
completed = true;
|
||||
cleanup();
|
||||
resolve({
|
||||
success: true,
|
||||
console: consoleProvider.getLogs(),
|
||||
files,
|
||||
returnValue: message.returnValue,
|
||||
});
|
||||
} else if (message.type === "execution-error") {
|
||||
completed = true;
|
||||
cleanup();
|
||||
resolve({
|
||||
success: false,
|
||||
console: consoleProvider.getLogs(),
|
||||
error: message.error,
|
||||
files,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
RUNTIME_MESSAGE_ROUTER.addConsumer(sandboxId, executionConsumer);
|
||||
|
||||
const cleanup = () => {
|
||||
// Notify providers that execution has ended
|
||||
for (const provider of providers) {
|
||||
provider.onExecutionEnd?.(sandboxId);
|
||||
}
|
||||
|
||||
RUNTIME_MESSAGE_ROUTER.unregisterSandbox(sandboxId);
|
||||
signal?.removeEventListener("abort", abortHandler);
|
||||
clearTimeout(timeoutId);
|
||||
this.iframe?.remove();
|
||||
this.iframe = undefined;
|
||||
};
|
||||
|
||||
// Abort handler
|
||||
const abortHandler = () => {
|
||||
if (!completed) {
|
||||
completed = true;
|
||||
cleanup();
|
||||
reject(new Error("Execution aborted"));
|
||||
}
|
||||
};
|
||||
|
||||
if (signal) {
|
||||
signal.addEventListener("abort", abortHandler);
|
||||
}
|
||||
|
||||
// Timeout handler (30 seconds)
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (!completed) {
|
||||
completed = true;
|
||||
cleanup();
|
||||
resolve({
|
||||
success: false,
|
||||
console: consoleProvider.getLogs(),
|
||||
error: { message: "Execution timeout (120s)", stack: "" },
|
||||
files,
|
||||
});
|
||||
}
|
||||
}, 120000);
|
||||
|
||||
// 4. Prepare HTML and create iframe
|
||||
const completeHtml = this.prepareHtmlDocument(
|
||||
sandboxId,
|
||||
code,
|
||||
providers,
|
||||
{
|
||||
isHtmlArtifact,
|
||||
isStandalone: false,
|
||||
},
|
||||
);
|
||||
|
||||
// 5. Validate HTML before sending to sandbox
|
||||
const validationError = this.validateHtml(completeHtml);
|
||||
if (validationError) {
|
||||
reject(new Error(`HTML validation failed: ${validationError}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.sandboxUrlProvider) {
|
||||
// Browser extension mode: wait for sandbox-ready
|
||||
this.iframe = document.createElement("iframe");
|
||||
this.iframe.sandbox.add("allow-scripts", "allow-modals");
|
||||
this.iframe.style.cssText = "width: 100%; height: 100%; border: none;";
|
||||
this.iframe.src = this.sandboxUrlProvider();
|
||||
|
||||
// Update router with iframe reference BEFORE appending to DOM
|
||||
RUNTIME_MESSAGE_ROUTER.setSandboxIframe(sandboxId, this.iframe);
|
||||
|
||||
// Listen for sandbox-ready and sandbox-error messages
|
||||
const readyHandler = (e: MessageEvent) => {
|
||||
if (
|
||||
e.data.type === "sandbox-ready" &&
|
||||
e.source === this.iframe?.contentWindow
|
||||
) {
|
||||
window.removeEventListener("message", readyHandler);
|
||||
window.removeEventListener("message", errorHandler);
|
||||
|
||||
// Send content to sandbox
|
||||
this.iframe?.contentWindow?.postMessage(
|
||||
{
|
||||
type: "sandbox-load",
|
||||
sandboxId,
|
||||
code: completeHtml,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const errorHandler = (e: MessageEvent) => {
|
||||
if (
|
||||
e.data.type === "sandbox-error" &&
|
||||
e.source === this.iframe?.contentWindow
|
||||
) {
|
||||
window.removeEventListener("message", readyHandler);
|
||||
window.removeEventListener("message", errorHandler);
|
||||
|
||||
// Convert sandbox-error to execution-error for the execution consumer
|
||||
window.postMessage(
|
||||
{
|
||||
sandboxId: sandboxId,
|
||||
type: "execution-error",
|
||||
error: { message: e.data.error, stack: e.data.stack },
|
||||
},
|
||||
"*",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("message", readyHandler);
|
||||
window.addEventListener("message", errorHandler);
|
||||
|
||||
this.appendChild(this.iframe);
|
||||
} else {
|
||||
// Web mode: use srcdoc
|
||||
this.iframe = document.createElement("iframe");
|
||||
this.iframe.sandbox.add("allow-scripts", "allow-modals");
|
||||
this.iframe.style.cssText =
|
||||
"width: 100%; height: 100%; border: none; display: none;";
|
||||
this.iframe.srcdoc = completeHtml;
|
||||
|
||||
// Update router with iframe reference BEFORE appending to DOM
|
||||
RUNTIME_MESSAGE_ROUTER.setSandboxIframe(sandboxId, this.iframe);
|
||||
|
||||
this.appendChild(this.iframe);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate HTML using DOMParser - returns error message if invalid, null if valid
|
||||
* Note: JavaScript syntax validation is done in sandbox.js to avoid CSP restrictions
|
||||
*/
|
||||
private validateHtml(html: string): string | null {
|
||||
try {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, "text/html");
|
||||
|
||||
// Check for parser errors
|
||||
const parserError = doc.querySelector("parsererror");
|
||||
if (parserError) {
|
||||
return parserError.textContent || "Unknown parse error";
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error: any) {
|
||||
return error.message || "Unknown validation error";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare complete HTML document with runtime + user code
|
||||
* PUBLIC so HtmlArtifact can use it for download button
|
||||
*/
|
||||
public prepareHtmlDocument(
|
||||
sandboxId: string,
|
||||
userCode: string,
|
||||
providers: SandboxRuntimeProvider[] = [],
|
||||
options?: PrepareHtmlOptions,
|
||||
): string {
|
||||
// Default options
|
||||
const opts: PrepareHtmlOptions = {
|
||||
isHtmlArtifact: false,
|
||||
isStandalone: false,
|
||||
...options,
|
||||
};
|
||||
|
||||
// Runtime script that will be injected
|
||||
const runtime = this.getRuntimeScript(
|
||||
sandboxId,
|
||||
providers,
|
||||
opts.isStandalone || false,
|
||||
);
|
||||
|
||||
// Only check for HTML tags if explicitly marked as HTML artifact
|
||||
// For javascript_repl, userCode is JavaScript that may contain HTML in string literals
|
||||
if (opts.isHtmlArtifact) {
|
||||
// HTML Artifact - inject runtime into existing HTML
|
||||
const headMatch = userCode.match(/<head[^>]*>/i);
|
||||
if (headMatch) {
|
||||
const index = headMatch.index! + headMatch[0].length;
|
||||
return userCode.slice(0, index) + runtime + userCode.slice(index);
|
||||
}
|
||||
|
||||
const htmlMatch = userCode.match(/<html[^>]*>/i);
|
||||
if (htmlMatch) {
|
||||
const index = htmlMatch.index! + htmlMatch[0].length;
|
||||
return userCode.slice(0, index) + runtime + userCode.slice(index);
|
||||
}
|
||||
|
||||
// Fallback: prepend runtime
|
||||
return runtime + userCode;
|
||||
} else {
|
||||
// REPL - wrap code in HTML with runtime and call complete() when done
|
||||
// Escape </script> in user code to prevent premature tag closure
|
||||
const escapedUserCode = escapeScriptContent(userCode);
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
${runtime}
|
||||
</head>
|
||||
<body>
|
||||
<script type="module">
|
||||
(async () => {
|
||||
try {
|
||||
// Wrap user code in async function to capture return value
|
||||
const userCodeFunc = async () => {
|
||||
${escapedUserCode}
|
||||
};
|
||||
|
||||
const returnValue = await userCodeFunc();
|
||||
|
||||
// Call completion callbacks before complete()
|
||||
if (window.__completionCallbacks && window.__completionCallbacks.length > 0) {
|
||||
try {
|
||||
await Promise.all(window.__completionCallbacks.map(cb => cb(true)));
|
||||
} catch (e) {
|
||||
console.error('Completion callback error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
await window.complete(null, returnValue);
|
||||
} catch (error) {
|
||||
|
||||
// Call completion callbacks before complete() (error path)
|
||||
if (window.__completionCallbacks && window.__completionCallbacks.length > 0) {
|
||||
try {
|
||||
await Promise.all(window.__completionCallbacks.map(cb => cb(false)));
|
||||
} catch (e) {
|
||||
console.error('Completion callback error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
await window.complete({
|
||||
message: error?.message || String(error),
|
||||
stack: error?.stack || new Error().stack
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate runtime script from providers
|
||||
* @param sandboxId Unique sandbox ID
|
||||
* @param providers Runtime providers
|
||||
* @param isStandalone If true, skip runtime bridge and navigation interceptor (for standalone downloads)
|
||||
*/
|
||||
private getRuntimeScript(
|
||||
sandboxId: string,
|
||||
providers: SandboxRuntimeProvider[] = [],
|
||||
isStandalone: boolean = false,
|
||||
): string {
|
||||
// Collect all data from providers
|
||||
const allData: Record<string, any> = {};
|
||||
for (const provider of providers) {
|
||||
Object.assign(allData, provider.getData());
|
||||
}
|
||||
|
||||
// Generate bridge code (skip if standalone)
|
||||
const bridgeCode = isStandalone
|
||||
? ""
|
||||
: RuntimeMessageBridge.generateBridgeCode({
|
||||
context: "sandbox-iframe",
|
||||
sandboxId,
|
||||
});
|
||||
|
||||
// Collect all runtime functions - pass sandboxId as string literal
|
||||
const runtimeFunctions: string[] = [];
|
||||
for (const provider of providers) {
|
||||
runtimeFunctions.push(
|
||||
`(${provider.getRuntime().toString()})(${JSON.stringify(sandboxId)});`,
|
||||
);
|
||||
}
|
||||
|
||||
// Build script with HTML escaping
|
||||
// Escape </script> to prevent premature tag closure in HTML parser
|
||||
const dataInjection = Object.entries(allData)
|
||||
.map(([key, value]) => {
|
||||
const jsonStr = JSON.stringify(value).replace(
|
||||
/<\/script/gi,
|
||||
"<\\/script",
|
||||
);
|
||||
return `window.${key} = ${jsonStr};`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
// TODO the font-size is needed, as chrome seems to inject a stylesheet into iframes
|
||||
// found in an extension context like sidepanel, setting body { font-size: 75% }. It's
|
||||
// definitely not our code doing that.
|
||||
// See https://stackoverflow.com/questions/71480433/chrome-is-injecting-some-stylesheet-in-popup-ui-which-reduces-the-font-size-to-7
|
||||
|
||||
// Navigation interceptor (only if NOT standalone)
|
||||
const navigationInterceptor = isStandalone
|
||||
? ""
|
||||
: `
|
||||
// Navigation interceptor: prevent all navigation and open externally
|
||||
(function() {
|
||||
// Intercept link clicks
|
||||
document.addEventListener('click', function(e) {
|
||||
const link = e.target.closest('a');
|
||||
if (link && link.href) {
|
||||
// Check if it's an external link (not javascript: or #hash)
|
||||
if (link.href.startsWith('http://') || link.href.startsWith('https://')) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
window.parent.postMessage({ type: 'open-external-url', url: link.href }, '*');
|
||||
}
|
||||
}
|
||||
}, true);
|
||||
|
||||
// Intercept form submissions
|
||||
document.addEventListener('submit', function(e) {
|
||||
const form = e.target;
|
||||
if (form && form.action) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
window.parent.postMessage({ type: 'open-external-url', url: form.action }, '*');
|
||||
}
|
||||
}, true);
|
||||
|
||||
// Prevent window.location changes (only if not already redefined)
|
||||
try {
|
||||
const originalLocation = window.location;
|
||||
Object.defineProperty(window, 'location', {
|
||||
get: function() { return originalLocation; },
|
||||
set: function(url) {
|
||||
window.parent.postMessage({ type: 'open-external-url', url: url.toString() }, '*');
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
// Already defined, skip
|
||||
}
|
||||
})();
|
||||
`;
|
||||
|
||||
return `<style>
|
||||
html, body {
|
||||
font-size: initial;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
window.sandboxId = ${JSON.stringify(sandboxId)};
|
||||
${dataInjection}
|
||||
${bridgeCode}
|
||||
${runtimeFunctions.join("\n")}
|
||||
${navigationInterceptor}
|
||||
</script>`;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
import type { AgentMessage, AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import type { ToolResultMessage } from "@mariozechner/pi-ai";
|
||||
import { html, LitElement } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
|
||||
export class StreamingMessageContainer extends LitElement {
|
||||
@property({ type: Array }) tools: AgentTool[] = [];
|
||||
@property({ type: Boolean }) isStreaming = false;
|
||||
@property({ type: Object }) pendingToolCalls?: Set<string>;
|
||||
@property({ type: Object }) toolResultsById?: Map<string, ToolResultMessage>;
|
||||
@property({ attribute: false }) onCostClick?: () => void;
|
||||
|
||||
@state() private _message: AgentMessage | null = null;
|
||||
private _pendingMessage: AgentMessage | null = null;
|
||||
private _updateScheduled = false;
|
||||
private _immediateUpdate = false;
|
||||
|
||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
||||
return this;
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.style.display = "block";
|
||||
}
|
||||
|
||||
// Public method to update the message with batching for performance
|
||||
public setMessage(message: AgentMessage | null, immediate = false) {
|
||||
// Store the latest message
|
||||
this._pendingMessage = message;
|
||||
|
||||
// If this is an immediate update (like clearing), apply it right away
|
||||
if (immediate || message === null) {
|
||||
this._immediateUpdate = true;
|
||||
this._message = message;
|
||||
this.requestUpdate();
|
||||
// Cancel any pending updates since we're clearing
|
||||
this._pendingMessage = null;
|
||||
this._updateScheduled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise batch updates for performance during streaming
|
||||
if (!this._updateScheduled) {
|
||||
this._updateScheduled = true;
|
||||
|
||||
requestAnimationFrame(async () => {
|
||||
// Only apply the update if we haven't been cleared
|
||||
if (!this._immediateUpdate && this._pendingMessage !== null) {
|
||||
// Deep clone the message to ensure Lit detects changes in nested properties
|
||||
// (like toolCall.arguments being mutated during streaming)
|
||||
this._message = JSON.parse(JSON.stringify(this._pendingMessage));
|
||||
this.requestUpdate();
|
||||
}
|
||||
// Reset for next batch
|
||||
this._pendingMessage = null;
|
||||
this._updateScheduled = false;
|
||||
this._immediateUpdate = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
override render() {
|
||||
// Show loading indicator if loading but no message yet
|
||||
if (!this._message) {
|
||||
if (this.isStreaming)
|
||||
return html`<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" || msg.role === "user-with-attachments") {
|
||||
// Skip standalone tool result in streaming; the stable list will render it immediiately
|
||||
return html``;
|
||||
} else if (msg.role === "assistant") {
|
||||
// Assistant message - render inline tool messages during streaming
|
||||
return html`
|
||||
<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}
|
||||
.onCostClick=${this.onCostClick}
|
||||
></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,
|
||||
);
|
||||
}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
import { icon } from "@mariozechner/mini-lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { ChevronRight } from "lucide";
|
||||
|
||||
@customElement("thinking-block")
|
||||
export class ThinkingBlock extends LitElement {
|
||||
@property() content!: string;
|
||||
@property({ type: Boolean }) isStreaming = false;
|
||||
@state() private isExpanded = false;
|
||||
|
||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
||||
return this;
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.style.display = "block";
|
||||
}
|
||||
|
||||
private toggleExpanded() {
|
||||
this.isExpanded = !this.isExpanded;
|
||||
}
|
||||
|
||||
override render() {
|
||||
const shimmerClasses = this.isStreaming
|
||||
? "animate-shimmer bg-gradient-to-r from-muted-foreground via-foreground to-muted-foreground bg-[length:200%_100%] bg-clip-text text-transparent"
|
||||
: "";
|
||||
|
||||
return html`
|
||||
<div class="thinking-block">
|
||||
<div
|
||||
class="thinking-header cursor-pointer select-none flex items-center gap-2 py-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
@click=${this.toggleExpanded}
|
||||
>
|
||||
<span
|
||||
class="transition-transform inline-block ${this.isExpanded
|
||||
? "rotate-90"
|
||||
: ""}"
|
||||
>${icon(ChevronRight, "sm")}</span
|
||||
>
|
||||
<span class="${shimmerClasses}">Thinking...</span>
|
||||
</div>
|
||||
${this.isExpanded
|
||||
? html`<markdown-block
|
||||
.content=${this.content}
|
||||
.isThinking=${true}
|
||||
></markdown-block>`
|
||||
: ""}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import type { TemplateResult } from "lit";
|
||||
|
||||
// Extract role type from AppMessage union
|
||||
export type MessageRole = AgentMessage["role"];
|
||||
|
||||
// Generic message renderer typed to specific message type
|
||||
export interface MessageRenderer<TMessage extends AgentMessage = AgentMessage> {
|
||||
render(message: TMessage): TemplateResult;
|
||||
}
|
||||
|
||||
// Registry of custom message renderers by role
|
||||
const messageRenderers = new Map<MessageRole, MessageRenderer<any>>();
|
||||
|
||||
export function registerMessageRenderer<TRole extends MessageRole>(
|
||||
role: TRole,
|
||||
renderer: MessageRenderer<Extract<AgentMessage, { role: TRole }>>,
|
||||
): void {
|
||||
messageRenderers.set(role, renderer);
|
||||
}
|
||||
|
||||
export function getMessageRenderer(
|
||||
role: MessageRole,
|
||||
): MessageRenderer | undefined {
|
||||
return messageRenderers.get(role);
|
||||
}
|
||||
|
||||
export function renderMessage(
|
||||
message: AgentMessage,
|
||||
): TemplateResult | undefined {
|
||||
return messageRenderers.get(message.role)?.render(message);
|
||||
}
|
||||
|
|
@ -1,239 +0,0 @@
|
|||
import {
|
||||
ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RO,
|
||||
ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RW,
|
||||
} from "../../prompts/prompts.js";
|
||||
import type { SandboxRuntimeProvider } from "./SandboxRuntimeProvider.js";
|
||||
|
||||
// Define minimal interface for ArtifactsPanel to avoid circular dependencies
|
||||
interface ArtifactsPanelLike {
|
||||
artifacts: Map<string, { content: string }>;
|
||||
tool: {
|
||||
execute(
|
||||
toolCallId: string,
|
||||
args: { command: string; filename: string; content?: string },
|
||||
): Promise<any>;
|
||||
};
|
||||
}
|
||||
|
||||
interface AgentLike {
|
||||
appendMessage(message: any): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Artifacts Runtime Provider
|
||||
*
|
||||
* Provides programmatic access to session artifacts from sandboxed code.
|
||||
* Allows code to create, read, update, and delete artifacts dynamically.
|
||||
* Supports both online (extension) and offline (downloaded HTML) modes.
|
||||
*/
|
||||
export class ArtifactsRuntimeProvider implements SandboxRuntimeProvider {
|
||||
constructor(
|
||||
private artifactsPanel: ArtifactsPanelLike,
|
||||
private agent?: AgentLike,
|
||||
private readWrite: boolean = true,
|
||||
) {}
|
||||
|
||||
getData(): Record<string, any> {
|
||||
// Inject artifact snapshot for offline mode
|
||||
const snapshot: Record<string, string> = {};
|
||||
this.artifactsPanel.artifacts.forEach((artifact, filename) => {
|
||||
snapshot[filename] = artifact.content;
|
||||
});
|
||||
return { artifacts: snapshot };
|
||||
}
|
||||
|
||||
getRuntime(): (sandboxId: string) => void {
|
||||
// This function will be stringified, so no external references!
|
||||
return (_sandboxId: string) => {
|
||||
// Auto-parse/stringify for .json files
|
||||
const isJsonFile = (filename: string) => filename.endsWith(".json");
|
||||
|
||||
(window as any).listArtifacts = async (): Promise<string[]> => {
|
||||
// Online: ask extension
|
||||
if ((window as any).sendRuntimeMessage) {
|
||||
const response = await (window as any).sendRuntimeMessage({
|
||||
type: "artifact-operation",
|
||||
action: "list",
|
||||
});
|
||||
if (!response.success) throw new Error(response.error);
|
||||
return response.result;
|
||||
}
|
||||
// Offline: return snapshot keys
|
||||
else {
|
||||
return Object.keys((window as any).artifacts || {});
|
||||
}
|
||||
};
|
||||
|
||||
(window as any).getArtifact = async (filename: string): Promise<any> => {
|
||||
let content: string;
|
||||
|
||||
// Online: ask extension
|
||||
if ((window as any).sendRuntimeMessage) {
|
||||
const response = await (window as any).sendRuntimeMessage({
|
||||
type: "artifact-operation",
|
||||
action: "get",
|
||||
filename,
|
||||
});
|
||||
if (!response.success) throw new Error(response.error);
|
||||
content = response.result;
|
||||
}
|
||||
// Offline: read snapshot
|
||||
else {
|
||||
if (!(window as any).artifacts?.[filename]) {
|
||||
throw new Error(`Artifact not found (offline mode): ${filename}`);
|
||||
}
|
||||
content = (window as any).artifacts[filename];
|
||||
}
|
||||
|
||||
// Auto-parse .json files
|
||||
if (isJsonFile(filename)) {
|
||||
try {
|
||||
return JSON.parse(content);
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to parse JSON from ${filename}: ${e}`);
|
||||
}
|
||||
}
|
||||
return content;
|
||||
};
|
||||
|
||||
(window as any).createOrUpdateArtifact = async (
|
||||
filename: string,
|
||||
content: any,
|
||||
mimeType?: string,
|
||||
): Promise<void> => {
|
||||
if (!(window as any).sendRuntimeMessage) {
|
||||
throw new Error(
|
||||
"Cannot create/update artifacts in offline mode (read-only)",
|
||||
);
|
||||
}
|
||||
|
||||
let finalContent = content;
|
||||
// Auto-stringify .json files
|
||||
if (isJsonFile(filename) && typeof content !== "string") {
|
||||
finalContent = JSON.stringify(content, null, 2);
|
||||
} else if (typeof content !== "string") {
|
||||
finalContent = JSON.stringify(content, null, 2);
|
||||
}
|
||||
|
||||
const response = await (window as any).sendRuntimeMessage({
|
||||
type: "artifact-operation",
|
||||
action: "createOrUpdate",
|
||||
filename,
|
||||
content: finalContent,
|
||||
mimeType,
|
||||
});
|
||||
if (!response.success) throw new Error(response.error);
|
||||
};
|
||||
|
||||
(window as any).deleteArtifact = async (
|
||||
filename: string,
|
||||
): Promise<void> => {
|
||||
if (!(window as any).sendRuntimeMessage) {
|
||||
throw new Error(
|
||||
"Cannot delete artifacts in offline mode (read-only)",
|
||||
);
|
||||
}
|
||||
|
||||
const response = await (window as any).sendRuntimeMessage({
|
||||
type: "artifact-operation",
|
||||
action: "delete",
|
||||
filename,
|
||||
});
|
||||
if (!response.success) throw new Error(response.error);
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
async handleMessage(
|
||||
message: any,
|
||||
respond: (response: any) => void,
|
||||
): Promise<void> {
|
||||
if (message.type !== "artifact-operation") {
|
||||
return;
|
||||
}
|
||||
|
||||
const { action, filename, content } = message;
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case "list": {
|
||||
const filenames = Array.from(this.artifactsPanel.artifacts.keys());
|
||||
respond({ success: true, result: filenames });
|
||||
break;
|
||||
}
|
||||
|
||||
case "get": {
|
||||
const artifact = this.artifactsPanel.artifacts.get(filename);
|
||||
if (!artifact) {
|
||||
respond({
|
||||
success: false,
|
||||
error: `Artifact not found: ${filename}`,
|
||||
});
|
||||
} else {
|
||||
respond({ success: true, result: artifact.content });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "createOrUpdate": {
|
||||
try {
|
||||
const exists = this.artifactsPanel.artifacts.has(filename);
|
||||
const command = exists ? "rewrite" : "create";
|
||||
const action = exists ? "update" : "create";
|
||||
|
||||
await this.artifactsPanel.tool.execute("", {
|
||||
command,
|
||||
filename,
|
||||
content,
|
||||
});
|
||||
this.agent?.appendMessage({
|
||||
role: "artifact",
|
||||
action,
|
||||
filename,
|
||||
content,
|
||||
...(action === "create" && { title: filename }),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
respond({ success: true });
|
||||
} catch (err: any) {
|
||||
respond({ success: false, error: err.message });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "delete": {
|
||||
try {
|
||||
await this.artifactsPanel.tool.execute("", {
|
||||
command: "delete",
|
||||
filename,
|
||||
});
|
||||
this.agent?.appendMessage({
|
||||
role: "artifact",
|
||||
action: "delete",
|
||||
filename,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
respond({ success: true });
|
||||
} catch (err: any) {
|
||||
respond({ success: false, error: err.message });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
respond({
|
||||
success: false,
|
||||
error: `Unknown artifact action: ${action}`,
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
respond({ success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return this.readWrite
|
||||
? ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RW
|
||||
: ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RO;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
import { ATTACHMENTS_RUNTIME_DESCRIPTION } from "../../prompts/prompts.js";
|
||||
import type { Attachment } from "../../utils/attachment-utils.js";
|
||||
import type { SandboxRuntimeProvider } from "./SandboxRuntimeProvider.js";
|
||||
|
||||
/**
|
||||
* Attachments Runtime Provider
|
||||
*
|
||||
* OPTIONAL provider that provides file access APIs to sandboxed code.
|
||||
* Only needed when attachments are present.
|
||||
* Attachments are read-only snapshot data - no messaging needed.
|
||||
*/
|
||||
export class AttachmentsRuntimeProvider implements SandboxRuntimeProvider {
|
||||
constructor(private attachments: Attachment[]) {}
|
||||
|
||||
getData(): Record<string, any> {
|
||||
const attachmentsData = this.attachments.map((a) => ({
|
||||
id: a.id,
|
||||
fileName: a.fileName,
|
||||
mimeType: a.mimeType,
|
||||
size: a.size,
|
||||
content: a.content,
|
||||
extractedText: a.extractedText,
|
||||
}));
|
||||
|
||||
return { attachments: attachmentsData };
|
||||
}
|
||||
|
||||
getRuntime(): (sandboxId: string) => void {
|
||||
// This function will be stringified, so no external references!
|
||||
// These functions read directly from window.attachments
|
||||
// Works both online AND offline (no messaging needed!)
|
||||
return (_sandboxId: string) => {
|
||||
(window as any).listAttachments = () =>
|
||||
((window as any).attachments || []).map((a: any) => ({
|
||||
id: a.id,
|
||||
fileName: a.fileName,
|
||||
mimeType: a.mimeType,
|
||||
size: a.size,
|
||||
}));
|
||||
|
||||
(window as any).readTextAttachment = (attachmentId: string) => {
|
||||
const a = ((window as any).attachments || []).find(
|
||||
(x: any) => x.id === attachmentId,
|
||||
);
|
||||
if (!a) throw new Error(`Attachment not found: ${attachmentId}`);
|
||||
if (a.extractedText) return a.extractedText;
|
||||
try {
|
||||
return atob(a.content);
|
||||
} catch {
|
||||
throw new Error(`Failed to decode text content for: ${attachmentId}`);
|
||||
}
|
||||
};
|
||||
|
||||
(window as any).readBinaryAttachment = (attachmentId: string) => {
|
||||
const a = ((window as any).attachments || []).find(
|
||||
(x: any) => x.id === attachmentId,
|
||||
);
|
||||
if (!a) throw new Error(`Attachment not found: ${attachmentId}`);
|
||||
const bin = atob(a.content);
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||
return bytes;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return ATTACHMENTS_RUNTIME_DESCRIPTION;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,197 +0,0 @@
|
|||
import type { SandboxRuntimeProvider } from "./SandboxRuntimeProvider.js";
|
||||
|
||||
export interface ConsoleLog {
|
||||
type: "log" | "warn" | "error" | "info";
|
||||
text: string;
|
||||
args?: unknown[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Console Runtime Provider
|
||||
*
|
||||
* REQUIRED provider that should always be included first.
|
||||
* Provides console capture, error handling, and execution lifecycle management.
|
||||
* Collects console output for retrieval by caller.
|
||||
*/
|
||||
export class ConsoleRuntimeProvider implements SandboxRuntimeProvider {
|
||||
private logs: ConsoleLog[] = [];
|
||||
private completionError: { message: string; stack: string } | null = null;
|
||||
private completed = false;
|
||||
|
||||
getData(): Record<string, any> {
|
||||
// No data needed
|
||||
return {};
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
getRuntime(): (sandboxId: string) => void {
|
||||
return (_sandboxId: string) => {
|
||||
// Store truly original console methods on first wrap only
|
||||
// This prevents accumulation of wrapper functions across multiple executions
|
||||
if (!(window as any).__originalConsole) {
|
||||
(window as any).__originalConsole = {
|
||||
log: console.log.bind(console),
|
||||
error: console.error.bind(console),
|
||||
warn: console.warn.bind(console),
|
||||
info: console.info.bind(console),
|
||||
};
|
||||
}
|
||||
|
||||
// Always use the truly original console, not the current (possibly wrapped) one
|
||||
const originalConsole = (window as any).__originalConsole;
|
||||
|
||||
// Track pending send promises to wait for them in onCompleted
|
||||
const pendingSends: Promise<any>[] = [];
|
||||
|
||||
["log", "error", "warn", "info"].forEach((method) => {
|
||||
(console as any)[method] = (...args: any[]) => {
|
||||
const text = args
|
||||
.map((arg) => {
|
||||
try {
|
||||
return typeof arg === "object"
|
||||
? JSON.stringify(arg)
|
||||
: String(arg);
|
||||
} catch {
|
||||
return String(arg);
|
||||
}
|
||||
})
|
||||
.join(" ");
|
||||
|
||||
// Always log locally too (using truly original console)
|
||||
(originalConsole as any)[method].apply(console, args);
|
||||
|
||||
// Send immediately and track the promise (only in extension context)
|
||||
if ((window as any).sendRuntimeMessage) {
|
||||
const sendPromise = (window as any)
|
||||
.sendRuntimeMessage({
|
||||
type: "console",
|
||||
method,
|
||||
text,
|
||||
args,
|
||||
})
|
||||
.catch(() => {});
|
||||
pendingSends.push(sendPromise);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Register completion callback to wait for all pending sends
|
||||
if ((window as any).onCompleted) {
|
||||
(window as any).onCompleted(async (_success: boolean) => {
|
||||
// Wait for all pending console sends to complete
|
||||
if (pendingSends.length > 0) {
|
||||
await Promise.all(pendingSends);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Track errors for HTML artifacts
|
||||
let lastError: { message: string; stack: string } | null = null;
|
||||
|
||||
// Error handlers - track errors but don't log them
|
||||
// (they'll be shown via execution-error message)
|
||||
window.addEventListener("error", (e) => {
|
||||
const text = `${e.error?.stack || e.message || String(e)} at line ${e.lineno || "?"}:${e.colno || "?"}`;
|
||||
|
||||
lastError = {
|
||||
message: e.error?.message || e.message || String(e),
|
||||
stack: e.error?.stack || text,
|
||||
};
|
||||
});
|
||||
|
||||
window.addEventListener("unhandledrejection", (e) => {
|
||||
const text = `Unhandled promise rejection: ${e.reason?.message || e.reason || "Unknown error"}`;
|
||||
|
||||
lastError = {
|
||||
message:
|
||||
e.reason?.message ||
|
||||
String(e.reason) ||
|
||||
"Unhandled promise rejection",
|
||||
stack: e.reason?.stack || text,
|
||||
};
|
||||
});
|
||||
|
||||
// Expose complete() method for user code to call
|
||||
let completionSent = false;
|
||||
(window as any).complete = async (
|
||||
error?: { message: string; stack: string },
|
||||
returnValue?: any,
|
||||
) => {
|
||||
if (completionSent) return;
|
||||
completionSent = true;
|
||||
|
||||
const finalError = error || lastError;
|
||||
|
||||
if ((window as any).sendRuntimeMessage) {
|
||||
if (finalError) {
|
||||
await (window as any).sendRuntimeMessage({
|
||||
type: "execution-error",
|
||||
error: finalError,
|
||||
});
|
||||
} else {
|
||||
await (window as any).sendRuntimeMessage({
|
||||
type: "execution-complete",
|
||||
returnValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
async handleMessage(
|
||||
message: any,
|
||||
respond: (response: any) => void,
|
||||
): Promise<void> {
|
||||
if (message.type === "console") {
|
||||
// Collect console output
|
||||
this.logs.push({
|
||||
type:
|
||||
message.method === "error"
|
||||
? "error"
|
||||
: message.method === "warn"
|
||||
? "warn"
|
||||
: message.method === "info"
|
||||
? "info"
|
||||
: "log",
|
||||
text: message.text,
|
||||
args: message.args,
|
||||
});
|
||||
// Acknowledge receipt
|
||||
respond({ success: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get collected console logs
|
||||
*/
|
||||
getLogs(): ConsoleLog[] {
|
||||
return this.logs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get completion status
|
||||
*/
|
||||
isCompleted(): boolean {
|
||||
return this.completed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get completion error if any
|
||||
*/
|
||||
getCompletionError(): { message: string; stack: string } | null {
|
||||
return this.completionError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset state for reuse
|
||||
*/
|
||||
reset(): void {
|
||||
this.logs = [];
|
||||
this.completionError = null;
|
||||
this.completed = false;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,121 +0,0 @@
|
|||
import type { SandboxRuntimeProvider } from "./SandboxRuntimeProvider.js";
|
||||
|
||||
export interface DownloadableFile {
|
||||
fileName: string;
|
||||
content: string | Uint8Array;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* File Download Runtime Provider
|
||||
*
|
||||
* Provides returnDownloadableFile() for creating user downloads.
|
||||
* Files returned this way are NOT accessible to the LLM later (one-time download).
|
||||
* Works both online (sends to extension) and offline (triggers browser download directly).
|
||||
* Collects files for retrieval by caller.
|
||||
*/
|
||||
export class FileDownloadRuntimeProvider implements SandboxRuntimeProvider {
|
||||
private files: DownloadableFile[] = [];
|
||||
|
||||
getData(): Record<string, any> {
|
||||
// No data needed
|
||||
return {};
|
||||
}
|
||||
|
||||
getRuntime(): (sandboxId: string) => void {
|
||||
return (_sandboxId: string) => {
|
||||
(window as any).returnDownloadableFile = async (
|
||||
fileName: string,
|
||||
content: any,
|
||||
mimeType?: string,
|
||||
) => {
|
||||
let finalContent: any, finalMimeType: string;
|
||||
|
||||
if (content instanceof Blob) {
|
||||
const arrayBuffer = await content.arrayBuffer();
|
||||
finalContent = new Uint8Array(arrayBuffer);
|
||||
finalMimeType =
|
||||
mimeType || content.type || "application/octet-stream";
|
||||
if (!mimeType && !content.type) {
|
||||
throw new Error(
|
||||
"returnDownloadableFile: MIME type is required for Blob content. Please provide a mimeType parameter (e.g., 'image/png').",
|
||||
);
|
||||
}
|
||||
} else if (content instanceof Uint8Array) {
|
||||
finalContent = content;
|
||||
if (!mimeType) {
|
||||
throw new Error(
|
||||
"returnDownloadableFile: MIME type is required for Uint8Array content. Please provide a mimeType parameter (e.g., 'image/png').",
|
||||
);
|
||||
}
|
||||
finalMimeType = mimeType;
|
||||
} else if (typeof content === "string") {
|
||||
finalContent = content;
|
||||
finalMimeType = mimeType || "text/plain";
|
||||
} else {
|
||||
finalContent = JSON.stringify(content, null, 2);
|
||||
finalMimeType = mimeType || "application/json";
|
||||
}
|
||||
|
||||
// Send to extension if in extension context (online mode)
|
||||
if ((window as any).sendRuntimeMessage) {
|
||||
const response = await (window as any).sendRuntimeMessage({
|
||||
type: "file-returned",
|
||||
fileName,
|
||||
content: finalContent,
|
||||
mimeType: finalMimeType,
|
||||
});
|
||||
if (response.error) throw new Error(response.error);
|
||||
} else {
|
||||
// Offline mode: trigger browser download directly
|
||||
const blob = new Blob(
|
||||
[finalContent instanceof Uint8Array ? finalContent : finalContent],
|
||||
{
|
||||
type: finalMimeType,
|
||||
},
|
||||
);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
async handleMessage(
|
||||
message: any,
|
||||
respond: (response: any) => void,
|
||||
): Promise<void> {
|
||||
if (message.type === "file-returned") {
|
||||
// Collect file for caller
|
||||
this.files.push({
|
||||
fileName: message.fileName,
|
||||
content: message.content,
|
||||
mimeType: message.mimeType,
|
||||
});
|
||||
|
||||
respond({ success: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get collected files
|
||||
*/
|
||||
getFiles(): DownloadableFile[] {
|
||||
return this.files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset state for reuse
|
||||
*/
|
||||
reset(): void {
|
||||
this.files = [];
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return "returnDownloadableFile(filename, content, mimeType?) - Create downloadable file for user (one-time download, not accessible later)";
|
||||
}
|
||||
}
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
/**
|
||||
* Generates sendRuntimeMessage() function for injection into execution contexts.
|
||||
* Provides unified messaging API that works in both sandbox iframe and user script contexts.
|
||||
*/
|
||||
|
||||
export type MessageType = "request-response" | "fire-and-forget";
|
||||
|
||||
export interface RuntimeMessageBridgeOptions {
|
||||
context: "sandbox-iframe" | "user-script";
|
||||
sandboxId: string;
|
||||
}
|
||||
|
||||
// biome-ignore lint/complexity/noStaticOnlyClass: fine
|
||||
export class RuntimeMessageBridge {
|
||||
/**
|
||||
* Generate sendRuntimeMessage() function as injectable string.
|
||||
* Returns the function source code to be injected into target context.
|
||||
*/
|
||||
static generateBridgeCode(options: RuntimeMessageBridgeOptions): string {
|
||||
if (options.context === "sandbox-iframe") {
|
||||
return RuntimeMessageBridge.generateSandboxBridge(options.sandboxId);
|
||||
} else {
|
||||
return RuntimeMessageBridge.generateUserScriptBridge(options.sandboxId);
|
||||
}
|
||||
}
|
||||
|
||||
private static generateSandboxBridge(sandboxId: string): string {
|
||||
// Returns stringified function that uses window.parent.postMessage
|
||||
return `
|
||||
window.__completionCallbacks = [];
|
||||
window.sendRuntimeMessage = async (message) => {
|
||||
const messageId = 'msg_' + Date.now() + '_' + Math.random().toString(36).substring(2, 9);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const handler = (e) => {
|
||||
if (e.data.type === 'runtime-response' && e.data.messageId === messageId) {
|
||||
window.removeEventListener('message', handler);
|
||||
if (e.data.success) {
|
||||
resolve(e.data);
|
||||
} else {
|
||||
reject(new Error(e.data.error || 'Operation failed'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handler);
|
||||
|
||||
window.parent.postMessage({
|
||||
...message,
|
||||
sandboxId: ${JSON.stringify(sandboxId)},
|
||||
messageId: messageId
|
||||
}, '*');
|
||||
|
||||
// Timeout after 30s
|
||||
setTimeout(() => {
|
||||
window.removeEventListener('message', handler);
|
||||
reject(new Error('Runtime message timeout'));
|
||||
}, 30000);
|
||||
});
|
||||
};
|
||||
window.onCompleted = (callback) => {
|
||||
window.__completionCallbacks.push(callback);
|
||||
};
|
||||
`.trim();
|
||||
}
|
||||
|
||||
private static generateUserScriptBridge(sandboxId: string): string {
|
||||
// Returns stringified function that uses chrome.runtime.sendMessage
|
||||
return `
|
||||
window.__completionCallbacks = [];
|
||||
window.sendRuntimeMessage = async (message) => {
|
||||
return await chrome.runtime.sendMessage({
|
||||
...message,
|
||||
sandboxId: ${JSON.stringify(sandboxId)}
|
||||
});
|
||||
};
|
||||
window.onCompleted = (callback) => {
|
||||
window.__completionCallbacks.push(callback);
|
||||
};
|
||||
`.trim();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,239 +0,0 @@
|
|||
import type { SandboxRuntimeProvider } from "./SandboxRuntimeProvider.js";
|
||||
|
||||
// Type declaration for chrome extension API (when available)
|
||||
declare const chrome: any;
|
||||
|
||||
/**
|
||||
* Message consumer interface - components that want to receive messages from sandboxes
|
||||
*/
|
||||
export interface MessageConsumer {
|
||||
/**
|
||||
* Handle a message from a sandbox.
|
||||
* All consumers receive all messages - decide internally what to handle.
|
||||
*/
|
||||
handleMessage(message: any): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sandbox context - tracks active sandboxes and their consumers
|
||||
*/
|
||||
interface SandboxContext {
|
||||
sandboxId: string;
|
||||
iframe: HTMLIFrameElement | null; // null until setSandboxIframe() or null for user scripts
|
||||
providers: SandboxRuntimeProvider[];
|
||||
consumers: Set<MessageConsumer>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Centralized message router for all runtime communication.
|
||||
*
|
||||
* This singleton replaces all individual window.addEventListener("message") calls
|
||||
* with a single global listener that routes messages to the appropriate handlers.
|
||||
* Also handles user script messages from chrome.runtime.onUserScriptMessage.
|
||||
*
|
||||
* Benefits:
|
||||
* - Single global listener instead of multiple independent listeners
|
||||
* - Automatic cleanup when sandboxes are destroyed
|
||||
* - Support for bidirectional communication (providers) and broadcasting (consumers)
|
||||
* - Works with both sandbox iframes and user scripts
|
||||
* - Clear lifecycle management
|
||||
*/
|
||||
export class RuntimeMessageRouter {
|
||||
private sandboxes = new Map<string, SandboxContext>();
|
||||
private messageListener: ((e: MessageEvent) => void) | null = null;
|
||||
private userScriptMessageListener:
|
||||
| ((
|
||||
message: any,
|
||||
sender: any,
|
||||
sendResponse: (response: any) => void,
|
||||
) => boolean)
|
||||
| null = null;
|
||||
|
||||
/**
|
||||
* Register a new sandbox with its runtime providers.
|
||||
* Call this BEFORE creating the iframe (for sandbox contexts) or executing user script.
|
||||
*/
|
||||
registerSandbox(
|
||||
sandboxId: string,
|
||||
providers: SandboxRuntimeProvider[],
|
||||
consumers: MessageConsumer[],
|
||||
): void {
|
||||
this.sandboxes.set(sandboxId, {
|
||||
sandboxId,
|
||||
iframe: null, // Will be set via setSandboxIframe() for sandbox contexts
|
||||
providers,
|
||||
consumers: new Set(consumers),
|
||||
});
|
||||
|
||||
// Setup global listener if not already done
|
||||
this.setupListener();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the iframe reference for a sandbox.
|
||||
* Call this AFTER creating the iframe.
|
||||
* This is needed so providers can send responses back to the sandbox.
|
||||
*/
|
||||
setSandboxIframe(sandboxId: string, iframe: HTMLIFrameElement): void {
|
||||
const context = this.sandboxes.get(sandboxId);
|
||||
if (context) {
|
||||
context.iframe = iframe;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a sandbox and remove all its consumers.
|
||||
* Call this when the sandbox is destroyed.
|
||||
*/
|
||||
unregisterSandbox(sandboxId: string): void {
|
||||
this.sandboxes.delete(sandboxId);
|
||||
|
||||
// If no more sandboxes, remove global listeners
|
||||
if (this.sandboxes.size === 0) {
|
||||
// Remove iframe listener
|
||||
if (this.messageListener) {
|
||||
window.removeEventListener("message", this.messageListener);
|
||||
this.messageListener = null;
|
||||
}
|
||||
|
||||
// Remove user script listener
|
||||
if (
|
||||
this.userScriptMessageListener &&
|
||||
typeof chrome !== "undefined" &&
|
||||
chrome.runtime?.onUserScriptMessage
|
||||
) {
|
||||
chrome.runtime.onUserScriptMessage.removeListener(
|
||||
this.userScriptMessageListener,
|
||||
);
|
||||
this.userScriptMessageListener = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a message consumer for a sandbox.
|
||||
* Consumers receive broadcast messages (console, execution-complete, etc.)
|
||||
*/
|
||||
addConsumer(sandboxId: string, consumer: MessageConsumer): void {
|
||||
const context = this.sandboxes.get(sandboxId);
|
||||
if (context) {
|
||||
context.consumers.add(consumer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a message consumer from a sandbox.
|
||||
*/
|
||||
removeConsumer(sandboxId: string, consumer: MessageConsumer): void {
|
||||
const context = this.sandboxes.get(sandboxId);
|
||||
if (context) {
|
||||
context.consumers.delete(consumer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup the global message listeners (called automatically)
|
||||
*/
|
||||
private setupListener(): void {
|
||||
// Setup sandbox iframe listener
|
||||
if (!this.messageListener) {
|
||||
this.messageListener = async (e: MessageEvent) => {
|
||||
const { sandboxId, messageId } = e.data;
|
||||
if (!sandboxId) return;
|
||||
|
||||
const context = this.sandboxes.get(sandboxId);
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create respond() function for bidirectional communication
|
||||
const respond = (response: any) => {
|
||||
context.iframe?.contentWindow?.postMessage(
|
||||
{
|
||||
type: "runtime-response",
|
||||
messageId,
|
||||
sandboxId,
|
||||
...response,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
};
|
||||
|
||||
// 1. Try provider handlers first (for bidirectional comm)
|
||||
for (const provider of context.providers) {
|
||||
if (provider.handleMessage) {
|
||||
await provider.handleMessage(e.data, respond);
|
||||
// Don't stop - let consumers also handle the message
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Broadcast to consumers (one-way messages or lifecycle events)
|
||||
for (const consumer of context.consumers) {
|
||||
await consumer.handleMessage(e.data);
|
||||
// Don't stop - let all consumers see the message
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("message", this.messageListener);
|
||||
}
|
||||
|
||||
// Setup user script message listener
|
||||
if (!this.userScriptMessageListener) {
|
||||
// Guard: check if we're in extension context
|
||||
if (
|
||||
typeof chrome === "undefined" ||
|
||||
!chrome.runtime?.onUserScriptMessage
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.userScriptMessageListener = (
|
||||
message: any,
|
||||
_sender: any,
|
||||
sendResponse: (response: any) => void,
|
||||
) => {
|
||||
const { sandboxId } = message;
|
||||
if (!sandboxId) return false;
|
||||
|
||||
const context = this.sandboxes.get(sandboxId);
|
||||
if (!context) return false;
|
||||
|
||||
const respond = (response: any) => {
|
||||
sendResponse({
|
||||
...response,
|
||||
sandboxId,
|
||||
});
|
||||
};
|
||||
|
||||
// Route to providers (async)
|
||||
(async () => {
|
||||
// 1. Try provider handlers first (for bidirectional comm)
|
||||
for (const provider of context.providers) {
|
||||
if (provider.handleMessage) {
|
||||
await provider.handleMessage(message, respond);
|
||||
// Don't stop - let consumers also handle the message
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Broadcast to consumers (one-way messages or lifecycle events)
|
||||
for (const consumer of context.consumers) {
|
||||
await consumer.handleMessage(message);
|
||||
// Don't stop - let all consumers see the message
|
||||
}
|
||||
})();
|
||||
|
||||
return true; // Indicates async response
|
||||
};
|
||||
|
||||
chrome.runtime.onUserScriptMessage.addListener(
|
||||
this.userScriptMessageListener,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Global singleton instance.
|
||||
* Import this from wherever you need to interact with the message router.
|
||||
*/
|
||||
export const RUNTIME_MESSAGE_ROUTER = new RuntimeMessageRouter();
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
/**
|
||||
* Interface for providing runtime capabilities to sandboxed iframes.
|
||||
* Each provider injects data and runtime functions into the sandbox context.
|
||||
*/
|
||||
export interface SandboxRuntimeProvider {
|
||||
/**
|
||||
* Returns data to inject into window scope.
|
||||
* Keys become window properties (e.g., { attachments: [...] } -> window.attachments)
|
||||
*/
|
||||
getData(): Record<string, any>;
|
||||
|
||||
/**
|
||||
* Returns a runtime function that will be stringified and executed in the sandbox.
|
||||
* The function receives sandboxId and has access to data from getData() via window.
|
||||
*
|
||||
* IMPORTANT: This function will be converted to string via .toString() and injected
|
||||
* into the sandbox, so it cannot reference external variables or imports.
|
||||
*/
|
||||
getRuntime(): (sandboxId: string) => void;
|
||||
|
||||
/**
|
||||
* Optional message handler for bidirectional communication.
|
||||
* All providers receive all messages - decide internally what to handle.
|
||||
*
|
||||
* @param message - The message from the sandbox
|
||||
* @param respond - Function to send a response back to the sandbox
|
||||
*/
|
||||
handleMessage?(message: any, respond: (response: any) => void): Promise<void>;
|
||||
|
||||
/**
|
||||
* Optional documentation describing what globals/functions this provider injects.
|
||||
* This will be appended to tool descriptions dynamically so the LLM knows what's available.
|
||||
*/
|
||||
getDescription(): string;
|
||||
|
||||
/**
|
||||
* Optional lifecycle callback invoked when sandbox execution starts.
|
||||
* Providers can use this to track abort signals for cancellation of async operations.
|
||||
*
|
||||
* @param sandboxId - The unique identifier for this sandbox execution
|
||||
* @param signal - Optional AbortSignal that will be triggered if execution is cancelled
|
||||
*/
|
||||
onExecutionStart?(sandboxId: string, signal?: AbortSignal): void;
|
||||
|
||||
/**
|
||||
* Optional lifecycle callback invoked when sandbox execution ends (success, error, or abort).
|
||||
* Providers can use this to clean up any resources associated with the sandbox.
|
||||
*
|
||||
* @param sandboxId - The unique identifier for this sandbox execution
|
||||
*/
|
||||
onExecutionEnd?(sandboxId: string): void;
|
||||
}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
import { customElement, state } from "lit/decorators.js";
|
||||
import "../components/ProviderKeyInput.js";
|
||||
import {
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
} from "@mariozechner/mini-lit/dist/Dialog.js";
|
||||
import { DialogBase } from "@mariozechner/mini-lit/dist/DialogBase.js";
|
||||
import { html } from "lit";
|
||||
import { getAppStorage } from "../storage/app-storage.js";
|
||||
import { i18n } from "../utils/i18n.js";
|
||||
|
||||
@customElement("api-key-prompt-dialog")
|
||||
export class ApiKeyPromptDialog extends DialogBase {
|
||||
@state() private provider = "";
|
||||
|
||||
private resolvePromise?: (success: boolean) => void;
|
||||
private unsubscribe?: () => void;
|
||||
|
||||
protected modalWidth = "min(500px, 90vw)";
|
||||
protected modalHeight = "auto";
|
||||
|
||||
static async prompt(provider: string): Promise<boolean> {
|
||||
const dialog = new ApiKeyPromptDialog();
|
||||
dialog.provider = provider;
|
||||
dialog.open();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
dialog.resolvePromise = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
override async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
// Poll for key existence - when key is added, resolve and close
|
||||
const checkInterval = setInterval(async () => {
|
||||
const hasKey = !!(await getAppStorage().providerKeys.get(this.provider));
|
||||
if (hasKey) {
|
||||
clearInterval(checkInterval);
|
||||
if (this.resolvePromise) {
|
||||
this.resolvePromise(true);
|
||||
this.resolvePromise = undefined;
|
||||
}
|
||||
this.close();
|
||||
}
|
||||
}, 500);
|
||||
|
||||
this.unsubscribe = () => clearInterval(checkInterval);
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
if (this.unsubscribe) {
|
||||
this.unsubscribe();
|
||||
this.unsubscribe = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
override close() {
|
||||
super.close();
|
||||
if (this.resolvePromise) {
|
||||
this.resolvePromise(false);
|
||||
}
|
||||
}
|
||||
|
||||
protected override renderContent() {
|
||||
return html`
|
||||
${DialogContent({
|
||||
children: html`
|
||||
${DialogHeader({
|
||||
title: i18n("API Key Required"),
|
||||
})}
|
||||
<provider-key-input .provider=${this.provider}></provider-key-input>
|
||||
`,
|
||||
})}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,677 +0,0 @@
|
|||
import "@mariozechner/mini-lit/dist/ModeToggle.js";
|
||||
import { icon } from "@mariozechner/mini-lit";
|
||||
import { Button } from "@mariozechner/mini-lit/dist/Button.js";
|
||||
import { renderAsync } from "docx-preview";
|
||||
import { html, LitElement } from "lit";
|
||||
import { state } from "lit/decorators.js";
|
||||
import { Download, X } from "lucide";
|
||||
import * as pdfjsLib from "pdfjs-dist";
|
||||
import * as XLSX from "xlsx";
|
||||
import type { Attachment } from "../utils/attachment-utils.js";
|
||||
import { i18n } from "../utils/i18n.js";
|
||||
|
||||
type FileType = "image" | "pdf" | "docx" | "pptx" | "excel" | "text";
|
||||
|
||||
export class AttachmentOverlay extends LitElement {
|
||||
@state() private attachment?: Attachment;
|
||||
@state() private showExtractedText = false;
|
||||
@state() private error: string | null = null;
|
||||
|
||||
// Track current loading task to cancel if needed
|
||||
private currentLoadingTask: any = null;
|
||||
private onCloseCallback?: () => void;
|
||||
private boundHandleKeyDown?: (e: KeyboardEvent) => void;
|
||||
|
||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
||||
return this;
|
||||
}
|
||||
|
||||
static open(attachment: Attachment, onClose?: () => void) {
|
||||
const overlay = new AttachmentOverlay();
|
||||
overlay.attachment = attachment;
|
||||
overlay.onCloseCallback = onClose;
|
||||
document.body.appendChild(overlay);
|
||||
overlay.setupEventListeners();
|
||||
}
|
||||
|
||||
private setupEventListeners() {
|
||||
this.boundHandleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
this.close();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", this.boundHandleKeyDown);
|
||||
}
|
||||
|
||||
private close() {
|
||||
this.cleanup();
|
||||
if (this.boundHandleKeyDown) {
|
||||
window.removeEventListener("keydown", this.boundHandleKeyDown);
|
||||
}
|
||||
this.onCloseCallback?.();
|
||||
this.remove();
|
||||
}
|
||||
|
||||
private getFileType(): FileType {
|
||||
if (!this.attachment) return "text";
|
||||
|
||||
if (this.attachment.type === "image") return "image";
|
||||
if (this.attachment.mimeType === "application/pdf") return "pdf";
|
||||
if (this.attachment.mimeType?.includes("wordprocessingml")) return "docx";
|
||||
if (
|
||||
this.attachment.mimeType?.includes("presentationml") ||
|
||||
this.attachment.fileName.toLowerCase().endsWith(".pptx")
|
||||
)
|
||||
return "pptx";
|
||||
if (
|
||||
this.attachment.mimeType?.includes("spreadsheetml") ||
|
||||
this.attachment.mimeType?.includes("ms-excel") ||
|
||||
this.attachment.fileName.toLowerCase().endsWith(".xlsx") ||
|
||||
this.attachment.fileName.toLowerCase().endsWith(".xls")
|
||||
)
|
||||
return "excel";
|
||||
|
||||
return "text";
|
||||
}
|
||||
|
||||
private getFileTypeLabel(): string {
|
||||
const type = this.getFileType();
|
||||
switch (type) {
|
||||
case "pdf":
|
||||
return i18n("PDF");
|
||||
case "docx":
|
||||
return i18n("Document");
|
||||
case "pptx":
|
||||
return i18n("Presentation");
|
||||
case "excel":
|
||||
return i18n("Spreadsheet");
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private handleBackdropClick = () => {
|
||||
this.close();
|
||||
};
|
||||
|
||||
private handleDownload = () => {
|
||||
if (!this.attachment) return;
|
||||
|
||||
// Create a blob from the base64 content
|
||||
const byteCharacters = atob(this.attachment.content);
|
||||
const byteNumbers = new Array(byteCharacters.length);
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
byteNumbers[i] = byteCharacters.charCodeAt(i);
|
||||
}
|
||||
const byteArray = new Uint8Array(byteNumbers);
|
||||
const blob = new Blob([byteArray], { type: this.attachment.mimeType });
|
||||
|
||||
// Create download link
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = this.attachment.fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
private cleanup() {
|
||||
this.showExtractedText = false;
|
||||
this.error = null;
|
||||
// Cancel any loading PDF task when closing
|
||||
if (this.currentLoadingTask) {
|
||||
this.currentLoadingTask.destroy();
|
||||
this.currentLoadingTask = null;
|
||||
}
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (!this.attachment) return html``;
|
||||
|
||||
return html`
|
||||
<!-- 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);
|
||||
}
|
||||
|
|
@ -1,306 +0,0 @@
|
|||
import { i18n } from "@mariozechner/mini-lit";
|
||||
import { Button } from "@mariozechner/mini-lit/dist/Button.js";
|
||||
import { DialogBase } from "@mariozechner/mini-lit/dist/DialogBase.js";
|
||||
import { Input } from "@mariozechner/mini-lit/dist/Input.js";
|
||||
import { Label } from "@mariozechner/mini-lit/dist/Label.js";
|
||||
import { Select } from "@mariozechner/mini-lit/dist/Select.js";
|
||||
import type { Model } from "@mariozechner/pi-ai";
|
||||
import { html, type TemplateResult } from "lit";
|
||||
import { state } from "lit/decorators.js";
|
||||
import { getAppStorage } from "../storage/app-storage.js";
|
||||
import type {
|
||||
CustomProvider,
|
||||
CustomProviderType,
|
||||
} from "../storage/stores/custom-providers-store.js";
|
||||
import { discoverModels } from "../utils/model-discovery.js";
|
||||
|
||||
export class CustomProviderDialog extends DialogBase {
|
||||
private provider?: CustomProvider;
|
||||
private initialType?: CustomProviderType;
|
||||
private onSaveCallback?: () => void;
|
||||
|
||||
@state() private name = "";
|
||||
@state() private type: CustomProviderType = "openai-completions";
|
||||
@state() private baseUrl = "";
|
||||
@state() private apiKey = "";
|
||||
@state() private testing = false;
|
||||
@state() private testError = "";
|
||||
@state() private discoveredModels: Model<any>[] = [];
|
||||
|
||||
protected modalWidth = "min(800px, 90vw)";
|
||||
protected modalHeight = "min(700px, 90vh)";
|
||||
|
||||
static async open(
|
||||
provider: CustomProvider | undefined,
|
||||
initialType: CustomProviderType | undefined,
|
||||
onSave?: () => void,
|
||||
) {
|
||||
const dialog = new CustomProviderDialog();
|
||||
dialog.provider = provider;
|
||||
dialog.initialType = initialType;
|
||||
dialog.onSaveCallback = onSave;
|
||||
document.body.appendChild(dialog);
|
||||
dialog.initializeFromProvider();
|
||||
dialog.open();
|
||||
dialog.requestUpdate();
|
||||
}
|
||||
|
||||
private initializeFromProvider() {
|
||||
if (this.provider) {
|
||||
this.name = this.provider.name;
|
||||
this.type = this.provider.type;
|
||||
this.baseUrl = this.provider.baseUrl;
|
||||
this.apiKey = this.provider.apiKey || "";
|
||||
this.discoveredModels = this.provider.models || [];
|
||||
} else {
|
||||
this.name = "";
|
||||
this.type = this.initialType || "openai-completions";
|
||||
this.baseUrl = "";
|
||||
this.updateDefaultBaseUrl();
|
||||
this.apiKey = "";
|
||||
this.discoveredModels = [];
|
||||
}
|
||||
this.testError = "";
|
||||
this.testing = false;
|
||||
}
|
||||
|
||||
private updateDefaultBaseUrl() {
|
||||
if (this.baseUrl) return;
|
||||
|
||||
const defaults: Record<string, string> = {
|
||||
ollama: "http://localhost:11434",
|
||||
"llama.cpp": "http://localhost:8080",
|
||||
vllm: "http://localhost:8000",
|
||||
lmstudio: "http://localhost:1234",
|
||||
"openai-completions": "",
|
||||
"openai-responses": "",
|
||||
"anthropic-messages": "",
|
||||
};
|
||||
|
||||
this.baseUrl = defaults[this.type] || "";
|
||||
}
|
||||
|
||||
private isAutoDiscoveryType(): boolean {
|
||||
return (
|
||||
this.type === "ollama" ||
|
||||
this.type === "llama.cpp" ||
|
||||
this.type === "vllm" ||
|
||||
this.type === "lmstudio"
|
||||
);
|
||||
}
|
||||
|
||||
private async testConnection() {
|
||||
if (!this.isAutoDiscoveryType()) return;
|
||||
|
||||
this.testing = true;
|
||||
this.testError = "";
|
||||
this.discoveredModels = [];
|
||||
|
||||
try {
|
||||
const models = await discoverModels(
|
||||
this.type as "ollama" | "llama.cpp" | "vllm" | "lmstudio",
|
||||
this.baseUrl,
|
||||
this.apiKey || undefined,
|
||||
);
|
||||
|
||||
this.discoveredModels = models.map((model) => ({
|
||||
...model,
|
||||
provider: this.name || this.type,
|
||||
}));
|
||||
|
||||
this.testError = "";
|
||||
} catch (error) {
|
||||
this.testError = error instanceof Error ? error.message : String(error);
|
||||
this.discoveredModels = [];
|
||||
} finally {
|
||||
this.testing = false;
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private async save() {
|
||||
if (!this.name || !this.baseUrl) {
|
||||
alert(i18n("Please fill in all required fields"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const storage = getAppStorage();
|
||||
|
||||
const provider: CustomProvider = {
|
||||
id: this.provider?.id || crypto.randomUUID(),
|
||||
name: this.name,
|
||||
type: this.type,
|
||||
baseUrl: this.baseUrl,
|
||||
apiKey: this.apiKey || undefined,
|
||||
models: this.isAutoDiscoveryType()
|
||||
? undefined
|
||||
: this.provider?.models || [],
|
||||
};
|
||||
|
||||
await storage.customProviders.set(provider);
|
||||
|
||||
if (this.onSaveCallback) {
|
||||
this.onSaveCallback();
|
||||
}
|
||||
this.close();
|
||||
} catch (error) {
|
||||
console.error("Failed to save provider:", error);
|
||||
alert(i18n("Failed to save provider"));
|
||||
}
|
||||
}
|
||||
|
||||
protected override renderContent(): TemplateResult {
|
||||
const providerTypes = [
|
||||
{ value: "ollama", label: "Ollama (auto-discovery)" },
|
||||
{ value: "llama.cpp", label: "llama.cpp (auto-discovery)" },
|
||||
{ value: "vllm", label: "vLLM (auto-discovery)" },
|
||||
{ value: "lmstudio", label: "LM Studio (auto-discovery)" },
|
||||
{ value: "openai-completions", label: "OpenAI Completions Compatible" },
|
||||
{ value: "openai-responses", label: "OpenAI Responses Compatible" },
|
||||
{ value: "anthropic-messages", label: "Anthropic Messages Compatible" },
|
||||
];
|
||||
|
||||
return html`
|
||||
<div class="flex flex-col h-full overflow-hidden">
|
||||
<div class="p-6 flex-shrink-0 border-b border-border">
|
||||
<h2 class="text-lg font-semibold text-foreground">
|
||||
${this.provider ? i18n("Edit Provider") : i18n("Add Provider")}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-6">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
${Label({
|
||||
htmlFor: "provider-name",
|
||||
children: i18n("Provider Name"),
|
||||
})}
|
||||
${Input({
|
||||
value: this.name,
|
||||
placeholder: i18n("e.g., My Ollama Server"),
|
||||
onInput: (e: Event) => {
|
||||
this.name = (e.target as HTMLInputElement).value;
|
||||
this.requestUpdate();
|
||||
},
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
${Label({
|
||||
htmlFor: "provider-type",
|
||||
children: i18n("Provider Type"),
|
||||
})}
|
||||
${Select({
|
||||
value: this.type,
|
||||
options: providerTypes.map((pt) => ({
|
||||
value: pt.value,
|
||||
label: pt.label,
|
||||
})),
|
||||
onChange: (value: string) => {
|
||||
this.type = value as CustomProviderType;
|
||||
this.baseUrl = "";
|
||||
this.updateDefaultBaseUrl();
|
||||
this.requestUpdate();
|
||||
},
|
||||
width: "100%",
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
${Label({ htmlFor: "base-url", children: i18n("Base URL") })}
|
||||
${Input({
|
||||
value: this.baseUrl,
|
||||
placeholder: i18n("e.g., http://localhost:11434"),
|
||||
onInput: (e: Event) => {
|
||||
this.baseUrl = (e.target as HTMLInputElement).value;
|
||||
this.requestUpdate();
|
||||
},
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
${Label({
|
||||
htmlFor: "api-key",
|
||||
children: i18n("API Key (Optional)"),
|
||||
})}
|
||||
${Input({
|
||||
type: "password",
|
||||
value: this.apiKey,
|
||||
placeholder: i18n("Leave empty if not required"),
|
||||
onInput: (e: Event) => {
|
||||
this.apiKey = (e.target as HTMLInputElement).value;
|
||||
this.requestUpdate();
|
||||
},
|
||||
})}
|
||||
</div>
|
||||
|
||||
${this.isAutoDiscoveryType()
|
||||
? html`
|
||||
<div class="flex flex-col gap-2">
|
||||
${Button({
|
||||
onClick: () => this.testConnection(),
|
||||
variant: "outline",
|
||||
disabled: this.testing || !this.baseUrl,
|
||||
children: this.testing
|
||||
? i18n("Testing...")
|
||||
: i18n("Test Connection"),
|
||||
})}
|
||||
${this.testError
|
||||
? html`
|
||||
<div class="text-sm text-destructive">
|
||||
${this.testError}
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
${this.discoveredModels.length > 0
|
||||
? html`
|
||||
<div class="text-sm text-muted-foreground">
|
||||
${i18n("Discovered")}
|
||||
${this.discoveredModels.length} ${i18n("models")}:
|
||||
<ul class="list-disc list-inside mt-2">
|
||||
${this.discoveredModels
|
||||
.slice(0, 5)
|
||||
.map((model) => html`<li>${model.name}</li>`)}
|
||||
${this.discoveredModels.length > 5
|
||||
? html`<li>
|
||||
...${i18n("and")}
|
||||
${this.discoveredModels.length - 5}
|
||||
${i18n("more")}
|
||||
</li>`
|
||||
: ""}
|
||||
</ul>
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
</div>
|
||||
`
|
||||
: html` <div class="text-sm text-muted-foreground">
|
||||
${i18n(
|
||||
"For manual provider types, add models after saving the provider.",
|
||||
)}
|
||||
</div>`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="p-6 flex-shrink-0 border-t border-border flex justify-end gap-2"
|
||||
>
|
||||
${Button({
|
||||
onClick: () => this.close(),
|
||||
variant: "ghost",
|
||||
children: i18n("Cancel"),
|
||||
})}
|
||||
${Button({
|
||||
onClick: () => this.save(),
|
||||
variant: "default",
|
||||
disabled: !this.name || !this.baseUrl,
|
||||
children: i18n("Save"),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("custom-provider-dialog", CustomProviderDialog);
|
||||
|
|
@ -1,367 +0,0 @@
|
|||
import { icon } from "@mariozechner/mini-lit";
|
||||
import { Badge } from "@mariozechner/mini-lit/dist/Badge.js";
|
||||
import { Button } from "@mariozechner/mini-lit/dist/Button.js";
|
||||
import { DialogHeader } from "@mariozechner/mini-lit/dist/Dialog.js";
|
||||
import { DialogBase } from "@mariozechner/mini-lit/dist/DialogBase.js";
|
||||
import {
|
||||
getModels,
|
||||
getProviders,
|
||||
type Model,
|
||||
modelsAreEqual,
|
||||
} from "@mariozechner/pi-ai";
|
||||
import { html, type PropertyValues, type TemplateResult } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { createRef, ref } from "lit/directives/ref.js";
|
||||
import { Brain, Image as ImageIcon } from "lucide";
|
||||
import { Input } from "../components/Input.js";
|
||||
import { getAppStorage } from "../storage/app-storage.js";
|
||||
import type { AutoDiscoveryProviderType } from "../storage/stores/custom-providers-store.js";
|
||||
import { formatModelCost } from "../utils/format.js";
|
||||
import { i18n } from "../utils/i18n.js";
|
||||
import { discoverModels } from "../utils/model-discovery.js";
|
||||
|
||||
@customElement("agent-model-selector")
|
||||
export class ModelSelector extends DialogBase {
|
||||
@state() currentModel: Model<any> | null = null;
|
||||
@state() searchQuery = "";
|
||||
@state() filterThinking = false;
|
||||
@state() filterVision = false;
|
||||
@state() customProvidersLoading = false;
|
||||
@state() selectedIndex = 0;
|
||||
@state() private navigationMode: "mouse" | "keyboard" = "mouse";
|
||||
@state() private customProviderModels: Model<any>[] = [];
|
||||
|
||||
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.loadCustomProviders();
|
||||
}
|
||||
|
||||
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 loadCustomProviders() {
|
||||
this.customProvidersLoading = true;
|
||||
const allCustomModels: Model<any>[] = [];
|
||||
|
||||
try {
|
||||
const storage = getAppStorage();
|
||||
const customProviders = await storage.customProviders.getAll();
|
||||
|
||||
// Load models from custom providers
|
||||
for (const provider of customProviders) {
|
||||
const isAutoDiscovery: boolean =
|
||||
provider.type === "ollama" ||
|
||||
provider.type === "llama.cpp" ||
|
||||
provider.type === "vllm" ||
|
||||
provider.type === "lmstudio";
|
||||
|
||||
if (isAutoDiscovery) {
|
||||
try {
|
||||
const models = await discoverModels(
|
||||
provider.type as AutoDiscoveryProviderType,
|
||||
provider.baseUrl,
|
||||
provider.apiKey,
|
||||
);
|
||||
|
||||
const modelsWithProvider = models.map((model) => ({
|
||||
...model,
|
||||
provider: provider.name,
|
||||
}));
|
||||
|
||||
allCustomModels.push(...modelsWithProvider);
|
||||
} catch (error) {
|
||||
console.debug(
|
||||
`Failed to load models from ${provider.name}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
} else if (provider.models) {
|
||||
// Manual provider - models already defined
|
||||
allCustomModels.push(...provider.models);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load custom providers:", error);
|
||||
} finally {
|
||||
this.customProviderModels = allCustomModels;
|
||||
this.customProvidersLoading = false;
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private formatTokens(tokens: number): string {
|
||||
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(0)}M`;
|
||||
if (tokens >= 1000) return `${(tokens / 1000).toFixed(0)}`;
|
||||
return String(tokens);
|
||||
}
|
||||
|
||||
private handleSelect(model: Model<any>) {
|
||||
if (model) {
|
||||
this.onSelectCallback?.(model);
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
private getFilteredModels(): Array<{
|
||||
provider: string;
|
||||
id: string;
|
||||
model: any;
|
||||
}> {
|
||||
// Collect all models from known providers
|
||||
const allModels: Array<{ provider: string; id: string; model: any }> = [];
|
||||
const knownProviders = getProviders();
|
||||
|
||||
for (const provider of knownProviders) {
|
||||
const models = getModels(provider as any);
|
||||
for (const model of models) {
|
||||
allModels.push({ provider, id: model.id, model });
|
||||
}
|
||||
}
|
||||
|
||||
// Add custom provider models
|
||||
for (const model of this.customProviderModels) {
|
||||
allModels.push({ provider: model.provider, id: model.id, model });
|
||||
}
|
||||
|
||||
// Filter models based on search and capability filters
|
||||
let filteredModels = allModels;
|
||||
|
||||
// Apply search filter
|
||||
if (this.searchQuery) {
|
||||
filteredModels = filteredModels.filter(({ provider, id, model }) => {
|
||||
const searchTokens = this.searchQuery
|
||||
.toLowerCase()
|
||||
.split(/\s+/)
|
||||
.filter((t) => t);
|
||||
const searchText = `${provider} ${id} ${model.name}`.toLowerCase();
|
||||
return searchTokens.every((token) => searchText.includes(token));
|
||||
});
|
||||
}
|
||||
|
||||
// Apply capability filters
|
||||
if (this.filterThinking) {
|
||||
filteredModels = filteredModels.filter(({ model }) => model.reasoning);
|
||||
}
|
||||
if (this.filterVision) {
|
||||
filteredModels = filteredModels.filter(({ model }) =>
|
||||
model.input.includes("image"),
|
||||
);
|
||||
}
|
||||
|
||||
// Sort: current model first, then by provider
|
||||
filteredModels.sort((a, b) => {
|
||||
const aIsCurrent = modelsAreEqual(this.currentModel, a.model);
|
||||
const bIsCurrent = modelsAreEqual(this.currentModel, b.model);
|
||||
if (aIsCurrent && !bIsCurrent) return -1;
|
||||
if (!aIsCurrent && bIsCurrent) return 1;
|
||||
return a.provider.localeCompare(b.provider);
|
||||
});
|
||||
|
||||
return filteredModels;
|
||||
}
|
||||
|
||||
private scrollToSelected() {
|
||||
requestAnimationFrame(() => {
|
||||
const scrollContainer = this.scrollContainerRef.value;
|
||||
const selectedElement = scrollContainer?.querySelectorAll(
|
||||
"[data-model-item]",
|
||||
)[this.selectedIndex] as HTMLElement;
|
||||
if (selectedElement) {
|
||||
selectedElement.scrollIntoView({
|
||||
block: "nearest",
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected override renderContent(): TemplateResult {
|
||||
const filteredModels = this.getFilteredModels();
|
||||
|
||||
return html`
|
||||
<!-- 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) => {
|
||||
const isCurrent = modelsAreEqual(this.currentModel, model);
|
||||
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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,178 +0,0 @@
|
|||
import { Button } from "@mariozechner/mini-lit/dist/Button.js";
|
||||
import {
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
} from "@mariozechner/mini-lit/dist/Dialog.js";
|
||||
import { DialogBase } from "@mariozechner/mini-lit/dist/DialogBase.js";
|
||||
import { html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { i18n } from "../utils/i18n.js";
|
||||
|
||||
@customElement("persistent-storage-dialog")
|
||||
export class PersistentStorageDialog extends DialogBase {
|
||||
@state() private requesting = false;
|
||||
|
||||
private resolvePromise?: (userApproved: boolean) => void;
|
||||
|
||||
protected modalWidth = "min(500px, 90vw)";
|
||||
protected modalHeight = "auto";
|
||||
|
||||
/**
|
||||
* Request persistent storage permission.
|
||||
* Returns true if browser granted persistent storage, false otherwise.
|
||||
*/
|
||||
static async request(): Promise<boolean> {
|
||||
// Check if already persisted
|
||||
if (navigator.storage?.persisted) {
|
||||
const alreadyPersisted = await navigator.storage.persisted();
|
||||
if (alreadyPersisted) {
|
||||
console.log("✓ Persistent storage already granted");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Show dialog and wait for user response
|
||||
const dialog = new PersistentStorageDialog();
|
||||
dialog.open();
|
||||
|
||||
const userApproved = await new Promise<boolean>((resolve) => {
|
||||
dialog.resolvePromise = resolve;
|
||||
});
|
||||
|
||||
if (!userApproved) {
|
||||
console.warn("⚠ User declined persistent storage - sessions may be lost");
|
||||
return false;
|
||||
}
|
||||
|
||||
// User approved, request from browser
|
||||
if (!navigator.storage?.persist) {
|
||||
console.warn("⚠ Persistent storage API not available");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const granted = await navigator.storage.persist();
|
||||
if (granted) {
|
||||
console.log(
|
||||
"✓ Persistent storage granted - sessions will be preserved",
|
||||
);
|
||||
} else {
|
||||
console.warn(
|
||||
"⚠ Browser denied persistent storage - sessions may be lost under storage pressure",
|
||||
);
|
||||
}
|
||||
return granted;
|
||||
} catch (error) {
|
||||
console.error("Failed to request persistent storage:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private handleGrant() {
|
||||
if (this.resolvePromise) {
|
||||
this.resolvePromise(true);
|
||||
this.resolvePromise = undefined;
|
||||
}
|
||||
this.close();
|
||||
}
|
||||
|
||||
private handleDeny() {
|
||||
if (this.resolvePromise) {
|
||||
this.resolvePromise(false);
|
||||
this.resolvePromise = undefined;
|
||||
}
|
||||
this.close();
|
||||
}
|
||||
|
||||
override close() {
|
||||
super.close();
|
||||
if (this.resolvePromise) {
|
||||
this.resolvePromise(false);
|
||||
}
|
||||
}
|
||||
|
||||
protected override renderContent() {
|
||||
return html`
|
||||
${DialogContent({
|
||||
children: html`
|
||||
${DialogHeader({
|
||||
title: i18n("Storage Permission Required"),
|
||||
description: i18n(
|
||||
"This app needs persistent storage to save your conversations",
|
||||
),
|
||||
})}
|
||||
|
||||
<div class="mt-4 flex flex-col gap-4">
|
||||
<div
|
||||
class="flex gap-3 p-4 bg-warning/10 border border-warning/20 rounded-lg"
|
||||
>
|
||||
<div class="flex-shrink-0 text-warning">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"
|
||||
></path>
|
||||
<line x1="12" y1="9" x2="12" y2="13"></line>
|
||||
<line x1="12" y1="17" x2="12.01" y2="17"></line>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
<p class="font-medium text-foreground mb-1">
|
||||
${i18n("Why is this needed?")}
|
||||
</p>
|
||||
<p class="text-muted-foreground">
|
||||
${i18n(
|
||||
"Without persistent storage, your browser may delete saved conversations when it needs disk space. Granting this permission ensures your chat history is preserved.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-muted-foreground">
|
||||
<p class="mb-2">${i18n("What this means:")}</p>
|
||||
<ul class="list-disc list-inside space-y-1 ml-2">
|
||||
<li>
|
||||
${i18n(
|
||||
"Your conversations will be saved locally in your browser",
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
${i18n(
|
||||
"Data will not be deleted automatically to free up space",
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
${i18n("You can still manually clear data at any time")}
|
||||
</li>
|
||||
<li>${i18n("No data is sent to external servers")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex gap-3 justify-end">
|
||||
${Button({
|
||||
variant: "outline",
|
||||
onClick: () => this.handleDeny(),
|
||||
disabled: this.requesting,
|
||||
children: i18n("Continue Anyway"),
|
||||
})}
|
||||
${Button({
|
||||
variant: "default",
|
||||
onClick: () => this.handleGrant(),
|
||||
disabled: this.requesting,
|
||||
children: this.requesting
|
||||
? i18n("Requesting...")
|
||||
: i18n("Grant Permission"),
|
||||
})}
|
||||
</div>
|
||||
`,
|
||||
})}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,249 +0,0 @@
|
|||
import { i18n } from "@mariozechner/mini-lit";
|
||||
import { Select } from "@mariozechner/mini-lit/dist/Select.js";
|
||||
import { getProviders } from "@mariozechner/pi-ai";
|
||||
import { html, type TemplateResult } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import "../components/CustomProviderCard.js";
|
||||
import "../components/ProviderKeyInput.js";
|
||||
import { getAppStorage } from "../storage/app-storage.js";
|
||||
import type {
|
||||
AutoDiscoveryProviderType,
|
||||
CustomProvider,
|
||||
CustomProviderType,
|
||||
} from "../storage/stores/custom-providers-store.js";
|
||||
import { discoverModels } from "../utils/model-discovery.js";
|
||||
import { CustomProviderDialog } from "./CustomProviderDialog.js";
|
||||
import { SettingsTab } from "./SettingsDialog.js";
|
||||
|
||||
@customElement("providers-models-tab")
|
||||
export class ProvidersModelsTab extends SettingsTab {
|
||||
@state() private customProviders: CustomProvider[] = [];
|
||||
@state() private providerStatus: Map<
|
||||
string,
|
||||
{ modelCount: number; status: "connected" | "disconnected" | "checking" }
|
||||
> = new Map();
|
||||
|
||||
override async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
await this.loadCustomProviders();
|
||||
}
|
||||
|
||||
private async loadCustomProviders() {
|
||||
try {
|
||||
const storage = getAppStorage();
|
||||
this.customProviders = await storage.customProviders.getAll();
|
||||
|
||||
// Check status for auto-discovery providers
|
||||
for (const provider of this.customProviders) {
|
||||
const isAutoDiscovery =
|
||||
provider.type === "ollama" ||
|
||||
provider.type === "llama.cpp" ||
|
||||
provider.type === "vllm" ||
|
||||
provider.type === "lmstudio";
|
||||
if (isAutoDiscovery) {
|
||||
this.checkProviderStatus(provider);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load custom providers:", error);
|
||||
}
|
||||
}
|
||||
|
||||
getTabName(): string {
|
||||
return "Providers & Models";
|
||||
}
|
||||
|
||||
private async checkProviderStatus(provider: CustomProvider) {
|
||||
this.providerStatus.set(provider.id, { modelCount: 0, status: "checking" });
|
||||
this.requestUpdate();
|
||||
|
||||
try {
|
||||
const models = await discoverModels(
|
||||
provider.type as AutoDiscoveryProviderType,
|
||||
provider.baseUrl,
|
||||
provider.apiKey,
|
||||
);
|
||||
|
||||
this.providerStatus.set(provider.id, {
|
||||
modelCount: models.length,
|
||||
status: "connected",
|
||||
});
|
||||
} catch (_error) {
|
||||
this.providerStatus.set(provider.id, {
|
||||
modelCount: 0,
|
||||
status: "disconnected",
|
||||
});
|
||||
}
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private renderKnownProviders(): TemplateResult {
|
||||
const providers = getProviders();
|
||||
|
||||
return html`
|
||||
<div class="flex flex-col gap-6">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-foreground mb-2">
|
||||
Cloud Providers
|
||||
</h3>
|
||||
<p class="text-sm text-muted-foreground mb-4">
|
||||
Cloud LLM providers with predefined models. API keys are stored
|
||||
locally in your browser.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-6">
|
||||
${providers.map(
|
||||
(provider) => html`
|
||||
<provider-key-input .provider=${provider}></provider-key-input>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderCustomProviders(): TemplateResult {
|
||||
const isAutoDiscovery = (type: string) =>
|
||||
type === "ollama" ||
|
||||
type === "llama.cpp" ||
|
||||
type === "vllm" ||
|
||||
type === "lmstudio";
|
||||
|
||||
return html`
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-foreground mb-2">
|
||||
Custom Providers
|
||||
</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
User-configured servers with auto-discovered or manually defined
|
||||
models.
|
||||
</p>
|
||||
</div>
|
||||
${Select({
|
||||
placeholder: i18n("Add Provider"),
|
||||
options: [
|
||||
{ value: "ollama", label: "Ollama" },
|
||||
{ value: "llama.cpp", label: "llama.cpp" },
|
||||
{ value: "vllm", label: "vLLM" },
|
||||
{ value: "lmstudio", label: "LM Studio" },
|
||||
{
|
||||
value: "openai-completions",
|
||||
label: i18n("OpenAI Completions Compatible"),
|
||||
},
|
||||
{
|
||||
value: "openai-responses",
|
||||
label: i18n("OpenAI Responses Compatible"),
|
||||
},
|
||||
{
|
||||
value: "anthropic-messages",
|
||||
label: i18n("Anthropic Messages Compatible"),
|
||||
},
|
||||
],
|
||||
onChange: (value: string) =>
|
||||
this.addCustomProvider(value as CustomProviderType),
|
||||
variant: "outline",
|
||||
size: "sm",
|
||||
})}
|
||||
</div>
|
||||
|
||||
${this.customProviders.length === 0
|
||||
? html`
|
||||
<div class="text-sm text-muted-foreground text-center py-8">
|
||||
No custom providers configured. Click 'Add Provider' to get
|
||||
started.
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<div class="flex flex-col gap-4">
|
||||
${this.customProviders.map(
|
||||
(provider) => html`
|
||||
<custom-provider-card
|
||||
.provider=${provider}
|
||||
.isAutoDiscovery=${isAutoDiscovery(provider.type)}
|
||||
.status=${this.providerStatus.get(provider.id)}
|
||||
.onRefresh=${(p: CustomProvider) =>
|
||||
this.refreshProvider(p)}
|
||||
.onEdit=${(p: CustomProvider) => this.editProvider(p)}
|
||||
.onDelete=${(p: CustomProvider) => this.deleteProvider(p)}
|
||||
></custom-provider-card>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private async addCustomProvider(type: CustomProviderType) {
|
||||
await CustomProviderDialog.open(undefined, type, async () => {
|
||||
await this.loadCustomProviders();
|
||||
this.requestUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
private async editProvider(provider: CustomProvider) {
|
||||
await CustomProviderDialog.open(provider, undefined, async () => {
|
||||
await this.loadCustomProviders();
|
||||
this.requestUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
private async refreshProvider(provider: CustomProvider) {
|
||||
this.providerStatus.set(provider.id, { modelCount: 0, status: "checking" });
|
||||
this.requestUpdate();
|
||||
|
||||
try {
|
||||
const models = await discoverModels(
|
||||
provider.type as AutoDiscoveryProviderType,
|
||||
provider.baseUrl,
|
||||
provider.apiKey,
|
||||
);
|
||||
|
||||
this.providerStatus.set(provider.id, {
|
||||
modelCount: models.length,
|
||||
status: "connected",
|
||||
});
|
||||
this.requestUpdate();
|
||||
|
||||
console.log(`Refreshed ${models.length} models from ${provider.name}`);
|
||||
} catch (error) {
|
||||
this.providerStatus.set(provider.id, {
|
||||
modelCount: 0,
|
||||
status: "disconnected",
|
||||
});
|
||||
this.requestUpdate();
|
||||
|
||||
console.error(`Failed to refresh provider ${provider.name}:`, error);
|
||||
alert(
|
||||
`Failed to refresh provider: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteProvider(provider: CustomProvider) {
|
||||
if (!confirm("Are you sure you want to delete this provider?")) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const storage = getAppStorage();
|
||||
await storage.customProviders.delete(provider.id);
|
||||
await this.loadCustomProviders();
|
||||
this.requestUpdate();
|
||||
} catch (error) {
|
||||
console.error("Failed to delete provider:", error);
|
||||
}
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
return html`
|
||||
<div class="flex flex-col gap-8">
|
||||
${this.renderKnownProviders()}
|
||||
<div class="border-t border-border"></div>
|
||||
${this.renderCustomProviders()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,179 +0,0 @@
|
|||
import {
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
} from "@mariozechner/mini-lit/dist/Dialog.js";
|
||||
import { DialogBase } from "@mariozechner/mini-lit/dist/DialogBase.js";
|
||||
import { html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { getAppStorage } from "../storage/app-storage.js";
|
||||
import type { SessionMetadata } from "../storage/types.js";
|
||||
import { formatUsage } from "../utils/format.js";
|
||||
import { i18n } from "../utils/i18n.js";
|
||||
|
||||
@customElement("session-list-dialog")
|
||||
export class SessionListDialog extends DialogBase {
|
||||
@state() private sessions: SessionMetadata[] = [];
|
||||
@state() private loading = true;
|
||||
|
||||
private onSelectCallback?: (sessionId: string) => void;
|
||||
private onDeleteCallback?: (sessionId: string) => void;
|
||||
private deletedSessions = new Set<string>();
|
||||
private closedViaSelection = false;
|
||||
|
||||
protected modalWidth = "min(600px, 90vw)";
|
||||
protected modalHeight = "min(700px, 90vh)";
|
||||
|
||||
static async open(
|
||||
onSelect: (sessionId: string) => void,
|
||||
onDelete?: (sessionId: string) => void,
|
||||
) {
|
||||
const dialog = new SessionListDialog();
|
||||
dialog.onSelectCallback = onSelect;
|
||||
dialog.onDeleteCallback = onDelete;
|
||||
dialog.open();
|
||||
await dialog.loadSessions();
|
||||
}
|
||||
|
||||
private async loadSessions() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const storage = getAppStorage();
|
||||
this.sessions = await storage.sessions.getAllMetadata();
|
||||
} catch (err) {
|
||||
console.error("Failed to load sessions:", err);
|
||||
this.sessions = [];
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleDelete(sessionId: string, event: Event) {
|
||||
event.stopPropagation();
|
||||
|
||||
if (!confirm(i18n("Delete this session?"))) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const storage = getAppStorage();
|
||||
if (!storage.sessions) return;
|
||||
|
||||
await storage.sessions.deleteSession(sessionId);
|
||||
await this.loadSessions();
|
||||
|
||||
// Track deleted session
|
||||
this.deletedSessions.add(sessionId);
|
||||
} catch (err) {
|
||||
console.error("Failed to delete session:", err);
|
||||
}
|
||||
}
|
||||
|
||||
override close() {
|
||||
super.close();
|
||||
|
||||
// Only notify about deleted sessions if dialog wasn't closed via selection
|
||||
if (
|
||||
!this.closedViaSelection &&
|
||||
this.onDeleteCallback &&
|
||||
this.deletedSessions.size > 0
|
||||
) {
|
||||
for (const sessionId of this.deletedSessions) {
|
||||
this.onDeleteCallback(sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleSelect(sessionId: string) {
|
||||
this.closedViaSelection = true;
|
||||
if (this.onSelectCallback) {
|
||||
this.onSelectCallback(sessionId);
|
||||
}
|
||||
this.close();
|
||||
}
|
||||
|
||||
private formatDate(isoString: string): string {
|
||||
const date = new Date(isoString);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days === 0) {
|
||||
return i18n("Today");
|
||||
} else if (days === 1) {
|
||||
return i18n("Yesterday");
|
||||
} else if (days < 7) {
|
||||
return i18n("{days} days ago").replace("{days}", days.toString());
|
||||
} else {
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
}
|
||||
|
||||
protected override renderContent() {
|
||||
return html`
|
||||
${DialogContent({
|
||||
className: "h-full flex flex-col",
|
||||
children: html`
|
||||
${DialogHeader({
|
||||
title: i18n("Sessions"),
|
||||
description: i18n("Load a previous conversation"),
|
||||
})}
|
||||
|
||||
<div class="flex-1 overflow-y-auto mt-4 space-y-2">
|
||||
${this.loading
|
||||
? html`<div class="text-center py-8 text-muted-foreground">
|
||||
${i18n("Loading...")}
|
||||
</div>`
|
||||
: this.sessions.length === 0
|
||||
? html`<div class="text-center py-8 text-muted-foreground">
|
||||
${i18n("No sessions yet")}
|
||||
</div>`
|
||||
: this.sessions.map(
|
||||
(session) => html`
|
||||
<div
|
||||
class="group flex items-start gap-3 p-3 rounded-lg border border-border hover:bg-secondary/50 cursor-pointer transition-colors"
|
||||
@click=${() => this.handleSelect(session.id)}
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div
|
||||
class="font-medium text-sm text-foreground truncate"
|
||||
>
|
||||
${session.title}
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground mt-1">
|
||||
${this.formatDate(session.lastModified)}
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground mt-1">
|
||||
${session.messageCount} ${i18n("messages")} ·
|
||||
${formatUsage(session.usage)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="opacity-0 group-hover:opacity-100 p-1 rounded hover:bg-destructive/10 text-destructive transition-opacity"
|
||||
@click=${(e: Event) =>
|
||||
this.handleDelete(session.id, e)}
|
||||
title=${i18n("Delete")}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M3 6h18"></path>
|
||||
<path
|
||||
d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"
|
||||
></path>
|
||||
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`,
|
||||
})}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,241 +0,0 @@
|
|||
import { i18n } from "@mariozechner/mini-lit";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
} from "@mariozechner/mini-lit/dist/Dialog.js";
|
||||
import { Input } from "@mariozechner/mini-lit/dist/Input.js";
|
||||
import { Label } from "@mariozechner/mini-lit/dist/Label.js";
|
||||
import { Switch } from "@mariozechner/mini-lit/dist/Switch.js";
|
||||
import { getProviders } from "@mariozechner/pi-ai";
|
||||
import { html, LitElement, type TemplateResult } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import "../components/ProviderKeyInput.js";
|
||||
import { getAppStorage } from "../storage/app-storage.js";
|
||||
|
||||
// Base class for settings tabs
|
||||
export abstract class SettingsTab extends LitElement {
|
||||
abstract getTabName(): string;
|
||||
|
||||
protected createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
// API Keys Tab
|
||||
@customElement("api-keys-tab")
|
||||
export class ApiKeysTab extends SettingsTab {
|
||||
getTabName(): string {
|
||||
return i18n("API Keys");
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
const providers = getProviders();
|
||||
|
||||
return html`
|
||||
<div class="flex flex-col gap-6">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
${i18n(
|
||||
"Configure API keys for LLM providers. Keys are stored locally in your browser.",
|
||||
)}
|
||||
</p>
|
||||
${providers.map(
|
||||
(provider) =>
|
||||
html`<provider-key-input
|
||||
.provider=${provider}
|
||||
></provider-key-input>`,
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Proxy Tab
|
||||
@customElement("proxy-tab")
|
||||
export class ProxyTab extends SettingsTab {
|
||||
@state() private proxyEnabled = false;
|
||||
@state() private proxyUrl = "http://localhost:3001";
|
||||
|
||||
override async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
// Load proxy settings when tab is connected
|
||||
try {
|
||||
const storage = getAppStorage();
|
||||
const enabled = await storage.settings.get<boolean>("proxy.enabled");
|
||||
const url = await storage.settings.get<string>("proxy.url");
|
||||
|
||||
if (enabled !== null) this.proxyEnabled = enabled;
|
||||
if (url !== null) this.proxyUrl = url;
|
||||
} catch (error) {
|
||||
console.error("Failed to load proxy settings:", error);
|
||||
}
|
||||
}
|
||||
|
||||
private async saveProxySettings() {
|
||||
try {
|
||||
const storage = getAppStorage();
|
||||
await storage.settings.set("proxy.enabled", this.proxyEnabled);
|
||||
await storage.settings.set("proxy.url", this.proxyUrl);
|
||||
} catch (error) {
|
||||
console.error("Failed to save proxy settings:", error);
|
||||
}
|
||||
}
|
||||
|
||||
getTabName(): string {
|
||||
return i18n("Proxy");
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
return html`
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
${i18n(
|
||||
"Allows browser-based apps to bypass CORS restrictions when calling LLM providers. Required for Z-AI and Anthropic with OAuth token.",
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-foreground"
|
||||
>${i18n("Use CORS Proxy")}</span
|
||||
>
|
||||
${Switch({
|
||||
checked: this.proxyEnabled,
|
||||
onChange: (checked: boolean) => {
|
||||
this.proxyEnabled = checked;
|
||||
this.saveProxySettings();
|
||||
},
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
${Label({ children: i18n("Proxy URL") })}
|
||||
${Input({
|
||||
type: "text",
|
||||
value: this.proxyUrl,
|
||||
disabled: !this.proxyEnabled,
|
||||
onInput: (e) => {
|
||||
this.proxyUrl = (e.target as HTMLInputElement).value;
|
||||
},
|
||||
onChange: () => this.saveProxySettings(),
|
||||
})}
|
||||
<p class="text-xs text-muted-foreground">
|
||||
${i18n(
|
||||
"Format: The proxy must accept requests as <proxy-url>/?url=<target-url>",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement("settings-dialog")
|
||||
export class SettingsDialog extends LitElement {
|
||||
@property({ type: Array, attribute: false }) tabs: SettingsTab[] = [];
|
||||
@state() private isOpen = false;
|
||||
@state() private activeTabIndex = 0;
|
||||
|
||||
protected createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
static async open(tabs: SettingsTab[]) {
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.tabs = tabs;
|
||||
dialog.isOpen = true;
|
||||
document.body.appendChild(dialog);
|
||||
}
|
||||
|
||||
private setActiveTab(index: number) {
|
||||
this.activeTabIndex = index;
|
||||
}
|
||||
|
||||
private renderSidebarItem(tab: SettingsTab, index: number): TemplateResult {
|
||||
const isActive = this.activeTabIndex === index;
|
||||
return html`
|
||||
<button
|
||||
class="w-full text-left px-4 py-3 rounded-md transition-colors ${isActive
|
||||
? "bg-secondary text-foreground font-medium"
|
||||
: "text-muted-foreground hover:bg-secondary/50 hover:text-foreground"}"
|
||||
@click=${() => this.setActiveTab(index)}
|
||||
>
|
||||
${tab.getTabName()}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderMobileTab(tab: SettingsTab, index: number): TemplateResult {
|
||||
const isActive = this.activeTabIndex === index;
|
||||
return html`
|
||||
<button
|
||||
class="px-3 py-2 text-sm font-medium transition-colors ${isActive
|
||||
? "border-b-2 border-primary text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"}"
|
||||
@click=${() => this.setActiveTab(index)}
|
||||
>
|
||||
${tab.getTabName()}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.tabs.length === 0) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
return Dialog({
|
||||
isOpen: this.isOpen,
|
||||
onClose: () => {
|
||||
this.isOpen = false;
|
||||
this.remove();
|
||||
},
|
||||
width: "min(1000px, 90vw)",
|
||||
height: "min(800px, 90vh)",
|
||||
backdropClassName: "bg-black/50 backdrop-blur-sm",
|
||||
children: html`
|
||||
${DialogContent({
|
||||
className: "h-full p-6",
|
||||
children: html`
|
||||
<div class="flex flex-col h-full overflow-hidden">
|
||||
<!-- Header -->
|
||||
<div class="pb-4 flex-shrink-0">
|
||||
${DialogHeader({ title: i18n("Settings") })}
|
||||
</div>
|
||||
|
||||
<!-- Mobile Tabs -->
|
||||
<div class="md:hidden flex flex-shrink-0 pb-4">
|
||||
${this.tabs.map((tab, index) =>
|
||||
this.renderMobileTab(tab, index),
|
||||
)}
|
||||
</div>
|
||||
|
||||
<!-- Layout -->
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<!-- Sidebar (desktop only) -->
|
||||
<div class="hidden md:block w-64 flex-shrink-0 space-y-1">
|
||||
${this.tabs.map((tab, index) =>
|
||||
this.renderSidebarItem(tab, index),
|
||||
)}
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 overflow-y-auto md:pl-6">
|
||||
${this.tabs.map(
|
||||
(tab, index) =>
|
||||
html`<div
|
||||
style="display: ${this.activeTabIndex === index
|
||||
? "block"
|
||||
: "none"}"
|
||||
>
|
||||
${tab}
|
||||
</div>`,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})}
|
||||
`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,167 +0,0 @@
|
|||
// Main chat interface
|
||||
|
||||
export type {
|
||||
Agent,
|
||||
AgentMessage,
|
||||
AgentState,
|
||||
ThinkingLevel,
|
||||
} from "@mariozechner/pi-agent-core";
|
||||
export type { Model } from "@mariozechner/pi-ai";
|
||||
export { ChatPanel } from "./ChatPanel.js";
|
||||
// Components
|
||||
export { AgentInterface } from "./components/AgentInterface.js";
|
||||
export { AttachmentTile } from "./components/AttachmentTile.js";
|
||||
export { ConsoleBlock } from "./components/ConsoleBlock.js";
|
||||
export { CustomProviderCard } from "./components/CustomProviderCard.js";
|
||||
export { ExpandableSection } from "./components/ExpandableSection.js";
|
||||
export { Input } from "./components/Input.js";
|
||||
export { MessageEditor } from "./components/MessageEditor.js";
|
||||
export { MessageList } from "./components/MessageList.js";
|
||||
// Message components
|
||||
export type {
|
||||
ArtifactMessage,
|
||||
UserMessageWithAttachments,
|
||||
} from "./components/Messages.js";
|
||||
export {
|
||||
AbortedMessage,
|
||||
AssistantMessage,
|
||||
convertAttachments,
|
||||
defaultConvertToLlm,
|
||||
isArtifactMessage,
|
||||
isUserMessageWithAttachments,
|
||||
ToolMessage,
|
||||
ToolMessageDebugView,
|
||||
UserMessage,
|
||||
} from "./components/Messages.js";
|
||||
// Message renderer registry
|
||||
export {
|
||||
getMessageRenderer,
|
||||
type MessageRenderer,
|
||||
type MessageRole,
|
||||
registerMessageRenderer,
|
||||
renderMessage,
|
||||
} from "./components/message-renderer-registry.js";
|
||||
export { ProviderKeyInput } from "./components/ProviderKeyInput.js";
|
||||
export {
|
||||
type SandboxFile,
|
||||
SandboxIframe,
|
||||
type SandboxResult,
|
||||
type SandboxUrlProvider,
|
||||
} from "./components/SandboxedIframe.js";
|
||||
export { StreamingMessageContainer } from "./components/StreamingMessageContainer.js";
|
||||
// Sandbox Runtime Providers
|
||||
export { ArtifactsRuntimeProvider } from "./components/sandbox/ArtifactsRuntimeProvider.js";
|
||||
export { AttachmentsRuntimeProvider } from "./components/sandbox/AttachmentsRuntimeProvider.js";
|
||||
export {
|
||||
type ConsoleLog,
|
||||
ConsoleRuntimeProvider,
|
||||
} from "./components/sandbox/ConsoleRuntimeProvider.js";
|
||||
export {
|
||||
type DownloadableFile,
|
||||
FileDownloadRuntimeProvider,
|
||||
} from "./components/sandbox/FileDownloadRuntimeProvider.js";
|
||||
export { RuntimeMessageBridge } from "./components/sandbox/RuntimeMessageBridge.js";
|
||||
export { RUNTIME_MESSAGE_ROUTER } from "./components/sandbox/RuntimeMessageRouter.js";
|
||||
export type { SandboxRuntimeProvider } from "./components/sandbox/SandboxRuntimeProvider.js";
|
||||
export { ThinkingBlock } from "./components/ThinkingBlock.js";
|
||||
export { ApiKeyPromptDialog } from "./dialogs/ApiKeyPromptDialog.js";
|
||||
export { AttachmentOverlay } from "./dialogs/AttachmentOverlay.js";
|
||||
// Dialogs
|
||||
export { ModelSelector } from "./dialogs/ModelSelector.js";
|
||||
export { PersistentStorageDialog } from "./dialogs/PersistentStorageDialog.js";
|
||||
export { ProvidersModelsTab } from "./dialogs/ProvidersModelsTab.js";
|
||||
export { SessionListDialog } from "./dialogs/SessionListDialog.js";
|
||||
export {
|
||||
ApiKeysTab,
|
||||
ProxyTab,
|
||||
SettingsDialog,
|
||||
SettingsTab,
|
||||
} from "./dialogs/SettingsDialog.js";
|
||||
// Prompts
|
||||
export {
|
||||
ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RO,
|
||||
ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RW,
|
||||
ATTACHMENTS_RUNTIME_DESCRIPTION,
|
||||
} from "./prompts/prompts.js";
|
||||
// Storage
|
||||
export {
|
||||
AppStorage,
|
||||
getAppStorage,
|
||||
setAppStorage,
|
||||
} from "./storage/app-storage.js";
|
||||
export { IndexedDBStorageBackend } from "./storage/backends/indexeddb-storage-backend.js";
|
||||
export { Store } from "./storage/store.js";
|
||||
export type {
|
||||
AutoDiscoveryProviderType,
|
||||
CustomProvider,
|
||||
CustomProviderType,
|
||||
} from "./storage/stores/custom-providers-store.js";
|
||||
export { CustomProvidersStore } from "./storage/stores/custom-providers-store.js";
|
||||
export { ProviderKeysStore } from "./storage/stores/provider-keys-store.js";
|
||||
export { SessionsStore } from "./storage/stores/sessions-store.js";
|
||||
export { SettingsStore } from "./storage/stores/settings-store.js";
|
||||
export type {
|
||||
IndexConfig,
|
||||
IndexedDBConfig,
|
||||
SessionData,
|
||||
SessionMetadata,
|
||||
StorageBackend,
|
||||
StorageTransaction,
|
||||
StoreConfig,
|
||||
} from "./storage/types.js";
|
||||
// Artifacts
|
||||
export { ArtifactElement } from "./tools/artifacts/ArtifactElement.js";
|
||||
export { ArtifactPill } from "./tools/artifacts/ArtifactPill.js";
|
||||
export {
|
||||
type Artifact,
|
||||
ArtifactsPanel,
|
||||
type ArtifactsParams,
|
||||
} from "./tools/artifacts/artifacts.js";
|
||||
export { ArtifactsToolRenderer } from "./tools/artifacts/artifacts-tool-renderer.js";
|
||||
export { HtmlArtifact } from "./tools/artifacts/HtmlArtifact.js";
|
||||
export { ImageArtifact } from "./tools/artifacts/ImageArtifact.js";
|
||||
export { MarkdownArtifact } from "./tools/artifacts/MarkdownArtifact.js";
|
||||
export { SvgArtifact } from "./tools/artifacts/SvgArtifact.js";
|
||||
export { TextArtifact } from "./tools/artifacts/TextArtifact.js";
|
||||
export {
|
||||
createExtractDocumentTool,
|
||||
extractDocumentTool,
|
||||
} from "./tools/extract-document.js";
|
||||
// Tools
|
||||
export {
|
||||
getToolRenderer,
|
||||
registerToolRenderer,
|
||||
renderTool,
|
||||
setShowJsonMode,
|
||||
} from "./tools/index.js";
|
||||
export {
|
||||
createJavaScriptReplTool,
|
||||
javascriptReplTool,
|
||||
} from "./tools/javascript-repl.js";
|
||||
export {
|
||||
renderCollapsibleHeader,
|
||||
renderHeader,
|
||||
} from "./tools/renderer-registry.js";
|
||||
export { BashRenderer } from "./tools/renderers/BashRenderer.js";
|
||||
export { CalculateRenderer } from "./tools/renderers/CalculateRenderer.js";
|
||||
// Tool renderers
|
||||
export { DefaultRenderer } from "./tools/renderers/DefaultRenderer.js";
|
||||
export { GetCurrentTimeRenderer } from "./tools/renderers/GetCurrentTimeRenderer.js";
|
||||
export type { ToolRenderer, ToolRenderResult } from "./tools/types.js";
|
||||
export type { Attachment } from "./utils/attachment-utils.js";
|
||||
// Utils
|
||||
export { loadAttachment } from "./utils/attachment-utils.js";
|
||||
export { clearAuthToken, getAuthToken } from "./utils/auth-token.js";
|
||||
export {
|
||||
formatCost,
|
||||
formatModelCost,
|
||||
formatTokenCount,
|
||||
formatUsage,
|
||||
} from "./utils/format.js";
|
||||
export { i18n, setLanguage, translations } from "./utils/i18n.js";
|
||||
export {
|
||||
applyProxyIfNeeded,
|
||||
createStreamFn,
|
||||
isCorsError,
|
||||
shouldUseProxyForProvider,
|
||||
} from "./utils/proxy-utils.js";
|
||||
|
|
@ -1,286 +0,0 @@
|
|||
/**
|
||||
* Centralized tool prompts/descriptions.
|
||||
* Each prompt is either a string constant or a template function.
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// JavaScript REPL Tool
|
||||
// ============================================================================
|
||||
|
||||
export const JAVASCRIPT_REPL_TOOL_DESCRIPTION = (
|
||||
runtimeProviderDescriptions: string[],
|
||||
) => `# JavaScript REPL
|
||||
|
||||
## Purpose
|
||||
Execute JavaScript code in a sandboxed browser environment with full Web APIs.
|
||||
|
||||
## When to Use
|
||||
- Quick calculations or data transformations
|
||||
- Testing JavaScript code snippets in isolation
|
||||
- Processing data with libraries (XLSX, CSV, etc.)
|
||||
- Creating artifacts from data
|
||||
|
||||
## Environment
|
||||
- ES2023+ JavaScript (async/await, optional chaining, nullish coalescing, etc.)
|
||||
- All browser APIs: DOM, Canvas, WebGL, Fetch, Web Workers, WebSockets, Crypto, etc.
|
||||
- Import any npm package: await import('https://esm.run/package-name')
|
||||
|
||||
## Common Libraries
|
||||
- XLSX: const XLSX = await import('https://esm.run/xlsx');
|
||||
- CSV: const Papa = (await import('https://esm.run/papaparse')).default;
|
||||
- Chart.js: const Chart = (await import('https://esm.run/chart.js/auto')).default;
|
||||
- Three.js: const THREE = await import('https://esm.run/three');
|
||||
|
||||
## Persistence between tool calls
|
||||
- Objects stored on global scope do not persist between calls.
|
||||
- Use artifacts as a key-value JSON object store:
|
||||
- Use createOrUpdateArtifact(filename, content) to persist data between calls. JSON objects are auto-stringified.
|
||||
- Use listArtifacts() and getArtifact(filename) to read persisted data. JSON files are auto-parsed to objects.
|
||||
- Prefer to use a single artifact throughout the session to store intermediate data (e.g. 'data.json').
|
||||
|
||||
## Input
|
||||
- You have access to the user's attachments via listAttachments(), readTextAttachment(id), and readBinaryAttachment(id)
|
||||
- You have access to previously created artifacts via listArtifacts() and getArtifact(filename)
|
||||
|
||||
## Output
|
||||
- All console.log() calls are captured for you to inspect. The user does not see these logs.
|
||||
- Create artifacts for file results (images, JSON, CSV, etc.) which persiste throughout the
|
||||
session and are accessible to you and the user.
|
||||
|
||||
## Example
|
||||
const data = [10, 20, 15, 25];
|
||||
const sum = data.reduce((a, b) => a + b, 0);
|
||||
const avg = sum / data.length;
|
||||
console.log('Sum:', sum, 'Average:', avg);
|
||||
|
||||
## Important Notes
|
||||
- Graphics: Use fixed dimensions (800x600), NOT window.innerWidth/Height
|
||||
- Chart.js: Set options: { responsive: false, animation: false }
|
||||
- Three.js: renderer.setSize(800, 600) with matching aspect ratio
|
||||
|
||||
## Helper Functions (Automatically Available)
|
||||
|
||||
These functions are injected into the execution environment and available globally:
|
||||
|
||||
${runtimeProviderDescriptions.join("\n\n")}
|
||||
`;
|
||||
|
||||
// ============================================================================
|
||||
// Artifacts Tool
|
||||
// ============================================================================
|
||||
|
||||
export const ARTIFACTS_TOOL_DESCRIPTION = (
|
||||
runtimeProviderDescriptions: string[],
|
||||
) => `# Artifacts
|
||||
|
||||
Create and manage persistent files that live alongside the conversation.
|
||||
|
||||
## When to Use - Artifacts Tool vs REPL
|
||||
|
||||
**Use artifacts tool when YOU are the author:**
|
||||
- Writing research summaries, analysis, ideas, documentation
|
||||
- Creating markdown notes for user to read
|
||||
- Building HTML applications/visualizations that present data
|
||||
- Creating HTML artifacts that render charts from programmatically generated data
|
||||
|
||||
**Use repl + artifact storage functions when CODE processes data:**
|
||||
- Scraping workflows that extract and store data
|
||||
- Processing CSV/Excel files programmatically
|
||||
- Data transformation pipelines
|
||||
- Binary file generation requiring libraries (PDF, DOCX)
|
||||
|
||||
**Pattern: REPL generates data → Artifacts tool creates HTML that visualizes it**
|
||||
Example: repl scrapes products → stores products.json → you author dashboard.html that reads products.json and renders Chart.js visualizations
|
||||
|
||||
## Input
|
||||
- { action: "create", filename: "notes.md", content: "..." } - Create new file
|
||||
- { action: "update", filename: "notes.md", old_str: "...", new_str: "..." } - Update part of file (PREFERRED)
|
||||
- { action: "rewrite", filename: "notes.md", content: "..." } - Replace entire file (LAST RESORT)
|
||||
- { action: "get", filename: "data.json" } - Retrieve file content
|
||||
- { action: "delete", filename: "old.csv" } - Delete file
|
||||
- { action: "htmlArtifactLogs", filename: "app.html" } - Get console logs from HTML artifact
|
||||
|
||||
## Returns
|
||||
Depends on action:
|
||||
- create/update/rewrite/delete: Success status or error
|
||||
- get: File content
|
||||
- htmlArtifactLogs: Console logs and errors
|
||||
|
||||
## Supported File Types
|
||||
✅ Text-based files you author: .md, .txt, .html, .js, .css, .json, .csv, .svg
|
||||
❌ Binary files requiring libraries (use repl): .pdf, .docx
|
||||
|
||||
## Critical - Prefer Update Over Rewrite
|
||||
❌ NEVER: get entire file + rewrite to change small sections
|
||||
✅ ALWAYS: update for targeted edits (token efficient)
|
||||
✅ Ask: Can I describe the change as old_str → new_str? Use update.
|
||||
|
||||
---
|
||||
|
||||
## HTML Artifacts
|
||||
|
||||
Interactive HTML applications that can visualize data from other artifacts.
|
||||
|
||||
### Data Access
|
||||
- Can read artifacts created by repl and user attachments
|
||||
- Use to build dashboards, visualizations, interactive tools
|
||||
- See Helper Functions section below for available functions
|
||||
|
||||
### Requirements
|
||||
- Self-contained single file
|
||||
- Import ES modules from esm.sh: <script type="module">import X from 'https://esm.sh/pkg';</script>
|
||||
- Use Tailwind CDN: <script src="https://cdn.tailwindcss.com"></script>
|
||||
- Can embed images from any domain: <img src="https://example.com/image.jpg">
|
||||
- MUST set background color explicitly (avoid transparent)
|
||||
- Inline CSS or Tailwind utility classes
|
||||
- No localStorage/sessionStorage
|
||||
|
||||
### Styling
|
||||
- Use Tailwind utility classes for clean, functional designs
|
||||
- Ensure responsive layout (iframe may be resized)
|
||||
- Avoid purple gradients, AI aesthetic clichés, and emojis
|
||||
|
||||
### Helper Functions (Automatically Available)
|
||||
|
||||
These functions are injected into HTML artifact sandbox:
|
||||
|
||||
${runtimeProviderDescriptions.join("\n\n")}
|
||||
`;
|
||||
|
||||
// ============================================================================
|
||||
// Artifacts Runtime Provider
|
||||
// ============================================================================
|
||||
|
||||
export const ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RW = `
|
||||
### Artifacts Storage
|
||||
|
||||
Create, read, update, and delete files in artifacts storage.
|
||||
|
||||
#### When to Use
|
||||
- Store intermediate results between tool calls
|
||||
- Save generated files (images, CSVs, processed data) for user to view and download
|
||||
|
||||
#### Do NOT Use For
|
||||
- Content you author directly, like summaries of content you read (use artifacts tool instead)
|
||||
|
||||
#### Functions
|
||||
- listArtifacts() - List all artifact filenames, returns Promise<string[]>
|
||||
- getArtifact(filename) - Read artifact content, returns Promise<string | object>. JSON files auto-parse to objects, binary files return base64 string
|
||||
- createOrUpdateArtifact(filename, content, mimeType?) - Create or update artifact, returns Promise<void>. JSON files auto-stringify objects, binary requires base64 string with mimeType
|
||||
- deleteArtifact(filename) - Delete artifact, returns Promise<void>
|
||||
|
||||
#### Example
|
||||
JSON workflow:
|
||||
\`\`\`javascript
|
||||
// Fetch and save
|
||||
const response = await fetch('https://api.example.com/products');
|
||||
const products = await response.json();
|
||||
await createOrUpdateArtifact('products.json', products);
|
||||
|
||||
// Later: read and filter
|
||||
const all = await getArtifact('products.json');
|
||||
const cheap = all.filter(p => p.price < 100);
|
||||
await createOrUpdateArtifact('cheap.json', cheap);
|
||||
\`\`\`
|
||||
|
||||
Binary file (image):
|
||||
\`\`\`javascript
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 800; canvas.height = 600;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = 'blue';
|
||||
ctx.fillRect(0, 0, 800, 600);
|
||||
// Remove data:image/png;base64, prefix
|
||||
const base64 = canvas.toDataURL().split(',')[1];
|
||||
await createOrUpdateArtifact('chart.png', base64, 'image/png');
|
||||
\`\`\`
|
||||
`;
|
||||
|
||||
export const ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RO = `
|
||||
### Artifacts Storage
|
||||
|
||||
Read files from artifacts storage.
|
||||
|
||||
#### When to Use
|
||||
- Read artifacts created by REPL or artifacts tool
|
||||
- Access data from other HTML artifacts
|
||||
- Load configuration or data files
|
||||
|
||||
#### Do NOT Use For
|
||||
- Creating new artifacts (not available in HTML artifacts)
|
||||
- Modifying artifacts (read-only access)
|
||||
|
||||
#### Functions
|
||||
- listArtifacts() - List all artifact filenames, returns Promise<string[]>
|
||||
- getArtifact(filename) - Read artifact content, returns Promise<string | object>. JSON files auto-parse to objects, binary files return base64 string
|
||||
|
||||
#### Example
|
||||
JSON data:
|
||||
\`\`\`javascript
|
||||
const products = await getArtifact('products.json');
|
||||
const html = products.map(p => \`<div>\${p.name}: $\${p.price}</div>\`).join('');
|
||||
document.body.innerHTML = html;
|
||||
\`\`\`
|
||||
|
||||
Binary image:
|
||||
\`\`\`javascript
|
||||
const base64 = await getArtifact('chart.png');
|
||||
const img = document.createElement('img');
|
||||
img.src = 'data:image/png;base64,' + base64;
|
||||
document.body.appendChild(img);
|
||||
\`\`\`
|
||||
`;
|
||||
|
||||
// ============================================================================
|
||||
// Attachments Runtime Provider
|
||||
// ============================================================================
|
||||
|
||||
export const ATTACHMENTS_RUNTIME_DESCRIPTION = `
|
||||
### User Attachments
|
||||
|
||||
Read files the user uploaded to the conversation.
|
||||
|
||||
#### When to Use
|
||||
- Process user-uploaded files (CSV, JSON, Excel, images, PDFs)
|
||||
|
||||
#### Functions
|
||||
- listAttachments() - List all attachments, returns array of {id, fileName, mimeType, size}
|
||||
- readTextAttachment(id) - Read attachment as text, returns string
|
||||
- readBinaryAttachment(id) - Read attachment as binary data, returns Uint8Array
|
||||
|
||||
#### Example
|
||||
CSV file:
|
||||
\`\`\`javascript
|
||||
const files = listAttachments();
|
||||
const csvFile = files.find(f => f.fileName.endsWith('.csv'));
|
||||
const csvData = readTextAttachment(csvFile.id);
|
||||
const rows = csvData.split('\\n').map(row => row.split(','));
|
||||
\`\`\`
|
||||
|
||||
Excel file:
|
||||
\`\`\`javascript
|
||||
const XLSX = await import('https://esm.run/xlsx');
|
||||
const files = listAttachments();
|
||||
const xlsxFile = files.find(f => f.fileName.endsWith('.xlsx'));
|
||||
const bytes = readBinaryAttachment(xlsxFile.id);
|
||||
const workbook = XLSX.read(bytes);
|
||||
const data = XLSX.utils.sheet_to_json(workbook.Sheets[workbook.SheetNames[0]]);
|
||||
\`\`\`
|
||||
`;
|
||||
|
||||
// ============================================================================
|
||||
// Extract Document Tool
|
||||
// ============================================================================
|
||||
|
||||
export const EXTRACT_DOCUMENT_DESCRIPTION = `# Extract Document
|
||||
|
||||
Extract plain text from documents on the web (PDF, DOCX, XLSX, PPTX).
|
||||
|
||||
## When to Use
|
||||
User wants you to read a document at a URL.
|
||||
|
||||
## Input
|
||||
- { url: "https://example.com/document.pdf" } - URL to PDF, DOCX, XLSX, or PPTX
|
||||
|
||||
## Returns
|
||||
Structured plain text with page/sheet/slide delimiters.`;
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
import type { CustomProvidersStore } from "./stores/custom-providers-store.js";
|
||||
import type { ProviderKeysStore } from "./stores/provider-keys-store.js";
|
||||
import type { SessionsStore } from "./stores/sessions-store.js";
|
||||
import type { SettingsStore } from "./stores/settings-store.js";
|
||||
import type { StorageBackend } from "./types.js";
|
||||
|
||||
/**
|
||||
* High-level storage API providing access to all storage operations.
|
||||
* Subclasses can extend this to add domain-specific stores.
|
||||
*/
|
||||
export class AppStorage {
|
||||
readonly backend: StorageBackend;
|
||||
readonly settings: SettingsStore;
|
||||
readonly providerKeys: ProviderKeysStore;
|
||||
readonly sessions: SessionsStore;
|
||||
readonly customProviders: CustomProvidersStore;
|
||||
|
||||
constructor(
|
||||
settings: SettingsStore,
|
||||
providerKeys: ProviderKeysStore,
|
||||
sessions: SessionsStore,
|
||||
customProviders: CustomProvidersStore,
|
||||
backend: StorageBackend,
|
||||
) {
|
||||
this.settings = settings;
|
||||
this.providerKeys = providerKeys;
|
||||
this.sessions = sessions;
|
||||
this.customProviders = customProviders;
|
||||
this.backend = backend;
|
||||
}
|
||||
|
||||
async getQuotaInfo(): Promise<{
|
||||
usage: number;
|
||||
quota: number;
|
||||
percent: number;
|
||||
}> {
|
||||
return this.backend.getQuotaInfo();
|
||||
}
|
||||
|
||||
async requestPersistence(): Promise<boolean> {
|
||||
return this.backend.requestPersistence();
|
||||
}
|
||||
}
|
||||
|
||||
// Global instance management
|
||||
let globalAppStorage: AppStorage | null = null;
|
||||
|
||||
/**
|
||||
* Get the global AppStorage instance.
|
||||
* Throws if not initialized.
|
||||
*/
|
||||
export function getAppStorage(): AppStorage {
|
||||
if (!globalAppStorage) {
|
||||
throw new Error("AppStorage not initialized. Call setAppStorage() first.");
|
||||
}
|
||||
return globalAppStorage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the global AppStorage instance.
|
||||
*/
|
||||
export function setAppStorage(storage: AppStorage): void {
|
||||
globalAppStorage = storage;
|
||||
}
|
||||
|
|
@ -1,210 +0,0 @@
|
|||
import type {
|
||||
IndexedDBConfig,
|
||||
StorageBackend,
|
||||
StorageTransaction,
|
||||
} from "../types.js";
|
||||
|
||||
/**
|
||||
* IndexedDB implementation of StorageBackend.
|
||||
* Provides multi-store key-value storage with transactions and quota management.
|
||||
*/
|
||||
export class IndexedDBStorageBackend implements StorageBackend {
|
||||
private dbPromise: Promise<IDBDatabase> | null = null;
|
||||
|
||||
constructor(private config: IndexedDBConfig) {}
|
||||
|
||||
private async getDB(): Promise<IDBDatabase> {
|
||||
if (!this.dbPromise) {
|
||||
this.dbPromise = new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(this.config.dbName, this.config.version);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
|
||||
request.onupgradeneeded = (_event) => {
|
||||
const db = request.result;
|
||||
|
||||
// Create object stores from config
|
||||
for (const storeConfig of this.config.stores) {
|
||||
if (!db.objectStoreNames.contains(storeConfig.name)) {
|
||||
const store = db.createObjectStore(storeConfig.name, {
|
||||
keyPath: storeConfig.keyPath,
|
||||
autoIncrement: storeConfig.autoIncrement,
|
||||
});
|
||||
|
||||
// Create indices
|
||||
if (storeConfig.indices) {
|
||||
for (const indexConfig of storeConfig.indices) {
|
||||
store.createIndex(indexConfig.name, indexConfig.keyPath, {
|
||||
unique: indexConfig.unique,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return this.dbPromise;
|
||||
}
|
||||
|
||||
private promisifyRequest<T>(request: IDBRequest<T>): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async get<T = unknown>(storeName: string, key: string): Promise<T | null> {
|
||||
const db = await this.getDB();
|
||||
const tx = db.transaction(storeName, "readonly");
|
||||
const store = tx.objectStore(storeName);
|
||||
const result = await this.promisifyRequest(store.get(key));
|
||||
return result ?? null;
|
||||
}
|
||||
|
||||
async set<T = unknown>(
|
||||
storeName: string,
|
||||
key: string,
|
||||
value: T,
|
||||
): Promise<void> {
|
||||
const db = await this.getDB();
|
||||
const tx = db.transaction(storeName, "readwrite");
|
||||
const store = tx.objectStore(storeName);
|
||||
// If store has keyPath, only pass value (in-line key)
|
||||
// Otherwise pass both value and key (out-of-line key)
|
||||
if (store.keyPath) {
|
||||
await this.promisifyRequest(store.put(value));
|
||||
} else {
|
||||
await this.promisifyRequest(store.put(value, key));
|
||||
}
|
||||
}
|
||||
|
||||
async delete(storeName: string, key: string): Promise<void> {
|
||||
const db = await this.getDB();
|
||||
const tx = db.transaction(storeName, "readwrite");
|
||||
const store = tx.objectStore(storeName);
|
||||
await this.promisifyRequest(store.delete(key));
|
||||
}
|
||||
|
||||
async keys(storeName: string, prefix?: string): Promise<string[]> {
|
||||
const db = await this.getDB();
|
||||
const tx = db.transaction(storeName, "readonly");
|
||||
const store = tx.objectStore(storeName);
|
||||
|
||||
if (prefix) {
|
||||
// Use IDBKeyRange for efficient prefix filtering
|
||||
const range = IDBKeyRange.bound(prefix, `${prefix}\uffff`, false, false);
|
||||
const keys = await this.promisifyRequest(store.getAllKeys(range));
|
||||
return keys.map((k) => String(k));
|
||||
} else {
|
||||
const keys = await this.promisifyRequest(store.getAllKeys());
|
||||
return keys.map((k) => String(k));
|
||||
}
|
||||
}
|
||||
|
||||
async getAllFromIndex<T = unknown>(
|
||||
storeName: string,
|
||||
indexName: string,
|
||||
direction: "asc" | "desc" = "asc",
|
||||
): Promise<T[]> {
|
||||
const db = await this.getDB();
|
||||
const tx = db.transaction(storeName, "readonly");
|
||||
const store = tx.objectStore(storeName);
|
||||
const index = store.index(indexName);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const results: T[] = [];
|
||||
const request = index.openCursor(
|
||||
null,
|
||||
direction === "desc" ? "prev" : "next",
|
||||
);
|
||||
|
||||
request.onsuccess = () => {
|
||||
const cursor = request.result;
|
||||
if (cursor) {
|
||||
results.push(cursor.value as T);
|
||||
cursor.continue();
|
||||
} else {
|
||||
resolve(results);
|
||||
}
|
||||
};
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async clear(storeName: string): Promise<void> {
|
||||
const db = await this.getDB();
|
||||
const tx = db.transaction(storeName, "readwrite");
|
||||
const store = tx.objectStore(storeName);
|
||||
await this.promisifyRequest(store.clear());
|
||||
}
|
||||
|
||||
async has(storeName: string, key: string): Promise<boolean> {
|
||||
const db = await this.getDB();
|
||||
const tx = db.transaction(storeName, "readonly");
|
||||
const store = tx.objectStore(storeName);
|
||||
const result = await this.promisifyRequest(store.getKey(key));
|
||||
return result !== undefined;
|
||||
}
|
||||
|
||||
async transaction<T>(
|
||||
storeNames: string[],
|
||||
mode: "readonly" | "readwrite",
|
||||
operation: (tx: StorageTransaction) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const db = await this.getDB();
|
||||
const idbTx = db.transaction(storeNames, mode);
|
||||
|
||||
const storageTx: StorageTransaction = {
|
||||
get: async <T>(storeName: string, key: string) => {
|
||||
const store = idbTx.objectStore(storeName);
|
||||
const result = await this.promisifyRequest(store.get(key));
|
||||
return (result ?? null) as T | null;
|
||||
},
|
||||
set: async <T>(storeName: string, key: string, value: T) => {
|
||||
const store = idbTx.objectStore(storeName);
|
||||
// If store has keyPath, only pass value (in-line key)
|
||||
// Otherwise pass both value and key (out-of-line key)
|
||||
if (store.keyPath) {
|
||||
await this.promisifyRequest(store.put(value));
|
||||
} else {
|
||||
await this.promisifyRequest(store.put(value, key));
|
||||
}
|
||||
},
|
||||
delete: async (storeName: string, key: string) => {
|
||||
const store = idbTx.objectStore(storeName);
|
||||
await this.promisifyRequest(store.delete(key));
|
||||
},
|
||||
};
|
||||
|
||||
return operation(storageTx);
|
||||
}
|
||||
|
||||
async getQuotaInfo(): Promise<{
|
||||
usage: number;
|
||||
quota: number;
|
||||
percent: number;
|
||||
}> {
|
||||
if (navigator.storage?.estimate) {
|
||||
const estimate = await navigator.storage.estimate();
|
||||
return {
|
||||
usage: estimate.usage || 0,
|
||||
quota: estimate.quota || 0,
|
||||
percent: estimate.quota
|
||||
? ((estimate.usage || 0) / estimate.quota) * 100
|
||||
: 0,
|
||||
};
|
||||
}
|
||||
return { usage: 0, quota: 0, percent: 0 };
|
||||
}
|
||||
|
||||
async requestPersistence(): Promise<boolean> {
|
||||
if (navigator.storage?.persist) {
|
||||
return await navigator.storage.persist();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
import type { StorageBackend, StoreConfig } from "./types.js";
|
||||
|
||||
/**
|
||||
* Base class for all storage stores.
|
||||
* Each store defines its IndexedDB schema and provides domain-specific methods.
|
||||
*/
|
||||
export abstract class Store {
|
||||
private backend: StorageBackend | null = null;
|
||||
|
||||
/**
|
||||
* Returns the IndexedDB configuration for this store.
|
||||
* Defines store name, key path, and indices.
|
||||
*/
|
||||
abstract getConfig(): StoreConfig;
|
||||
|
||||
/**
|
||||
* Sets the storage backend. Called by AppStorage after backend creation.
|
||||
*/
|
||||
setBackend(backend: StorageBackend): void {
|
||||
this.backend = backend;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the storage backend. Throws if backend not set.
|
||||
* Concrete stores must use this to access the backend.
|
||||
*/
|
||||
protected getBackend(): StorageBackend {
|
||||
if (!this.backend) {
|
||||
throw new Error(`Backend not set on ${this.constructor.name}`);
|
||||
}
|
||||
return this.backend;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
import type { Model } from "@mariozechner/pi-ai";
|
||||
import { Store } from "../store.js";
|
||||
import type { StoreConfig } from "../types.js";
|
||||
|
||||
export type AutoDiscoveryProviderType =
|
||||
| "ollama"
|
||||
| "llama.cpp"
|
||||
| "vllm"
|
||||
| "lmstudio";
|
||||
|
||||
export type CustomProviderType =
|
||||
| AutoDiscoveryProviderType // Auto-discovery - models fetched on-demand
|
||||
| "openai-completions" // Manual models - stored in provider.models
|
||||
| "openai-responses" // Manual models - stored in provider.models
|
||||
| "anthropic-messages"; // Manual models - stored in provider.models
|
||||
|
||||
export interface CustomProvider {
|
||||
id: string; // UUID
|
||||
name: string; // Display name, also used as Model.provider
|
||||
type: CustomProviderType;
|
||||
baseUrl: string;
|
||||
apiKey?: string; // Optional, applies to all models
|
||||
|
||||
// For manual types ONLY - models stored directly on provider
|
||||
// Auto-discovery types: models fetched on-demand, never stored
|
||||
models?: Model<any>[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Store for custom LLM providers (auto-discovery servers + manual providers).
|
||||
*/
|
||||
export class CustomProvidersStore extends Store {
|
||||
getConfig(): StoreConfig {
|
||||
return {
|
||||
name: "custom-providers",
|
||||
};
|
||||
}
|
||||
|
||||
async get(id: string): Promise<CustomProvider | null> {
|
||||
return this.getBackend().get("custom-providers", id);
|
||||
}
|
||||
|
||||
async set(provider: CustomProvider): Promise<void> {
|
||||
await this.getBackend().set("custom-providers", provider.id, provider);
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.getBackend().delete("custom-providers", id);
|
||||
}
|
||||
|
||||
async getAll(): Promise<CustomProvider[]> {
|
||||
const keys = await this.getBackend().keys("custom-providers");
|
||||
const providers: CustomProvider[] = [];
|
||||
for (const key of keys) {
|
||||
const provider = await this.get(key);
|
||||
if (provider) {
|
||||
providers.push(provider);
|
||||
}
|
||||
}
|
||||
return providers;
|
||||
}
|
||||
|
||||
async has(id: string): Promise<boolean> {
|
||||
return this.getBackend().has("custom-providers", id);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
import { Store } from "../store.js";
|
||||
import type { StoreConfig } from "../types.js";
|
||||
|
||||
/**
|
||||
* Store for LLM provider API keys (Anthropic, OpenAI, etc.).
|
||||
*/
|
||||
export class ProviderKeysStore extends Store {
|
||||
getConfig(): StoreConfig {
|
||||
return {
|
||||
name: "provider-keys",
|
||||
};
|
||||
}
|
||||
|
||||
async get(provider: string): Promise<string | null> {
|
||||
return this.getBackend().get("provider-keys", provider);
|
||||
}
|
||||
|
||||
async set(provider: string, key: string): Promise<void> {
|
||||
await this.getBackend().set("provider-keys", provider, key);
|
||||
}
|
||||
|
||||
async delete(provider: string): Promise<void> {
|
||||
await this.getBackend().delete("provider-keys", provider);
|
||||
}
|
||||
|
||||
async list(): Promise<string[]> {
|
||||
return this.getBackend().keys("provider-keys");
|
||||
}
|
||||
|
||||
async has(provider: string): Promise<boolean> {
|
||||
return this.getBackend().has("provider-keys", provider);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,152 +0,0 @@
|
|||
import type { AgentState } from "@mariozechner/pi-agent-core";
|
||||
import { Store } from "../store.js";
|
||||
import type { SessionData, SessionMetadata, StoreConfig } from "../types.js";
|
||||
|
||||
/**
|
||||
* Store for chat sessions (data and metadata).
|
||||
* Uses two object stores: sessions (full data) and sessions-metadata (lightweight).
|
||||
*/
|
||||
export class SessionsStore extends Store {
|
||||
getConfig(): StoreConfig {
|
||||
return {
|
||||
name: "sessions",
|
||||
keyPath: "id",
|
||||
indices: [{ name: "lastModified", keyPath: "lastModified" }],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Additional config for sessions-metadata store.
|
||||
* Must be included when creating the backend.
|
||||
*/
|
||||
static getMetadataConfig(): StoreConfig {
|
||||
return {
|
||||
name: "sessions-metadata",
|
||||
keyPath: "id",
|
||||
indices: [{ name: "lastModified", keyPath: "lastModified" }],
|
||||
};
|
||||
}
|
||||
|
||||
async save(data: SessionData, metadata: SessionMetadata): Promise<void> {
|
||||
await this.getBackend().transaction(
|
||||
["sessions", "sessions-metadata"],
|
||||
"readwrite",
|
||||
async (tx) => {
|
||||
await tx.set("sessions", data.id, data);
|
||||
await tx.set("sessions-metadata", metadata.id, metadata);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async get(id: string): Promise<SessionData | null> {
|
||||
return this.getBackend().get("sessions", id);
|
||||
}
|
||||
|
||||
async getMetadata(id: string): Promise<SessionMetadata | null> {
|
||||
return this.getBackend().get("sessions-metadata", id);
|
||||
}
|
||||
|
||||
async getAllMetadata(): Promise<SessionMetadata[]> {
|
||||
// Use the lastModified index to get sessions sorted by most recent first
|
||||
return this.getBackend().getAllFromIndex<SessionMetadata>(
|
||||
"sessions-metadata",
|
||||
"lastModified",
|
||||
"desc",
|
||||
);
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.getBackend().transaction(
|
||||
["sessions", "sessions-metadata"],
|
||||
"readwrite",
|
||||
async (tx) => {
|
||||
await tx.delete("sessions", id);
|
||||
await tx.delete("sessions-metadata", id);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Alias for backward compatibility
|
||||
async deleteSession(id: string): Promise<void> {
|
||||
return this.delete(id);
|
||||
}
|
||||
|
||||
async updateTitle(id: string, title: string): Promise<void> {
|
||||
const metadata = await this.getMetadata(id);
|
||||
if (metadata) {
|
||||
metadata.title = title;
|
||||
await this.getBackend().set("sessions-metadata", id, metadata);
|
||||
}
|
||||
|
||||
// Also update in full session data
|
||||
const data = await this.get(id);
|
||||
if (data) {
|
||||
data.title = title;
|
||||
await this.getBackend().set("sessions", id, data);
|
||||
}
|
||||
}
|
||||
|
||||
async getQuotaInfo(): Promise<{
|
||||
usage: number;
|
||||
quota: number;
|
||||
percent: number;
|
||||
}> {
|
||||
return this.getBackend().getQuotaInfo();
|
||||
}
|
||||
|
||||
async requestPersistence(): Promise<boolean> {
|
||||
return this.getBackend().requestPersistence();
|
||||
}
|
||||
|
||||
// Alias methods for backward compatibility
|
||||
async saveSession(
|
||||
id: string,
|
||||
state: AgentState,
|
||||
metadata: SessionMetadata | undefined,
|
||||
title?: string,
|
||||
): Promise<void> {
|
||||
// If metadata is provided, use it; otherwise create it from state
|
||||
const meta: SessionMetadata = metadata || {
|
||||
id,
|
||||
title: title || "",
|
||||
createdAt: new Date().toISOString(),
|
||||
lastModified: new Date().toISOString(),
|
||||
messageCount: state.messages?.length || 0,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
thinkingLevel: state.thinkingLevel || "off",
|
||||
preview: "",
|
||||
};
|
||||
|
||||
const data: SessionData = {
|
||||
id,
|
||||
title: title || meta.title,
|
||||
model: state.model,
|
||||
thinkingLevel: state.thinkingLevel,
|
||||
messages: state.messages || [],
|
||||
createdAt: meta.createdAt,
|
||||
lastModified: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await this.save(data, meta);
|
||||
}
|
||||
|
||||
async loadSession(id: string): Promise<SessionData | null> {
|
||||
return this.get(id);
|
||||
}
|
||||
|
||||
async getLatestSessionId(): Promise<string | null> {
|
||||
const allMetadata = await this.getAllMetadata();
|
||||
if (allMetadata.length === 0) return null;
|
||||
|
||||
// Sort by lastModified descending
|
||||
allMetadata.sort((a, b) => b.lastModified.localeCompare(a.lastModified));
|
||||
return allMetadata[0].id;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
import { Store } from "../store.js";
|
||||
import type { StoreConfig } from "../types.js";
|
||||
|
||||
/**
|
||||
* Store for application settings (theme, proxy config, etc.).
|
||||
*/
|
||||
export class SettingsStore extends Store {
|
||||
getConfig(): StoreConfig {
|
||||
return {
|
||||
name: "settings",
|
||||
// No keyPath - uses out-of-line keys
|
||||
};
|
||||
}
|
||||
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
return this.getBackend().get("settings", key);
|
||||
}
|
||||
|
||||
async set<T>(key: string, value: T): Promise<void> {
|
||||
await this.getBackend().set("settings", key, value);
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
await this.getBackend().delete("settings", key);
|
||||
}
|
||||
|
||||
async list(): Promise<string[]> {
|
||||
return this.getBackend().keys("settings");
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
await this.getBackend().clear("settings");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,210 +0,0 @@
|
|||
import type { AgentMessage, ThinkingLevel } from "@mariozechner/pi-agent-core";
|
||||
import type { Model } from "@mariozechner/pi-ai";
|
||||
|
||||
/**
|
||||
* Transaction interface for atomic operations across stores.
|
||||
*/
|
||||
export interface StorageTransaction {
|
||||
/**
|
||||
* Get a value by key from a specific store.
|
||||
*/
|
||||
get<T = unknown>(storeName: string, key: string): Promise<T | null>;
|
||||
|
||||
/**
|
||||
* Set a value for a key in a specific store.
|
||||
*/
|
||||
set<T = unknown>(storeName: string, key: string, value: T): Promise<void>;
|
||||
|
||||
/**
|
||||
* Delete a key from a specific store.
|
||||
*/
|
||||
delete(storeName: string, key: string): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base interface for all storage backends.
|
||||
* Multi-store key-value storage abstraction that can be implemented
|
||||
* by IndexedDB, remote APIs, or any other multi-collection storage system.
|
||||
*/
|
||||
export interface StorageBackend {
|
||||
/**
|
||||
* Get a value by key from a specific store. Returns null if key doesn't exist.
|
||||
*/
|
||||
get<T = unknown>(storeName: string, key: string): Promise<T | null>;
|
||||
|
||||
/**
|
||||
* Set a value for a key in a specific store.
|
||||
*/
|
||||
set<T = unknown>(storeName: string, key: string, value: T): Promise<void>;
|
||||
|
||||
/**
|
||||
* Delete a key from a specific store.
|
||||
*/
|
||||
delete(storeName: string, key: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get all keys from a specific store, optionally filtered by prefix.
|
||||
*/
|
||||
keys(storeName: string, prefix?: string): Promise<string[]>;
|
||||
|
||||
/**
|
||||
* Get all values from a specific store, ordered by an index.
|
||||
* @param storeName - The store to query
|
||||
* @param indexName - The index to use for ordering
|
||||
* @param direction - Sort direction ("asc" or "desc")
|
||||
*/
|
||||
getAllFromIndex<T = unknown>(
|
||||
storeName: string,
|
||||
indexName: string,
|
||||
direction?: "asc" | "desc",
|
||||
): Promise<T[]>;
|
||||
|
||||
/**
|
||||
* Clear all data from a specific store.
|
||||
*/
|
||||
clear(storeName: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Check if a key exists in a specific store.
|
||||
*/
|
||||
has(storeName: string, key: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Execute atomic operations across multiple stores.
|
||||
*/
|
||||
transaction<T>(
|
||||
storeNames: string[],
|
||||
mode: "readonly" | "readwrite",
|
||||
operation: (tx: StorageTransaction) => Promise<T>,
|
||||
): Promise<T>;
|
||||
|
||||
/**
|
||||
* Get storage quota information.
|
||||
* Used for warning users when approaching limits.
|
||||
*/
|
||||
getQuotaInfo(): Promise<{ usage: number; quota: number; percent: number }>;
|
||||
|
||||
/**
|
||||
* Request persistent storage (prevents eviction).
|
||||
* Returns true if granted, false otherwise.
|
||||
*/
|
||||
requestPersistence(): Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight session metadata for listing and searching.
|
||||
* Stored separately from full session data for performance.
|
||||
*/
|
||||
export interface SessionMetadata {
|
||||
/** Unique session identifier (UUID v4) */
|
||||
id: string;
|
||||
|
||||
/** User-defined title or auto-generated from first message */
|
||||
title: string;
|
||||
|
||||
/** ISO 8601 UTC timestamp of creation */
|
||||
createdAt: string;
|
||||
|
||||
/** ISO 8601 UTC timestamp of last modification */
|
||||
lastModified: string;
|
||||
|
||||
/** Total number of messages (user + assistant + tool results) */
|
||||
messageCount: number;
|
||||
|
||||
/** Cumulative usage statistics */
|
||||
usage: {
|
||||
/** Total input tokens */
|
||||
input: number;
|
||||
/** Total output tokens */
|
||||
output: number;
|
||||
/** Total cache read tokens */
|
||||
cacheRead: number;
|
||||
/** Total cache write tokens */
|
||||
cacheWrite: number;
|
||||
/** Total tokens processed */
|
||||
totalTokens: number;
|
||||
/** Total cost breakdown */
|
||||
cost: {
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
total: number;
|
||||
};
|
||||
};
|
||||
|
||||
/** Last used thinking level */
|
||||
thinkingLevel: ThinkingLevel;
|
||||
|
||||
/**
|
||||
* Preview text for search and display.
|
||||
* First 2KB of conversation text (user + assistant messages in sequence).
|
||||
* Tool calls and tool results are excluded.
|
||||
*/
|
||||
preview: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full session data including all messages.
|
||||
* Only loaded when user opens a specific session.
|
||||
*/
|
||||
export interface SessionData {
|
||||
/** Unique session identifier (UUID v4) */
|
||||
id: string;
|
||||
|
||||
/** User-defined title or auto-generated from first message */
|
||||
title: string;
|
||||
|
||||
/** Last selected model */
|
||||
model: Model<any>;
|
||||
|
||||
/** Last selected thinking level */
|
||||
thinkingLevel: ThinkingLevel;
|
||||
|
||||
/** Full conversation history (with attachments inline) */
|
||||
messages: AgentMessage[];
|
||||
|
||||
/** ISO 8601 UTC timestamp of creation */
|
||||
createdAt: string;
|
||||
|
||||
/** ISO 8601 UTC timestamp of last modification */
|
||||
lastModified: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for IndexedDB backend.
|
||||
*/
|
||||
export interface IndexedDBConfig {
|
||||
/** Database name */
|
||||
dbName: string;
|
||||
/** Database version */
|
||||
version: number;
|
||||
/** Object stores to create */
|
||||
stores: StoreConfig[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for an IndexedDB object store.
|
||||
*/
|
||||
export interface StoreConfig {
|
||||
/** Store name */
|
||||
name: string;
|
||||
/** Key path (optional, for auto-extracting keys from objects) */
|
||||
keyPath?: string;
|
||||
/** Auto-increment keys (optional) */
|
||||
autoIncrement?: boolean;
|
||||
/** Indices to create on this store */
|
||||
indices?: IndexConfig[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for an IndexedDB index.
|
||||
*/
|
||||
export interface IndexConfig {
|
||||
/** Index name */
|
||||
name: string;
|
||||
/** Key path to index on */
|
||||
keyPath: string;
|
||||
/** Unique constraint (optional) */
|
||||
unique?: boolean;
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
import { LitElement, type TemplateResult } from "lit";
|
||||
|
||||
export abstract class ArtifactElement extends LitElement {
|
||||
public filename = "";
|
||||
|
||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
||||
return this; // light DOM for shared styles
|
||||
}
|
||||
|
||||
public abstract get content(): string;
|
||||
public abstract set content(value: string);
|
||||
|
||||
abstract getHeaderButtons(): TemplateResult | HTMLElement;
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
import { icon } from "@mariozechner/mini-lit";
|
||||
import { html, type TemplateResult } from "lit";
|
||||
import { FileCode2 } from "lucide";
|
||||
import type { ArtifactsPanel } from "./artifacts.js";
|
||||
|
||||
export function ArtifactPill(
|
||||
filename: string,
|
||||
artifactsPanel?: ArtifactsPanel,
|
||||
): TemplateResult {
|
||||
const handleClick = (e: Event) => {
|
||||
if (!artifactsPanel) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// openArtifact will show the artifact and call onOpen() to open the panel if needed
|
||||
artifactsPanel.openArtifact(filename);
|
||||
};
|
||||
|
||||
return html`
|
||||
<span
|
||||
class="inline-flex items-center gap-1 px-2 py-0.5 text-xs bg-muted/50 border border-border rounded ${artifactsPanel
|
||||
? "cursor-pointer hover:bg-muted transition-colors"
|
||||
: ""}"
|
||||
@click=${artifactsPanel ? handleClick : null}
|
||||
>
|
||||
${icon(FileCode2, "sm")}
|
||||
<span class="text-foreground">${filename}</span>
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
import { icon } from "@mariozechner/mini-lit";
|
||||
import "@mariozechner/mini-lit/dist/CopyButton.js";
|
||||
import { html, LitElement, type TemplateResult } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { createRef, type Ref, ref } from "lit/directives/ref.js";
|
||||
import { repeat } from "lit/directives/repeat.js";
|
||||
import { ChevronDown, ChevronRight, ChevronsDown, Lock } from "lucide";
|
||||
import { i18n } from "../../utils/i18n.js";
|
||||
|
||||
interface LogEntry {
|
||||
type: "log" | "error";
|
||||
text: string;
|
||||
}
|
||||
|
||||
@customElement("artifact-console")
|
||||
export class Console extends LitElement {
|
||||
@property({ attribute: false }) logs: LogEntry[] = [];
|
||||
@state() private expanded = false;
|
||||
@state() private autoscroll = true;
|
||||
private logsContainerRef: Ref<HTMLDivElement> = createRef();
|
||||
|
||||
protected createRenderRoot() {
|
||||
return this; // light DOM
|
||||
}
|
||||
|
||||
override updated() {
|
||||
// Autoscroll to bottom when new logs arrive
|
||||
if (this.autoscroll && this.expanded && this.logsContainerRef.value) {
|
||||
this.logsContainerRef.value.scrollTop =
|
||||
this.logsContainerRef.value.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
private getLogsText(): string {
|
||||
return this.logs.map((l) => `[${l.type}] ${l.text}`).join("\n");
|
||||
}
|
||||
|
||||
override render(): TemplateResult {
|
||||
const errorCount = this.logs.filter((l) => l.type === "error").length;
|
||||
const summary =
|
||||
errorCount > 0
|
||||
? `${i18n("console")} (${errorCount} ${errorCount === 1 ? "error" : "errors"})`
|
||||
: `${i18n("console")} (${this.logs.length})`;
|
||||
|
||||
return html`
|
||||
<div class="border-t border-border p-2">
|
||||
<div class="flex items-center gap-2 w-full">
|
||||
<button
|
||||
@click=${() => {
|
||||
this.expanded = !this.expanded;
|
||||
}}
|
||||
class="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors flex-1 text-left"
|
||||
>
|
||||
${icon(this.expanded ? ChevronDown : ChevronRight, "sm")}
|
||||
<span>${summary}</span>
|
||||
</button>
|
||||
${this.expanded
|
||||
? html`
|
||||
<button
|
||||
@click=${() => {
|
||||
this.autoscroll = !this.autoscroll;
|
||||
}}
|
||||
class="p-1 rounded transition-colors ${this.autoscroll
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "hover:bg-muted"}"
|
||||
title=${this.autoscroll
|
||||
? i18n("Autoscroll enabled")
|
||||
: i18n("Autoscroll disabled")}
|
||||
>
|
||||
${icon(this.autoscroll ? ChevronsDown : Lock, "sm")}
|
||||
</button>
|
||||
<copy-button
|
||||
.text=${this.getLogsText()}
|
||||
title=${i18n("Copy logs")}
|
||||
.showText=${false}
|
||||
class="!bg-transparent hover:!bg-accent"
|
||||
></copy-button>
|
||||
`
|
||||
: ""}
|
||||
</div>
|
||||
${this.expanded
|
||||
? html`
|
||||
<div
|
||||
class="max-h-48 overflow-y-auto space-y-1 mt-2"
|
||||
${ref(this.logsContainerRef)}
|
||||
>
|
||||
${repeat(
|
||||
this.logs,
|
||||
(_log, index) => index,
|
||||
(log) => html`
|
||||
<div
|
||||
class="text-xs font-mono ${log.type === "error"
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground"}"
|
||||
>
|
||||
[${log.type}] ${log.text}
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,218 +0,0 @@
|
|||
import { DownloadButton } from "@mariozechner/mini-lit/dist/DownloadButton.js";
|
||||
import { renderAsync } from "docx-preview";
|
||||
import { html, type TemplateResult } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { i18n } from "../../utils/i18n.js";
|
||||
import { ArtifactElement } from "./ArtifactElement.js";
|
||||
|
||||
@customElement("docx-artifact")
|
||||
export class DocxArtifact extends ArtifactElement {
|
||||
@property({ type: String }) private _content = "";
|
||||
@state() private error: string | null = null;
|
||||
|
||||
get content(): string {
|
||||
return this._content;
|
||||
}
|
||||
|
||||
set content(value: string) {
|
||||
this._content = value;
|
||||
this.error = null;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
||||
return this;
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.style.display = "block";
|
||||
this.style.height = "100%";
|
||||
}
|
||||
|
||||
private base64ToArrayBuffer(base64: string): ArrayBuffer {
|
||||
// Remove data URL prefix if present
|
||||
let base64Data = base64;
|
||||
if (base64.startsWith("data:")) {
|
||||
const base64Match = base64.match(/base64,(.+)/);
|
||||
if (base64Match) {
|
||||
base64Data = base64Match[1];
|
||||
}
|
||||
}
|
||||
|
||||
const binaryString = atob(base64Data);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
private decodeBase64(): Uint8Array {
|
||||
let base64Data = this._content;
|
||||
if (this._content.startsWith("data:")) {
|
||||
const base64Match = this._content.match(/base64,(.+)/);
|
||||
if (base64Match) {
|
||||
base64Data = base64Match[1];
|
||||
}
|
||||
}
|
||||
|
||||
const binaryString = atob(base64Data);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
public getHeaderButtons() {
|
||||
return html`
|
||||
<div class="flex items-center gap-1">
|
||||
${DownloadButton({
|
||||
content: this.decodeBase64(),
|
||||
filename: this.filename,
|
||||
mimeType:
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
title: i18n("Download"),
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
override async updated(changedProperties: Map<string, any>) {
|
||||
super.updated(changedProperties);
|
||||
|
||||
if (changedProperties.has("_content") && this._content && !this.error) {
|
||||
await this.renderDocx();
|
||||
}
|
||||
}
|
||||
|
||||
private async renderDocx() {
|
||||
const container = this.querySelector("#docx-container");
|
||||
if (!container || !this._content) return;
|
||||
|
||||
try {
|
||||
const arrayBuffer = this.base64ToArrayBuffer(this._content);
|
||||
|
||||
// Clear container first
|
||||
container.innerHTML = "";
|
||||
|
||||
// Create a wrapper div for the document
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "docx-wrapper-custom";
|
||||
container.appendChild(wrapper);
|
||||
|
||||
// Render the DOCX file into the wrapper
|
||||
await renderAsync(arrayBuffer, wrapper as HTMLElement, undefined, {
|
||||
className: "docx",
|
||||
inWrapper: true,
|
||||
ignoreWidth: true,
|
||||
ignoreHeight: false,
|
||||
ignoreFonts: false,
|
||||
breakPages: true,
|
||||
ignoreLastRenderedPageBreak: true,
|
||||
experimental: false,
|
||||
trimXmlDeclaration: true,
|
||||
useBase64URL: false,
|
||||
renderHeaders: true,
|
||||
renderFooters: true,
|
||||
renderFootnotes: true,
|
||||
renderEndnotes: true,
|
||||
});
|
||||
|
||||
// Apply custom styles to match theme and fix sizing
|
||||
const style = document.createElement("style");
|
||||
style.textContent = `
|
||||
#docx-container {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#docx-container .docx-wrapper-custom {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
#docx-container .docx-wrapper {
|
||||
max-width: 100% !important;
|
||||
margin: 0 !important;
|
||||
background: transparent !important;
|
||||
padding: 0em !important;
|
||||
}
|
||||
|
||||
#docx-container .docx-wrapper > section.docx {
|
||||
box-shadow: none !important;
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
margin: 0 !important;
|
||||
padding: 2em !important;
|
||||
background: white !important;
|
||||
color: black !important;
|
||||
max-width: 100% !important;
|
||||
width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
overflow-x: auto !important;
|
||||
}
|
||||
|
||||
/* Fix tables and wide content */
|
||||
#docx-container table {
|
||||
max-width: 100% !important;
|
||||
width: auto !important;
|
||||
overflow-x: auto !important;
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
#docx-container img {
|
||||
max-width: 100% !important;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
/* Fix paragraphs and text */
|
||||
#docx-container p,
|
||||
#docx-container span,
|
||||
#docx-container div {
|
||||
max-width: 100% !important;
|
||||
word-wrap: break-word !important;
|
||||
overflow-wrap: break-word !important;
|
||||
}
|
||||
|
||||
/* Hide page breaks in web view */
|
||||
#docx-container .docx-page-break {
|
||||
display: none !important;
|
||||
}
|
||||
`;
|
||||
container.appendChild(style);
|
||||
} catch (error: any) {
|
||||
console.error("Error rendering DOCX:", error);
|
||||
this.error = error?.message || i18n("Failed to load document");
|
||||
}
|
||||
}
|
||||
|
||||
override render(): TemplateResult {
|
||||
if (this.error) {
|
||||
return html`
|
||||
<div class="h-full flex items-center justify-center bg-background p-4">
|
||||
<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 document")}
|
||||
</div>
|
||||
<div class="text-sm opacity-90">${this.error}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="h-full flex flex-col bg-background overflow-auto">
|
||||
<div id="docx-container" class="flex-1 overflow-auto"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"docx-artifact": DocxArtifact;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,243 +0,0 @@
|
|||
import { DownloadButton } from "@mariozechner/mini-lit/dist/DownloadButton.js";
|
||||
import { html, type TemplateResult } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import * as XLSX from "xlsx";
|
||||
import { i18n } from "../../utils/i18n.js";
|
||||
import { ArtifactElement } from "./ArtifactElement.js";
|
||||
|
||||
@customElement("excel-artifact")
|
||||
export class ExcelArtifact extends ArtifactElement {
|
||||
@property({ type: String }) private _content = "";
|
||||
@state() private error: string | null = null;
|
||||
|
||||
get content(): string {
|
||||
return this._content;
|
||||
}
|
||||
|
||||
set content(value: string) {
|
||||
this._content = value;
|
||||
this.error = null;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
||||
return this;
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.style.display = "block";
|
||||
this.style.height = "100%";
|
||||
}
|
||||
|
||||
private base64ToArrayBuffer(base64: string): ArrayBuffer {
|
||||
// Remove data URL prefix if present
|
||||
let base64Data = base64;
|
||||
if (base64.startsWith("data:")) {
|
||||
const base64Match = base64.match(/base64,(.+)/);
|
||||
if (base64Match) {
|
||||
base64Data = base64Match[1];
|
||||
}
|
||||
}
|
||||
|
||||
const binaryString = atob(base64Data);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
private decodeBase64(): Uint8Array {
|
||||
let base64Data = this._content;
|
||||
if (this._content.startsWith("data:")) {
|
||||
const base64Match = this._content.match(/base64,(.+)/);
|
||||
if (base64Match) {
|
||||
base64Data = base64Match[1];
|
||||
}
|
||||
}
|
||||
|
||||
const binaryString = atob(base64Data);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private getMimeType(): string {
|
||||
const ext = this.filename.split(".").pop()?.toLowerCase();
|
||||
if (ext === "xls") return "application/vnd.ms-excel";
|
||||
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||
}
|
||||
|
||||
public getHeaderButtons() {
|
||||
return html`
|
||||
<div class="flex items-center gap-1">
|
||||
${DownloadButton({
|
||||
content: this.decodeBase64(),
|
||||
filename: this.filename,
|
||||
mimeType: this.getMimeType(),
|
||||
title: i18n("Download"),
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
override async updated(changedProperties: Map<string, any>) {
|
||||
super.updated(changedProperties);
|
||||
|
||||
if (changedProperties.has("_content") && this._content && !this.error) {
|
||||
await this.renderExcel();
|
||||
}
|
||||
}
|
||||
|
||||
private async renderExcel() {
|
||||
const container = this.querySelector("#excel-container");
|
||||
if (!container || !this._content) return;
|
||||
|
||||
try {
|
||||
const arrayBuffer = this.base64ToArrayBuffer(this._content);
|
||||
const workbook = XLSX.read(arrayBuffer, { type: "array" });
|
||||
|
||||
container.innerHTML = "";
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "overflow-auto h-full flex flex-col";
|
||||
container.appendChild(wrapper);
|
||||
|
||||
// Create tabs for multiple sheets
|
||||
if (workbook.SheetNames.length > 1) {
|
||||
const tabContainer = document.createElement("div");
|
||||
tabContainer.className =
|
||||
"flex gap-2 mb-4 border-b border-border sticky top-0 bg-background z-10";
|
||||
|
||||
const sheetContents: HTMLElement[] = [];
|
||||
|
||||
workbook.SheetNames.forEach((sheetName, index) => {
|
||||
// Create tab button
|
||||
const tab = document.createElement("button");
|
||||
tab.textContent = sheetName;
|
||||
tab.className =
|
||||
index === 0
|
||||
? "px-4 py-2 text-sm font-medium border-b-2 border-primary text-primary"
|
||||
: "px-4 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:border-b-2 hover:border-border transition-colors";
|
||||
|
||||
// Create sheet content
|
||||
const sheetDiv = document.createElement("div");
|
||||
sheetDiv.style.display = index === 0 ? "flex" : "none";
|
||||
sheetDiv.className = "flex-1 overflow-auto";
|
||||
sheetDiv.appendChild(
|
||||
this.renderExcelSheet(workbook.Sheets[sheetName], sheetName),
|
||||
);
|
||||
sheetContents.push(sheetDiv);
|
||||
|
||||
// Tab click handler
|
||||
tab.onclick = () => {
|
||||
// Update tab styles
|
||||
tabContainer.querySelectorAll("button").forEach((btn, btnIndex) => {
|
||||
if (btnIndex === index) {
|
||||
btn.className =
|
||||
"px-4 py-2 text-sm font-medium border-b-2 border-primary text-primary";
|
||||
} else {
|
||||
btn.className =
|
||||
"px-4 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:border-b-2 hover:border-border transition-colors";
|
||||
}
|
||||
});
|
||||
// Show/hide sheets
|
||||
sheetContents.forEach((content, contentIndex) => {
|
||||
content.style.display = contentIndex === index ? "flex" : "none";
|
||||
});
|
||||
};
|
||||
|
||||
tabContainer.appendChild(tab);
|
||||
});
|
||||
|
||||
wrapper.appendChild(tabContainer);
|
||||
sheetContents.forEach((content) => {
|
||||
wrapper.appendChild(content);
|
||||
});
|
||||
} else {
|
||||
// Single sheet
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
wrapper.appendChild(
|
||||
this.renderExcelSheet(workbook.Sheets[sheetName], sheetName),
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Error rendering Excel:", error);
|
||||
this.error = error?.message || i18n("Failed to load spreadsheet");
|
||||
}
|
||||
}
|
||||
|
||||
private renderExcelSheet(worksheet: any, sheetName: string): HTMLElement {
|
||||
const sheetDiv = document.createElement("div");
|
||||
|
||||
// Generate HTML table
|
||||
const htmlTable = XLSX.utils.sheet_to_html(worksheet, {
|
||||
id: `sheet-${sheetName}`,
|
||||
});
|
||||
const tempDiv = document.createElement("div");
|
||||
tempDiv.innerHTML = htmlTable;
|
||||
|
||||
// Find and style the table
|
||||
const table = tempDiv.querySelector("table");
|
||||
if (table) {
|
||||
table.className = "w-full border-collapse text-foreground";
|
||||
|
||||
// Style all cells
|
||||
table.querySelectorAll("td, th").forEach((cell) => {
|
||||
const cellEl = cell as HTMLElement;
|
||||
cellEl.className = "border border-border px-3 py-2 text-sm text-left";
|
||||
});
|
||||
|
||||
// Style header row
|
||||
const headerCells = table.querySelectorAll("thead th, tr:first-child td");
|
||||
if (headerCells.length > 0) {
|
||||
headerCells.forEach((th) => {
|
||||
const thEl = th as HTMLElement;
|
||||
thEl.className =
|
||||
"border border-border px-3 py-2 text-sm font-semibold bg-muted text-foreground sticky top-0";
|
||||
});
|
||||
}
|
||||
|
||||
// Alternate row colors
|
||||
table.querySelectorAll("tbody tr:nth-child(even)").forEach((row) => {
|
||||
const rowEl = row as HTMLElement;
|
||||
rowEl.className = "bg-muted/30";
|
||||
});
|
||||
|
||||
sheetDiv.appendChild(table);
|
||||
}
|
||||
|
||||
return sheetDiv;
|
||||
}
|
||||
|
||||
override render(): TemplateResult {
|
||||
if (this.error) {
|
||||
return html`
|
||||
<div class="h-full flex items-center justify-center bg-background p-4">
|
||||
<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 spreadsheet")}
|
||||
</div>
|
||||
<div class="text-sm opacity-90">${this.error}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="h-full flex flex-col bg-background overflow-auto">
|
||||
<div id="excel-container" class="flex-1 overflow-auto"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"excel-artifact": ExcelArtifact;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
import { DownloadButton } from "@mariozechner/mini-lit/dist/DownloadButton.js";
|
||||
import { html, type TemplateResult } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { i18n } from "../../utils/i18n.js";
|
||||
import { ArtifactElement } from "./ArtifactElement.js";
|
||||
|
||||
@customElement("generic-artifact")
|
||||
export class GenericArtifact extends ArtifactElement {
|
||||
@property({ type: String }) private _content = "";
|
||||
|
||||
get content(): string {
|
||||
return this._content;
|
||||
}
|
||||
|
||||
set content(value: string) {
|
||||
this._content = value;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
||||
return this;
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.style.display = "block";
|
||||
this.style.height = "100%";
|
||||
}
|
||||
|
||||
private decodeBase64(): Uint8Array {
|
||||
let base64Data = this._content;
|
||||
if (this._content.startsWith("data:")) {
|
||||
const base64Match = this._content.match(/base64,(.+)/);
|
||||
if (base64Match) {
|
||||
base64Data = base64Match[1];
|
||||
}
|
||||
}
|
||||
|
||||
const binaryString = atob(base64Data);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private getMimeType(): string {
|
||||
const ext = this.filename.split(".").pop()?.toLowerCase();
|
||||
// Add common MIME types
|
||||
const mimeTypes: Record<string, string> = {
|
||||
pdf: "application/pdf",
|
||||
zip: "application/zip",
|
||||
tar: "application/x-tar",
|
||||
gz: "application/gzip",
|
||||
rar: "application/vnd.rar",
|
||||
"7z": "application/x-7z-compressed",
|
||||
mp3: "audio/mpeg",
|
||||
mp4: "video/mp4",
|
||||
avi: "video/x-msvideo",
|
||||
mov: "video/quicktime",
|
||||
wav: "audio/wav",
|
||||
ogg: "audio/ogg",
|
||||
json: "application/json",
|
||||
xml: "application/xml",
|
||||
bin: "application/octet-stream",
|
||||
};
|
||||
return mimeTypes[ext || ""] || "application/octet-stream";
|
||||
}
|
||||
|
||||
public getHeaderButtons() {
|
||||
return html`
|
||||
<div class="flex items-center gap-1">
|
||||
${DownloadButton({
|
||||
content: this.decodeBase64(),
|
||||
filename: this.filename,
|
||||
mimeType: this.getMimeType(),
|
||||
title: i18n("Download"),
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
override render(): TemplateResult {
|
||||
return html`
|
||||
<div class="h-full flex items-center justify-center bg-background p-8">
|
||||
<div class="text-center max-w-md">
|
||||
<div class="text-muted-foreground text-lg mb-4">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-16 w-16 mx-auto mb-4 text-muted-foreground/50"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<div class="font-medium text-foreground mb-2">${this.filename}</div>
|
||||
<p class="text-sm">
|
||||
${i18n("Preview not available for this file type.")}
|
||||
${i18n(
|
||||
"Click the download button above to view it on your computer.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"generic-artifact": GenericArtifact;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,232 +0,0 @@
|
|||
import hljs from "highlight.js";
|
||||
import { html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { createRef, type Ref, ref } from "lit/directives/ref.js";
|
||||
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
||||
import { RefreshCw } from "lucide";
|
||||
import type { SandboxIframe } from "../../components/SandboxedIframe.js";
|
||||
import {
|
||||
type MessageConsumer,
|
||||
RUNTIME_MESSAGE_ROUTER,
|
||||
} from "../../components/sandbox/RuntimeMessageRouter.js";
|
||||
import type { SandboxRuntimeProvider } from "../../components/sandbox/SandboxRuntimeProvider.js";
|
||||
import { i18n } from "../../utils/i18n.js";
|
||||
import "../../components/SandboxedIframe.js";
|
||||
import { ArtifactElement } from "./ArtifactElement.js";
|
||||
import type { Console } from "./Console.js";
|
||||
import "./Console.js";
|
||||
import { icon } from "@mariozechner/mini-lit";
|
||||
import { Button } from "@mariozechner/mini-lit/dist/Button.js";
|
||||
import { CopyButton } from "@mariozechner/mini-lit/dist/CopyButton.js";
|
||||
import { DownloadButton } from "@mariozechner/mini-lit/dist/DownloadButton.js";
|
||||
import { PreviewCodeToggle } from "@mariozechner/mini-lit/dist/PreviewCodeToggle.js";
|
||||
|
||||
@customElement("html-artifact")
|
||||
export class HtmlArtifact extends ArtifactElement {
|
||||
@property() override filename = "";
|
||||
@property({ attribute: false }) runtimeProviders: SandboxRuntimeProvider[] =
|
||||
[];
|
||||
@property({ attribute: false }) sandboxUrlProvider?: () => string;
|
||||
|
||||
private _content = "";
|
||||
private logs: Array<{ type: "log" | "error"; text: string }> = [];
|
||||
|
||||
// Refs for DOM elements
|
||||
public sandboxIframeRef: Ref<SandboxIframe> = createRef();
|
||||
private consoleRef: Ref<Console> = createRef();
|
||||
|
||||
@state() private viewMode: "preview" | "code" = "preview";
|
||||
|
||||
private setViewMode(mode: "preview" | "code") {
|
||||
this.viewMode = mode;
|
||||
}
|
||||
|
||||
public getHeaderButtons() {
|
||||
const toggle = new PreviewCodeToggle();
|
||||
toggle.mode = this.viewMode;
|
||||
toggle.addEventListener("mode-change", (e: Event) => {
|
||||
this.setViewMode((e as CustomEvent).detail);
|
||||
});
|
||||
|
||||
const copyButton = new CopyButton();
|
||||
copyButton.text = this._content;
|
||||
copyButton.title = i18n("Copy HTML");
|
||||
copyButton.showText = false;
|
||||
|
||||
// Generate standalone HTML with all runtime code injected for download
|
||||
const sandbox = this.sandboxIframeRef.value;
|
||||
const sandboxId = `artifact-${this.filename}`;
|
||||
const downloadContent =
|
||||
sandbox?.prepareHtmlDocument(
|
||||
sandboxId,
|
||||
this._content,
|
||||
this.runtimeProviders || [],
|
||||
{
|
||||
isHtmlArtifact: true,
|
||||
isStandalone: true, // Skip runtime bridge and navigation interceptor for standalone downloads
|
||||
},
|
||||
) || this._content;
|
||||
|
||||
return html`
|
||||
<div class="flex items-center gap-2">
|
||||
${toggle}
|
||||
${Button({
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
this.logs = [];
|
||||
this.executeContent(this._content);
|
||||
},
|
||||
title: i18n("Reload HTML"),
|
||||
children: icon(RefreshCw, "sm"),
|
||||
})}
|
||||
${copyButton}
|
||||
${DownloadButton({
|
||||
content: downloadContent,
|
||||
filename: this.filename,
|
||||
mimeType: "text/html",
|
||||
title: i18n("Download HTML"),
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
override set content(value: string) {
|
||||
const oldValue = this._content;
|
||||
this._content = value;
|
||||
if (oldValue !== value) {
|
||||
// Reset logs when content changes
|
||||
this.logs = [];
|
||||
this.requestUpdate();
|
||||
// Execute content in sandbox if it exists
|
||||
if (this.sandboxIframeRef.value && value) {
|
||||
this.executeContent(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public executeContent(html: string) {
|
||||
const sandbox = this.sandboxIframeRef.value;
|
||||
if (!sandbox) return;
|
||||
|
||||
// Configure sandbox URL provider if provided (for browser extensions)
|
||||
if (this.sandboxUrlProvider) {
|
||||
sandbox.sandboxUrlProvider = this.sandboxUrlProvider;
|
||||
}
|
||||
|
||||
const sandboxId = `artifact-${this.filename}`;
|
||||
|
||||
// Create consumer for console messages
|
||||
const consumer: MessageConsumer = {
|
||||
handleMessage: async (message: any): Promise<void> => {
|
||||
if (message.type === "console") {
|
||||
// Create new array reference for Lit reactivity
|
||||
this.logs = [
|
||||
...this.logs,
|
||||
{
|
||||
type: message.method === "error" ? "error" : "log",
|
||||
text: message.text,
|
||||
},
|
||||
];
|
||||
this.requestUpdate(); // Re-render to show console
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Inject window.complete() call at the end of the HTML to signal when page is loaded
|
||||
// HTML artifacts don't time out - they call complete() when ready
|
||||
let modifiedHtml = html;
|
||||
if (modifiedHtml.includes("</html>")) {
|
||||
modifiedHtml = modifiedHtml.replace(
|
||||
"</html>",
|
||||
"<script>if (window.complete) window.complete();</script></html>",
|
||||
);
|
||||
} else {
|
||||
// If no closing </html> tag, append the script
|
||||
modifiedHtml +=
|
||||
"<script>if (window.complete) window.complete();</script>";
|
||||
}
|
||||
|
||||
// Load content - this handles sandbox registration, consumer registration, and iframe creation
|
||||
sandbox.loadContent(sandboxId, modifiedHtml, this.runtimeProviders, [
|
||||
consumer,
|
||||
]);
|
||||
}
|
||||
|
||||
override get content(): string {
|
||||
return this._content;
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
// Unregister sandbox when element is removed from DOM
|
||||
const sandboxId = `artifact-${this.filename}`;
|
||||
RUNTIME_MESSAGE_ROUTER.unregisterSandbox(sandboxId);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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`<artifact-console
|
||||
.logs=${this.logs}
|
||||
${ref(this.consoleRef)}
|
||||
></artifact-console>`
|
||||
: ""}
|
||||
</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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,116 +0,0 @@
|
|||
import { DownloadButton } from "@mariozechner/mini-lit/dist/DownloadButton.js";
|
||||
import { html, type TemplateResult } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { i18n } from "../../utils/i18n.js";
|
||||
import { ArtifactElement } from "./ArtifactElement.js";
|
||||
|
||||
@customElement("image-artifact")
|
||||
export class ImageArtifact extends ArtifactElement {
|
||||
@property({ type: String }) private _content = "";
|
||||
|
||||
get content(): string {
|
||||
return this._content;
|
||||
}
|
||||
|
||||
set content(value: string) {
|
||||
this._content = value;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
||||
return this;
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.style.display = "block";
|
||||
this.style.height = "100%";
|
||||
}
|
||||
|
||||
private getMimeType(): string {
|
||||
const ext = this.filename.split(".").pop()?.toLowerCase();
|
||||
if (ext === "jpg" || ext === "jpeg") return "image/jpeg";
|
||||
if (ext === "gif") return "image/gif";
|
||||
if (ext === "webp") return "image/webp";
|
||||
if (ext === "svg") return "image/svg+xml";
|
||||
if (ext === "bmp") return "image/bmp";
|
||||
if (ext === "ico") return "image/x-icon";
|
||||
return "image/png";
|
||||
}
|
||||
|
||||
private getImageUrl(): string {
|
||||
// If content is already a data URL, use it directly
|
||||
if (this._content.startsWith("data:")) {
|
||||
return this._content;
|
||||
}
|
||||
// Otherwise assume it's base64 and construct data URL
|
||||
return `data:${this.getMimeType()};base64,${this._content}`;
|
||||
}
|
||||
|
||||
private decodeBase64(): Uint8Array {
|
||||
let base64Data: string;
|
||||
|
||||
// If content is a data URL, extract the base64 part
|
||||
if (this._content.startsWith("data:")) {
|
||||
const base64Match = this._content.match(/base64,(.+)/);
|
||||
if (base64Match) {
|
||||
base64Data = base64Match[1];
|
||||
} else {
|
||||
// Not a base64 data URL, return empty
|
||||
return new Uint8Array(0);
|
||||
}
|
||||
} else {
|
||||
// Otherwise use content as-is
|
||||
base64Data = this._content;
|
||||
}
|
||||
|
||||
// Decode base64 to binary string
|
||||
const binaryString = atob(base64Data);
|
||||
|
||||
// Convert binary string to Uint8Array
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
public getHeaderButtons() {
|
||||
return html`
|
||||
<div class="flex items-center gap-1">
|
||||
${DownloadButton({
|
||||
content: this.decodeBase64(),
|
||||
filename: this.filename,
|
||||
mimeType: this.getMimeType(),
|
||||
title: i18n("Download"),
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
override render(): TemplateResult {
|
||||
return html`
|
||||
<div class="h-full flex flex-col bg-background overflow-auto">
|
||||
<div class="flex-1 flex items-center justify-center p-4">
|
||||
<img
|
||||
src="${this.getImageUrl()}"
|
||||
alt="${this.filename}"
|
||||
class="max-w-full max-h-full object-contain"
|
||||
@error=${(e: Event) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.src =
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext x='50' y='50' text-anchor='middle' dominant-baseline='middle' fill='%23999'%3EImage Error%3C/text%3E%3C/svg%3E";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"image-artifact": ImageArtifact;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
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 { CopyButton } from "@mariozechner/mini-lit/dist/CopyButton.js";
|
||||
import { DownloadButton } from "@mariozechner/mini-lit/dist/DownloadButton.js";
|
||||
import { PreviewCodeToggle } from "@mariozechner/mini-lit/dist/PreviewCodeToggle.js";
|
||||
import { ArtifactElement } from "./ArtifactElement.js";
|
||||
|
||||
@customElement("markdown-artifact")
|
||||
export class MarkdownArtifact extends ArtifactElement {
|
||||
@property() override filename = "";
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,207 +0,0 @@
|
|||
import { DownloadButton } from "@mariozechner/mini-lit/dist/DownloadButton.js";
|
||||
import { html, type TemplateResult } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import * as pdfjsLib from "pdfjs-dist";
|
||||
import { i18n } from "../../utils/i18n.js";
|
||||
import { ArtifactElement } from "./ArtifactElement.js";
|
||||
|
||||
// Configure PDF.js worker
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||
"pdfjs-dist/build/pdf.worker.min.mjs",
|
||||
import.meta.url,
|
||||
).toString();
|
||||
|
||||
@customElement("pdf-artifact")
|
||||
export class PdfArtifact extends ArtifactElement {
|
||||
@property({ type: String }) private _content = "";
|
||||
@state() private error: string | null = null;
|
||||
private currentLoadingTask: any = null;
|
||||
|
||||
get content(): string {
|
||||
return this._content;
|
||||
}
|
||||
|
||||
set content(value: string) {
|
||||
this._content = value;
|
||||
this.error = null;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
||||
return this;
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.style.display = "block";
|
||||
this.style.height = "100%";
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this.cleanup();
|
||||
}
|
||||
|
||||
private cleanup() {
|
||||
if (this.currentLoadingTask) {
|
||||
this.currentLoadingTask.destroy();
|
||||
this.currentLoadingTask = null;
|
||||
}
|
||||
}
|
||||
|
||||
private base64ToArrayBuffer(base64: string): ArrayBuffer {
|
||||
// Remove data URL prefix if present
|
||||
let base64Data = base64;
|
||||
if (base64.startsWith("data:")) {
|
||||
const base64Match = base64.match(/base64,(.+)/);
|
||||
if (base64Match) {
|
||||
base64Data = base64Match[1];
|
||||
}
|
||||
}
|
||||
|
||||
const binaryString = atob(base64Data);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
private decodeBase64(): Uint8Array {
|
||||
let base64Data = this._content;
|
||||
if (this._content.startsWith("data:")) {
|
||||
const base64Match = this._content.match(/base64,(.+)/);
|
||||
if (base64Match) {
|
||||
base64Data = base64Match[1];
|
||||
}
|
||||
}
|
||||
|
||||
const binaryString = atob(base64Data);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
public getHeaderButtons() {
|
||||
return html`
|
||||
<div class="flex items-center gap-1">
|
||||
${DownloadButton({
|
||||
content: this.decodeBase64(),
|
||||
filename: this.filename,
|
||||
mimeType: "application/pdf",
|
||||
title: i18n("Download"),
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
override async updated(changedProperties: Map<string, any>) {
|
||||
super.updated(changedProperties);
|
||||
|
||||
if (changedProperties.has("_content") && this._content && !this.error) {
|
||||
await this.renderPdf();
|
||||
}
|
||||
}
|
||||
|
||||
private async renderPdf() {
|
||||
const container = this.querySelector("#pdf-container");
|
||||
if (!container || !this._content) return;
|
||||
|
||||
let pdf: any = null;
|
||||
|
||||
try {
|
||||
const arrayBuffer = this.base64ToArrayBuffer(this._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
|
||||
container.innerHTML = "";
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "p-4";
|
||||
container.appendChild(wrapper);
|
||||
|
||||
// Render all pages
|
||||
for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) {
|
||||
const page = await pdf.getPage(pageNum);
|
||||
|
||||
const pageContainer = document.createElement("div");
|
||||
pageContainer.className = "mb-4 last:mb-0";
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
const context = canvas.getContext("2d");
|
||||
|
||||
const viewport = page.getViewport({ scale: 1.5 });
|
||||
canvas.height = viewport.height;
|
||||
canvas.width = viewport.width;
|
||||
|
||||
canvas.className =
|
||||
"w-full max-w-full h-auto block mx-auto bg-white rounded shadow-sm border border-border";
|
||||
|
||||
if (context) {
|
||||
context.fillStyle = "white";
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
await page.render({
|
||||
canvasContext: context!,
|
||||
viewport: viewport,
|
||||
canvas: canvas,
|
||||
}).promise;
|
||||
|
||||
pageContainer.appendChild(canvas);
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override render(): TemplateResult {
|
||||
if (this.error) {
|
||||
return html`
|
||||
<div class="h-full flex items-center justify-center bg-background p-4">
|
||||
<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 PDF")}</div>
|
||||
<div class="text-sm opacity-90">${this.error}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="h-full flex flex-col bg-background overflow-auto">
|
||||
<div id="pdf-container" class="flex-1 overflow-auto"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"pdf-artifact": PdfArtifact;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
import { CopyButton } from "@mariozechner/mini-lit/dist/CopyButton.js";
|
||||
import { DownloadButton } from "@mariozechner/mini-lit/dist/DownloadButton.js";
|
||||
import { PreviewCodeToggle } from "@mariozechner/mini-lit/dist/PreviewCodeToggle.js";
|
||||
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 = "";
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,150 +0,0 @@
|
|||
import { CopyButton } from "@mariozechner/mini-lit/dist/CopyButton.js";
|
||||
import { DownloadButton } from "@mariozechner/mini-lit/dist/DownloadButton.js";
|
||||
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 = "";
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,483 +0,0 @@
|
|||
import "@mariozechner/mini-lit/dist/CodeBlock.js";
|
||||
import type { ToolResultMessage } from "@mariozechner/pi-ai";
|
||||
import { createRef, ref } from "lit/directives/ref.js";
|
||||
import { FileCode2 } from "lucide";
|
||||
import "../../components/ConsoleBlock.js";
|
||||
import { Diff } from "@mariozechner/mini-lit/dist/Diff.js";
|
||||
import { html, type TemplateResult } from "lit";
|
||||
import { i18n } from "../../utils/i18n.js";
|
||||
import { renderCollapsibleHeader, renderHeader } from "../renderer-registry.js";
|
||||
import type { ToolRenderer, ToolRenderResult } from "../types.js";
|
||||
import { ArtifactPill } from "./ArtifactPill.js";
|
||||
import type { ArtifactsPanel, ArtifactsParams } from "./artifacts.js";
|
||||
|
||||
// Helper to extract text from content blocks
|
||||
function getTextOutput(result: ToolResultMessage<any> | undefined): string {
|
||||
if (!result) return "";
|
||||
return (
|
||||
result.content
|
||||
?.filter((c) => c.type === "text")
|
||||
.map((c: any) => c.text)
|
||||
.join("\n") || ""
|
||||
);
|
||||
}
|
||||
|
||||
// Helper to determine language for syntax highlighting
|
||||
function 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";
|
||||
}
|
||||
|
||||
export class ArtifactsToolRenderer implements ToolRenderer<
|
||||
ArtifactsParams,
|
||||
undefined
|
||||
> {
|
||||
constructor(public artifactsPanel?: ArtifactsPanel) {}
|
||||
|
||||
render(
|
||||
params: ArtifactsParams | undefined,
|
||||
result: ToolResultMessage<undefined> | undefined,
|
||||
isStreaming?: boolean,
|
||||
): ToolRenderResult {
|
||||
const state = result
|
||||
? result.isError
|
||||
? "error"
|
||||
: "complete"
|
||||
: isStreaming
|
||||
? "inprogress"
|
||||
: "complete";
|
||||
|
||||
// Create refs for collapsible sections
|
||||
const contentRef = createRef<HTMLDivElement>();
|
||||
const chevronRef = createRef<HTMLSpanElement>();
|
||||
|
||||
// Helper to get command labels
|
||||
const getCommandLabels = (
|
||||
command: string,
|
||||
): { streaming: string; complete: string } => {
|
||||
const labels: Record<string, { streaming: string; complete: string }> = {
|
||||
create: {
|
||||
streaming: i18n("Creating artifact"),
|
||||
complete: i18n("Created artifact"),
|
||||
},
|
||||
update: {
|
||||
streaming: i18n("Updating artifact"),
|
||||
complete: i18n("Updated artifact"),
|
||||
},
|
||||
rewrite: {
|
||||
streaming: i18n("Rewriting artifact"),
|
||||
complete: i18n("Rewrote artifact"),
|
||||
},
|
||||
get: {
|
||||
streaming: i18n("Getting artifact"),
|
||||
complete: i18n("Got artifact"),
|
||||
},
|
||||
delete: {
|
||||
streaming: i18n("Deleting artifact"),
|
||||
complete: i18n("Deleted artifact"),
|
||||
},
|
||||
logs: { streaming: i18n("Getting logs"), complete: i18n("Got logs") },
|
||||
};
|
||||
return (
|
||||
labels[command] || {
|
||||
streaming: i18n("Processing artifact"),
|
||||
complete: i18n("Processed artifact"),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// Helper to render header text with inline artifact pill
|
||||
const renderHeaderWithPill = (
|
||||
labelText: string,
|
||||
filename?: string,
|
||||
): TemplateResult => {
|
||||
if (filename) {
|
||||
return html`<span
|
||||
>${labelText} ${ArtifactPill(filename, this.artifactsPanel)}</span
|
||||
>`;
|
||||
}
|
||||
return html`<span>${labelText}</span>`;
|
||||
};
|
||||
|
||||
// Error handling
|
||||
if (result?.isError) {
|
||||
const command = params?.command;
|
||||
const filename = params?.filename;
|
||||
const labels = command
|
||||
? getCommandLabels(command)
|
||||
: {
|
||||
streaming: i18n("Processing artifact"),
|
||||
complete: i18n("Processed artifact"),
|
||||
};
|
||||
const headerText = labels.streaming;
|
||||
|
||||
// For create/update/rewrite errors, show code block + console/error
|
||||
if (
|
||||
command === "create" ||
|
||||
command === "update" ||
|
||||
command === "rewrite"
|
||||
) {
|
||||
const content = params?.content || "";
|
||||
const { old_str, new_str } = params || {};
|
||||
const isDiff = command === "update";
|
||||
const diffContent =
|
||||
old_str !== undefined && new_str !== undefined
|
||||
? Diff({ oldText: old_str, newText: new_str })
|
||||
: "";
|
||||
|
||||
const isHtml = filename?.endsWith(".html");
|
||||
|
||||
return {
|
||||
content: html`
|
||||
<div>
|
||||
${renderCollapsibleHeader(
|
||||
state,
|
||||
FileCode2,
|
||||
renderHeaderWithPill(headerText, filename),
|
||||
contentRef,
|
||||
chevronRef,
|
||||
false,
|
||||
)}
|
||||
<div
|
||||
${ref(contentRef)}
|
||||
class="max-h-0 overflow-hidden transition-all duration-300 space-y-3"
|
||||
>
|
||||
${isDiff
|
||||
? diffContent
|
||||
: content
|
||||
? html`<code-block
|
||||
.code=${content}
|
||||
language=${getLanguageFromFilename(filename)}
|
||||
></code-block>`
|
||||
: ""}
|
||||
${isHtml
|
||||
? html`<console-block
|
||||
.content=${getTextOutput(result) ||
|
||||
i18n("An error occurred")}
|
||||
variant="error"
|
||||
></console-block>`
|
||||
: html`<div class="text-sm text-destructive">
|
||||
${getTextOutput(result) || i18n("An error occurred")}
|
||||
</div>`}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
isCustom: false,
|
||||
};
|
||||
}
|
||||
|
||||
// For other errors, just show error message
|
||||
return {
|
||||
content: html`
|
||||
<div class="space-y-3">
|
||||
${renderHeader(state, FileCode2, headerText)}
|
||||
<div class="text-sm text-destructive">
|
||||
${getTextOutput(result) || i18n("An error occurred")}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
isCustom: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Full params + result
|
||||
if (result && params) {
|
||||
const { command, filename, content } = params;
|
||||
const labels = command
|
||||
? getCommandLabels(command)
|
||||
: {
|
||||
streaming: i18n("Processing artifact"),
|
||||
complete: i18n("Processed artifact"),
|
||||
};
|
||||
const headerText = labels.complete;
|
||||
|
||||
// GET command: show code block with file content
|
||||
if (command === "get") {
|
||||
const fileContent = getTextOutput(result) || i18n("(no output)");
|
||||
return {
|
||||
content: html`
|
||||
<div>
|
||||
${renderCollapsibleHeader(
|
||||
state,
|
||||
FileCode2,
|
||||
renderHeaderWithPill(headerText, filename),
|
||||
contentRef,
|
||||
chevronRef,
|
||||
false,
|
||||
)}
|
||||
<div
|
||||
${ref(contentRef)}
|
||||
class="max-h-0 overflow-hidden transition-all duration-300"
|
||||
>
|
||||
<code-block
|
||||
.code=${fileContent}
|
||||
language=${getLanguageFromFilename(filename)}
|
||||
></code-block>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
isCustom: false,
|
||||
};
|
||||
}
|
||||
|
||||
// LOGS command: show console block
|
||||
if (command === "logs") {
|
||||
const logs = getTextOutput(result) || i18n("(no output)");
|
||||
return {
|
||||
content: html`
|
||||
<div>
|
||||
${renderCollapsibleHeader(
|
||||
state,
|
||||
FileCode2,
|
||||
renderHeaderWithPill(headerText, filename),
|
||||
contentRef,
|
||||
chevronRef,
|
||||
false,
|
||||
)}
|
||||
<div
|
||||
${ref(contentRef)}
|
||||
class="max-h-0 overflow-hidden transition-all duration-300"
|
||||
>
|
||||
<console-block .content=${logs}></console-block>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
isCustom: false,
|
||||
};
|
||||
}
|
||||
|
||||
// CREATE/UPDATE/REWRITE: always show code block, + console block for .html files
|
||||
if (command === "create" || command === "rewrite") {
|
||||
const codeContent = content || "";
|
||||
const isHtml = filename?.endsWith(".html");
|
||||
const logs = getTextOutput(result) || "";
|
||||
|
||||
return {
|
||||
content: html`
|
||||
<div>
|
||||
${renderCollapsibleHeader(
|
||||
state,
|
||||
FileCode2,
|
||||
renderHeaderWithPill(headerText, filename),
|
||||
contentRef,
|
||||
chevronRef,
|
||||
false,
|
||||
)}
|
||||
<div
|
||||
${ref(contentRef)}
|
||||
class="max-h-0 overflow-hidden transition-all duration-300 space-y-3"
|
||||
>
|
||||
${codeContent
|
||||
? html`<code-block
|
||||
.code=${codeContent}
|
||||
language=${getLanguageFromFilename(filename)}
|
||||
></code-block>`
|
||||
: ""}
|
||||
${isHtml && logs
|
||||
? html`<console-block .content=${logs}></console-block>`
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
isCustom: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (command === "update") {
|
||||
const isHtml = filename?.endsWith(".html");
|
||||
const logs = getTextOutput(result) || "";
|
||||
return {
|
||||
content: html`
|
||||
<div>
|
||||
${renderCollapsibleHeader(
|
||||
state,
|
||||
FileCode2,
|
||||
renderHeaderWithPill(headerText, filename),
|
||||
contentRef,
|
||||
chevronRef,
|
||||
false,
|
||||
)}
|
||||
<div
|
||||
${ref(contentRef)}
|
||||
class="max-h-0 overflow-hidden transition-all duration-300 space-y-3"
|
||||
>
|
||||
${Diff({
|
||||
oldText: params.old_str || "",
|
||||
newText: params.new_str || "",
|
||||
})}
|
||||
${isHtml && logs
|
||||
? html`<console-block .content=${logs}></console-block>`
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
isCustom: false,
|
||||
};
|
||||
}
|
||||
|
||||
// For DELETE, just show header
|
||||
return {
|
||||
content: html`
|
||||
<div class="space-y-3">
|
||||
${renderHeader(
|
||||
state,
|
||||
FileCode2,
|
||||
renderHeaderWithPill(headerText, filename),
|
||||
)}
|
||||
</div>
|
||||
`,
|
||||
isCustom: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Params only (streaming or waiting for result)
|
||||
if (params) {
|
||||
const { command, filename, content, old_str, new_str } = params;
|
||||
|
||||
// If no command yet
|
||||
if (!command) {
|
||||
return {
|
||||
content: renderHeader(
|
||||
state,
|
||||
FileCode2,
|
||||
i18n("Preparing artifact..."),
|
||||
),
|
||||
isCustom: false,
|
||||
};
|
||||
}
|
||||
|
||||
const labels = getCommandLabels(command);
|
||||
const headerText = labels.streaming;
|
||||
|
||||
// Render based on command type
|
||||
switch (command) {
|
||||
case "create":
|
||||
case "rewrite":
|
||||
return {
|
||||
content: html`
|
||||
<div>
|
||||
${renderCollapsibleHeader(
|
||||
state,
|
||||
FileCode2,
|
||||
renderHeaderWithPill(headerText, filename),
|
||||
contentRef,
|
||||
chevronRef,
|
||||
false,
|
||||
)}
|
||||
<div
|
||||
${ref(contentRef)}
|
||||
class="max-h-0 overflow-hidden transition-all duration-300"
|
||||
>
|
||||
${content
|
||||
? html`<code-block
|
||||
.code=${content}
|
||||
language=${getLanguageFromFilename(filename)}
|
||||
></code-block>`
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
isCustom: false,
|
||||
};
|
||||
|
||||
case "update":
|
||||
return {
|
||||
content: html`
|
||||
<div>
|
||||
${renderCollapsibleHeader(
|
||||
state,
|
||||
FileCode2,
|
||||
renderHeaderWithPill(headerText, filename),
|
||||
contentRef,
|
||||
chevronRef,
|
||||
false,
|
||||
)}
|
||||
<div
|
||||
${ref(contentRef)}
|
||||
class="max-h-0 overflow-hidden transition-all duration-300"
|
||||
>
|
||||
${old_str !== undefined && new_str !== undefined
|
||||
? Diff({ oldText: old_str, newText: new_str })
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
isCustom: false,
|
||||
};
|
||||
|
||||
case "get":
|
||||
case "logs":
|
||||
return {
|
||||
content: html`
|
||||
<div>
|
||||
${renderCollapsibleHeader(
|
||||
state,
|
||||
FileCode2,
|
||||
renderHeaderWithPill(headerText, filename),
|
||||
contentRef,
|
||||
chevronRef,
|
||||
false,
|
||||
)}
|
||||
<div
|
||||
${ref(contentRef)}
|
||||
class="max-h-0 overflow-hidden transition-all duration-300"
|
||||
></div>
|
||||
</div>
|
||||
`,
|
||||
isCustom: false,
|
||||
};
|
||||
|
||||
default:
|
||||
return {
|
||||
content: html`
|
||||
<div>
|
||||
${renderHeader(
|
||||
state,
|
||||
FileCode2,
|
||||
renderHeaderWithPill(headerText, filename),
|
||||
)}
|
||||
</div>
|
||||
`,
|
||||
isCustom: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// No params or result yet
|
||||
return {
|
||||
content: renderHeader(state, FileCode2, i18n("Preparing artifact...")),
|
||||
isCustom: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,776 +0,0 @@
|
|||
import { icon } from "@mariozechner/mini-lit";
|
||||
import "@mariozechner/mini-lit/dist/MarkdownBlock.js";
|
||||
import { Button } from "@mariozechner/mini-lit/dist/Button.js";
|
||||
import type {
|
||||
Agent,
|
||||
AgentMessage,
|
||||
AgentTool,
|
||||
} from "@mariozechner/pi-agent-core";
|
||||
import { StringEnum, type ToolCall } 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 { ArtifactMessage } from "../../components/Messages.js";
|
||||
import { ArtifactsRuntimeProvider } from "../../components/sandbox/ArtifactsRuntimeProvider.js";
|
||||
import { AttachmentsRuntimeProvider } from "../../components/sandbox/AttachmentsRuntimeProvider.js";
|
||||
import type { SandboxRuntimeProvider } from "../../components/sandbox/SandboxRuntimeProvider.js";
|
||||
import {
|
||||
ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RO,
|
||||
ARTIFACTS_TOOL_DESCRIPTION,
|
||||
ATTACHMENTS_RUNTIME_DESCRIPTION,
|
||||
} from "../../prompts/prompts.js";
|
||||
import type { Attachment } from "../../utils/attachment-utils.js";
|
||||
import { i18n } from "../../utils/i18n.js";
|
||||
import type { ArtifactElement } from "./ArtifactElement.js";
|
||||
import { DocxArtifact } from "./DocxArtifact.js";
|
||||
import { ExcelArtifact } from "./ExcelArtifact.js";
|
||||
import { GenericArtifact } from "./GenericArtifact.js";
|
||||
import { HtmlArtifact } from "./HtmlArtifact.js";
|
||||
import { ImageArtifact } from "./ImageArtifact.js";
|
||||
import { MarkdownArtifact } from "./MarkdownArtifact.js";
|
||||
import { PdfArtifact } from "./PdfArtifact.js";
|
||||
import { SvgArtifact } from "./SvgArtifact.js";
|
||||
import { TextArtifact } from "./TextArtifact.js";
|
||||
|
||||
// Simple artifact model
|
||||
export interface Artifact {
|
||||
filename: 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')",
|
||||
}),
|
||||
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>;
|
||||
|
||||
@customElement("artifacts-panel")
|
||||
export class ArtifactsPanel extends LitElement {
|
||||
@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();
|
||||
|
||||
// Agent reference (needed to get attachments for HTML artifacts)
|
||||
@property({ attribute: false }) agent?: Agent;
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Get runtime providers for HTML artifacts (read-only: attachments + artifacts)
|
||||
private getHtmlArtifactRuntimeProviders(): SandboxRuntimeProvider[] {
|
||||
const providers: SandboxRuntimeProvider[] = [];
|
||||
|
||||
// Get attachments from agent messages
|
||||
if (this.agent) {
|
||||
const attachments: Attachment[] = [];
|
||||
for (const message of this.agent.state.messages) {
|
||||
if (message.role === "user-with-attachments" && message.attachments) {
|
||||
attachments.push(...message.attachments);
|
||||
}
|
||||
}
|
||||
if (attachments.length > 0) {
|
||||
providers.push(new AttachmentsRuntimeProvider(attachments));
|
||||
}
|
||||
}
|
||||
|
||||
// Add read-only artifacts provider
|
||||
providers.push(new ArtifactsRuntimeProvider(this, this.agent, false));
|
||||
|
||||
return providers;
|
||||
}
|
||||
|
||||
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"
|
||||
| "image"
|
||||
| "pdf"
|
||||
| "excel"
|
||||
| "docx"
|
||||
| "text"
|
||||
| "generic" {
|
||||
const ext = filename.split(".").pop()?.toLowerCase();
|
||||
if (ext === "html") return "html";
|
||||
if (ext === "svg") return "svg";
|
||||
if (ext === "md" || ext === "markdown") return "markdown";
|
||||
if (ext === "pdf") return "pdf";
|
||||
if (ext === "xlsx" || ext === "xls") return "excel";
|
||||
if (ext === "docx") return "docx";
|
||||
if (
|
||||
ext === "png" ||
|
||||
ext === "jpg" ||
|
||||
ext === "jpeg" ||
|
||||
ext === "gif" ||
|
||||
ext === "webp" ||
|
||||
ext === "bmp" ||
|
||||
ext === "ico"
|
||||
)
|
||||
return "image";
|
||||
// Text files
|
||||
if (
|
||||
ext === "txt" ||
|
||||
ext === "json" ||
|
||||
ext === "xml" ||
|
||||
ext === "yaml" ||
|
||||
ext === "yml" ||
|
||||
ext === "csv" ||
|
||||
ext === "js" ||
|
||||
ext === "ts" ||
|
||||
ext === "jsx" ||
|
||||
ext === "tsx" ||
|
||||
ext === "py" ||
|
||||
ext === "java" ||
|
||||
ext === "c" ||
|
||||
ext === "cpp" ||
|
||||
ext === "h" ||
|
||||
ext === "css" ||
|
||||
ext === "scss" ||
|
||||
ext === "sass" ||
|
||||
ext === "less" ||
|
||||
ext === "sh"
|
||||
)
|
||||
return "text";
|
||||
// Everything else gets generic fallback
|
||||
return "generic";
|
||||
}
|
||||
|
||||
// Get or create artifact element
|
||||
private getOrCreateArtifactElement(
|
||||
filename: string,
|
||||
content: string,
|
||||
): ArtifactElement {
|
||||
let element = this.artifactElements.get(filename);
|
||||
|
||||
if (!element) {
|
||||
const type = this.getFileType(filename);
|
||||
if (type === "html") {
|
||||
element = new HtmlArtifact();
|
||||
(element as HtmlArtifact).runtimeProviders =
|
||||
this.getHtmlArtifactRuntimeProviders();
|
||||
if (this.sandboxUrlProvider) {
|
||||
(element as HtmlArtifact).sandboxUrlProvider =
|
||||
this.sandboxUrlProvider;
|
||||
}
|
||||
} else if (type === "svg") {
|
||||
element = new SvgArtifact();
|
||||
} else if (type === "markdown") {
|
||||
element = new MarkdownArtifact();
|
||||
} else if (type === "image") {
|
||||
element = new ImageArtifact();
|
||||
} else if (type === "pdf") {
|
||||
element = new PdfArtifact();
|
||||
} else if (type === "excel") {
|
||||
element = new ExcelArtifact();
|
||||
} else if (type === "docx") {
|
||||
element = new DocxArtifact();
|
||||
} else if (type === "text") {
|
||||
element = new TextArtifact();
|
||||
} else {
|
||||
element = new GenericArtifact();
|
||||
}
|
||||
element.filename = filename;
|
||||
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;
|
||||
if (element instanceof HtmlArtifact) {
|
||||
element.runtimeProviders = this.getHtmlArtifactRuntimeProviders();
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
// Scroll the active tab into view after render
|
||||
requestAnimationFrame(() => {
|
||||
const activeButton = this.querySelector(
|
||||
`button[data-filename="${filename}"]`,
|
||||
);
|
||||
if (activeButton) {
|
||||
activeButton.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
inline: "center",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Open panel and focus an artifact tab by filename
|
||||
public 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",
|
||||
get description() {
|
||||
// HTML artifacts have read-only access to attachments and artifacts
|
||||
const runtimeProviderDescriptions = [
|
||||
ATTACHMENTS_RUNTIME_DESCRIPTION,
|
||||
ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION_RO,
|
||||
];
|
||||
return ARTIFACTS_TOOL_DESCRIPTION(runtimeProviderDescriptions);
|
||||
},
|
||||
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 {
|
||||
content: [{ type: "text", text: output }],
|
||||
details: undefined,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Re-apply artifacts by scanning a message list (optional utility)
|
||||
public async reconstructFromMessages(
|
||||
messages: Array<AgentMessage | { role: "aborted" } | { role: "artifact" }>,
|
||||
): 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 === "artifact") {
|
||||
const artifactMsg = m as ArtifactMessage;
|
||||
switch (artifactMsg.action) {
|
||||
case "create":
|
||||
operations.push({
|
||||
command: "create",
|
||||
filename: artifactMsg.filename,
|
||||
content: artifactMsg.content,
|
||||
});
|
||||
break;
|
||||
case "update":
|
||||
operations.push({
|
||||
command: "rewrite",
|
||||
filename: artifactMsg.filename,
|
||||
content: artifactMsg.content,
|
||||
});
|
||||
break;
|
||||
case "delete":
|
||||
operations.push({
|
||||
command: "delete",
|
||||
filename: artifactMsg.filename,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Handle tool result messages (from artifacts tool calls)
|
||||
else 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
|
||||
const finalArtifacts = new Map<string, string>();
|
||||
for (const op of operations) {
|
||||
const filename = op.filename;
|
||||
switch (op.command) {
|
||||
case "create": {
|
||||
if (op.content) {
|
||||
finalArtifacts.set(filename, op.content);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "rewrite": {
|
||||
if (op.content) {
|
||||
finalArtifacts.set(filename, op.content);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "update": {
|
||||
let 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 = existing.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, content] of finalArtifacts.entries()) {
|
||||
const createParams: ArtifactsParams = {
|
||||
command: "create",
|
||||
filename,
|
||||
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) => {
|
||||
// Fallback timeout - just get logs after execution should complete
|
||||
setTimeout(() => {
|
||||
// Get whatever logs we have
|
||||
const logs = element.getLogs();
|
||||
resolve(logs);
|
||||
}, 1500);
|
||||
});
|
||||
}
|
||||
|
||||
// Reload all HTML artifacts (called when any artifact changes)
|
||||
private reloadAllHtmlArtifacts() {
|
||||
this.artifactElements.forEach((element) => {
|
||||
if (element instanceof HtmlArtifact && element.sandboxIframeRef.value) {
|
||||
// Update runtime providers with latest artifact state
|
||||
element.runtimeProviders = this.getHtmlArtifactRuntimeProviders();
|
||||
// Re-execute the HTML content
|
||||
element.executeContent(element.content);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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 artifact: Artifact = {
|
||||
filename: params.filename,
|
||||
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);
|
||||
if (!options.silent) {
|
||||
this.showArtifact(params.filename);
|
||||
this.onArtifactsChange?.();
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
// Reload all HTML artifacts since they might depend on this new artifact
|
||||
this.reloadAllHtmlArtifacts();
|
||||
|
||||
// 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 += `\n${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);
|
||||
if (!options.silent) {
|
||||
this.onArtifactsChange?.();
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
// Show the artifact
|
||||
this.showArtifact(params.filename);
|
||||
|
||||
// Reload all HTML artifacts since they might depend on this updated artifact
|
||||
this.reloadAllHtmlArtifacts();
|
||||
|
||||
// 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 += `\n${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;
|
||||
artifact.updatedAt = new Date();
|
||||
this._artifacts.set(params.filename, artifact);
|
||||
|
||||
// Update element
|
||||
this.getOrCreateArtifactElement(params.filename, artifact.content);
|
||||
if (!options.silent) {
|
||||
this.onArtifactsChange?.();
|
||||
}
|
||||
|
||||
// Show the artifact
|
||||
this.showArtifact(params.filename);
|
||||
|
||||
// Reload all HTML artifacts since they might depend on this rewritten artifact
|
||||
this.reloadAllHtmlArtifacts();
|
||||
|
||||
// For HTML files, wait for execution
|
||||
let result = "";
|
||||
if (this.getFileType(params.filename) === "html" && !options.skipWait) {
|
||||
const logs = await this.waitForHtmlExecution(params.filename);
|
||||
result += `\n${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();
|
||||
|
||||
// Reload all HTML artifacts since they might have depended on this deleted artifact
|
||||
this.reloadAllHtmlArtifacts();
|
||||
|
||||
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}"
|
||||
data-filename="${a.filename}"
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
export { ArtifactElement } from "./ArtifactElement.js";
|
||||
export {
|
||||
type Artifact,
|
||||
ArtifactsPanel,
|
||||
type ArtifactsParams,
|
||||
} from "./artifacts.js";
|
||||
export { ArtifactsToolRenderer } from "./artifacts-tool-renderer.js";
|
||||
export { HtmlArtifact } from "./HtmlArtifact.js";
|
||||
export { MarkdownArtifact } from "./MarkdownArtifact.js";
|
||||
export { SvgArtifact } from "./SvgArtifact.js";
|
||||
export { TextArtifact } from "./TextArtifact.js";
|
||||
|
|
@ -1,321 +0,0 @@
|
|||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import type { ToolResultMessage } from "@mariozechner/pi-ai";
|
||||
import { type Static, Type } from "@sinclair/typebox";
|
||||
import { html } from "lit";
|
||||
import { createRef, ref } from "lit/directives/ref.js";
|
||||
import { FileText } from "lucide";
|
||||
import { EXTRACT_DOCUMENT_DESCRIPTION } from "../prompts/prompts.js";
|
||||
import { loadAttachment } from "../utils/attachment-utils.js";
|
||||
import { isCorsError } from "../utils/proxy-utils.js";
|
||||
import {
|
||||
registerToolRenderer,
|
||||
renderCollapsibleHeader,
|
||||
renderHeader,
|
||||
} from "./renderer-registry.js";
|
||||
import type { ToolRenderer, ToolRenderResult } from "./types.js";
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
const extractDocumentSchema = Type.Object({
|
||||
url: Type.String({
|
||||
description:
|
||||
"URL of the document to extract text from (PDF, DOCX, XLSX, or PPTX)",
|
||||
}),
|
||||
});
|
||||
|
||||
export type ExtractDocumentParams = Static<typeof extractDocumentSchema>;
|
||||
|
||||
export interface ExtractDocumentResult {
|
||||
extractedText: string;
|
||||
format: string;
|
||||
fileName: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TOOL
|
||||
// ============================================================================
|
||||
|
||||
export function createExtractDocumentTool(): AgentTool<
|
||||
typeof extractDocumentSchema,
|
||||
ExtractDocumentResult
|
||||
> & {
|
||||
corsProxyUrl?: string;
|
||||
} {
|
||||
const tool = {
|
||||
label: "Extract Document",
|
||||
name: "extract_document",
|
||||
corsProxyUrl: undefined as string | undefined, // Can be set by consumer (e.g., from user settings)
|
||||
description: EXTRACT_DOCUMENT_DESCRIPTION,
|
||||
parameters: extractDocumentSchema,
|
||||
execute: async (
|
||||
_toolCallId: string,
|
||||
args: ExtractDocumentParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
if (signal?.aborted) {
|
||||
throw new Error("Extract document aborted");
|
||||
}
|
||||
|
||||
const url = args.url.trim();
|
||||
if (!url) {
|
||||
throw new Error("URL is required");
|
||||
}
|
||||
|
||||
// Validate URL format
|
||||
try {
|
||||
new URL(url);
|
||||
} catch {
|
||||
throw new Error(`Invalid URL: ${url}`);
|
||||
}
|
||||
|
||||
// Size limit: 50MB
|
||||
const MAX_SIZE = 50 * 1024 * 1024;
|
||||
|
||||
// Helper function to fetch and process document
|
||||
const fetchAndProcess = async (fetchUrl: string) => {
|
||||
const response = await fetch(fetchUrl, { signal });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`TELL USER: Unable to download the document (${response.status} ${response.statusText}). The site likely blocks automated downloads.\n\n` +
|
||||
`INSTRUCT USER: Please download the file manually and attach it to your message using the attachment button (paperclip icon) in the message input area. I can then extract the text from the attached file.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check size before downloading
|
||||
const contentLength = response.headers.get("content-length");
|
||||
if (contentLength) {
|
||||
const size = Number.parseInt(contentLength, 10);
|
||||
if (size > MAX_SIZE) {
|
||||
throw new Error(
|
||||
`Document is too large (${(size / 1024 / 1024).toFixed(1)}MB). Maximum supported size is 50MB.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Download the document
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const size = arrayBuffer.byteLength;
|
||||
|
||||
if (size > MAX_SIZE) {
|
||||
throw new Error(
|
||||
`Document is too large (${(size / 1024 / 1024).toFixed(1)}MB). Maximum supported size is 50MB.`,
|
||||
);
|
||||
}
|
||||
|
||||
return arrayBuffer;
|
||||
};
|
||||
|
||||
// Try without proxy first, fallback to proxy on CORS error
|
||||
let arrayBuffer: ArrayBuffer;
|
||||
|
||||
try {
|
||||
// Attempt direct fetch first
|
||||
arrayBuffer = await fetchAndProcess(url);
|
||||
} catch (directError: any) {
|
||||
// If CORS error and proxy is available, retry with proxy
|
||||
if (isCorsError(directError) && tool.corsProxyUrl) {
|
||||
try {
|
||||
const proxiedUrl = tool.corsProxyUrl + encodeURIComponent(url);
|
||||
arrayBuffer = await fetchAndProcess(proxiedUrl);
|
||||
} catch (proxyError: any) {
|
||||
// Proxy fetch also failed - throw helpful message
|
||||
throw new Error(
|
||||
`TELL USER: Unable to fetch the document due to CORS restrictions.\n\n` +
|
||||
`Tried with proxy but it also failed: ${proxyError.message}\n\n` +
|
||||
`INSTRUCT USER: Please download the file manually and attach it to your message using the attachment button (paperclip icon) in the message input area. I can then extract the text from the attached file.`,
|
||||
);
|
||||
}
|
||||
} else if (isCorsError(directError) && !tool.corsProxyUrl) {
|
||||
// CORS error but no proxy configured
|
||||
throw new Error(
|
||||
`TELL USER: Unable to fetch the document due to CORS restrictions (the server blocks requests from browser extensions).\n\n` +
|
||||
`To fix this, you need to configure a CORS proxy in Sitegeist settings:\n` +
|
||||
`1. Open Sitegeist settings\n` +
|
||||
`2. Find "CORS Proxy URL" setting\n` +
|
||||
`3. Enter a proxy URL like: https://corsproxy.io/?\n` +
|
||||
`4. Save and try again\n\n` +
|
||||
`Alternatively, download the file manually and attach it to your message using the attachment button (paperclip icon).`,
|
||||
);
|
||||
} else {
|
||||
// Not a CORS error - re-throw
|
||||
throw directError;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract filename from URL
|
||||
const urlParts = url.split("/");
|
||||
let fileName = urlParts[urlParts.length - 1]?.split("?")[0] || "document";
|
||||
if (url.startsWith("https://arxiv.org/")) {
|
||||
fileName = `${fileName}.pdf`;
|
||||
}
|
||||
|
||||
// Use loadAttachment to process the document
|
||||
const attachment = await loadAttachment(arrayBuffer, fileName);
|
||||
|
||||
if (!attachment.extractedText) {
|
||||
throw new Error(
|
||||
`Document format not supported. Supported formats:\n` +
|
||||
`- PDF (.pdf)\n` +
|
||||
`- Word (.docx)\n` +
|
||||
`- Excel (.xlsx, .xls)\n` +
|
||||
`- PowerPoint (.pptx)`,
|
||||
);
|
||||
}
|
||||
|
||||
// Determine format from attachment
|
||||
let format = "unknown";
|
||||
if (attachment.mimeType.includes("pdf")) {
|
||||
format = "pdf";
|
||||
} else if (attachment.mimeType.includes("wordprocessingml")) {
|
||||
format = "docx";
|
||||
} else if (
|
||||
attachment.mimeType.includes("spreadsheetml") ||
|
||||
attachment.mimeType.includes("ms-excel")
|
||||
) {
|
||||
format = "xlsx";
|
||||
} else if (attachment.mimeType.includes("presentationml")) {
|
||||
format = "pptx";
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text" as const, text: attachment.extractedText }],
|
||||
details: {
|
||||
extractedText: attachment.extractedText,
|
||||
format,
|
||||
fileName: attachment.fileName,
|
||||
size: attachment.size,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
return tool;
|
||||
}
|
||||
|
||||
// Export a default instance
|
||||
export const extractDocumentTool = createExtractDocumentTool();
|
||||
|
||||
// ============================================================================
|
||||
// RENDERER
|
||||
// ============================================================================
|
||||
|
||||
export const extractDocumentRenderer: ToolRenderer<
|
||||
ExtractDocumentParams,
|
||||
ExtractDocumentResult
|
||||
> = {
|
||||
render(
|
||||
params: ExtractDocumentParams | undefined,
|
||||
result: ToolResultMessage<ExtractDocumentResult> | undefined,
|
||||
isStreaming?: boolean,
|
||||
): ToolRenderResult {
|
||||
// Determine status
|
||||
const state = result
|
||||
? result.isError
|
||||
? "error"
|
||||
: "complete"
|
||||
: isStreaming
|
||||
? "inprogress"
|
||||
: "complete";
|
||||
|
||||
// Create refs for collapsible sections
|
||||
const contentRef = createRef<HTMLDivElement>();
|
||||
const chevronRef = createRef<HTMLSpanElement>();
|
||||
|
||||
// With result: show params + result
|
||||
if (result && params) {
|
||||
const details = result.details;
|
||||
const title = details
|
||||
? result.isError
|
||||
? `Failed to extract ${details.fileName || "document"}`
|
||||
: `Extracted text from ${details.fileName} (${details.format.toUpperCase()}, ${(details.size / 1024).toFixed(1)}KB)`
|
||||
: result.isError
|
||||
? "Failed to extract document"
|
||||
: "Extracted text from document";
|
||||
|
||||
const output =
|
||||
result.content
|
||||
?.filter((c) => c.type === "text")
|
||||
.map((c: any) => c.text)
|
||||
.join("\n") || "";
|
||||
|
||||
return {
|
||||
content: html`
|
||||
<div>
|
||||
${renderCollapsibleHeader(
|
||||
state,
|
||||
FileText,
|
||||
title,
|
||||
contentRef,
|
||||
chevronRef,
|
||||
false,
|
||||
)}
|
||||
<div
|
||||
${ref(contentRef)}
|
||||
class="max-h-0 overflow-hidden transition-all duration-300 space-y-3"
|
||||
>
|
||||
${params.url
|
||||
? html`<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
<strong>URL:</strong> ${params.url}
|
||||
</div>`
|
||||
: ""}
|
||||
${output && !result.isError
|
||||
? html`<code-block
|
||||
.code=${output}
|
||||
language="plaintext"
|
||||
></code-block>`
|
||||
: ""}
|
||||
${result.isError && output
|
||||
? html`<console-block
|
||||
.content=${output}
|
||||
.variant=${"error"}
|
||||
></console-block>`
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
isCustom: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Just params (streaming or waiting for result)
|
||||
if (params) {
|
||||
const title = "Extracting document...";
|
||||
|
||||
return {
|
||||
content: html`
|
||||
<div>
|
||||
${renderCollapsibleHeader(
|
||||
state,
|
||||
FileText,
|
||||
title,
|
||||
contentRef,
|
||||
chevronRef,
|
||||
false,
|
||||
)}
|
||||
<div
|
||||
${ref(contentRef)}
|
||||
class="max-h-0 overflow-hidden transition-all duration-300"
|
||||
>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
<strong>URL:</strong> ${params.url}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
isCustom: false,
|
||||
};
|
||||
}
|
||||
|
||||
// No params or result yet
|
||||
return {
|
||||
content: renderHeader(state, FileText, "Preparing extraction..."),
|
||||
isCustom: false,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// Auto-register the renderer
|
||||
registerToolRenderer("extract_document", extractDocumentRenderer);
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
import type { ToolResultMessage } from "@mariozechner/pi-ai";
|
||||
import "./javascript-repl.js"; // Auto-registers the renderer
|
||||
import "./extract-document.js"; // Auto-registers the renderer
|
||||
import { getToolRenderer, registerToolRenderer } from "./renderer-registry.js";
|
||||
import { BashRenderer } from "./renderers/BashRenderer.js";
|
||||
import { DefaultRenderer } from "./renderers/DefaultRenderer.js";
|
||||
import type { ToolRenderResult } from "./types.js";
|
||||
|
||||
// Register all built-in tool renderers
|
||||
registerToolRenderer("bash", new BashRenderer());
|
||||
|
||||
const defaultRenderer = new DefaultRenderer();
|
||||
|
||||
// Global flag to force default JSON rendering for all tools
|
||||
let showJsonMode = false;
|
||||
|
||||
/**
|
||||
* Enable or disable show JSON mode
|
||||
* When enabled, all tool renderers will use the default JSON renderer
|
||||
*/
|
||||
export function setShowJsonMode(enabled: boolean): void {
|
||||
showJsonMode = enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render tool - unified function that handles params, result, and streaming state
|
||||
*/
|
||||
export function renderTool(
|
||||
toolName: string,
|
||||
params: any | undefined,
|
||||
result: ToolResultMessage | undefined,
|
||||
isStreaming?: boolean,
|
||||
): ToolRenderResult {
|
||||
// If showJsonMode is enabled, always use the default renderer
|
||||
if (showJsonMode) {
|
||||
return defaultRenderer.render(params, result, isStreaming);
|
||||
}
|
||||
|
||||
const renderer = getToolRenderer(toolName);
|
||||
if (renderer) {
|
||||
return renderer.render(params, result, isStreaming);
|
||||
}
|
||||
return defaultRenderer.render(params, result, isStreaming);
|
||||
}
|
||||
|
||||
export { getToolRenderer, registerToolRenderer };
|
||||
|
|
@ -1,369 +0,0 @@
|
|||
import { i18n } from "@mariozechner/mini-lit";
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import type { ToolResultMessage } from "@mariozechner/pi-ai";
|
||||
import { type Static, Type } from "@sinclair/typebox";
|
||||
import { html } from "lit";
|
||||
import { createRef, ref } from "lit/directives/ref.js";
|
||||
import { Code } from "lucide";
|
||||
import {
|
||||
type SandboxFile,
|
||||
SandboxIframe,
|
||||
type SandboxResult,
|
||||
} from "../components/SandboxedIframe.js";
|
||||
import type { SandboxRuntimeProvider } from "../components/sandbox/SandboxRuntimeProvider.js";
|
||||
import { JAVASCRIPT_REPL_TOOL_DESCRIPTION } from "../prompts/prompts.js";
|
||||
import type { Attachment } from "../utils/attachment-utils.js";
|
||||
import {
|
||||
registerToolRenderer,
|
||||
renderCollapsibleHeader,
|
||||
renderHeader,
|
||||
} from "./renderer-registry.js";
|
||||
import type { ToolRenderer, ToolRenderResult } from "./types.js";
|
||||
|
||||
// Execute JavaScript code with attachments using SandboxedIframe
|
||||
export async function executeJavaScript(
|
||||
code: string,
|
||||
runtimeProviders: SandboxRuntimeProvider[],
|
||||
signal?: AbortSignal,
|
||||
sandboxUrlProvider?: () => string,
|
||||
): Promise<{ output: string; files?: SandboxFile[] }> {
|
||||
if (!code) {
|
||||
throw new Error("Code parameter is required");
|
||||
}
|
||||
|
||||
// Check for abort before starting
|
||||
if (signal?.aborted) {
|
||||
throw new Error("Execution aborted");
|
||||
}
|
||||
|
||||
// Create a SandboxedIframe instance for execution
|
||||
const sandbox = new SandboxIframe();
|
||||
if (sandboxUrlProvider) {
|
||||
sandbox.sandboxUrlProvider = sandboxUrlProvider;
|
||||
}
|
||||
sandbox.style.display = "none";
|
||||
document.body.appendChild(sandbox);
|
||||
|
||||
try {
|
||||
const sandboxId = `repl-${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
||||
|
||||
// Pass providers to execute (router handles all message routing)
|
||||
// No additional consumers needed - execute() has its own internal consumer
|
||||
const result: SandboxResult = await sandbox.execute(
|
||||
sandboxId,
|
||||
code,
|
||||
runtimeProviders,
|
||||
[],
|
||||
signal,
|
||||
);
|
||||
|
||||
// Remove the sandbox iframe after execution
|
||||
sandbox.remove();
|
||||
|
||||
// Build plain text response
|
||||
let output = "";
|
||||
|
||||
// Add console output - result.console contains { type: string, text: string } from sandbox.js
|
||||
if (result.console && result.console.length > 0) {
|
||||
for (const entry of result.console) {
|
||||
output += `${entry.text}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
// Add error if execution failed
|
||||
if (!result.success) {
|
||||
if (output) output += "\n";
|
||||
output += `Error: ${result.error?.message || "Unknown error"}\n${result.error?.stack || ""}`;
|
||||
|
||||
// Throw error so tool call is marked as failed
|
||||
throw new Error(output.trim());
|
||||
}
|
||||
|
||||
// Add return value if present
|
||||
if (result.returnValue !== undefined) {
|
||||
if (output) output += "\n";
|
||||
output += `=> ${typeof result.returnValue === "object" ? JSON.stringify(result.returnValue, null, 2) : result.returnValue}`;
|
||||
}
|
||||
|
||||
// Add file notifications
|
||||
if (result.files && result.files.length > 0) {
|
||||
output += `\n[Files returned: ${result.files.length}]\n`;
|
||||
for (const file of result.files) {
|
||||
output += ` - ${file.fileName} (${file.mimeType})\n`;
|
||||
}
|
||||
} else {
|
||||
// Explicitly note when no files were returned (helpful for debugging)
|
||||
if (code.includes("returnFile")) {
|
||||
output += "\n[No files returned - check async operations]";
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
output: output.trim() || "Code executed successfully (no output)",
|
||||
files: result.files,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
// Clean up on error
|
||||
sandbox.remove();
|
||||
throw new Error((error as Error).message || "Execution failed");
|
||||
}
|
||||
}
|
||||
|
||||
export type JavaScriptReplToolResult = {
|
||||
files?:
|
||||
| {
|
||||
fileName: string;
|
||||
contentBase64: string;
|
||||
mimeType: string;
|
||||
}[]
|
||||
| undefined;
|
||||
};
|
||||
|
||||
const javascriptReplSchema = Type.Object({
|
||||
title: Type.String({
|
||||
description:
|
||||
"Brief title describing what the code snippet tries to achieve in active form, e.g. 'Calculating sum'",
|
||||
}),
|
||||
code: Type.String({ description: "JavaScript code to execute" }),
|
||||
});
|
||||
|
||||
export type JavaScriptReplParams = Static<typeof javascriptReplSchema>;
|
||||
|
||||
interface JavaScriptReplResult {
|
||||
output?: string;
|
||||
files?: Array<{
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
contentBase64: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function createJavaScriptReplTool(): AgentTool<
|
||||
typeof javascriptReplSchema,
|
||||
JavaScriptReplToolResult
|
||||
> & {
|
||||
runtimeProvidersFactory?: () => SandboxRuntimeProvider[];
|
||||
sandboxUrlProvider?: () => string;
|
||||
} {
|
||||
return {
|
||||
label: "JavaScript REPL",
|
||||
name: "javascript_repl",
|
||||
runtimeProvidersFactory: () => [], // default to empty array
|
||||
sandboxUrlProvider: undefined, // optional, for browser extensions
|
||||
get description() {
|
||||
const runtimeProviderDescriptions =
|
||||
this.runtimeProvidersFactory?.()
|
||||
.map((d) => d.getDescription())
|
||||
.filter((d) => d.trim().length > 0) || [];
|
||||
return JAVASCRIPT_REPL_TOOL_DESCRIPTION(runtimeProviderDescriptions);
|
||||
},
|
||||
parameters: javascriptReplSchema,
|
||||
execute: async function (
|
||||
_toolCallId: string,
|
||||
args: Static<typeof javascriptReplSchema>,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const result = await executeJavaScript(
|
||||
args.code,
|
||||
this.runtimeProvidersFactory?.() ?? [],
|
||||
signal,
|
||||
this.sandboxUrlProvider,
|
||||
);
|
||||
// Convert files to JSON-serializable with base64 payloads
|
||||
const files = (result.files || []).map((f) => {
|
||||
const toBase64 = (
|
||||
input: string | Uint8Array,
|
||||
): { base64: string; size: number } => {
|
||||
if (input instanceof Uint8Array) {
|
||||
let binary = "";
|
||||
const chunk = 0x8000;
|
||||
for (let i = 0; i < input.length; i += chunk) {
|
||||
binary += String.fromCharCode(...input.subarray(i, i + chunk));
|
||||
}
|
||||
return { base64: btoa(binary), size: input.length };
|
||||
} else if (typeof input === "string") {
|
||||
const enc = new TextEncoder();
|
||||
const bytes = enc.encode(input);
|
||||
let binary = "";
|
||||
const chunk = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += chunk) {
|
||||
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
|
||||
}
|
||||
return { base64: btoa(binary), size: bytes.length };
|
||||
} else {
|
||||
const s = String(input);
|
||||
const enc = new TextEncoder();
|
||||
const bytes = enc.encode(s);
|
||||
let binary = "";
|
||||
const chunk = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += chunk) {
|
||||
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
|
||||
}
|
||||
return { base64: btoa(binary), size: bytes.length };
|
||||
}
|
||||
};
|
||||
|
||||
const { base64, size } = toBase64(f.content);
|
||||
return {
|
||||
fileName: f.fileName || "file",
|
||||
mimeType: f.mimeType || "application/octet-stream",
|
||||
size,
|
||||
contentBase64: base64,
|
||||
};
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text", text: result.output }],
|
||||
details: { files },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Export a default instance for backward compatibility
|
||||
export const javascriptReplTool = createJavaScriptReplTool();
|
||||
|
||||
export const javascriptReplRenderer: ToolRenderer<
|
||||
JavaScriptReplParams,
|
||||
JavaScriptReplResult
|
||||
> = {
|
||||
render(
|
||||
params: JavaScriptReplParams | undefined,
|
||||
result: ToolResultMessage<JavaScriptReplResult> | undefined,
|
||||
isStreaming?: boolean,
|
||||
): ToolRenderResult {
|
||||
// Determine status
|
||||
const state = result
|
||||
? result.isError
|
||||
? "error"
|
||||
: "complete"
|
||||
: isStreaming
|
||||
? "inprogress"
|
||||
: "complete";
|
||||
|
||||
// Create refs for collapsible code section
|
||||
const codeContentRef = createRef<HTMLDivElement>();
|
||||
const codeChevronRef = createRef<HTMLSpanElement>();
|
||||
|
||||
// With result: show params + result
|
||||
if (result && params) {
|
||||
const output =
|
||||
result.content
|
||||
?.filter((c) => c.type === "text")
|
||||
.map((c: any) => c.text)
|
||||
.join("\n") || "";
|
||||
const files = result.details?.files || [];
|
||||
|
||||
const attachments: Attachment[] = files.map((f, i) => {
|
||||
// Decode base64 content for text files to show in overlay
|
||||
let extractedText: string | undefined;
|
||||
const isTextBased =
|
||||
f.mimeType?.startsWith("text/") ||
|
||||
f.mimeType === "application/json" ||
|
||||
f.mimeType === "application/javascript" ||
|
||||
f.mimeType?.includes("xml");
|
||||
|
||||
if (isTextBased && f.contentBase64) {
|
||||
try {
|
||||
extractedText = atob(f.contentBase64);
|
||||
} catch (_e) {
|
||||
console.warn("Failed to decode base64 content for", f.fileName);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: `repl-${Date.now()}-${i}`,
|
||||
type: f.mimeType?.startsWith("image/") ? "image" : "document",
|
||||
fileName: f.fileName || `file-${i}`,
|
||||
mimeType: f.mimeType || "application/octet-stream",
|
||||
size: f.size ?? 0,
|
||||
content: f.contentBase64,
|
||||
preview: f.mimeType?.startsWith("image/")
|
||||
? f.contentBase64
|
||||
: undefined,
|
||||
extractedText,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
content: html`
|
||||
<div>
|
||||
${renderCollapsibleHeader(
|
||||
state,
|
||||
Code,
|
||||
params.title ? params.title : i18n("Executing JavaScript"),
|
||||
codeContentRef,
|
||||
codeChevronRef,
|
||||
false,
|
||||
)}
|
||||
<div
|
||||
${ref(codeContentRef)}
|
||||
class="max-h-0 overflow-hidden transition-all duration-300 space-y-3"
|
||||
>
|
||||
<code-block
|
||||
.code=${params.code || ""}
|
||||
language="javascript"
|
||||
></code-block>
|
||||
${output
|
||||
? html`<console-block
|
||||
.content=${output}
|
||||
.variant=${result.isError ? "error" : "default"}
|
||||
></console-block>`
|
||||
: ""}
|
||||
</div>
|
||||
${attachments.length
|
||||
? html`<div class="flex flex-wrap gap-2 mt-3">
|
||||
${attachments.map(
|
||||
(att) =>
|
||||
html`<attachment-tile
|
||||
.attachment=${att}
|
||||
></attachment-tile>`,
|
||||
)}
|
||||
</div>`
|
||||
: ""}
|
||||
</div>
|
||||
`,
|
||||
isCustom: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Just params (streaming or waiting for result)
|
||||
if (params) {
|
||||
return {
|
||||
content: html`
|
||||
<div>
|
||||
${renderCollapsibleHeader(
|
||||
state,
|
||||
Code,
|
||||
params.title ? params.title : i18n("Executing JavaScript"),
|
||||
codeContentRef,
|
||||
codeChevronRef,
|
||||
false,
|
||||
)}
|
||||
<div
|
||||
${ref(codeContentRef)}
|
||||
class="max-h-0 overflow-hidden transition-all duration-300"
|
||||
>
|
||||
${params.code
|
||||
? html`<code-block
|
||||
.code=${params.code}
|
||||
language="javascript"
|
||||
></code-block>`
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
isCustom: false,
|
||||
};
|
||||
}
|
||||
|
||||
// No params or result yet
|
||||
return {
|
||||
content: renderHeader(state, Code, i18n("Preparing JavaScript...")),
|
||||
isCustom: false,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// Auto-register the renderer
|
||||
registerToolRenderer(javascriptReplTool.name, javascriptReplRenderer);
|
||||
|
|
@ -1,144 +0,0 @@
|
|||
import { icon } from "@mariozechner/mini-lit";
|
||||
import { html, type TemplateResult } from "lit";
|
||||
import type { Ref } from "lit/directives/ref.js";
|
||||
import { ref } from "lit/directives/ref.js";
|
||||
import { ChevronsUpDown, ChevronUp, Loader } from "lucide";
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to render a header for tool renderers
|
||||
* Shows icon on left when complete/error, spinner on right when in progress
|
||||
*/
|
||||
export function renderHeader(
|
||||
state: "inprogress" | "complete" | "error",
|
||||
toolIcon: any,
|
||||
text: string | TemplateResult,
|
||||
): TemplateResult {
|
||||
const statusIcon = (iconComponent: any, color: string) =>
|
||||
html`<span class="inline-block ${color}"
|
||||
>${icon(iconComponent, "sm")}</span
|
||||
>`;
|
||||
|
||||
switch (state) {
|
||||
case "inprogress":
|
||||
return html`
|
||||
<div
|
||||
class="flex items-center justify-between gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
${statusIcon(toolIcon, "text-foreground")} ${text}
|
||||
</div>
|
||||
${statusIcon(Loader, "text-foreground animate-spin")}
|
||||
</div>
|
||||
`;
|
||||
case "complete":
|
||||
return html`
|
||||
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
${statusIcon(toolIcon, "text-green-600 dark:text-green-500")} ${text}
|
||||
</div>
|
||||
`;
|
||||
case "error":
|
||||
return html`
|
||||
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
${statusIcon(toolIcon, "text-destructive")} ${text}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to render a collapsible header for tool renderers
|
||||
* Same as renderHeader but with a chevron button that toggles visibility of content
|
||||
*/
|
||||
export function renderCollapsibleHeader(
|
||||
state: "inprogress" | "complete" | "error",
|
||||
toolIcon: any,
|
||||
text: string | TemplateResult,
|
||||
contentRef: Ref<HTMLElement>,
|
||||
chevronRef: Ref<HTMLElement>,
|
||||
defaultExpanded = false,
|
||||
): TemplateResult {
|
||||
const statusIcon = (iconComponent: any, color: string) =>
|
||||
html`<span class="inline-block ${color}"
|
||||
>${icon(iconComponent, "sm")}</span
|
||||
>`;
|
||||
|
||||
const toggleContent = (e: Event) => {
|
||||
e.preventDefault();
|
||||
const content = contentRef.value;
|
||||
const chevron = chevronRef.value;
|
||||
if (content && chevron) {
|
||||
const isCollapsed = content.classList.contains("max-h-0");
|
||||
if (isCollapsed) {
|
||||
content.classList.remove("max-h-0");
|
||||
content.classList.add("max-h-[2000px]", "mt-3");
|
||||
// Show ChevronUp, hide ChevronsUpDown
|
||||
const upIcon = chevron.querySelector(".chevron-up");
|
||||
const downIcon = chevron.querySelector(".chevrons-up-down");
|
||||
if (upIcon && downIcon) {
|
||||
upIcon.classList.remove("hidden");
|
||||
downIcon.classList.add("hidden");
|
||||
}
|
||||
} else {
|
||||
content.classList.remove("max-h-[2000px]", "mt-3");
|
||||
content.classList.add("max-h-0");
|
||||
// Show ChevronsUpDown, hide ChevronUp
|
||||
const upIcon = chevron.querySelector(".chevron-up");
|
||||
const downIcon = chevron.querySelector(".chevrons-up-down");
|
||||
if (upIcon && downIcon) {
|
||||
upIcon.classList.add("hidden");
|
||||
downIcon.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const toolIconColor =
|
||||
state === "complete"
|
||||
? "text-green-600 dark:text-green-500"
|
||||
: state === "error"
|
||||
? "text-destructive"
|
||||
: "text-foreground";
|
||||
|
||||
return html`
|
||||
<button
|
||||
@click=${toggleContent}
|
||||
class="flex items-center justify-between gap-2 text-sm text-muted-foreground w-full text-left hover:text-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
${state === "inprogress"
|
||||
? statusIcon(Loader, "text-foreground animate-spin")
|
||||
: ""}
|
||||
${statusIcon(toolIcon, toolIconColor)} ${text}
|
||||
</div>
|
||||
<span class="inline-block text-muted-foreground" ${ref(chevronRef)}>
|
||||
<span class="chevron-up ${defaultExpanded ? "" : "hidden"}"
|
||||
>${icon(ChevronUp, "sm")}</span
|
||||
>
|
||||
<span class="chevrons-up-down ${defaultExpanded ? "hidden" : ""}"
|
||||
>${icon(ChevronsUpDown, "sm")}</span
|
||||
>
|
||||
</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
import type { ToolResultMessage } from "@mariozechner/pi-ai";
|
||||
import { html } from "lit";
|
||||
import { SquareTerminal } from "lucide";
|
||||
import { i18n } from "../../utils/i18n.js";
|
||||
import { renderHeader } from "../renderer-registry.js";
|
||||
import type { ToolRenderer, ToolRenderResult } from "../types.js";
|
||||
|
||||
interface BashParams {
|
||||
command: string;
|
||||
}
|
||||
|
||||
// Bash tool has undefined details (only uses output)
|
||||
export class BashRenderer implements ToolRenderer<BashParams, undefined> {
|
||||
render(
|
||||
params: BashParams | undefined,
|
||||
result: ToolResultMessage<undefined> | undefined,
|
||||
): ToolRenderResult {
|
||||
const state = result
|
||||
? result.isError
|
||||
? "error"
|
||||
: "complete"
|
||||
: "inprogress";
|
||||
|
||||
// With result: show command + output
|
||||
if (result && params?.command) {
|
||||
const output =
|
||||
result.content
|
||||
?.filter((c) => c.type === "text")
|
||||
.map((c: any) => c.text)
|
||||
.join("\n") || "";
|
||||
const combined = output
|
||||
? `> ${params.command}\n\n${output}`
|
||||
: `> ${params.command}`;
|
||||
return {
|
||||
content: html`
|
||||
<div class="space-y-3">
|
||||
${renderHeader(state, SquareTerminal, i18n("Running command..."))}
|
||||
<console-block
|
||||
.content=${combined}
|
||||
.variant=${result.isError ? "error" : "default"}
|
||||
></console-block>
|
||||
</div>
|
||||
`,
|
||||
isCustom: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Just params (streaming or waiting)
|
||||
if (params?.command) {
|
||||
return {
|
||||
content: html`
|
||||
<div class="space-y-3">
|
||||
${renderHeader(state, SquareTerminal, i18n("Running command..."))}
|
||||
<console-block .content=${`> ${params.command}`}></console-block>
|
||||
</div>
|
||||
`,
|
||||
isCustom: false,
|
||||
};
|
||||
}
|
||||
|
||||
// No params yet
|
||||
return {
|
||||
content: renderHeader(
|
||||
state,
|
||||
SquareTerminal,
|
||||
i18n("Waiting for command..."),
|
||||
),
|
||||
isCustom: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
import type { ToolResultMessage } from "@mariozechner/pi-ai";
|
||||
import { html } from "lit";
|
||||
import { Calculator } from "lucide";
|
||||
import { i18n } from "../../utils/i18n.js";
|
||||
import { renderHeader } from "../renderer-registry.js";
|
||||
import type { ToolRenderer, ToolRenderResult } from "../types.js";
|
||||
|
||||
interface CalculateParams {
|
||||
expression: string;
|
||||
}
|
||||
|
||||
// Calculate tool has undefined details (only uses output)
|
||||
export class CalculateRenderer implements ToolRenderer<
|
||||
CalculateParams,
|
||||
undefined
|
||||
> {
|
||||
render(
|
||||
params: CalculateParams | undefined,
|
||||
result: ToolResultMessage<undefined> | undefined,
|
||||
): ToolRenderResult {
|
||||
const state = result
|
||||
? result.isError
|
||||
? "error"
|
||||
: "complete"
|
||||
: "inprogress";
|
||||
|
||||
// Full params + full result
|
||||
if (result && params?.expression) {
|
||||
const output =
|
||||
result.content
|
||||
?.filter((c) => c.type === "text")
|
||||
.map((c: any) => c.text)
|
||||
.join("\n") || "";
|
||||
|
||||
// Error: show expression in header, error below
|
||||
if (result.isError) {
|
||||
return {
|
||||
content: html`
|
||||
<div class="space-y-3">
|
||||
${renderHeader(state, Calculator, params.expression)}
|
||||
<div class="text-sm text-destructive">${output}</div>
|
||||
</div>
|
||||
`,
|
||||
isCustom: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Success: show expression = result in header
|
||||
return {
|
||||
content: renderHeader(
|
||||
state,
|
||||
Calculator,
|
||||
`${params.expression} = ${output}`,
|
||||
),
|
||||
isCustom: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Full params, no result: just show header with expression in it
|
||||
if (params?.expression) {
|
||||
return {
|
||||
content: renderHeader(
|
||||
state,
|
||||
Calculator,
|
||||
`${i18n("Calculating")} ${params.expression}`,
|
||||
),
|
||||
isCustom: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Partial params (empty expression), no result
|
||||
if (params && !params.expression) {
|
||||
return {
|
||||
content: renderHeader(state, Calculator, i18n("Writing expression...")),
|
||||
isCustom: false,
|
||||
};
|
||||
}
|
||||
|
||||
// No params, no result
|
||||
return {
|
||||
content: renderHeader(
|
||||
state,
|
||||
Calculator,
|
||||
i18n("Waiting for expression..."),
|
||||
),
|
||||
isCustom: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,121 +0,0 @@
|
|||
import type { ToolResultMessage } from "@mariozechner/pi-ai";
|
||||
import { html } from "lit";
|
||||
import { Code } from "lucide";
|
||||
import { i18n } from "../../utils/i18n.js";
|
||||
import { renderHeader } from "../renderer-registry.js";
|
||||
import type { ToolRenderer, ToolRenderResult } from "../types.js";
|
||||
|
||||
export class DefaultRenderer implements ToolRenderer {
|
||||
render(
|
||||
params: any | undefined,
|
||||
result: ToolResultMessage | undefined,
|
||||
isStreaming?: boolean,
|
||||
): ToolRenderResult {
|
||||
const state = result
|
||||
? result.isError
|
||||
? "error"
|
||||
: "complete"
|
||||
: isStreaming
|
||||
? "inprogress"
|
||||
: "complete";
|
||||
|
||||
// Format params as JSON
|
||||
let paramsJson = "";
|
||||
if (params) {
|
||||
try {
|
||||
paramsJson = JSON.stringify(JSON.parse(params), null, 2);
|
||||
} catch {
|
||||
try {
|
||||
paramsJson = JSON.stringify(params, null, 2);
|
||||
} catch {
|
||||
paramsJson = String(params);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// With result: show header + params + result
|
||||
if (result) {
|
||||
let outputJson =
|
||||
result.content
|
||||
?.filter((c) => c.type === "text")
|
||||
.map((c: any) => c.text)
|
||||
.join("\n") || i18n("(no output)");
|
||||
let outputLanguage = "text";
|
||||
|
||||
// Try to parse and pretty-print if it's valid JSON
|
||||
try {
|
||||
const parsed = JSON.parse(outputJson);
|
||||
outputJson = JSON.stringify(parsed, null, 2);
|
||||
outputLanguage = "json";
|
||||
} catch {
|
||||
// Not valid JSON, leave as-is and use text highlighting
|
||||
}
|
||||
|
||||
return {
|
||||
content: html`
|
||||
<div class="space-y-3">
|
||||
${renderHeader(state, Code, "Tool Call")}
|
||||
${paramsJson
|
||||
? html`<div>
|
||||
<div class="text-xs font-medium mb-1 text-muted-foreground">
|
||||
${i18n("Input")}
|
||||
</div>
|
||||
<code-block .code=${paramsJson} language="json"></code-block>
|
||||
</div>`
|
||||
: ""}
|
||||
<div>
|
||||
<div class="text-xs font-medium mb-1 text-muted-foreground">
|
||||
${i18n("Output")}
|
||||
</div>
|
||||
<code-block
|
||||
.code=${outputJson}
|
||||
language="${outputLanguage}"
|
||||
></code-block>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
isCustom: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Just params (streaming or waiting for result)
|
||||
if (params) {
|
||||
if (
|
||||
isStreaming &&
|
||||
(!paramsJson || paramsJson === "{}" || paramsJson === "null")
|
||||
) {
|
||||
return {
|
||||
content: html`
|
||||
<div>
|
||||
${renderHeader(state, Code, "Preparing tool parameters...")}
|
||||
</div>
|
||||
`,
|
||||
isCustom: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: html`
|
||||
<div class="space-y-3">
|
||||
${renderHeader(state, Code, "Tool Call")}
|
||||
<div>
|
||||
<div class="text-xs font-medium mb-1 text-muted-foreground">
|
||||
${i18n("Input")}
|
||||
</div>
|
||||
<code-block .code=${paramsJson} language="json"></code-block>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
isCustom: false,
|
||||
};
|
||||
}
|
||||
|
||||
// No params or result yet
|
||||
return {
|
||||
content: html`
|
||||
<div>${renderHeader(state, Code, "Preparing tool...")}</div>
|
||||
`,
|
||||
isCustom: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
import type { ToolResultMessage } from "@mariozechner/pi-ai";
|
||||
import { html } from "lit";
|
||||
import { Clock } from "lucide";
|
||||
import { i18n } from "../../utils/i18n.js";
|
||||
import { renderHeader } from "../renderer-registry.js";
|
||||
import type { ToolRenderer, ToolRenderResult } from "../types.js";
|
||||
|
||||
interface GetCurrentTimeParams {
|
||||
timezone?: string;
|
||||
}
|
||||
|
||||
// GetCurrentTime tool has undefined details (only uses output)
|
||||
export class GetCurrentTimeRenderer implements ToolRenderer<
|
||||
GetCurrentTimeParams,
|
||||
undefined
|
||||
> {
|
||||
render(
|
||||
params: GetCurrentTimeParams | undefined,
|
||||
result: ToolResultMessage<undefined> | undefined,
|
||||
): ToolRenderResult {
|
||||
const state = result
|
||||
? result.isError
|
||||
? "error"
|
||||
: "complete"
|
||||
: "inprogress";
|
||||
|
||||
// Full params + full result
|
||||
if (result && params) {
|
||||
const output =
|
||||
result.content
|
||||
?.filter((c) => c.type === "text")
|
||||
.map((c: any) => c.text)
|
||||
.join("\n") || "";
|
||||
const headerText = params.timezone
|
||||
? `${i18n("Getting current time in")} ${params.timezone}`
|
||||
: i18n("Getting current date and time");
|
||||
|
||||
// Error: show header, error below
|
||||
if (result.isError) {
|
||||
return {
|
||||
content: html`
|
||||
<div class="space-y-3">
|
||||
${renderHeader(state, Clock, headerText)}
|
||||
<div class="text-sm text-destructive">${output}</div>
|
||||
</div>
|
||||
`,
|
||||
isCustom: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Success: show time in header
|
||||
return {
|
||||
content: renderHeader(state, Clock, `${headerText}: ${output}`),
|
||||
isCustom: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Full result, no params
|
||||
if (result) {
|
||||
const output =
|
||||
result.content
|
||||
?.filter((c) => c.type === "text")
|
||||
.map((c: any) => c.text)
|
||||
.join("\n") || "";
|
||||
|
||||
// Error: show header, error below
|
||||
if (result.isError) {
|
||||
return {
|
||||
content: html`
|
||||
<div class="space-y-3">
|
||||
${renderHeader(
|
||||
state,
|
||||
Clock,
|
||||
i18n("Getting current date and time"),
|
||||
)}
|
||||
<div class="text-sm text-destructive">${output}</div>
|
||||
</div>
|
||||
`,
|
||||
isCustom: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Success: show time in header
|
||||
return {
|
||||
content: renderHeader(
|
||||
state,
|
||||
Clock,
|
||||
`${i18n("Getting current date and time")}: ${output}`,
|
||||
),
|
||||
isCustom: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Full params, no result: show timezone info in header
|
||||
if (params?.timezone) {
|
||||
return {
|
||||
content: renderHeader(
|
||||
state,
|
||||
Clock,
|
||||
`${i18n("Getting current time in")} ${params.timezone}`,
|
||||
),
|
||||
isCustom: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Partial params (no timezone) or empty params, no result
|
||||
if (params) {
|
||||
return {
|
||||
content: renderHeader(
|
||||
state,
|
||||
Clock,
|
||||
i18n("Getting current date and time"),
|
||||
),
|
||||
isCustom: false,
|
||||
};
|
||||
}
|
||||
|
||||
// No params, no result
|
||||
return {
|
||||
content: renderHeader(state, Clock, i18n("Getting time...")),
|
||||
isCustom: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
import type { ToolResultMessage } from "@mariozechner/pi-ai";
|
||||
import type { TemplateResult } from "lit";
|
||||
|
||||
export interface ToolRenderResult {
|
||||
content: TemplateResult;
|
||||
isCustom: boolean; // true = no card wrapper, false = wrap in card
|
||||
}
|
||||
|
||||
export interface ToolRenderer<TParams = any, TDetails = any> {
|
||||
render(
|
||||
params: TParams | undefined,
|
||||
result: ToolResultMessage<TDetails> | undefined,
|
||||
isStreaming?: boolean,
|
||||
): ToolRenderResult;
|
||||
}
|
||||
|
|
@ -1,509 +0,0 @@
|
|||
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)}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
import PromptDialog from "@mariozechner/mini-lit/dist/PromptDialog.js";
|
||||
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`);
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
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`;
|
||||
}
|
||||
|
|
@ -1,675 +0,0 @@
|
|||
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;
|
||||
"Error loading PDF": string;
|
||||
"Error loading document": string;
|
||||
"Error loading spreadsheet": string;
|
||||
"Preview not available for this file type.": string;
|
||||
"Click the download button above to view it on your computer.": 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;
|
||||
Input: string;
|
||||
Output: string;
|
||||
"Writing expression...": string;
|
||||
"Waiting for expression...": string;
|
||||
Calculating: string;
|
||||
"Getting current time in": string;
|
||||
"Getting current date and time": string;
|
||||
"Waiting for command...": 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;
|
||||
"Preparing JavaScript...": string;
|
||||
"Preparing command...": string;
|
||||
"Preparing calculation...": string;
|
||||
"Preparing tool...": string;
|
||||
"Getting time...": string;
|
||||
// Artifacts strings
|
||||
"Processing artifact...": string;
|
||||
"Preparing artifact...": string;
|
||||
"Processing artifact": string;
|
||||
"Processed artifact": string;
|
||||
"Creating artifact": string;
|
||||
"Created artifact": string;
|
||||
"Updating artifact": string;
|
||||
"Updated artifact": string;
|
||||
"Rewriting artifact": string;
|
||||
"Rewrote artifact": string;
|
||||
"Getting artifact": string;
|
||||
"Got artifact": string;
|
||||
"Deleting artifact": string;
|
||||
"Deleted artifact": string;
|
||||
"Getting logs": string;
|
||||
"Got logs": string;
|
||||
"An error occurred": string;
|
||||
"Copy logs": string;
|
||||
"Autoscroll enabled": string;
|
||||
"Autoscroll disabled": 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;
|
||||
"Reload 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;
|
||||
Settings: string;
|
||||
"API Keys": string;
|
||||
Proxy: string;
|
||||
"Use CORS Proxy": string;
|
||||
"Proxy URL": string;
|
||||
"Format: The proxy must accept requests as <proxy-url>/?url=<target-url>": string;
|
||||
"Settings are stored locally in your browser": string;
|
||||
Clear: string;
|
||||
"API Key Required": string;
|
||||
"Enter your API key for {provider}": string;
|
||||
"Allows browser-based apps to bypass CORS restrictions when calling LLM providers. Required for Z-AI and Anthropic with OAuth token.": string;
|
||||
Off: string;
|
||||
Minimal: string;
|
||||
Low: string;
|
||||
Medium: string;
|
||||
High: string;
|
||||
"Storage Permission Required": string;
|
||||
"This app needs persistent storage to save your conversations": string;
|
||||
"Why is this needed?": string;
|
||||
"Without persistent storage, your browser may delete saved conversations when it needs disk space. Granting this permission ensures your chat history is preserved.": string;
|
||||
"What this means:": string;
|
||||
"Your conversations will be saved locally in your browser": string;
|
||||
"Data will not be deleted automatically to free up space": string;
|
||||
"You can still manually clear data at any time": string;
|
||||
"No data is sent to external servers": string;
|
||||
"Continue Anyway": string;
|
||||
"Requesting...": string;
|
||||
"Grant Permission": string;
|
||||
Sessions: string;
|
||||
"Load a previous conversation": string;
|
||||
"No sessions yet": string;
|
||||
"Delete this session?": string;
|
||||
Today: string;
|
||||
Yesterday: string;
|
||||
"{days} days ago": string;
|
||||
messages: string;
|
||||
tokens: string;
|
||||
"Drop files here": string;
|
||||
// Providers & Models
|
||||
"Providers & Models": string;
|
||||
"Cloud Providers": string;
|
||||
"Cloud LLM providers with predefined models. API keys are stored locally in your browser.": string;
|
||||
"Custom Providers": string;
|
||||
"User-configured servers with auto-discovered or manually defined models.": string;
|
||||
"Add Provider": string;
|
||||
"No custom providers configured. Click 'Add Provider' to get started.": string;
|
||||
Models: string;
|
||||
"auto-discovered": string;
|
||||
Refresh: string;
|
||||
Edit: string;
|
||||
"Are you sure you want to delete this provider?": string;
|
||||
"Edit Provider": string;
|
||||
"Provider Name": string;
|
||||
"e.g., My Ollama Server": string;
|
||||
"Provider Type": string;
|
||||
"Base URL": string;
|
||||
"e.g., http://localhost:11434": string;
|
||||
"API Key (Optional)": string;
|
||||
"Leave empty if not required": string;
|
||||
"Test Connection": string;
|
||||
Discovered: string;
|
||||
models: string;
|
||||
and: string;
|
||||
more: string;
|
||||
"For manual provider types, add models after saving the provider.": string;
|
||||
"Please fill in all required fields": string;
|
||||
"Failed to save provider": string;
|
||||
"OpenAI Completions Compatible": string;
|
||||
"OpenAI Responses Compatible": string;
|
||||
"Anthropic Messages Compatible": string;
|
||||
"Checking...": string;
|
||||
Disconnected: string;
|
||||
}
|
||||
}
|
||||
|
||||
export 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",
|
||||
"Error loading PDF": "Error loading PDF",
|
||||
"Error loading document": "Error loading document",
|
||||
"Error loading spreadsheet": "Error loading spreadsheet",
|
||||
"Preview not available for this file type.":
|
||||
"Preview not available for this file type.",
|
||||
"Click the download button above to view it on your computer.":
|
||||
"Click the download button above to view it on your computer.",
|
||||
"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)",
|
||||
Input: "Input",
|
||||
Output: "Output",
|
||||
"Waiting for expression...": "Waiting for expression...",
|
||||
"Writing expression...": "Writing expression...",
|
||||
Calculating: "Calculating",
|
||||
"Getting current time in": "Getting current time in",
|
||||
"Getting current date and time": "Getting current date and time",
|
||||
"Waiting for command...": "Waiting for command...",
|
||||
"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",
|
||||
"Preparing JavaScript...": "Preparing JavaScript...",
|
||||
"Preparing command...": "Preparing command...",
|
||||
"Preparing calculation...": "Preparing calculation...",
|
||||
"Preparing tool...": "Preparing tool...",
|
||||
"Getting time...": "Getting time...",
|
||||
// Artifacts strings
|
||||
"Processing artifact...": "Processing artifact...",
|
||||
"Preparing artifact...": "Preparing artifact...",
|
||||
"Processing artifact": "Processing artifact",
|
||||
"Processed artifact": "Processed artifact",
|
||||
"Creating artifact": "Creating artifact",
|
||||
"Created artifact": "Created artifact",
|
||||
"Updating artifact": "Updating artifact",
|
||||
"Updated artifact": "Updated artifact",
|
||||
"Rewriting artifact": "Rewriting artifact",
|
||||
"Rewrote artifact": "Rewrote artifact",
|
||||
"Getting artifact": "Getting artifact",
|
||||
"Got artifact": "Got artifact",
|
||||
"Deleting artifact": "Deleting artifact",
|
||||
"Deleted artifact": "Deleted artifact",
|
||||
"Getting logs": "Getting logs",
|
||||
"Got logs": "Got logs",
|
||||
"An error occurred": "An error occurred",
|
||||
"Copy logs": "Copy logs",
|
||||
"Autoscroll enabled": "Autoscroll enabled",
|
||||
"Autoscroll disabled": "Autoscroll disabled",
|
||||
Processing: "Processing",
|
||||
Create: "Create",
|
||||
Rewrite: "Rewrite",
|
||||
Get: "Get",
|
||||
"Get logs": "Get logs",
|
||||
"Show artifacts": "Show artifacts",
|
||||
"Close artifacts": "Close artifacts",
|
||||
Artifacts: "Artifacts",
|
||||
"Copy HTML": "Copy HTML",
|
||||
"Download HTML": "Download HTML",
|
||||
"Reload HTML": "Reload 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",
|
||||
Settings: "Settings",
|
||||
"API Keys": "API Keys",
|
||||
Proxy: "Proxy",
|
||||
"Use CORS Proxy": "Use CORS Proxy",
|
||||
"Proxy URL": "Proxy URL",
|
||||
"Format: The proxy must accept requests as <proxy-url>/?url=<target-url>":
|
||||
"Format: The proxy must accept requests as <proxy-url>/?url=<target-url>",
|
||||
"Settings are stored locally in your browser":
|
||||
"Settings are stored locally in your browser",
|
||||
Clear: "Clear",
|
||||
"API Key Required": "API Key Required",
|
||||
"Enter your API key for {provider}": "Enter your API key for {provider}",
|
||||
"Allows browser-based apps to bypass CORS restrictions when calling LLM providers. Required for Z-AI and Anthropic with OAuth token.":
|
||||
"Allows browser-based apps to bypass CORS restrictions when calling LLM providers. Required for Z-AI and Anthropic with OAuth token.",
|
||||
Off: "Off",
|
||||
Minimal: "Minimal",
|
||||
Low: "Low",
|
||||
Medium: "Medium",
|
||||
High: "High",
|
||||
"Storage Permission Required": "Storage Permission Required",
|
||||
"This app needs persistent storage to save your conversations":
|
||||
"This app needs persistent storage to save your conversations",
|
||||
"Why is this needed?": "Why is this needed?",
|
||||
"Without persistent storage, your browser may delete saved conversations when it needs disk space. Granting this permission ensures your chat history is preserved.":
|
||||
"Without persistent storage, your browser may delete saved conversations when it needs disk space. Granting this permission ensures your chat history is preserved.",
|
||||
"What this means:": "What this means:",
|
||||
"Your conversations will be saved locally in your browser":
|
||||
"Your conversations will be saved locally in your browser",
|
||||
"Data will not be deleted automatically to free up space":
|
||||
"Data will not be deleted automatically to free up space",
|
||||
"You can still manually clear data at any time":
|
||||
"You can still manually clear data at any time",
|
||||
"No data is sent to external servers":
|
||||
"No data is sent to external servers",
|
||||
"Continue Anyway": "Continue Anyway",
|
||||
"Requesting...": "Requesting...",
|
||||
"Grant Permission": "Grant Permission",
|
||||
Sessions: "Sessions",
|
||||
"Load a previous conversation": "Load a previous conversation",
|
||||
"No sessions yet": "No sessions yet",
|
||||
"Delete this session?": "Delete this session?",
|
||||
Today: "Today",
|
||||
Yesterday: "Yesterday",
|
||||
"{days} days ago": "{days} days ago",
|
||||
messages: "messages",
|
||||
tokens: "tokens",
|
||||
Delete: "Delete",
|
||||
"Drop files here": "Drop files here",
|
||||
"Command failed:": "Command failed:",
|
||||
// Providers & Models
|
||||
"Providers & Models": "Providers & Models",
|
||||
"Cloud Providers": "Cloud Providers",
|
||||
"Cloud LLM providers with predefined models. API keys are stored locally in your browser.":
|
||||
"Cloud LLM providers with predefined models. API keys are stored locally in your browser.",
|
||||
"Custom Providers": "Custom Providers",
|
||||
"User-configured servers with auto-discovered or manually defined models.":
|
||||
"User-configured servers with auto-discovered or manually defined models.",
|
||||
"Add Provider": "Add Provider",
|
||||
"No custom providers configured. Click 'Add Provider' to get started.":
|
||||
"No custom providers configured. Click 'Add Provider' to get started.",
|
||||
"auto-discovered": "auto-discovered",
|
||||
Refresh: "Refresh",
|
||||
Edit: "Edit",
|
||||
"Are you sure you want to delete this provider?":
|
||||
"Are you sure you want to delete this provider?",
|
||||
"Edit Provider": "Edit Provider",
|
||||
"Provider Name": "Provider Name",
|
||||
"e.g., My Ollama Server": "e.g., My Ollama Server",
|
||||
"Provider Type": "Provider Type",
|
||||
"Base URL": "Base URL",
|
||||
"e.g., http://localhost:11434": "e.g., http://localhost:11434",
|
||||
"API Key (Optional)": "API Key (Optional)",
|
||||
"Leave empty if not required": "Leave empty if not required",
|
||||
"Test Connection": "Test Connection",
|
||||
Discovered: "Discovered",
|
||||
Models: "Models",
|
||||
models: "models",
|
||||
and: "and",
|
||||
more: "more",
|
||||
"For manual provider types, add models after saving the provider.":
|
||||
"For manual provider types, add models after saving the provider.",
|
||||
"Please fill in all required fields": "Please fill in all required fields",
|
||||
"Failed to save provider": "Failed to save provider",
|
||||
"OpenAI Completions Compatible": "OpenAI Completions Compatible",
|
||||
"OpenAI Responses Compatible": "OpenAI Responses Compatible",
|
||||
"Anthropic Messages Compatible": "Anthropic Messages Compatible",
|
||||
"Checking...": "Checking...",
|
||||
Disconnected: "Disconnected",
|
||||
},
|
||||
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",
|
||||
"Error loading PDF": "Fehler beim Laden des PDFs",
|
||||
"Error loading document": "Fehler beim Laden des Dokuments",
|
||||
"Error loading spreadsheet": "Fehler beim Laden der Tabelle",
|
||||
"Preview not available for this file type.":
|
||||
"Vorschau für diesen Dateityp nicht verfügbar.",
|
||||
"Click the download button above to view it on your computer.":
|
||||
"Klicken Sie oben auf die Download-Schaltfläche, um die Datei auf Ihrem Computer anzuzeigen.",
|
||||
"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)",
|
||||
Input: "Eingabe",
|
||||
Output: "Ausgabe",
|
||||
"Waiting for expression...": "Warte auf Ausdruck",
|
||||
"Writing expression...": "Schreibe Ausdruck...",
|
||||
Calculating: "Berechne",
|
||||
"Getting current time in": "Hole aktuelle Zeit in",
|
||||
"Getting current date and time": "Hole aktuelles Datum und Uhrzeit",
|
||||
"Waiting for command...": "Warte auf Befehl...",
|
||||
"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",
|
||||
"Preparing JavaScript...": "Bereite JavaScript vor...",
|
||||
"Preparing command...": "Bereite Befehl vor...",
|
||||
"Preparing calculation...": "Bereite Berechnung vor...",
|
||||
"Preparing tool...": "Bereite Tool vor...",
|
||||
"Getting time...": "Hole Zeit...",
|
||||
// Artifacts strings
|
||||
"Processing artifact...": "Verarbeite Artefakt...",
|
||||
"Preparing artifact...": "Bereite Artefakt vor...",
|
||||
"Processing artifact": "Verarbeite Artefakt",
|
||||
"Processed artifact": "Artefakt verarbeitet",
|
||||
"Creating artifact": "Erstelle Artefakt",
|
||||
"Created artifact": "Artefakt erstellt",
|
||||
"Updating artifact": "Aktualisiere Artefakt",
|
||||
"Updated artifact": "Artefakt aktualisiert",
|
||||
"Rewriting artifact": "Überschreibe Artefakt",
|
||||
"Rewrote artifact": "Artefakt überschrieben",
|
||||
"Getting artifact": "Hole Artefakt",
|
||||
"Got artifact": "Artefakt geholt",
|
||||
"Deleting artifact": "Lösche Artefakt",
|
||||
"Deleted artifact": "Artefakt gelöscht",
|
||||
"Getting logs": "Hole Logs",
|
||||
"Got logs": "Logs geholt",
|
||||
"An error occurred": "Ein Fehler ist aufgetreten",
|
||||
"Copy logs": "Logs kopieren",
|
||||
"Autoscroll enabled": "Automatisches Scrollen aktiviert",
|
||||
"Autoscroll disabled": "Automatisches Scrollen deaktiviert",
|
||||
Processing: "Verarbeitung",
|
||||
Create: "Erstellen",
|
||||
Rewrite: "Überschreiben",
|
||||
Get: "Abrufen",
|
||||
"Get logs": "Logs abrufen",
|
||||
"Show artifacts": "Artefakte anzeigen",
|
||||
"Close artifacts": "Artefakte schließen",
|
||||
Artifacts: "Artefakte",
|
||||
"Copy HTML": "HTML kopieren",
|
||||
"Download HTML": "HTML herunterladen",
|
||||
"Reload HTML": "HTML neu laden",
|
||||
"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",
|
||||
Settings: "Einstellungen",
|
||||
"API Keys": "API-Schlüssel",
|
||||
Proxy: "Proxy",
|
||||
"Use CORS Proxy": "CORS-Proxy verwenden",
|
||||
"Proxy URL": "Proxy-URL",
|
||||
"Format: The proxy must accept requests as <proxy-url>/?url=<target-url>":
|
||||
"Format: Der Proxy muss Anfragen als <proxy-url>/?url=<ziel-url> akzeptieren",
|
||||
"Settings are stored locally in your browser":
|
||||
"Einstellungen werden lokal in Ihrem Browser gespeichert",
|
||||
Clear: "Löschen",
|
||||
"API Key Required": "API-Schlüssel erforderlich",
|
||||
"Enter your API key for {provider}":
|
||||
"Geben Sie Ihren API-Schlüssel für {provider} ein",
|
||||
"Allows browser-based apps to bypass CORS restrictions when calling LLM providers. Required for Z-AI and Anthropic with OAuth token.":
|
||||
"Ermöglicht browserbasierten Anwendungen, CORS-Einschränkungen beim Aufruf von LLM-Anbietern zu umgehen. Erforderlich für Z-AI und Anthropic mit OAuth-Token.",
|
||||
Off: "Aus",
|
||||
Minimal: "Minimal",
|
||||
Low: "Niedrig",
|
||||
Medium: "Mittel",
|
||||
High: "Hoch",
|
||||
"Storage Permission Required": "Speicherberechtigung erforderlich",
|
||||
"This app needs persistent storage to save your conversations":
|
||||
"Diese App benötigt dauerhaften Speicher, um Ihre Konversationen zu speichern",
|
||||
"Why is this needed?": "Warum wird das benötigt?",
|
||||
"Without persistent storage, your browser may delete saved conversations when it needs disk space. Granting this permission ensures your chat history is preserved.":
|
||||
"Ohne dauerhaften Speicher kann Ihr Browser gespeicherte Konversationen löschen, wenn Speicherplatz benötigt wird. Diese Berechtigung stellt sicher, dass Ihr Chatverlauf erhalten bleibt.",
|
||||
"What this means:": "Was das bedeutet:",
|
||||
"Your conversations will be saved locally in your browser":
|
||||
"Ihre Konversationen werden lokal in Ihrem Browser gespeichert",
|
||||
"Data will not be deleted automatically to free up space":
|
||||
"Daten werden nicht automatisch gelöscht, um Speicherplatz freizugeben",
|
||||
"You can still manually clear data at any time":
|
||||
"Sie können Daten jederzeit manuell löschen",
|
||||
"No data is sent to external servers":
|
||||
"Keine Daten werden an externe Server gesendet",
|
||||
"Continue Anyway": "Trotzdem fortfahren",
|
||||
"Requesting...": "Anfrage läuft...",
|
||||
"Grant Permission": "Berechtigung erteilen",
|
||||
Sessions: "Sitzungen",
|
||||
"Load a previous conversation": "Frühere Konversation laden",
|
||||
"No sessions yet": "Noch keine Sitzungen",
|
||||
"Delete this session?": "Diese Sitzung löschen?",
|
||||
Today: "Heute",
|
||||
Yesterday: "Gestern",
|
||||
"{days} days ago": "vor {days} Tagen",
|
||||
messages: "Nachrichten",
|
||||
tokens: "Tokens",
|
||||
Delete: "Löschen",
|
||||
"Drop files here": "Dateien hier ablegen",
|
||||
"Command failed:": "Befehl fehlgeschlagen:",
|
||||
// Providers & Models
|
||||
"Providers & Models": "Anbieter & Modelle",
|
||||
"Cloud Providers": "Cloud-Anbieter",
|
||||
"Cloud LLM providers with predefined models. API keys are stored locally in your browser.":
|
||||
"Cloud-LLM-Anbieter mit vordefinierten Modellen. API-Schlüssel werden lokal in Ihrem Browser gespeichert.",
|
||||
"Custom Providers": "Benutzerdefinierte Anbieter",
|
||||
"User-configured servers with auto-discovered or manually defined models.":
|
||||
"Benutzerkonfigurierte Server mit automatisch erkannten oder manuell definierten Modellen.",
|
||||
"Add Provider": "Anbieter hinzufügen",
|
||||
"No custom providers configured. Click 'Add Provider' to get started.":
|
||||
"Keine benutzerdefinierten Anbieter konfiguriert. Klicken Sie auf 'Anbieter hinzufügen', um zu beginnen.",
|
||||
"auto-discovered": "automatisch erkannt",
|
||||
Refresh: "Aktualisieren",
|
||||
Edit: "Bearbeiten",
|
||||
"Are you sure you want to delete this provider?":
|
||||
"Sind Sie sicher, dass Sie diesen Anbieter löschen möchten?",
|
||||
"Edit Provider": "Anbieter bearbeiten",
|
||||
"Provider Name": "Anbietername",
|
||||
"e.g., My Ollama Server": "z.B. Mein Ollama Server",
|
||||
"Provider Type": "Anbietertyp",
|
||||
"Base URL": "Basis-URL",
|
||||
"e.g., http://localhost:11434": "z.B. http://localhost:11434",
|
||||
"API Key (Optional)": "API-Schlüssel (Optional)",
|
||||
"Leave empty if not required": "Leer lassen, falls nicht erforderlich",
|
||||
"Test Connection": "Verbindung testen",
|
||||
Discovered: "Erkannt",
|
||||
Models: "Modelle",
|
||||
models: "Modelle",
|
||||
and: "und",
|
||||
more: "mehr",
|
||||
"For manual provider types, add models after saving the provider.":
|
||||
"Für manuelle Anbietertypen fügen Sie Modelle nach dem Speichern des Anbieters hinzu.",
|
||||
"Please fill in all required fields":
|
||||
"Bitte füllen Sie alle erforderlichen Felder aus",
|
||||
"Failed to save provider": "Fehler beim Speichern des Anbieters",
|
||||
"OpenAI Completions Compatible": "OpenAI Completions Kompatibel",
|
||||
"OpenAI Responses Compatible": "OpenAI Responses Kompatibel",
|
||||
"Anthropic Messages Compatible": "Anthropic Messages Kompatibel",
|
||||
"Checking...": "Überprüfe...",
|
||||
Disconnected: "Getrennt",
|
||||
},
|
||||
};
|
||||
|
||||
setTranslations(translations);
|
||||
|
||||
export * from "@mariozechner/mini-lit/dist/i18n.js";
|
||||
|
|
@ -1,306 +0,0 @@
|
|||
import { LMStudioClient } from "@lmstudio/sdk";
|
||||
import type { Model } from "@mariozechner/pi-ai";
|
||||
import { Ollama } from "ollama/browser";
|
||||
|
||||
/**
|
||||
* Discover models from an Ollama server.
|
||||
* @param baseUrl - Base URL of the Ollama server (e.g., "http://localhost:11434")
|
||||
* @param apiKey - Optional API key (currently unused by Ollama)
|
||||
* @returns Array of discovered models
|
||||
*/
|
||||
export async function discoverOllamaModels(
|
||||
baseUrl: string,
|
||||
_apiKey?: string,
|
||||
): Promise<Model<any>[]> {
|
||||
try {
|
||||
// Create Ollama client
|
||||
const ollama = new Ollama({ host: baseUrl });
|
||||
|
||||
// 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: any) => {
|
||||
try {
|
||||
// Get model details
|
||||
const details = await ollama.show({
|
||||
model: model.name,
|
||||
});
|
||||
|
||||
// Check capabilities - filter out models that don't support tools
|
||||
const capabilities: string[] = (details as any).capabilities || [];
|
||||
if (!capabilities.includes("tools")) {
|
||||
console.debug(
|
||||
`Skipping model ${model.name}: does not support tools`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
// Ollama caps max tokens at 10x context length
|
||||
const maxTokens = contextWindow * 10;
|
||||
|
||||
// Ollama only supports completions API
|
||||
const ollamaModel: Model<any> = {
|
||||
id: model.name,
|
||||
name: model.name,
|
||||
api: "openai-completions" as any,
|
||||
provider: "", // Will be set by caller
|
||||
baseUrl: `${baseUrl}/v1`,
|
||||
reasoning: capabilities.includes("thinking"),
|
||||
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;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const results = await Promise.all(ollamaModelPromises);
|
||||
return results.filter((m): m is Model<any> => m !== null);
|
||||
} catch (err) {
|
||||
console.error("Failed to discover Ollama models:", err);
|
||||
throw new Error(
|
||||
`Ollama discovery failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover models from a llama.cpp server via OpenAI-compatible /v1/models endpoint.
|
||||
* @param baseUrl - Base URL of the llama.cpp server (e.g., "http://localhost:8080")
|
||||
* @param apiKey - Optional API key
|
||||
* @returns Array of discovered models
|
||||
*/
|
||||
export async function discoverLlamaCppModels(
|
||||
baseUrl: string,
|
||||
apiKey?: string,
|
||||
): Promise<Model<any>[]> {
|
||||
try {
|
||||
const headers: HeadersInit = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
if (apiKey) {
|
||||
headers.Authorization = `Bearer ${apiKey}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}/v1/models`, {
|
||||
method: "GET",
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.data || !Array.isArray(data.data)) {
|
||||
throw new Error("Invalid response format from llama.cpp server");
|
||||
}
|
||||
|
||||
return data.data.map((model: any) => {
|
||||
// llama.cpp doesn't always provide context window info
|
||||
const contextWindow = model.context_length || 8192;
|
||||
const maxTokens = model.max_tokens || 4096;
|
||||
|
||||
const llamaModel: Model<any> = {
|
||||
id: model.id,
|
||||
name: model.id,
|
||||
api: "openai-completions" as any,
|
||||
provider: "", // Will be set by caller
|
||||
baseUrl: `${baseUrl}/v1`,
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: contextWindow,
|
||||
maxTokens: maxTokens,
|
||||
};
|
||||
|
||||
return llamaModel;
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to discover llama.cpp models:", err);
|
||||
throw new Error(
|
||||
`llama.cpp discovery failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover models from a vLLM server via OpenAI-compatible /v1/models endpoint.
|
||||
* @param baseUrl - Base URL of the vLLM server (e.g., "http://localhost:8000")
|
||||
* @param apiKey - Optional API key
|
||||
* @returns Array of discovered models
|
||||
*/
|
||||
export async function discoverVLLMModels(
|
||||
baseUrl: string,
|
||||
apiKey?: string,
|
||||
): Promise<Model<any>[]> {
|
||||
try {
|
||||
const headers: HeadersInit = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
if (apiKey) {
|
||||
headers.Authorization = `Bearer ${apiKey}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}/v1/models`, {
|
||||
method: "GET",
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.data || !Array.isArray(data.data)) {
|
||||
throw new Error("Invalid response format from vLLM server");
|
||||
}
|
||||
|
||||
return data.data.map((model: any) => {
|
||||
// vLLM provides max_model_len which is the context window
|
||||
const contextWindow = model.max_model_len || 8192;
|
||||
const maxTokens = Math.min(contextWindow, 4096); // Cap max tokens
|
||||
|
||||
const vllmModel: Model<any> = {
|
||||
id: model.id,
|
||||
name: model.id,
|
||||
api: "openai-completions" as any,
|
||||
provider: "", // Will be set by caller
|
||||
baseUrl: `${baseUrl}/v1`,
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: contextWindow,
|
||||
maxTokens: maxTokens,
|
||||
};
|
||||
|
||||
return vllmModel;
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to discover vLLM models:", err);
|
||||
throw new Error(
|
||||
`vLLM discovery failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover models from an LM Studio server using the LM Studio SDK.
|
||||
* @param baseUrl - Base URL of the LM Studio server (e.g., "http://localhost:1234")
|
||||
* @param apiKey - Optional API key (unused for LM Studio SDK)
|
||||
* @returns Array of discovered models
|
||||
*/
|
||||
export async function discoverLMStudioModels(
|
||||
baseUrl: string,
|
||||
_apiKey?: string,
|
||||
): Promise<Model<any>[]> {
|
||||
try {
|
||||
// Extract host and port from baseUrl
|
||||
const url = new URL(baseUrl);
|
||||
const port = url.port ? parseInt(url.port, 10) : 1234;
|
||||
|
||||
// Create LM Studio client
|
||||
const client = new LMStudioClient({
|
||||
baseUrl: `ws://${url.hostname}:${port}`,
|
||||
});
|
||||
|
||||
// List all downloaded models
|
||||
const models = await client.system.listDownloadedModels();
|
||||
|
||||
// Filter to only LLM models and map to our Model format
|
||||
return models
|
||||
.filter((model) => model.type === "llm")
|
||||
.map((model) => {
|
||||
const contextWindow = model.maxContextLength;
|
||||
// Use 10x context length like Ollama does
|
||||
const maxTokens = contextWindow;
|
||||
|
||||
const lmStudioModel: Model<any> = {
|
||||
id: model.path,
|
||||
name: model.displayName || model.path,
|
||||
api: "openai-completions" as any,
|
||||
provider: "", // Will be set by caller
|
||||
baseUrl: `${baseUrl}/v1`,
|
||||
reasoning: model.trainedForToolUse || false,
|
||||
input: model.vision ? ["text", "image"] : ["text"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: contextWindow,
|
||||
maxTokens: maxTokens,
|
||||
};
|
||||
|
||||
return lmStudioModel;
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to discover LM Studio models:", err);
|
||||
throw new Error(
|
||||
`LM Studio discovery failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function to discover models based on provider type.
|
||||
* @param type - Provider type
|
||||
* @param baseUrl - Base URL of the server
|
||||
* @param apiKey - Optional API key
|
||||
* @returns Array of discovered models
|
||||
*/
|
||||
export async function discoverModels(
|
||||
type: "ollama" | "llama.cpp" | "vllm" | "lmstudio",
|
||||
baseUrl: string,
|
||||
apiKey?: string,
|
||||
): Promise<Model<any>[]> {
|
||||
switch (type) {
|
||||
case "ollama":
|
||||
return discoverOllamaModels(baseUrl, apiKey);
|
||||
case "llama.cpp":
|
||||
return discoverLlamaCppModels(baseUrl, apiKey);
|
||||
case "vllm":
|
||||
return discoverVLLMModels(baseUrl, apiKey);
|
||||
case "lmstudio":
|
||||
return discoverLMStudioModels(baseUrl, apiKey);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,150 +0,0 @@
|
|||
import type {
|
||||
Api,
|
||||
Context,
|
||||
Model,
|
||||
SimpleStreamOptions,
|
||||
} from "@mariozechner/pi-ai";
|
||||
import { streamSimple } from "@mariozechner/pi-ai";
|
||||
|
||||
/**
|
||||
* Centralized proxy decision logic.
|
||||
*
|
||||
* Determines whether to use a CORS proxy for LLM API requests based on:
|
||||
* - Provider name
|
||||
* - API key pattern (for providers where it matters)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Check if a provider/API key combination requires a CORS proxy.
|
||||
*
|
||||
* @param provider - Provider name (e.g., "anthropic", "openai", "zai")
|
||||
* @param apiKey - API key for the provider
|
||||
* @returns true if proxy is required, false otherwise
|
||||
*/
|
||||
export function shouldUseProxyForProvider(
|
||||
provider: string,
|
||||
apiKey: string,
|
||||
): boolean {
|
||||
switch (provider.toLowerCase()) {
|
||||
case "zai":
|
||||
// Z-AI always requires proxy
|
||||
return true;
|
||||
|
||||
case "anthropic":
|
||||
// Anthropic OAuth tokens (sk-ant-oat-*) require proxy
|
||||
// Regular API keys (sk-ant-api-*) do NOT require proxy
|
||||
return apiKey.startsWith("sk-ant-oat");
|
||||
|
||||
// These providers work without proxy
|
||||
case "openai":
|
||||
case "google":
|
||||
case "groq":
|
||||
case "openrouter":
|
||||
case "cerebras":
|
||||
case "xai":
|
||||
case "ollama":
|
||||
case "lmstudio":
|
||||
return false;
|
||||
|
||||
// Unknown providers - assume no proxy needed
|
||||
// This allows new providers to work by default
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply CORS proxy to a model's baseUrl if needed.
|
||||
*
|
||||
* @param model - The model to potentially proxy
|
||||
* @param apiKey - API key for the provider
|
||||
* @param proxyUrl - CORS proxy URL (e.g., "https://proxy.mariozechner.at/proxy")
|
||||
* @returns Model with modified baseUrl if proxy is needed, otherwise original model
|
||||
*/
|
||||
export function applyProxyIfNeeded<T extends Api>(
|
||||
model: Model<T>,
|
||||
apiKey: string,
|
||||
proxyUrl?: string,
|
||||
): Model<T> {
|
||||
// If no proxy URL configured, return original model
|
||||
if (!proxyUrl) {
|
||||
return model;
|
||||
}
|
||||
|
||||
// If model has no baseUrl, can't proxy it
|
||||
if (!model.baseUrl) {
|
||||
return model;
|
||||
}
|
||||
|
||||
// Check if this provider/key needs proxy
|
||||
if (!shouldUseProxyForProvider(model.provider, apiKey)) {
|
||||
return model;
|
||||
}
|
||||
|
||||
// Apply proxy to baseUrl
|
||||
return {
|
||||
...model,
|
||||
baseUrl: `${proxyUrl}/?url=${encodeURIComponent(model.baseUrl)}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is likely a CORS error.
|
||||
*
|
||||
* CORS errors in browsers typically manifest as:
|
||||
* - TypeError with message "Failed to fetch"
|
||||
* - NetworkError
|
||||
*
|
||||
* @param error - The error to check
|
||||
* @returns true if error is likely a CORS error
|
||||
*/
|
||||
export function isCorsError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for common CORS error patterns
|
||||
const message = error.message.toLowerCase();
|
||||
|
||||
// "Failed to fetch" is the standard CORS error in most browsers
|
||||
if (error.name === "TypeError" && message.includes("failed to fetch")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Some browsers report "NetworkError"
|
||||
if (error.name === "NetworkError") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// CORS-specific messages
|
||||
if (message.includes("cors") || message.includes("cross-origin")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a streamFn that applies CORS proxy when needed.
|
||||
* Reads proxy settings from storage on each call.
|
||||
*
|
||||
* @param getProxyUrl - Async function to get current proxy URL (or undefined if disabled)
|
||||
* @returns A streamFn compatible with Agent's streamFn option
|
||||
*/
|
||||
export function createStreamFn(getProxyUrl: () => Promise<string | undefined>) {
|
||||
return async (
|
||||
model: Model<any>,
|
||||
context: Context,
|
||||
options?: SimpleStreamOptions,
|
||||
) => {
|
||||
const apiKey = options?.apiKey;
|
||||
const proxyUrl = await getProxyUrl();
|
||||
|
||||
if (!apiKey || !proxyUrl) {
|
||||
return streamSimple(model, context, options);
|
||||
}
|
||||
|
||||
const proxiedModel = applyProxyIfNeeded(model, apiKey, proxyUrl);
|
||||
return streamSimple(proxiedModel, context, options);
|
||||
};
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1,20 +0,0 @@
|
|||
{
|
||||
"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/**/*"]
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
|
|
@ -18,12 +18,10 @@
|
|||
"@sinclair/typebox": ["./node_modules/@sinclair/typebox"],
|
||||
"@mariozechner/pi-tui": ["./packages/tui/src/index.ts"],
|
||||
"@mariozechner/pi-tui/*": ["./packages/tui/src/*"],
|
||||
"@mariozechner/pi-web-ui": ["./packages/web-ui/src/index.ts"],
|
||||
"@mariozechner/pi-web-ui/*": ["./packages/web-ui/src/*"],
|
||||
"@mariozechner/pi-agent-old": ["./packages/agent-old/src/index.ts"],
|
||||
"@mariozechner/pi-agent-old/*": ["./packages/agent-old/src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["packages/*/src/**/*", "packages/*/test/**/*"],
|
||||
"exclude": ["packages/web-ui/**/*", "**/dist/**"]
|
||||
"exclude": ["**/dist/**"]
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue